fix: recover ADK LLM spans when ContextCacheConfig detaches the async context (#5524)#7305
fix: recover ADK LLM spans when ContextCacheConfig detaches the async context (#5524)#7305lupuletic wants to merge 12 commits into
Conversation
… context With ADK ContextCacheConfig enabled and SSE streaming (Cloud Run), the extra OTel attach/detach cycles from handle_context_caching/create_cache revert Opik's contextvar span stack to a snapshot predating before_model_callback's push. after_model_callback then sees top_span_data() == None and never finalizes the LLM span, so it is stuck at _OPIK_SPAN_STATUS=started with no output (opik#5524). Register each LLM span in before_model_callback under a stable, per-model-call key -- id(callback_context.actions), the EventActions instance ADK creates once per model call and passes to both callbacks; it is invariant across streaming partials and immune to contextvar mutation. after_model_callback treats this registry as authoritative for which span is ours: it recovers and finalizes the span whether or not the (possibly detached) stack still holds it, and only pops the stack when it actually does -- so a parent span left on top is never mis-finalized. The registry is thread-safe and size-bounded (evict oldest) so before-callbacks without a matching after-callback can't grow it. Adds PendingLlmSpanRegistry with unit tests and recovery tests that finalize a span with the stack detached and with a parent span left on top. Closes comet-ml#5524
20cf253 to
16db905
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 20cf2532de
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- Registry eviction / missing callback_context.actions: after_model_callback no longer treats a registry miss as authoritative. It falls back to the context stack top when that top is our not-yet-finalized LLM span (is_externally_created_llm_span_that_just_started), so an evicted entry or a callback context without `actions` can't drop an active span. A parent span left on top by a detached context fails that check and is never mis-finalized. before_model_callback guards the `actions` access with getattr. - Stale entry on finalization error: drop the registry entry in the finally on any final-response exit (success or error), instead of after the fallible finalization, so a reused id() can't later map to a stale span. - Duplication: extract the shared, thread-safe, size-bounded OrderedDict into BoundedCache; LastModelOutputCache and PendingLlmSpanRegistry now compose it, so eviction/locking lives in one place. - Tests: unit-test BoundedCache directly; drive recovery through the real before/after callbacks and read the span from the public context storage (not tracer internals); add a no-`actions` stack-fallback test and keep the parent-span-on-top test.
|
Thanks — all five addressed in the latest commit. Registry eviction (Codex) + missing Stale entry on finalization error (Baz). The registry entry is now dropped in the Duplicated bounded-cache logic (Baz). Extracted Private-state test assertions (Baz). The registry behaviour is now unit-tested through the shared
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eeca0796db
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- Empty-content leak (Codex P2 / Baz): has_empty_text_part_content returned before the span/key was resolved, so on a detached or final empty response neither the inline TTFT cleanup (top_span_data() was None) nor the finally ran, leaking _ttft_tracking entries and holding _pending_llm_spans entries until eviction. Resolve the span + key up front (registry-first, stack fallback), record span_id early, and let the finally drop both the TTFT entry and the pending-registry entry for final responses (partial chunks keep them). The empty-content branch now just returns. - Test naming: rename the BoundedCache tests to the repo's test_WHAT__CASE__EXPECTED_RESULT convention; add an empty-final-response leak-regression test.
|
Thanks — both addressed in the latest commit. Empty-content cleanup leak (Codex P2 / Baz — same root cause). Test naming (Baz). Renamed the
|
Strengthen the empty-final-response leak test so it fails on a broken registration too: assert the entry exists right after before_model_callback runs, then that it's gone after after_model_callback (per review).
|
@baz-reviewer good catch — strengthened |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f3d6df7fb9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
id(actions) alone is unsafe for entries that outlive their callback: a call whose after_model_callback never runs (a short-circuiting before-callback or a model error) leaves its entry behind, its actions object is GC'd, and CPython can reuse that id for a later call's EventActions -- so after_model_callback would recover/finalize the stale span while the real span stays started (comet-ml#5524 review). Store a strong reference to the actions object alongside the span and verify identity on lookup: the strong ref keeps the object alive so its id can't be recycled while the entry is live, and the identity check refuses a stale span if an id ever collides. register/get/pop now take the actions object. Adds registry tests: distinct-actions isolation and a recycled-id identity-refusal test.
|
@codex good catch on the @codex review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…ding them The pending-span registry is size-bounded to cap entries whose after_model_callback never runs (a short-circuited before-callback or a failed/cancelled model call). Its BoundedCache dropped the oldest entry silently, so the evicted span was left stuck in the 'started' state with no end time, output, or usage. Route evictions through a callback: BoundedCache gains an optional on_evict (fired outside the lock, for capacity evictions only), and the tracer finalizes the dropped span (ready-for-finalization status, end time, TTFT cleanup, flush) idempotently and best-effort. The common eviction target is an unclaimed span, so this closes spans that were previously leaked; a rare live span evicted under >max_size concurrency is closed early rather than stranded.
|
Good catch — fixed in You're right that
Worth noting this also improves the common path: the oldest entry is overwhelmingly an unclaimed one (short-circuited before-callback, or a failed/cancelled model call whose Covered by |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba41f631a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…e-prefix eviction metadata Two review follow-ups: - Pre-1.29 google-adk (where ContextCacheConfig + SSE can already strand the span) has no public CallbackContext.actions property -- the shared per-call EventActions is only reachable as the private _event_actions. getattr(..., 'actions', None) returned None there, so before_model_callback never registered and after_model_callback fell back to the old detached-context path, leaving the span unfinalized for the exact scenario this change recovers. Resolve the key via a shared helper that prefers .actions and falls back to _event_actions (both resolve to the same object, so the callbacks agree on the key), used in both callbacks. - Rename the force-closed marker to _opik_finalized_on_registry_eviction to follow the leading-underscore internal-metadata convention (cf. _OPIK_SPAN_STATUS, _opik_graph_definition).
|
Both addressed in @chatgpt-codex-connector — pre-1.29 def _resolve_event_actions(callback_context):
actions = getattr(callback_context, "actions", None)
if actions is None:
actions = getattr(callback_context, "_event_actions", None)
return actionsSince the public property just returns @baz-reviewer — internal metadata key naming: agreed — renamed |
|
@codex review |
|
To use Codex here, create an environment for this repo. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4677e2990f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
When ADK SSE-streams a response, the text can arrive in partial=True chunks and the terminal partial=False chunk can be empty. That empty terminal hits has_empty_text_part_content() and returned early before finalizing current_span, then the finally dropped the pending-span registry entry -- so under a detached ContextCacheConfig context (where the span is not on the stack either) the recovered span lost its only handle and stayed 'started' with no end time, output, or usage. Force-close the recovered span on the terminal empty response instead of just dropping it (popping it off the stack first if present). Partial empty chunks are unchanged: the span/TTFT/registry entry are kept so the later final response can finalize normally. Generalizes _finalize_evicted_llm_span into _force_close_llm_span(reason) shared by the eviction and empty-terminal paths; the force-closed marker becomes _opik_llm_span_force_closed_reason (registry_eviction / empty_terminal_response), still leading-underscore per the internal-key convention.
|
Fixed in You're right: when text streams as I took the force-close option you offered (we don't buffer partial text, so there's nothing to accumulate from — the terminal chunk is genuinely empty). On the terminal empty response, when a span was recovered, it's now closed via the shared force-close path (ready-for-finalization status + end time + TTFT cleanup + flush), popping it off the stack first if it's there: if adk_helpers.has_empty_text_part_content(llm_response):
if not is_partial and current_span is not None:
if stack_top is not None and stack_top.id == current_span.id:
context_storage.pop_span_data(ensure_id=current_span.id)
self._force_close_llm_span(current_span, reason="empty_terminal_response")
returnNotes:
New tests: |
|
@codex review |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
A force-closed span is set to READY_FOR_FINALIZATION, but the after_model_callback stack fallback only reclaims STARTED spans, so a force-closed span left on the context stack could not be reclaimed there and would mis-parent later directly-created spans. - _force_close_llm_span now pops the span off the current context stack when it is on top (covers the empty-terminal path and a same-context eviction; the redundant inline pop in the empty-terminal branch is removed). - before_model_callback creates its LLM span directly, bypassing the OTel patcher's READY_FOR_FINALIZATION reclaim, so it now first discards a stale force-closed top span via _discard_stale_finalized_top_span (a STARTED in-flight parent is never touched). This covers a span force-closed in a different async context (a concurrent registry eviction) that could not be popped there. Also add the fake_backend fixture to the tracer tests so span submissions hit the backend emulator instead of a real backend, matching the other ADK integration tests.
|
Both addressed in @baz-reviewer — "Evicted span stays on stack" (HIGH): correct — a force-closed span is
Tests: @baz-reviewer — "Real backend span submission": good catch — added the FYI a few older inline comments got re-anchored to |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0f832d83d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Force-finalizing on registry eviction corrupted a still-live call: under more than max_size concurrently-in-flight model calls, the evicted oldest entry can belong to a call whose after_model_callback has not run yet. Marking that span READY_FOR_FINALIZATION and sending it empty replaced a normally-traceable call with an empty span, and -- because the stack fallback only reclaims STARTED spans -- blocked its real after_model_callback from finalizing it. Evict the entry without mutating the span instead: the span stays on the context stack where the normal STARTED stack fallback still finalizes it (non-detached), and beyond the bound the detached-context recovery simply reverts to the pre-registry behavior -- strictly no worse. This also removes the force-closed span that could linger on the stack, so the before_model stale-top guard and its duplicate of the OTel patcher logic are gone too. Reverts the eviction on_evict/_finalize_evicted_llm_span/ _discard_stale_finalized_top_span machinery. Keeps _force_close_llm_span for the terminal empty-SSE-response path, which is the genuine final callback and so has no later after_model_callback to corrupt.
|
Both addressed in @chatgpt-codex-connector — "Avoid force-closing live evicted LLM spans" (P2): you're right, and this is the important one. Under > This was the right call in hindsight: force-finalizing on eviction (added last round) caused more harm than the stranding it was trying to prevent, and eviction only triggers under >1000 concurrent in-flight calls on one tracer, which is practically unreachable. @baz-reviewer — "Duplicate stale-span cleanup logic": resolved by the same change. Dropping the eviction force-close means no force-closed span can linger on the stack, so Net: reverted the |
|
@codex review |
|
To use Codex here, create an environment for this repo. |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
@alexkuzmik when you have a moment — this is the third in the ADK-tracer series you merged (#7266 output isolation, #7282 trace-output recovery), and it fixes the remaining case those two didn't: the per-LLM span itself getting stuck at Both automated reviewers are clean (Codex: "no major issues"; Baz: passing) and all review threads are resolved. It's mergeable and up to date with |
Summary
Fixes #5524.
When ADK's
ContextCacheConfigis enabled and responses stream over SSE (Cloud Run / production), Opik's per-LLM spans are never finalized — they stay at_OPIK_SPAN_STATUS: startedwith no output or usage.Root cause.
OpikTracercreates the LLM span inbefore_model_callbackand finalizes it inafter_model_callback, handing it over via Opik's contextvar span stack (OpikContextStorage).ContextCacheConfigadds extrahandle_context_caching/create_cacheOpenTelemetry spans (google_llm.py) whosecontext.attach()/detach()cycles, across an SSE async-generator suspension, revert the contextvar to a snapshot predatingbefore_model_callback's push. Sotop_span_data()returnsNoneinafter_model_callbackand the span is abandoned. (There's noasyncio.create_task/copy_contextfork — it's purely OTel contextvar attach/detach imbalance, which caching tips over.)Fix
Register each LLM span in
before_model_callbackunder a stable, per-model-call key that survives the fork:id(callback_context.actions). ADK builds oneEventActionsper model call and passes the same instance to bothbefore_andafter_model_callback(invariant across streaming partials); object identity is immune to contextvar mutation, unlike the span stack or the OTel span id.after_model_callbacktreats this registry as authoritative for which span is ours:The registry (
PendingLlmSpanRegistry) is thread-safe and size-bounded (evict-oldest), because ADK does not guarantee anafter_model_callbackfor everybefore_model_callback(a before-callback can short-circuit by returning a response, or the model call can error), so unclaimed entries must not accumulate on a long-lived shared tracer. It holds athreading.Lock, so it's excluded from__getstate__and recreated in__setstate__.Why
id(callback_context.actions)(not the alternatives)invocation_id— per invocation, so tool-loop LLM calls share it; not unique per call.call_llmspan id — read from the OTel current-span contextvar, i.e. the exact state the caching bug corrupts.EventActionsis a per-call object, is truthy (pydantic v2, no__bool__), and is exposed identically to both callbacks — so the key is unique-per-call, partial-invariant, and contextvar-free.Tests
tests/library_integration/adk/test_adk_context_cache_recovery.py:before_model/after_modelcallbacks with the span stack manually detached, asserting the span is finalized (output + end time) from the registry; a normal-path test (finalizes + clears both stack and registry); and a parent-span-on-top test (finalizes our span, leaves the parent untouched).ruff check/ruff formatpass. (I couldn't run the full ADK suite locally — nogoogle-adkin my env — but validated the registry and the recovery logic against ADK 1.31.1 source.)Notes
OpikTracer(ADK >= 1.3.0).LegacyOpikTracer(ADK < 1.3.0) predatesContextCacheConfig.EventActionsADK passes to agent (not plugin) callbacks.