Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bc4ad7d
fix(logger): stop dropping traces appended during an in-flight flush
fercor-cisco Aug 15, 2026
d426a0b
fix(logger): restore detached batch when an in-flight flush is cancelled
fercor-cisco Aug 17, 2026
7bcc29c
fix(logger): stop flushing traces another task is still building
fercor-cisco Aug 18, 2026
ef357fe
fix(decorator): release a trace when preparing its span fails
fercor-cisco Aug 18, 2026
db67cfa
fix(openai): release the trace when a call fails without a status code
fercor-cisco Aug 18, 2026
9cca439
fix(logger): send a trace whose owner is gone instead of holding it f…
fercor-cisco Aug 18, 2026
a21106f
fix(decorator): release the trace when a decorated generator returns
fercor-cisco Aug 18, 2026
3ffce80
chore(gitignore): anchor the local scratch ignore to the repo root
fercor-cisco Aug 18, 2026
a7bbdef
docs(changelog): record the concurrent flush fix under Unreleased
fercor-cisco Aug 18, 2026
cbdd070
fix(logger): let a flush send a sync-owned trace from the owner's own…
fercor-cisco Aug 18, 2026
02cff04
docs(logger): say what reset_parent_tracking does to trace ownership
fercor-cisco Aug 18, 2026
1f93c00
fix(decorator): don't let a nested generator release its caller's trace
fercor-cisco Aug 18, 2026
a3fb9f9
fix(decorator): only release a trace the failing call itself started
fercor-cisco Aug 18, 2026
d22439e
fix(logger): restore trace ownership along with a failed send's batch
fercor-cisco Aug 18, 2026
53e5f7e
docs(logger): note the await-free partition does not exclude other th…
fercor-cisco Aug 18, 2026
10405f1
docs(readme): state what a flush uploads and what its return value means
fercor-cisco Aug 18, 2026
033969b
docs(decorator): say what a flush uploads instead of promising all tr…
fercor-cisco Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,7 @@ node_modules/

# PyDoc generated docs
.generated_docs/

# Local-only scratch and credentials (never committed). Anchored, so it cannot also
# swallow a nested directory that happens to share the name.
/.local/
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@

## Unreleased

### Bug Fixes

- **Only flush traces that no other task is still building**: Concurrent tasks share one `GalileoLogger`, and therefore one internal trace list, whenever they run on the same thread against the same project and log stream. A flush treated that shared list as if it belonged to the caller, which produced three silent failures: a trace appended while a flush was in flight was discarded; two overlapping flushes each sent the whole list, so a trace went out once per in-flight flush; and a flush sent traces a sibling task had started and not concluded, so they reached Galileo with no output, no duration and no status code.

A flush now hands the batch off rather than clearing the list afterwards — so a trace appended mid-flush is picked up by the next flush instead of being lost — and it sends only the caller's own trace plus traces that no live context is still building. Traces held back leave with their owner's flush, or once that owner finishes. The batch is restored if the send fails or is cancelled, and finished traces still leave together in one request, so this costs no extra requests.

Most likely to be seen with `@log` on an async function plus `asyncio.gather`, since a decorated coroutine necessarily holds its trace open across every await inside it. The LangChain callbacks are affected by the dropped and duplicated sends, but not by the premature one: they start, log and conclude a trace with no await in between, so no sibling can observe a half-built trace on that path. ([#634](https://github.com/rungalileo/galileo-python/pull/634))

### Features

- **New `generated_output` field**: Add `generated_output` field to `DatasetRecord` for storing model-generated outputs separately from ground truth. This allows you to track both the expected output (ground truth) and the actual model output in the same dataset record. In the UI, this field is displayed as "Generated Output".
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ call_openai()
galileo_context.flush()
```

**What a flush uploads.** A flush uploads the trace the calling code is building, plus any trace that no live task or thread is still building. If a concurrent task has started a trace and not finished it, that trace stays queued and is uploaded by that task's own flush instead, so it is never sent without its output and spans.

**A flush is not a receipt for your trace.** `galileo_context.flush()` returns nothing and swallows upload errors, so a normal return is not a confirmation that a given trace was sent. `logger.flush()` returns the batch that was uploaded, which is still not the fate of your own trace: it returns an empty list in three different situations — nothing was pending, a concurrent flush had already carried your trace, or an upload error was swallowed. An empty list therefore does not mean your trace was not sent.

Using the Langchain callback handler:

```python
Expand Down
82 changes: 76 additions & 6 deletions src/galileo/decorator.py

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/galileo/decorator.py:1142-1142 (line not in diff)

🟡 minor (documentation): galileo_context.flush() is the flush most users reach for (it is the shape in SAO-15919), and its contract has changed: it now sends the caller's own trace plus traces no live context is still building, not "all captured traces". logger.flush()/async_flush() docstrings were updated for this; these were not, so the public-facing wording now overstates what happens and gives no hint why a return of [] or a partial send is expected. Same for flush_all() at line 1197 (and, less visibly, GalileoLoggerSingleton.flush()/flush_all()).

Suggested change
Upload captured traces under a project and log stream context to Galileo.
Sends this caller's own trace, plus every trace no live context is still building; a trace
another context is part-way through building leaves with that context's flush instead. See
`GalileoLogger.flush()`.

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, in 033969b3, and the README half in 10405f1b. You are right that this is the flush most users
reach for and the shape in the originating ticket, and that cbdd0705 updated GalileoLogger.flush()/
async_flush() while leaving the wrappers users actually call saying "Upload all captured traces".

All four sites updated — galileo_context.flush, galileo_context.flush_all, and both
GalileoLoggerSingleton equivalents — with the wording lifted from logger.py:2155-2157 so the flush docs
read as one voice. A repo-wide grep for the stale phrasing across src/, tests/, README.md and
CHANGELOG.md found exactly these four, nothing else to chase. (Minor: the definitions are at
decorator.py:1147 and :1209, not :1142/:1197.)

One adaptation of your second point. You asked for "the return value is the uploaded batch rather than
the fate of your trace" — that is GalileoLogger.flush()'s contract, but all four wrappers return
None
. So on galileo_context.flush() it became "nothing is returned and upload errors are swallowed
(see on_error), so a normal return is not a confirmation", which is the same warning in the shape these
functions actually have. The README paragraph covers both wrappers explicitly, since it sits under a
galileo_context.flush() example.

Contract only, no mechanism — no _traces_being_built, owner liveness, weakrefs or thread identity.
Deliberate: per-thread logger key isolation is still in play and would change how traces group, so
documenting the current mechanism would be documenting something I expect to move.

Prose-only, so the bar was "nothing executable moved": git diff -U0 inspected line by line, ruff clean,
mypy 12 errors both sides → 0 new, inspect.getdoc on all four renders with numpydoc Parameters
sections intact, suite unchanged at 2099 / 5.


Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,10 @@ def _safe_prepare_call(
bool
True if preparation succeeded, False if it failed
"""
# Captured before the attempt, not read in the handler below: an enclosing decorated call's
# span means the trace being built is that call's, not this one's, and the answer must not
# depend on how far `_prepare_call` got before raising.
outermost = not _get_or_init_list(_span_stack_context)
try:
self._prepare_call(span_type, span_params, dataset_record)
return True
Expand All @@ -641,8 +645,30 @@ def _safe_prepare_call(
_logger.error("Galileo logging initialization failed: %s", e, exc_info=True)
else:
_logger.warning("Galileo logging initialization failed, continuing without logging: %s", e)
# Preparation may already have started a trace, and returning False means the caller
# skips _finalize_call - so nothing else would ever report this context as done with
# it, leaving it held back from every flush. This context is not building it.
#
# Only when this call was the outermost one, though: an enclosing call is still building
# the trace, and releasing it on that call's behalf would expose it to a sibling's flush
# mid-build. That call reports its own hand-off when it returns.
if outermost:
self._release_trace_being_built()
return False
Comment thread
fercor-cisco marked this conversation as resolved.
Comment on lines +648 to 657

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor (bug): Same family as the generator guard above: this release is unconditional, so when a nested _prepare_call fails it releases the trace the outer decorated call is still building, exposing it to a sibling's flush.

Confirmed with a probe that makes only the nested call's add_workflow_span raise (the same failure mode test_trace_is_not_stranded_when_span_setup_fails patches in): a sibling flush then ships ('outer', None) while the outer call is still parked on an await, and the outer's real output is lost with it.

The reachability is narrower than the generator case — most raisers inside _prepare_call are swallowed by warn_catch_exception — but the guard costs one line, and it also makes the intent explicit (this call, not its caller, owns the trace it started).

Suggested change
# Preparation may already have started a trace, and returning False means the caller
# skips _finalize_call - so nothing else would ever report this context as done with
# it, leaving it held back from every flush. This context is not building it.
self._release_trace_being_built()
return False
# Preparation may already have started a trace, and returning False means the caller
# skips _finalize_call - so nothing else would ever report this context as done with
# it, leaving it held back from every flush. Only release when no outer decorated call
# is still building the trace: a nested failure must not release its caller's trace.
if not _get_or_init_list(_span_stack_context):
self._release_trace_being_built()

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in a3fb9f9a, which also closes your R5 point — the three test reworks came with it.

Reproduced with a depth-aware add_workflow_span patch (outer succeeds, nested raises) and the owner parked:
being_built: 0 at park, the sibling flush ships outer with span output null, "outer done" lost.
Identical on main. Two probe traps worth recording, because the first one nearly refuted it spuriously:
patching add_llm_span proves nothing (_prepare_call never calls it — patch add_workflow_span), and
without parking the owner the liveness bound masks the leak entirely.

Deviation from your fix, and I think it is strictly better. You suggested reading the stack inside the
handler; I snapshot it before the attempt:

outermost = not _get_or_init_list(_span_stack_context)
try:
    self._prepare_call(...)
except Exception:
    ...
    if outermost:
        self._release_trace_being_built()

Same cost. _prepare_call pushes at :716 and marks the trace at :729, so a raise landing between them
would leave the handler seeing this call's own span, skip the release, and leave the trace marked by
start_trace with nothing to release it. The snapshot answers "was there an enclosing decorated call"
regardless of how far preparation got.

Release-site audit, since your comment asks about scope. All ten _mark_trace_finished /
_release_trace_being_built sites checked; only this one lacked a correct guard. The OpenAI wrapper is
already safe — should_complete_trace starts False (openai/__init__.py:140) and is set True only in
the else of if galileo_logger.current_parent():, i.e. only when the chain was empty and the wrapper
itself called start_trace, so under an enclosing @log it cannot release the decorated call's trace.
reset_parent_tracking is ContextVar-isolated, _conclude is guarded by finished_step._parent is None,
_flush_batch pops only ids it detached, and the wholesale .clear() calls are distributed-mode
bookkeeping or deliberate lifecycle end. No shared helper added: there is no existing "did this context
start the trace" predicate, and factoring one for three sites of a two-line condition would mean
re-touching the two fixes already landed.

On reachability, you were right to hedge and I will go further. Every logger call _prepare_call makes
is wrapped in warn_catch_exception and swallows — start_trace, add_workflow_span, add_agent_span,
has_active_trace. The only genuinely escaping raiser is the bare client_instance.traces[-1] at
:691-692, i.e. the TOCTOU IndexError from the follow-up list. So the production trigger today is a
second bug, and the test's add_workflow_span failure is synthetic — fine for pinning the release site,
but I am not presenting it as a production shape.

Four tests, four distinct mutations, all verified separately. The first two are complementary and both
necessary — reverting the guard does not fail the top-level test, and deleting the release does not fail the
nested one:

Mutation Test that fails
Guard reverted (release unconditional) test_nested_span_setup_failure_does_not_release_the_outer_trace (new)
Release removed entirely test_trace_is_not_stranded_when_span_setup_fails (reworked)
mark-finished block removed from openai _wrap's except test_trace_is_not_stranded_when_the_openai_call_raises_a_non_status_error (reworked)
_mark_trace_finished removed from reset_parent_tracking() test_reset_parent_tracking_does_not_strand_its_trace (reworked)

The new test also failed pre-fix with [[('outer_forecast', None)]] == [].

Your R5 remedy needed one correction on the third test. The asyncio.Event trick fixes the first two.
It cannot fix the third: that owner is a thread, and _still_being_built treats an owner that is the
flushing thread as finished (flushing_thread is captured before dispatch). It now runs start_trace() +
reset_parent_tracking() on a spawned thread blocking alive on a threading.Event while MainThread
flushes — _mark_trace_unfinished falls back to threading.current_thread() with no running loop and
ContextVars are per-thread, so the spawned thread is the recorded owner and MainThread's claimed_trace_id
is None. All three docstrings were corrected too: they claimed the cross-task flush was the only masking
mechanism, which stopped being true when Fix 3 added owner liveness.

ef357fe3's original bug stays fixed — in the top-level-failure shape add_workflow_span raises
before the append, so the stack is empty, the guard fires, and the trace is still released and shipped
(verified with the owner alive; the probe's toplevel_fail result is byte-identical pre- and post-fix).
Suite 2098 passed / 5 skipped, inv type-check green, flake loops 20×/10× clean, 13 recorded artifact
checks all pass.


def _release_trace_being_built(self) -> None:
"""Report that this context is no longer building the trace it had started, if any."""
try:
logger = self.get_logger_instance()
trace_id = logger._current_trace_id()
if trace_id is not None:
logger._mark_trace_finished(trace_id)
except Exception as e:
# Debug, not a warning: the most likely raiser is `get_logger_instance()` above, and the
# caller that could not build a logger has already reported that failure. There is no
# trace to release in that case, so a second message per decorated call is pure noise.
_logger.debug("Could not release the trace being built: %s", e)

def _prepare_call(
self, span_type: SPAN_TYPE | None, span_params: dict[str, Any], dataset_record: DatasetRecord | None
) -> None:
Expand Down Expand Up @@ -698,6 +724,19 @@ def _prepare_call(
span = client_instance.add_workflow_span(input=input_, name=name, created_at=created_at)
_get_or_init_list(_span_stack_context).append(span)

# This context owns the trace until the outermost decorated call returns, which is what
# _finalize_call reports. Concurrent tasks share one logger and one trace list, so a
# sibling's flush must not carry the trace away while spans are still being added to it.
# Re-marked on every entry because a reused trace was released by the previous call.
#
# Deliberately the last statement: the caller skips _finalize_call when _prepare_call
# raises, so marking any earlier would strand the trace - held back from every flush with
# nothing left to release it. Nothing above yields, so the trace cannot be observed by
# another task before this runs.
trace_being_built = _trace_context.get()
if trace_being_built is not None:
client_instance._mark_trace_unfinished(trace_being_built.id)

def _get_input_from_func_args(
self, *, is_method: bool = False, func_args: tuple = (), func_kwargs: dict | None = None
) -> Any:
Expand Down Expand Up @@ -748,9 +787,23 @@ def _finalize_call(
-------
The original result, possibly wrapped if it's a generator
"""
if inspect.isgenerator(result):
return self._wrap_sync_generator_result(span_type, span_params, result)
if inspect.isasyncgen(result):
if inspect.isgenerator(result) or inspect.isasyncgen(result):
# The wrappers below report the hand-off from `_handle_call_result` when the generator
# is exhausted, but only `_async_log` keeps the wrapper it is handed: `_sync_log`
# discards it and returns the raw generator, and both generator kinds route through
# `_sync_log` (`asyncio.iscoroutinefunction` is False for an async generator function).
# So on that path nothing would ever release the trace. Reported here instead, where
# both paths pass. The stack still holds this call's own span - only
# `_handle_call_result` pops it - so the outermost call is the one that leaves it alone.
# Only a workflow, agent or untyped call pushed a span in `_prepare_call`: a
# non-concludable span type pushed nothing, so for it a stack of one holds an enclosing
# call's span, and reporting the hand-off would release a trace that call is still
# building.
pushed_own_span = not span_type or is_concludable_span_type(span_type)
if len(_get_or_init_list(_span_stack_context)) <= (1 if pushed_own_span else 0):
self._release_trace_being_built()
Comment thread
fercor-cisco marked this conversation as resolved.
if inspect.isgenerator(result):
return self._wrap_sync_generator_result(span_type, span_params, result)
return self._wrap_async_generator_result(span_type, span_params, result)
return self._handle_call_result(span_type, span_params, result)

Expand Down Expand Up @@ -905,6 +958,14 @@ def _handle_call_result(self, span_type: SPAN_TYPE | None, span_params: dict[str
except Exception as e:
_logger.error(f"Failed to create trace for span '{span_name}' (type: {span_type}): {e}", exc_info=True)

# The outermost decorated call has returned, so this context has stopped adding to the
# trace and any flush may now send it. The trace itself is deliberately left open for a
# later decorated call in this context to reuse, so concluding is not the signal here.
if not _get_or_init_list(_span_stack_context):
trace = _trace_context.get()
if trace is not None:
logger._mark_trace_finished(trace.id)
Comment thread
fercor-cisco marked this conversation as resolved.

return result

def _wrap_sync_generator_result(
Expand Down Expand Up @@ -1092,7 +1153,14 @@ def flush(
on_error: Callable[[Exception], None] | None = None,
) -> None:
"""
Upload all captured traces under a project and log stream context to Galileo.
Upload traces captured under a project and log stream context to Galileo.

Uploads the trace the calling code is building, plus every trace that no live task or thread is
still building. A trace another context is part-way through building stays queued and leaves with
that context's own flush instead, so it is not sent without its output, spans and duration.

Nothing is returned and upload errors are swallowed (see ``on_error``), so a normal return is not
a confirmation that a given trace was uploaded.

If no project or log stream is provided, then the currently initialized context is used.

Expand Down Expand Up @@ -1147,9 +1215,11 @@ def _on_flush_error(exc: Exception) -> None:

def flush_all(self) -> None:
"""
Upload all captured traces under all contexts to Galileo.
Upload traces captured under all contexts to Galileo.

This method flushes all traces regardless of project or log stream.
This method flushes every cached logger regardless of project or log stream. Each one uploads the
same set as `flush()` does: the trace the calling code is building, plus every trace that no live
task or thread is still building.
"""
GalileoLoggerSingleton().flush_all()
_span_stack_context.set([])
Expand Down
Loading
Loading