2424
2525from __future__ import annotations
2626
27+ import asyncio
2728import os
2829import warnings
2930from importlib .metadata import version
@@ -221,7 +222,75 @@ def get_backend_tracer() -> Any:
221222 return _backend_tracer
222223
223224
224- _in_flight_spans : dict [str , tuple [Span , Token [Context ]]] = {}
225+ _in_flight_spans : dict [str , tuple [Span , Token [Context ], asyncio .Task [Any ] | None ]] = {}
226+
227+ # reattach_span() entries, keyed by correlation key: the OTel context token plus
228+ # the task that attached it. Released by release_reattached_span() on that task.
229+ _reattached_tokens : dict [str , tuple [Token [Context ], asyncio .Task [Any ] | None ]] = {}
230+
231+
232+ def _current_task () -> asyncio .Task [Any ] | None :
233+ """Return the running asyncio task, or None when no loop is running."""
234+ try :
235+ return asyncio .current_task ()
236+ except RuntimeError :
237+ return None
238+
239+
240+ def _safe_detach (token : Token [Context ], attach_task : asyncio .Task [Any ] | None ) -> None :
241+ """Detach `token`, suppressing only the cross-task detach we expect and understand.
242+
243+ OTel context tokens are bound to the `contextvars.Context` of the task that
244+ created them, so detaching from a different task fails — OTel catches the
245+ `ValueError` and logs it at ERROR as "Failed to detach context". Most spans
246+ attach and finish on one task and never hit this.
247+
248+ A cross-task detach is suppressed (skipped, since it would only fail) and
249+ logged at debug only when the detaching task holds an open `reattach_span`
250+ scope — the marker that this task knowingly opened a span elsewhere and
251+ expects the mismatch. Any other cross-task detach is left to run so OTel
252+ surfaces its ERROR with a traceback to the real origin; a warning is added
253+ first to name the task mismatch OTel's message omits.
254+
255+ Example:
256+ Under `stream_with_chunking` the backend `chat` span attaches in the
257+ caller task but finishes in the orchestration task that drains the MOT.
258+ To keep that span's `chat` children nesting correctly, the orchestration
259+ task re-attaches the streaming span for the duration of the drain
260+ (`reattach_span` / `release_reattached_span`). The cross-task `chat`
261+ detach that then happens within that scope is the expected, suppressed
262+ case.
263+
264+ Note:
265+ The reattach scope is an *incomplete* proxy for "expected". It holds for
266+ streaming because that case both needs sibling-nesting protection (so it
267+ reattaches) and has an expected cross-task detach. A future case with the
268+ same open-in-parent / close-in-child shape but no siblings to protect
269+ would not reattach, so its equally-expected cross-task detach falls
270+ through to the warn-and-detach path. That is harmless (the detach only
271+ fails, and the task ends right after, so nothing leaks); the warning is
272+ the signal that the new use needs its own way to mark the detach expected.
273+
274+ Args:
275+ token: The OTel context token returned by the matching `attach`.
276+ attach_task: The task that performed the `attach`, or None if it was
277+ attached outside any running task.
278+ """
279+ current = _current_task ()
280+ if attach_task is not None and current is not attach_task :
281+ from mellea .core .utils import MelleaLogger
282+
283+ if any (task is current for _ , task in _reattached_tokens .values ()):
284+ MelleaLogger .get_logger ().debug (
285+ "Skipped expected cross-task OTel context detach within a "
286+ "reattached-span scope."
287+ )
288+ return
289+ MelleaLogger .get_logger ().warning (
290+ "Detaching an OTel context token across asyncio tasks; the span's "
291+ "attach and detach ran on different tasks. OTel will log the failure."
292+ )
293+ otel_context .detach (token )
225294
226295
227296def start_backend_span (
@@ -286,7 +355,7 @@ def start_backend_span(
286355 set_conversation_id (span )
287356
288357 token = otel_context .attach (trace .set_span_in_context (span ))
289- _in_flight_spans [generation_id ] = (span , token )
358+ _in_flight_spans [generation_id ] = (span , token , _current_task () )
290359 return span
291360
292361
@@ -321,7 +390,7 @@ def finish_backend_span_success(
321390 entry = _in_flight_spans .pop (generation_id , None )
322391 if entry is None :
323392 return
324- span , token = entry
393+ span , token , attach_task = entry
325394 try :
326395 if gen is not None :
327396 set_request_attrs (span , gen , operation )
@@ -330,7 +399,7 @@ def finish_backend_span_success(
330399 if mot is not None and gen is not None :
331400 set_mellea_attrs (span , mot , gen )
332401 finally :
333- otel_context . detach (token )
402+ _safe_detach (token , attach_task )
334403 span .end ()
335404
336405
@@ -355,15 +424,15 @@ def finish_backend_span_error(
355424 entry = _in_flight_spans .pop (generation_id , None )
356425 if entry is None :
357426 return
358- span , token = entry
427+ span , token , attach_task = entry
359428 try :
360429 if gen is not None :
361430 set_request_attrs (span , gen , operation )
362431 span .record_exception (exception )
363432 span .set_status (trace .Status (trace .StatusCode .ERROR , str (exception )))
364433 span .set_attribute ("error.type" , type (exception ).__name__ )
365434 finally :
366- otel_context . detach (token )
435+ _safe_detach (token , attach_task )
367436 span .end ()
368437
369438
@@ -392,7 +461,7 @@ def _start_application_span(
392461 set_attribute_safe (span , k , v )
393462
394463 token = otel_context .attach (trace .set_span_in_context (span ))
395- _in_flight_spans [key ] = (span , token )
464+ _in_flight_spans [key ] = (span , token , _current_task () )
396465 return span
397466
398467
@@ -414,13 +483,13 @@ def _finish_application_span_success(
414483 entry = _in_flight_spans .pop (key , None )
415484 if entry is None :
416485 return
417- span , token = entry
486+ span , token , attach_task = entry
418487 try :
419488 if extra_attributes :
420489 for k , v in extra_attributes .items ():
421490 set_attribute_safe (span , k , v )
422491 finally :
423- otel_context . detach (token )
492+ _safe_detach (token , attach_task )
424493 span .end ()
425494
426495
@@ -448,7 +517,7 @@ def _finish_application_span_error(
448517 entry = _in_flight_spans .pop (key , None )
449518 if entry is None :
450519 return
451- span , token = entry
520+ span , token , attach_task = entry
452521 try :
453522 if extra_attributes :
454523 for k , v in extra_attributes .items ():
@@ -460,7 +529,7 @@ def _finish_application_span_error(
460529 else :
461530 span .set_status (trace .Status (trace .StatusCode .ERROR , description or "" ))
462531 finally :
463- otel_context . detach (token )
532+ _safe_detach (token , attach_task )
464533 span .end ()
465534
466535
@@ -697,11 +766,45 @@ def add_streaming_event(
697766 entry = _in_flight_spans .get (streaming_id )
698767 if entry is None :
699768 return
700- span , _ = entry
769+ span = entry [ 0 ]
701770 filtered = {k : v for k , v in attributes .items () if v is not None }
702771 span .add_event (event_name , filtered )
703772
704773
774+ def reattach_span (key : str ) -> None :
775+ """Make the in-flight span `key` the current task's ambient context.
776+
777+ Spans opened by later work on this task then parent under it. Paired with
778+ `release_reattached_span()`, which must run on the same task. No-op when the
779+ span is not in flight. See `_safe_detach` for how this scope is used to
780+ classify the cross-task detach it enables.
781+
782+ Args:
783+ key: Correlation key of an in-flight span (the key it was stashed under).
784+ """
785+ entry = _in_flight_spans .get (key )
786+ if entry is None :
787+ return
788+ span = entry [0 ]
789+ token = otel_context .attach (trace .set_span_in_context (span ))
790+ _reattached_tokens [key ] = (token , _current_task ())
791+
792+
793+ def release_reattached_span (key : str ) -> None :
794+ """Release a reattached span from a matching `reattach_span()` call.
795+
796+ Must run on the same task that called `reattach_span()`. No-op when no token
797+ is stored.
798+
799+ Args:
800+ key: Correlation key from the matching `reattach_span()` call.
801+ """
802+ entry = _reattached_tokens .pop (key , None )
803+ if entry is not None :
804+ token , _ = entry
805+ otel_context .detach (token )
806+
807+
705808def finish_streaming_span (
706809 streaming_id : str ,
707810 * ,
0 commit comments