|
3 | 3 | The observer subscribes to all three §6 phases (``started``, |
4 | 4 | ``completed``, ``checkpoint_saved``) plus the LLM-provider events the |
5 | 5 | ``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`). |
10 | 27 |
|
11 | 28 | Spans are emitted through a **private** :class:`TracerProvider` |
12 | 29 | constructed by this observer — never the OTel global. Per spec §6 |
|
18 | 35 | Detached trace mode (§4.4) is implemented by minting a fresh |
19 | 36 | :class:`SpanContext` with a new ``trace_id`` when entering a |
20 | 37 | 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. |
24 | 43 | """ |
25 | 44 |
|
26 | 45 | from __future__ import annotations |
@@ -111,6 +130,21 @@ class OTelObserver: |
111 | 130 | canonical source of LLM spans. |
112 | 131 | - ``spec_version`` — string surfaced as |
113 | 132 | ``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. |
114 | 148 | """ |
115 | 149 |
|
116 | 150 | span_processor: SpanProcessor |
@@ -363,18 +397,22 @@ def _handle_llm_event(self, event: NodeEvent) -> None: |
363 | 397 |
|
364 | 398 | def _open_invocation_span(self, correlation_id: str, event: NodeEvent) -> None: |
365 | 399 | """Open the root invocation span for a new invocation.""" |
| 400 | + from openarmature.observability.correlation import current_invocation_id |
| 401 | + |
366 | 402 | # The first event we receive carries the entry node's |
367 | 403 | # 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. |
368 | 408 | attrs: dict[str, Any] = { |
369 | | - "openarmature.invocation_id": "<unset>", # set below from context |
370 | 409 | "openarmature.graph.entry_node": event.node_name, |
371 | 410 | "openarmature.graph.spec_version": self.spec_version, |
372 | 411 | "openarmature.correlation_id": correlation_id, |
373 | 412 | } |
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 |
378 | 416 | span = self._tracer.start_span( |
379 | 417 | name="openarmature.invocation", |
380 | 418 | kind=SpanKind.INTERNAL, |
@@ -535,6 +573,10 @@ def _close_subgraph_span(self, prefix: tuple[str, ...]) -> None: |
535 | 573 | try: |
536 | 574 | detach(cast("Any", open_span.token)) |
537 | 575 | 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. |
538 | 580 | pass |
539 | 581 |
|
540 | 582 | def _open_detached_subgraph_root(self, prefix: tuple[str, ...]) -> None: |
|
0 commit comments