Skip to content

Commit aa17f4b

Browse files
observability: PR #19 round-3 review followups
Seven Copilot/code-quality findings addressed: - **`_close_subgraph_span` empty-except comment** — added inline rationale inside the `except ValueError` block (the explanation was already above the `try`; rule wants it on the empty branch). - **Concurrency limitation documented** — class docstring now spells out that ``OTelObserver`` instances are NOT safe across concurrent invocations (overlapping namespaces collide; close-prior- correlation_id closes other in-flight invocations). Recommended pattern: one observer per ``CompiledGraph`` for sequential workloads, fresh per-invocation observers for parallel services. Phase 6.1 tracks the proper correlation_id-scoped state fix. - **Module docstring drift** — rewrote to reflect the ``(namespace, attempt_index, fan_out_index)`` leaf-span key (not the prior ``(trace_id, ...)`` shape) plus the dedicated dicts for subgraph dispatch, detached roots, and invocation spans. - **Real ``invocation_id`` on the invocation span** — added ``current_invocation_id()`` ContextVar reader to ``correlation.py`` mirroring ``current_correlation_id``; engine sets it in ``invoke()`` before worker creation. ``OTelObserver`` reads it when opening the invocation span and omits the attribute when None (replaces the prior ``"<unset>"`` sentinel). - **`_make_llm_event` phase typing** — tightened ``phase: str`` to ``Literal["started", "completed"]``; dropped the corresponding ``cast``. - **Test-isolation for global TracerProvider** — saved + restored the prior global provider in both ``test_observer_uses_private_provider_not_global`` and the fixture 005 driver (the only sites that call ``set_tracer_provider``). - **`test_correlation_id_and_invocation_id_are_structurally_distinct`** — rewrote to drive a real invocation with a checkpointer and assert ``record.correlation_id != record.invocation_id`` deterministically; also cross-checks via the new ``current_invocation_id()`` reader inside the node body. The prior assertion was on stdlib ``uuid.uuid4()``, not framework behavior. The Phase 6.1 conformance-fillin tracker (in openarmature-coord) is updated to add the concurrency-safe state scoping work alongside the deferred fixtures.
1 parent 448df88 commit aa17f4b

8 files changed

Lines changed: 211 additions & 85 deletions

File tree

src/openarmature/graph/compiled.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@
4545
_reset_active_dispatch,
4646
_reset_active_observers,
4747
_reset_correlation_id,
48+
_reset_invocation_id,
4849
_set_active_dispatch,
4950
_set_active_observers,
5051
_set_correlation_id,
52+
_set_invocation_id,
5153
)
5254

5355
from .edges import END, ConditionalEdge, EndSentinel, StaticEdge
@@ -388,6 +390,7 @@ async def invoke(
388390
# public ``invoke``, so they don't re-set; see §3.1's
389391
# "per-invocation is OUTERMOST invoke" wording).
390392
correlation_token = _set_correlation_id(resolved_correlation_id)
393+
invocation_token = _set_invocation_id(invocation_id)
391394
worker = asyncio.create_task(deliver_loop(queue))
392395
self._active_workers.add(worker)
393396
# Auto-prune: when the worker completes (after the sentinel is
@@ -397,6 +400,7 @@ async def invoke(
397400
try:
398401
return await self._invoke(starting_state, context)
399402
finally:
403+
_reset_invocation_id(invocation_token)
400404
_reset_correlation_id(correlation_token)
401405
# Sentinel terminates the worker after it processes events
402406
# already on the queue (including any error event we just

src/openarmature/llm/providers/openai.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939

4040
import json
4141
from collections.abc import Sequence
42-
from typing import Any, cast
42+
from typing import Any, Literal, cast
4343

4444
import httpx
4545
from pydantic import ValidationError
@@ -525,7 +525,7 @@ def _looks_like_model_not_loaded(message: object) -> bool:
525525

526526

527527
def _make_llm_event(
528-
phase: str,
528+
phase: Literal["started", "completed"],
529529
*,
530530
model: str,
531531
finish_reason: FinishReason | None = None,
@@ -567,7 +567,7 @@ def _make_llm_event(
567567
node_name="openarmature.llm.complete",
568568
namespace=("openarmature.llm.complete",),
569569
step=-1,
570-
phase=cast("Any", phase),
570+
phase=phase,
571571
# ``pre_state`` is overloaded here as the LLM-event payload
572572
# carrier — see the docstring above.
573573
pre_state=cast("Any", {"llm_event": payload}),

src/openarmature/observability/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
current_active_observers,
2525
current_correlation_id,
2626
current_dispatch,
27+
current_invocation_id,
2728
)
2829

2930
__all__ = [
3031
"current_active_observers",
3132
"current_correlation_id",
3233
"current_dispatch",
34+
"current_invocation_id",
3335
]

src/openarmature/observability/correlation.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,46 @@ def _reset_correlation_id(token: Token[str | None]) -> None:
7676
_correlation_id_var.reset(token)
7777

7878

79+
# ---------------------------------------------------------------------------
80+
# Invocation ID — spec observability §5.1
81+
#
82+
# The framework-generated UUIDv4 that ties spans of one invocation
83+
# together within a single backend. Distinct from ``correlation_id``
84+
# (which is the cross-backend join key, caller-supplied or auto-
85+
# generated). Engine sets this ContextVar in ``invoke()`` BEFORE the
86+
# delivery worker is created so the worker's captured context sees
87+
# the right value.
88+
# ---------------------------------------------------------------------------
89+
90+
91+
_invocation_id_var: ContextVar[str | None] = ContextVar("openarmature.invocation_id", default=None)
92+
93+
94+
def current_invocation_id() -> str | None:
95+
"""Return the engine-minted invocation ID for the current
96+
invocation, or ``None`` if no openarmature invocation is in
97+
scope.
98+
99+
Per spec observability §5.1 every invocation produces a unique
100+
UUIDv4 ``invocation_id``, framework-generated, surfaced as the
101+
``openarmature.invocation_id`` attribute on the invocation span
102+
+ on every per-backend record. This is the public reader for
103+
backend mappings (OTel, future Langfuse) that need to populate
104+
that attribute.
105+
"""
106+
return _invocation_id_var.get()
107+
108+
109+
def _set_invocation_id(value: str) -> Token[str | None]:
110+
"""Set the invocation ID for the current invocation. Internal —
111+
engine-only."""
112+
return _invocation_id_var.set(value)
113+
114+
115+
def _reset_invocation_id(token: Token[str | None]) -> None:
116+
_invocation_id_var.reset(token)
117+
118+
79119
# ---------------------------------------------------------------------------
80120
# Active observer set — for capability backends emitting from outside the
81121
# engine's per-step path (llm-provider span hook in Phase 6, future
@@ -170,14 +210,17 @@ def _reset_active_dispatch(
170210
"current_active_observers",
171211
"current_correlation_id",
172212
"current_dispatch",
213+
"current_invocation_id",
173214
# Engine-internal lifecycle helpers — exported so the engine in
174215
# ``openarmature.graph.compiled`` can drive set/reset without
175216
# pyright's strict ``reportUnusedFunction`` flagging them as
176217
# dead. Underscore-prefixed; not part of the user-facing API.
177218
"_reset_active_dispatch",
178219
"_reset_active_observers",
179220
"_reset_correlation_id",
221+
"_reset_invocation_id",
180222
"_set_active_dispatch",
181223
"_set_active_observers",
182224
"_set_correlation_id",
225+
"_set_invocation_id",
183226
]

src/openarmature/observability/otel/observer.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,27 @@
33
The observer subscribes to all three §6 phases (``started``,
44
``completed``, ``checkpoint_saved``) plus the LLM-provider events the
55
``OpenAIProvider`` enqueues from inside node bodies. On a ``started``
6-
event it opens a span and pushes it onto an in-flight map keyed by
7-
``(trace_id, namespace, attempt_index, fan_out_index)``; on the
8-
matching ``completed`` event it pops the span, applies §4.2 status
9-
mapping, and closes it.
6+
event it opens a leaf span and pushes it onto an in-flight map keyed
7+
by ``(namespace, attempt_index, fan_out_index)``; on the matching
8+
``completed`` event it pops the span, applies §4.2 status mapping,
9+
and closes it.
10+
11+
Subtree isolation lives in dedicated dicts rather than the leaf-span
12+
key:
13+
14+
- ``_subgraph_spans`` — synthetic subgraph dispatch spans (the
15+
engine wrapper is transparent per fixture 013, but observability
16+
§4.5 mandates a span). Keyed by namespace prefix. Open lazily on
17+
the first deeper-namespace event, close when subsequent events
18+
leave the prefix.
19+
- ``_detached_roots`` — root spans for detached subgraphs (§4.4)
20+
and per-instance detached fan-out roots. Each lives in its own
21+
fresh ``trace_id``; the parent's dispatch span carries an OTel
22+
:class:`Link` to the detached trace.
23+
- ``_invocation_span`` — root invocation span keyed by
24+
``correlation_id``. Closed eagerly when a new correlation_id's
25+
first event arrives (or explicitly via
26+
:meth:`close_invocation` / :meth:`shutdown`).
1027
1128
Spans are emitted through a **private** :class:`TracerProvider`
1229
constructed by this observer — never the OTel global. Per spec §6
@@ -18,9 +35,11 @@
1835
Detached trace mode (§4.4) is implemented by minting a fresh
1936
:class:`SpanContext` with a new ``trace_id`` when entering a
2037
configured-detached subgraph or fan-out; the parent's dispatch span
21-
carries an OTel :class:`Link` to the detached trace. The span-stack
22-
key includes ``trace_id`` so detached sub-trees and the parent trace
23-
maintain separate stacks naturally.
38+
carries an OTel :class:`Link` to the detached trace. Inner-event
39+
parent resolution checks the per-fan-out-instance key
40+
(``namespace[:1] + (str(fan_out_index),)``) before the generic
41+
prefix scan, so per-instance detached roots win without depending
42+
on attach-then-resolve ordering.
2443
"""
2544

2645
from __future__ import annotations
@@ -111,6 +130,21 @@ class OTelObserver:
111130
canonical source of LLM spans.
112131
- ``spec_version`` — string surfaced as
113132
``openarmature.graph.spec_version`` on the invocation span.
133+
134+
**Concurrency model.** A single ``OTelObserver`` instance is
135+
SAFE for sequential invocations on the same graph (one
136+
``invoke()`` followed by another, with the observer reused
137+
between). It is NOT safe to share an instance across CONCURRENT
138+
invocations — the internal span state (``_open_spans``,
139+
``_subgraph_spans``, ``_detached_roots``, ``_invocation_span``)
140+
is keyed without per-invocation scoping, so overlapping
141+
namespaces collide and the close-prior-correlation_id logic in
142+
``_handle_started`` would close another in-flight invocation's
143+
span. Recommended pattern: one observer per ``CompiledGraph``
144+
instance for sequential workloads; for ASGI / batch / parallel
145+
invocation services, attach a fresh observer per invocation
146+
(via ``invoke(observers=[...])``) until the Phase 6.1
147+
correlation_id-scoped state lands.
114148
"""
115149

116150
span_processor: SpanProcessor
@@ -363,18 +397,22 @@ def _handle_llm_event(self, event: NodeEvent) -> None:
363397

364398
def _open_invocation_span(self, correlation_id: str, event: NodeEvent) -> None:
365399
"""Open the root invocation span for a new invocation."""
400+
from openarmature.observability.correlation import current_invocation_id
401+
366402
# The first event we receive carries the entry node's
367403
# name; treat it as the invocation's entry_node attribute.
404+
# ``invocation_id`` is set by the engine in ``invoke()`` via
405+
# the ``current_invocation_id`` ContextVar; if the observer
406+
# somehow runs outside an engine invocation (None) we omit
407+
# the attribute rather than emitting a misleading sentinel.
368408
attrs: dict[str, Any] = {
369-
"openarmature.invocation_id": "<unset>", # set below from context
370409
"openarmature.graph.entry_node": event.node_name,
371410
"openarmature.graph.spec_version": self.spec_version,
372411
"openarmature.correlation_id": correlation_id,
373412
}
374-
# We don't have the engine's invocation_id directly without
375-
# threading it through. Phase 6 follow-up could surface it
376-
# via the correlation module; for now leave it blank for
377-
# the conformance fixtures that don't strictly need it.
413+
invocation_id = current_invocation_id()
414+
if invocation_id is not None:
415+
attrs["openarmature.invocation_id"] = invocation_id
378416
span = self._tracer.start_span(
379417
name="openarmature.invocation",
380418
kind=SpanKind.INTERNAL,
@@ -535,6 +573,10 @@ def _close_subgraph_span(self, prefix: tuple[str, ...]) -> None:
535573
try:
536574
detach(cast("Any", open_span.token))
537575
except ValueError:
576+
# Token was created in a different OTel context —
577+
# cross-context detach raises here. The span has
578+
# ended; the leaked context entry is cosmetic and
579+
# unwinds when the worker task exits.
538580
pass
539581

540582
def _open_detached_subgraph_root(self, prefix: tuple[str, ...]) -> None:

tests/conformance/test_observability.py

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -335,54 +335,56 @@ async def _run_fixture_005_case(case: Mapping[str, Any]) -> None:
335335
)
336336

337337
# Optional second exporter on the OTel global provider — sub-case 3.
338+
# Save the prior global provider so we can restore it after the
339+
# case (otherwise the global state leaks to subsequent tests).
338340
global_exporter: InMemorySpanExporter | None = None
341+
prior_global = otel_trace.get_tracer_provider() if caller_global_active else None
339342
if caller_global_active:
340343
global_exporter = InMemorySpanExporter()
341344
global_provider = TracerProvider()
342345
global_provider.add_span_processor(SimpleSpanProcessor(global_exporter))
343346
otel_trace.set_tracer_provider(global_provider)
344347

345-
private_exporter = InMemorySpanExporter()
346-
observer = OTelObserver(
347-
span_processor=SimpleSpanProcessor(private_exporter),
348-
disable_llm_spans=disable_llm_spans,
349-
)
350-
351-
# Build a graph whose entry node calls a mock LLM provider.
352-
graph, _ = _build_graph_with_mock_llm(case)
353-
graph.attach_observer(observer)
348+
try:
349+
private_exporter = InMemorySpanExporter()
350+
observer = OTelObserver(
351+
span_processor=SimpleSpanProcessor(private_exporter),
352+
disable_llm_spans=disable_llm_spans,
353+
)
354354

355-
# Drive the graph. The ``calls_llm`` node body reads the mock
356-
# responses set up in ``_build_graph_with_mock_llm`` (httpx
357-
# MockTransport keyed off the response queue).
358-
initial_state_cls = graph.state_cls
359-
final = await graph.invoke(initial_state_cls())
360-
await graph.drain()
361-
observer.shutdown()
362-
private_spans = private_exporter.get_finished_spans()
355+
# Build a graph whose entry node calls a mock LLM provider.
356+
graph, _ = _build_graph_with_mock_llm(case)
357+
graph.attach_observer(observer)
363358

364-
# Sub-case 3: external span emitted by the harness through the
365-
# global tracer (simulating auto-instrumentation).
366-
if caller_global_active:
367-
global_tracer = otel_trace.get_tracer("external-instrumentation")
368-
with global_tracer.start_as_current_span("external.llm.call"):
369-
pass
370-
assert global_exporter is not None
371-
global_spans = global_exporter.get_finished_spans()
372-
assert len(global_spans) == 1, (
373-
f"global exporter MUST see exactly one external span; got {len(global_spans)}"
374-
)
375-
assert global_spans[0].name == "external.llm.call"
376-
# The load-bearing isolation check.
377-
for s in global_spans:
378-
assert not s.name.startswith("openarmature."), (
379-
f"openarmature spans MUST NOT leak to the global provider; got {s.name!r}"
359+
# Drive the graph. The ``calls_llm`` node body reads the mock
360+
# responses set up in ``_build_graph_with_mock_llm`` (httpx
361+
# MockTransport keyed off the response queue).
362+
initial_state_cls = graph.state_cls
363+
final = await graph.invoke(initial_state_cls())
364+
await graph.drain()
365+
observer.shutdown()
366+
private_spans = private_exporter.get_finished_spans()
367+
368+
# Sub-case 3: external span emitted by the harness through the
369+
# global tracer (simulating auto-instrumentation).
370+
if caller_global_active:
371+
global_tracer = otel_trace.get_tracer("external-instrumentation")
372+
with global_tracer.start_as_current_span("external.llm.call"):
373+
pass
374+
assert global_exporter is not None
375+
global_spans = global_exporter.get_finished_spans()
376+
assert len(global_spans) == 1, (
377+
f"global exporter MUST see exactly one external span; got {len(global_spans)}"
380378
)
381-
# Reset the global provider so the next test in the file
382-
# doesn't see a stale state. The OTel SDK doesn't expose a
383-
# public reset; ``set_tracer_provider`` with a fresh empty
384-
# provider is the closest equivalent.
385-
otel_trace.set_tracer_provider(TracerProvider())
379+
assert global_spans[0].name == "external.llm.call"
380+
# The load-bearing isolation check.
381+
for s in global_spans:
382+
assert not s.name.startswith("openarmature."), (
383+
f"openarmature spans MUST NOT leak to the global provider; got {s.name!r}"
384+
)
385+
finally:
386+
if caller_global_active and prior_global is not None:
387+
otel_trace.set_tracer_provider(prior_global)
386388

387389
# Common assertions: the LLM span presence/absence + (when
388390
# present) attributes + parent-child to the calling node.

0 commit comments

Comments
 (0)