Skip to content

Commit 448df88

Browse files
observability: PR #19 round-2 review followups
Five Copilot findings addressed: - **`_close_subgraph_span` token leak** — was ending the synthetic subgraph span without detaching the OTel context token, leaving the current-span context pinned to a closed span. Added the detach with the same cross-context guard as ``_close_detached_root``. - **Detached fan-out instance parent lookup** — added an explicit ``namespace[:1] + (str(fan_out_index),)`` check at the top of ``_resolve_parent_context`` so inner-instance spans don't depend on the attach-then-resolve ordering of the surrounding ``_sync_subgraph_spans`` call. - **`isdigit()` heuristic** — replaced the string-shape guess for identifying fan-out instance roots with an explicit ``_fan_out_instance_root_prefixes: set[tuple[str, ...]]`` so a pure-digit node name (e.g. ``"123"``) doesn't get misclassified. - **Invocation-span memory leak in long-lived observers** — closing prior-correlation_id invocation spans when a new correlation_id's first event arrives. Plus a public ``OTelObserver.close_invocation(correlation_id)`` method for callers who want explicit control without driving a follow-on invocation. The very-last invocation still closes on ``shutdown()``. - **`current_dispatch` re-export** — added to ``openarmature.observability.__all__`` to match the public-API intent in ``correlation.py``'s ``__all__`` and the PR description. Plus one harness fix: - **test_observability.py collection-time skip** — added ``pytest.importorskip("opentelemetry.trace")`` before the OTel imports so the file skips cleanly when the ``otel`` extras aren't installed (matches ``tests/unit/test_observability_otel.py``). The two re-flagged ``frozenset`` lambdas are kept with the inline comment explaining why pyright strict mode needs them — same position as the prior PR review pass.
1 parent e4c6ddb commit 448df88

3 files changed

Lines changed: 110 additions & 30 deletions

File tree

src/openarmature/observability/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@
2020
established up front.
2121
"""
2222

23-
from .correlation import current_active_observers, current_correlation_id
23+
from .correlation import (
24+
current_active_observers,
25+
current_correlation_id,
26+
current_dispatch,
27+
)
2428

2529
__all__ = [
2630
"current_active_observers",
2731
"current_correlation_id",
32+
"current_dispatch",
2833
]

src/openarmature/observability/otel/observer.py

Lines changed: 95 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

tests/conformance/test_observability.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,16 @@
3333
import pytest
3434
import yaml
3535

36-
from openarmature.observability.otel import OTelObserver
36+
# Skip the entire module if the ``otel`` extras aren't installed —
37+
# importing ``openarmature.observability.otel`` raises ImportError at
38+
# import time when the extras are missing, which would fail
39+
# collection rather than skipping cleanly. Mirrors the pattern in
40+
# ``tests/unit/test_observability_otel.py``.
41+
pytest.importorskip("opentelemetry.trace")
3742

38-
from .adapter import build_graph
43+
from openarmature.observability.otel import OTelObserver # noqa: E402
44+
45+
from .adapter import build_graph # noqa: E402
3946

4047
CONFORMANCE_DIR = (
4148
Path(__file__).resolve().parents[2] / "openarmature-spec" / "spec" / "observability" / "conformance"

0 commit comments

Comments
 (0)