refactor(telemetry): migrate stream_with_chunking telemetry to plugin hooks#1361
Conversation
… hooks (generative-computing#1290) Move the stream_with_chunking orchestration span, its span events, and its metrics off inline telemetry calls onto the STREAMING_START/EVENT/END plugin hooks, completing the Phase 2 tracing migration. STREAMING_START fires in the caller task and STREAMING_END from acomplete(), giving the streaming span same-task attach/detach and correct stream_with_chunking > chat nesting. Removes the four deprecated public tracing helpers (trace_application, set_span_attribute, set_span_error, set_span_status_error). Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
…ask detach The backend `chat` span opened by stream_with_chunking attaches in the caller task but finishes in the orchestration task that drains the MOT. Detaching its OTel context token from that second task failed (logged by OTel as "Failed to detach context"), and the failed detach left the generation span ambient, so a subsequent validation `chat` span nested under generation instead of being its sibling under stream_with_chunking. Add STREAMING_ORCHESTRATION_START/END hooks fired on the orchestration task. The tracing plugin uses them to re-attach the stream_with_chunking span as that task's ambient context for the drain/validate loop, so mid-stream spans parent correctly. _safe_detach skips a detach that would cross asyncio tasks: within a reattach scope the skip is expected and logged at debug; otherwise the detach runs so OTel surfaces its own error, preceded by a warning naming the mismatch. Add unit coverage for the reattach helpers, the task-identity classification, and both log paths; an integration test asserting both chat spans are siblings under stream_with_chunking and carry no streaming events; and an e2e streaming test. Document the stream_with_chunking span and its hierarchy. Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
…eamingTracingPlugin The SEQUENTIAL requirement also guards the release-before-close ordering between streaming_orchestration_end and streaming_end, not just same-task token detach. Document it so the mode is not switched to FIRE_AND_FORGET. Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
|
FYI I'm working on solving the CI failure. tl;dr python 3.11 |
cpex runs hooks through asyncio.wait_for, which on Python <=3.11 copies the contextvars Context into a child Task — so an OTel attach done inside a hook is lost and its detach fails cross-task, breaking span nesting on 3.11. Skip the in-hook attach/detach on <=3.11 so spans still emit, just flat. The gate lives in tracing_plugins.py; tracing.py stays version-agnostic via a neutral attach_context flag. Directly-attached spans (session) are unaffected. Adds a session>action>chat test through the real hook path, the gap that let this through. Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
Python 3.11 tracing fixThe 3.11 CI job was failing on the streaming tracing tests. Root cause: cpex runs every plugin hook through Since we can't attach context from inside a hook on 3.11, I gate that attach/detach off there — spans still emit, just flattened instead of nested. Directly-attached spans (like I also added a |
|
seems test is still failing. Codex summary (in case it helps):
Why Tests are failing: The active CI failure is Python 3.11-specific. The PR added |
|
And from claude: CI failure root causeOnly One test failed: Root cause — regression from the last commit
_CONTEXT_ATTACH_SUPPORTED: bool = sys.version_info >= (3, 12)and made The failing test ( assert by_name["nested-caller-task"].parent is not None # ← fails on 3.11
assert by_name["nested-caller-task"].parent.span_id == backend_span_idWith no ambient attach on 3.11, those nested spans have no parent → Why the gate is too broad for this testThe commit's rationale is correct for the real hook path — cpex runs hooks through Confirmed locally: Fix optionsThe behavior change (flat spans on ≤3.11) is intentional, so the test should reflect it:
One thing worth flagging: it's slightly surprising that a directly-invoked |
Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
|
Pushed the CI fix, I missed a test in the plugins file that tested nesting when updating all the nesting test in my last commit |
|
@psschwei I believe all of your comments thus far have been addressed now |
|
This came up on the last review. I don't know the hooks/telemetry code well enough to be able to really evaluate the problem, but it did survive a few rounds of pushback, so I'm going to post it. Telemetry regression: On This PR moves
This is a plausible pattern, not misuse — a token-streaming UI genuinely doesn't need FixFire All the payload data ( 1. Extract a one-shot helper on async def _fire_streaming_end(self) -> None:
"""Fire STREAMING_END exactly once. Safe to call from the orchestrator
finally and from acomplete(); the first caller wins."""
if self._streaming_end_fired:
return
self._streaming_end_fired = True
if has_plugins(HookType.STREAMING_END):
from ..plugins.hooks.streaming import StreamingEndPayload
await invoke_hook(
HookType.STREAMING_END,
StreamingEndPayload(
streaming_id=self._streaming_id,
success=self.completed,
failure_reason=self._streaming_failure_reason,
exception=self._streaming_end_exception,
model=self._mot.generation.model,
provider=self._mot.generation.provider,
full_text_length=len(self.full_text),
),
)2. Call it from the orchestrator # ... existing STREAMING_ORCHESTRATION_END invoke_hook (shielded) ...
# Fire STREAMING_END here (not only in acomplete()) so the span always
# closes when the orchestration task finishes, regardless of whether the
# caller drains astream()/events() without calling acomplete(). Shielded
# like ORCHESTRATION_END above. Runs AFTER ORCHESTRATION_END so the
# reattached span is released before the span is finished
# (StreamingTracingPlugin relies on that ordering).
try:
await result._fire_streaming_end()
except BaseException:
pass
completed_ev = CompletedEvent(
success=result.completed, full_text=result.full_text, attempts_used=1
)
# ... existing terminal put_nowait / _done.set() ...3. Simplify async def acomplete(self) -> None:
...
await self._done.wait()
await self._fire_streaming_end() # no-op if the orchestrator already fired
# ... existing raise-once logic ...Plus a regression test for the drain- |
|
The other thing that's gnawing at me a little bit (though I know the reasons for it and don't have an alternative to propose) is that we have different behavior in different versions of Python. That just doesn't feel right on some level, but it may be something that just is what it is too. |
This is actually just Claude not reading deep enough into the code, I have hit this misunderstanding multiple times.
I agree, but in this case the only "solution" would be to change how the cpex library works to avoid the python version implemented difference, but given apex is in the middle of replacing it's python implementation with rust (which would indirectly solve this issue) I decided to introduce this workaround and remove it when we update cpex to 0.2 in the future (contextforge-org/cpex#20) |
…strator Assisted-by: Claude Code Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
|
I did a sanity check on my above statement with Claude and it went down the same rabbit hole again, so I just added another comment and validated that it prevented Claude from going down that hole again |
|
I also added a comment on the streaming phase 2 epic issue detailing the edge case that Claude keeps getting caught on so I make sure to address it in one of the eventual sub-issues on that epic when I plan it. #1013 (comment) |
|
Honestly, I think some of my unease stems from hooks not being a great fit for how streaming is usually instrumented: typically you use one span for the whole response and close it when the stream ends (it's what the OTel GenAI semconv does and roughly where #444 is already heading). Because plugin hooks are decoupled from the streaming code, we end up managing the span's lifecycle from the outside instead. Which works... but it isn't ideal. I don't want to block this PR on that (it's a non-trivial redesign) but I think we should strongly consider "one span per stream" as the target design for streaming, and maybe capture that on #444 so it doesn't get lost. WDYT? |
|
Talked with @psschwei offline and we've decided that any improvements to the design should land with the streaming validation feature itself and not the telemetry. I've outline our discussion in #1013 (comment) and further design improvements would be bundled with that work |
psschwei
left a comment
There was a problem hiding this comment.
As discussed, deferring design questions to the parent epic.
One small, non-blocking test issue noticed when running the tests locally: There are new "coroutine never awaited" warnings in the streaming test suite. main emits 0; this branch emits 2 (PluginExecutor._with_semaphore / execute_plugin), attributed to test_no_requirements_events_omits_full_validation_event but originating from the new FIRE_AND_FORGET streaming metrics hooks in an earlier test. This is test hygiene, not a production defect — in production cpex schedules these via asyncio.create_task() and they run; the warning only appears because the test loop tears down first without draining. The streaming stdlib tests that fire metrics hooks could drain_background_tasks() / discard_background_tasks() (as test_tracing_backend.py already does) to keep the suite clean.
…ted-coroutine warning
The suite-wide `fandf` acceptance plugin schedules a background task on every
hook fire. Streaming's STREAMING_END hook fires in acomplete() and returns with
no further await, so the loop tears down before the task runs and its coroutine
is GC'd unawaited ("coroutine never awaited"), misattributed to a later test.
Not a metrics or streaming defect — in production cpex's loop outlives the
tasks. Streaming is just the first hook that fires right before loop teardown.
Add an autouse fixture to drain the tasks; the general fix would be for the
fandf set to drain its own.
Assisted-by: Claude Code
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
nrfulton
left a comment
There was a problem hiding this comment.
FYI @jakelorocco maybe also take a look when you get back.
Pull Request
Issue
Fixes #1290
Description
Migrates
stream_with_chunkingtelemetry to plugin hooks as part of the Phase 2 migration: moves the orchestration span and its streaming metrics off directmellea.telemetrycalls and onto plugin hooks. With this change, no async path inmellea/stdlib/calls the oldtrace_application/set_span_*API, so the deprecated helpers can finally be removed.Span and metrics on hooks. New streaming hook types in
mellea/plugins/types.py(with payload dataclasses inmellea/plugins/hooks/streaming.py) carry the data previously stamped inline — chunk index, requirement counts, pass/fail, exceptions — keyed by astreaming_idso pre/post hooks pair cleanly across the_run_async_in_threadtasks. A newStreamingTracingPluginsubscribes to these hooks and emits the orchestration span via internal helpers inmellea/telemetry/tracing.py; the existing metrics plugins gain streaming subscriptions, replacing the inlinerecord_*calls. Metrics record fire-and-forget so they never block the stream. Spans, events, and metric names/attributes matchmain— no observability regression — and the span still nests correctly under the action span when called via a session.Cleanup. With streaming migrated, the four deprecated public tracing helpers (
trace_application,set_span_attribute,set_span_error,set_span_status_error) are removed; the internal setter logic moves to_tracing_setters.py.Bug fix surfaced during migration. The backend
chatspan attaches in the caller task but finishes in the orchestration task that drains the MOT, so its OTel context detach crossed asyncio tasks and failed — leaving the generation span ambient and mis-nesting validationchatspans under generation instead of as its sibling. The fix adds orchestration-task hooks that re-attach thestream_with_chunkingspan as ambient context for the drain/validate loop, and_safe_detachnow skips cross-task detaches within a reattach scope. See6c3de9e5for details.Follow-up bug fix.
functional.pynow catchesBaseException(not justException) in the component error path, soCancelledError/KeyboardInterruptfireCOMPONENT_POST_ERROR(closing the action span) before propagating. Caught while writing this PR; the handler re-raises, so nothing is swallowed.Docs.
docs/docs/observability/tracing.mddocuments thestream_with_chunkingspan (attributes + lifecycle events) and shows the twochatspans as siblings under it.Testing
Attribution
Adding a new component, requirement, sampling strategy, or tool?
If your PR adds or modifies one of the types below, check the matching box. A checklist of type-specific review items will be posted as a comment.
NOTE: Please ensure you have an issue that has been acknowledged by a core contributor and routed you to open a pull request against this repository. Otherwise, please open an issue before continuing with this pull request.