@@ -162,6 +162,15 @@ class OTelObserver:
162162 _detached_roots : dict [tuple [str , ...], _OpenSpan ] = field (
163163 init = False , repr = False , default_factory = dict [tuple [str , ...], _OpenSpan ]
164164 )
165+ # Subset of ``_detached_roots`` keys that represent per-instance
166+ # fan-out roots — they're closed by ``_handle_completed`` on the
167+ # fan-out node's own completion, NOT by ``_sync_subgraph_spans``.
168+ # Using an explicit set rather than parsing the key (e.g.,
169+ # checking ``prefix[-1].isdigit()``) so node names that happen
170+ # to be pure digits don't get misclassified.
171+ _fan_out_instance_root_prefixes : set [tuple [str , ...]] = field (
172+ init = False , repr = False , default_factory = set [tuple [str , ...]]
173+ )
165174
166175 def __post_init__ (self ) -> None :
167176 # Private provider per spec §6 TracerProvider isolation —
@@ -199,12 +208,20 @@ def _handle_started(self, event: NodeEvent) -> None:
199208
200209 # Lazily open the invocation span on the first event we see
201210 # for this invocation. Detect "first event" by matching the
202- # correlation_id; the invocation span lives until we see the
203- # outermost completed event (or until the engine teardown
204- # closes it via aclose).
211+ # correlation_id; the invocation span lives until either
212+ # ``shutdown()`` runs OR a new correlation_id arrives (i.e.,
213+ # a new invocation starts on the same long-lived observer).
214+ # The latter close path matters for shared observers reused
215+ # across many invocations — without it the
216+ # ``_invocation_span`` dict grows unbounded.
205217 correlation_id = current_correlation_id ()
206- if correlation_id is not None and correlation_id not in self ._invocation_span :
207- self ._open_invocation_span (correlation_id , event )
218+ if correlation_id is not None :
219+ # New correlation_id → close prior invocation spans.
220+ for prior_cid in list (self ._invocation_span .keys ()):
221+ if prior_cid != correlation_id :
222+ self ._close_invocation_span (prior_cid )
223+ if correlation_id not in self ._invocation_span :
224+ self ._open_invocation_span (correlation_id , event )
208225
209226 # Synthesize subgraph dispatch spans for any ancestor namespace
210227 # prefix that doesn't have one yet (per observability §4.5).
@@ -378,9 +395,21 @@ def _resolve_parent_context(self, event: NodeEvent) -> object:
378395 """Return the OTel context to use as the parent for this
379396 event's span. Walks namespace ancestors finding the
380397 innermost-open subgraph or detached root span."""
381- # 1. Detached root at any matching prefix wins (highest
382- # precedence — events inside a detached subtree always
383- # parent under the detached root, never bleed up).
398+ # 1a. Detached fan-out instance root — keyed by
399+ # ``namespace[:1] + (str(fan_out_index),)`` per
400+ # ``_open_detached_fan_out_instance_root``. Checked
401+ # explicitly before the generic prefix scan so the
402+ # parent attribution doesn't depend on the
403+ # attach-then-resolve ordering of the surrounding
404+ # ``_sync_subgraph_spans`` call.
405+ if event .fan_out_index is not None and event .namespace :
406+ instance_key = event .namespace [:1 ] + (str (event .fan_out_index ),)
407+ if instance_key in self ._detached_roots :
408+ root = self ._detached_roots [instance_key ]
409+ return set_span_in_context (root .span )
410+ # 1b. Detached subgraph root at any matching prefix wins
411+ # (highest precedence — events inside a detached subtree
412+ # always parent under the detached root, never bleed up).
384413 for prefix_len in range (len (event .namespace ) - 1 , - 1 , - 1 ):
385414 prefix = event .namespace [:prefix_len ]
386415 if prefix in self ._detached_roots :
@@ -430,7 +459,9 @@ def _sync_subgraph_spans(self, event: NodeEvent) -> None:
430459 # Detached fan-out instance roots: keyed by namespace +
431460 # (str(fan_out_index),); leave those alone here, they're
432461 # closed when the fan-out parent dispatch completes.
433- if len (prefix ) > 1 and prefix [- 1 ].isdigit ():
462+ if prefix in self ._fan_out_instance_root_prefixes :
463+ # Closed by ``_handle_completed`` on the fan-out
464+ # node's own completion, not here.
434465 continue
435466 if not (len (prefix ) < len (namespace ) and namespace [: len (prefix )] == prefix ):
436467 self ._close_detached_root (prefix )
@@ -494,6 +525,17 @@ def _close_subgraph_span(self, prefix: tuple[str, ...]) -> None:
494525 return
495526 open_span .span .set_status (Status (StatusCode .OK ))
496527 open_span .span .end ()
528+ # Mirror ``_close_detached_root``: the attach token MUST be
529+ # detached or the OTel current-span context stays pinned to
530+ # a closed span and corrupts parent/child for subsequent
531+ # spans. The cross-context guard handles the case where the
532+ # token was created in a different OTel context (e.g.,
533+ # subgraph open/close straddles a detached descent).
534+ if open_span .token is not None :
535+ try :
536+ detach (cast ("Any" , open_span .token ))
537+ except ValueError :
538+ pass
497539
498540 def _open_detached_subgraph_root (self , prefix : tuple [str , ...]) -> None :
499541 """Mint a fresh trace for a detached subgraph entry. The
@@ -602,11 +644,16 @@ def _open_detached_fan_out_instance_root(self, prefix: tuple[str, ...], event: N
602644 )
603645 token = attach (set_span_in_context (instance_root ))
604646 # Key by prefix + (str(fan_out_index),) so per-instance
605- # roots stay distinct.
647+ # roots stay distinct. Track separately in
648+ # ``_fan_out_instance_root_prefixes`` so
649+ # ``_sync_subgraph_spans`` can identify them by membership
650+ # (rather than by parsing the key shape).
606651 instance_key = prefix + (str (event .fan_out_index ),)
607652 self ._detached_roots [instance_key ] = _OpenSpan (span = instance_root , token = token )
653+ self ._fan_out_instance_root_prefixes .add (instance_key )
608654
609655 def _close_detached_root (self , prefix : tuple [str , ...]) -> None :
656+ self ._fan_out_instance_root_prefixes .discard (prefix )
610657 open_span = self ._detached_roots .pop (prefix , None )
611658 if open_span is None :
612659 return
@@ -649,25 +696,46 @@ def _node_attrs(self, event: NodeEvent) -> dict[str, Any]:
649696 # Lifecycle
650697 # ------------------------------------------------------------------
651698
699+ def close_invocation (self , correlation_id : str ) -> None :
700+ """Public lifecycle hook: close the invocation span for
701+ ``correlation_id``. Idempotent — calling twice (or for a
702+ correlation_id with no open span) is a no-op.
703+
704+ Long-lived observers shared across many invocations
705+ automatically close prior invocation spans on the first
706+ event of a new invocation (see ``_handle_started``). This
707+ method is for callers who want explicit control without
708+ driving a follow-on invocation, e.g.::
709+
710+ await graph.invoke(state, correlation_id=cid)
711+ await graph.drain()
712+ otel_observer.close_invocation(cid)
713+ """
714+ self ._close_invocation_span (correlation_id )
715+
716+ def _close_invocation_span (self , correlation_id : str ) -> None :
717+ """End and remove the invocation span for ``correlation_id``.
718+
719+ The OTel context token captured when the span opened was
720+ created in the worker task's context — we don't try to
721+ ``detach()`` it (cross-context detach raises ValueError;
722+ the leaked context entry is cosmetic since the worker
723+ eventually exits)."""
724+ open_span = self ._invocation_span .pop (correlation_id , None )
725+ if open_span is None :
726+ return
727+ # Status defaults to OK for completed invocations; if the
728+ # engine surfaced an error, the failing node's span
729+ # already carries it and OTel propagates ERROR up the
730+ # parent chain on its own.
731+ open_span .span .set_status (Status (StatusCode .OK ))
732+ open_span .span .end ()
733+
652734 def shutdown (self ) -> None :
653735 """Close any still-open invocation/detached spans and shut
654- down the underlying provider.
655-
656- The OTel context tokens captured when the invocation spans
657- opened were created in the worker task's context — they
658- cannot be ``detach()``ed from a different async context (OTel
659- raises ``ValueError`` on cross-context detach). We end the
660- spans without detaching; the worker task is exiting so the
661- leaked context entries are cosmetic.
662- """
663- for open_span in list (self ._invocation_span .values ()):
664- # Status defaults to OK for completed invocations; if
665- # the engine surfaced an error, the failing node's span
666- # already carries it and OTel propagates ERROR up the
667- # parent chain on its own.
668- open_span .span .set_status (Status (StatusCode .OK ))
669- open_span .span .end ()
670- self ._invocation_span .clear ()
736+ down the underlying provider."""
737+ for cid in list (self ._invocation_span .keys ()):
738+ self ._close_invocation_span (cid )
671739 self ._provider .shutdown ()
672740
673741
0 commit comments