Skip to content

Commit b1e1325

Browse files
observability: spec v0.7 OTel mapping (proposal 0007) — phase 6.0
Land the observability capability per spec proposal 0007. Adds `openarmature.observability` with the §3 correlation_id ContextVar + queue-mediated dispatch primitives (core, no OTel deps), and an extras-gated `openarmature.observability.otel` subpackage with `OTelObserver`, detached trace mode, LLM provider span hook, and the log bridge. Engine integration: `correlation_id` ContextVar set/reset around the outermost `invoke()`; `_active_observers` and `_active_dispatch` ContextVars set/reset around every chain invocation in `_step_function_node` / `_step_subgraph_node` / `_step_fan_out_node`. The LLM provider span hook in `OpenAIProvider.complete` calls `current_dispatch()` to enqueue NodeEvent-shaped records on the same delivery worker the engine uses, preserving spec §6 serial ordering. OTelObserver implementation: - Private TracerProvider per §6 — never registers globally. - Subscribes to `started` / `completed` / `checkpoint_saved` phases. - §4.5 subgraph dispatch span synthesis (the engine wrapper is transparent per fixture 013, but observability mandates a span). - §4.4 detached trace mode for subgraphs (one Link, two traces) and fan-outs (one trace per instance, one Link per). - §5 attribute population including the cross-cutting `openarmature.correlation_id` on every span. - §5.5 LLM provider span with `disable_llm_spans` opt-out. - §10.8 checkpoint_saved → zero-duration `openarmature.checkpoint.save` span. - `spec_version` reads from `openarmature.__spec_version__` so the three-place version sync covers the OTel surface automatically. Charter alignment: OTel deps are optional via `pip install openarmature[otel]`; correlation primitives stay in core. Plan to lift `observability.otel` into a sibling `openarmature-otel` package at the v1.0 launch alongside `openarmature-eval` per charter §3.2. `opentelemetry-sdk>=1.27,<2` upper-bound guards against the SDK 2.x LoggingHandler removal until the migration to opentelemetry-instrumentation-logging lands. Test coverage: 4 of 11 conformance fixtures driven end-to-end — 001 (basic trace), 005 (LLM provider span + isolation under external auto-instrumentation), 008 (detached subgraph + detached fan-out), 009 (correlation_id cross-cutting). The Phase 5 deferred fixture 031 span/log assertions activate. The remaining 7 fixtures defer to Phase 6.1 with per-fixture wiring notes (tracked in openarmature-coord). 15 unit tests cover the engine path independently of conformance harness wiring.
1 parent 689c336 commit b1e1325

12 files changed

Lines changed: 2591 additions & 22 deletions

File tree

pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ dependencies = [
1616
"httpx>=0.27",
1717
]
1818

19+
[project.optional-dependencies]
20+
# Spec observability §6 OTel mapping. Optional per charter §3.1
21+
# principle 5 — backend mappings are pluggable rather than core
22+
# dependencies. Plan to lift into a sibling ``openarmature-otel``
23+
# package at v1.0 launch alongside ``openarmature-eval``.
24+
otel = [
25+
# Upper bound guards against the SDK 2.x release that removes
26+
# ``opentelemetry.sdk._logs.LoggingHandler`` (currently emits a
27+
# DeprecationWarning). Migration to
28+
# ``opentelemetry-instrumentation-logging`` lands in Phase 6.1
29+
# before bumping the upper bound.
30+
"opentelemetry-api>=1.27,<2",
31+
"opentelemetry-sdk>=1.27,<2",
32+
]
33+
1934
[project.urls]
2035
Repository = "https://github.com/LunarCommand/openarmature-python"
2136
Specification = "https://github.com/LunarCommand/openarmature-spec"

src/openarmature/graph/compiled.py

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@
4141
CheckpointRecord,
4242
NodePosition,
4343
)
44+
from openarmature.observability.correlation import (
45+
_reset_active_dispatch,
46+
_reset_active_observers,
47+
_reset_correlation_id,
48+
_set_active_dispatch,
49+
_set_active_observers,
50+
_set_correlation_id,
51+
)
4452

4553
from .edges import END, ConditionalEdge, EndSentinel, StaticEdge
4654
from .errors import (
@@ -369,6 +377,17 @@ async def invoke(
369377
pending_resume_states=pending_resume_states,
370378
resume_invocation=resume_invocation,
371379
)
380+
# Spec observability §3.1: the correlation_id MUST be readable
381+
# from anywhere within the invocation's async call tree via the
382+
# language's idiomatic context primitive. Set the ContextVar
383+
# BEFORE creating the delivery worker so the worker's captured
384+
# context sees the correlation_id (asyncio.create_task snapshots
385+
# the current Context at creation time). Reset on return so
386+
# subsequent invocations get a fresh slate. Nested ``invoke()``
387+
# calls (subgraph-as-node uses ``_invoke`` directly, not the
388+
# public ``invoke``, so they don't re-set; see §3.1's
389+
# "per-invocation is OUTERMOST invoke" wording).
390+
correlation_token = _set_correlation_id(resolved_correlation_id)
372391
worker = asyncio.create_task(deliver_loop(queue))
373392
self._active_workers.add(worker)
374393
# Auto-prune: when the worker completes (after the sentinel is
@@ -378,6 +397,7 @@ async def invoke(
378397
try:
379398
return await self._invoke(starting_state, context)
380399
finally:
400+
_reset_correlation_id(correlation_token)
381401
# Sentinel terminates the worker after it processes events
382402
# already on the queue (including any error event we just
383403
# dispatched on the failure path). Drain semantics live on
@@ -585,14 +605,27 @@ async def innermost(s: Any) -> Mapping[str, Any]:
585605
innermost,
586606
)
587607

608+
# Spec observability §3 / Phase 6 LLM-span hook: capability
609+
# backends emitting from inside a node body (the
610+
# llm-provider span instrumentation in OpenAIProvider) need
611+
# to find the observers active for THIS invocation. Set the
612+
# ContextVar around the chain invocation; reset in
613+
# ``try/finally`` so an exception escaping the chain still
614+
# restores the prior value.
615+
observers_token = _set_active_observers(context.full_observers())
616+
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
588617
try:
589-
final_partial = await chain(state)
590-
except RuntimeGraphError:
591-
raise
592-
except Exception as e:
593-
# A raw exception (node-raised or middleware-raised) escaped
594-
# the chain unrecovered. Wrap as NodeException per §4.
595-
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
618+
try:
619+
final_partial = await chain(state)
620+
except RuntimeGraphError:
621+
raise
622+
except Exception as e:
623+
# A raw exception (node-raised or middleware-raised) escaped
624+
# the chain unrecovered. Wrap as NodeException per §4.
625+
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
626+
finally:
627+
_reset_active_dispatch(dispatch_token)
628+
_reset_active_observers(observers_token)
596629
# Engine's canonical merge uses the ORIGINAL state per §2: "the
597630
# transformed state is passed to ``next``, NOT to the engine's
598631
# merge step." If middleware transformed state mid-chain, the
@@ -649,18 +682,29 @@ async def innermost(s: Any) -> Mapping[str, Any]:
649682
list(self.middleware) + list(node.middleware),
650683
innermost,
651684
)
685+
# Same active-observers scope as _step_function_node — parent
686+
# middleware running before the descent should see the parent's
687+
# observer set; the inner _invoke (called via ``node.run``)
688+
# descends into its own context and sets a new scope from
689+
# there.
690+
observers_token = _set_active_observers(context.full_observers())
691+
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
652692

653693
try:
654-
final_partial = await chain(state)
655-
except RuntimeGraphError:
656-
raise
657-
except Exception as e:
658-
# Same wrap as _step_function_node: a raw exception escaping
659-
# the parent's middleware chain (e.g., a middleware bug or a
660-
# projection error) becomes NodeException tagged with the
661-
# SubgraphNode's wrapper name so §4 recoverable_state is
662-
# preserved.
663-
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
694+
try:
695+
final_partial = await chain(state)
696+
except RuntimeGraphError:
697+
raise
698+
except Exception as e:
699+
# Same wrap as _step_function_node: a raw exception escaping
700+
# the parent's middleware chain (e.g., a middleware bug or a
701+
# projection error) becomes NodeException tagged with the
702+
# SubgraphNode's wrapper name so §4 recoverable_state is
703+
# preserved.
704+
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
705+
finally:
706+
_reset_active_dispatch(dispatch_token)
707+
_reset_active_observers(observers_token)
664708
return _merge_partial(state, final_partial, self.reducers, current)
665709

666710
async def _step_fan_out_node(
@@ -740,12 +784,23 @@ async def innermost(s: Any) -> Mapping[str, Any]:
740784
innermost,
741785
)
742786

787+
# Same observability §3 / LLM-span hook contract as
788+
# _step_function_node: set the active observer set in scope
789+
# around the chain invocation so capability backends emitting
790+
# from inside the fan-out's parent dispatch (or any code
791+
# running on its call stack) can find the observers.
792+
observers_token = _set_active_observers(context.full_observers())
793+
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
743794
try:
744-
final_partial = await chain(state)
745-
except RuntimeGraphError:
746-
raise
747-
except Exception as e:
748-
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
795+
try:
796+
final_partial = await chain(state)
797+
except RuntimeGraphError:
798+
raise
799+
except Exception as e:
800+
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
801+
finally:
802+
_reset_active_dispatch(dispatch_token)
803+
_reset_active_observers(observers_token)
749804
merged_outer = _merge_partial(state, final_partial, self.reducers, current)
750805
# Spec §10.3 + §10.7: the fan-out's own completion DOES save —
751806
# one record once the fan-out as a whole has finished and

src/openarmature/llm/providers/openai.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
import httpx
4545
from pydantic import ValidationError
4646

47+
from openarmature.graph.events import NodeEvent
48+
from openarmature.observability.correlation import current_dispatch
49+
4750
from ..errors import (
4851
ProviderAuthentication,
4952
ProviderInvalidModel,
@@ -190,6 +193,41 @@ async def complete(
190193
validate_tools(tools)
191194
body = self._build_request_body(messages, tools, config)
192195

196+
# Spec observability §5.5 LLM provider span: when an
197+
# observability backend is active in the current invocation,
198+
# emit a started/completed event pair around the wire call so
199+
# the backend can build a span. Queue-mediated dispatch
200+
# preserves spec §6 serial event ordering across all event
201+
# sources within an invocation. ``current_dispatch()`` returns
202+
# ``None`` outside an openarmature invocation (direct
203+
# provider use in scripts/tests), in which case the call
204+
# proceeds without span emission.
205+
dispatch = current_dispatch()
206+
if dispatch is not None:
207+
dispatch(_make_llm_event("started", model=self.model))
208+
209+
try:
210+
response = await self._do_complete(body)
211+
except Exception as exc:
212+
if dispatch is not None:
213+
dispatch(_make_llm_event("completed", model=self.model, error=exc))
214+
raise
215+
216+
if dispatch is not None:
217+
dispatch(
218+
_make_llm_event(
219+
"completed",
220+
model=self.model,
221+
finish_reason=response.finish_reason,
222+
usage=response.usage,
223+
)
224+
)
225+
return response
226+
227+
async def _do_complete(self, body: dict[str, Any]) -> Response:
228+
"""Wire-call helper: separated from ``complete()`` so the
229+
LLM-provider span hook in ``complete()`` can wrap success and
230+
failure paths uniformly."""
193231
try:
194232
resp = await self._client.post("/v1/chat/completions", json=body)
195233
except httpx.HTTPError as exc:
@@ -481,6 +519,64 @@ def _looks_like_model_not_loaded(message: object) -> bool:
481519
return "not loaded" in lower or "loading" in lower
482520

483521

522+
# ---------------------------------------------------------------------------
523+
# Observability §5.5 LLM provider span event helpers
524+
# ---------------------------------------------------------------------------
525+
526+
527+
def _make_llm_event(
528+
phase: str,
529+
*,
530+
model: str,
531+
finish_reason: FinishReason | None = None,
532+
usage: Usage | None = None,
533+
error: BaseException | None = None,
534+
) -> NodeEvent:
535+
"""Build a NodeEvent-shaped record for the engine's delivery
536+
queue. The OTel observer (or any backend mapping) recognises the
537+
sentinel ``node_name`` and ``namespace`` and emits an LLM-specific
538+
span instead of a node span. Backend-specific attribute extraction
539+
reads ``model``, ``finish_reason``, and ``usage`` from
540+
``pre_state``'s ``llm_event`` payload.
541+
542+
The pre_state field is reused as the carrier for LLM event detail
543+
because NodeEvent's shape is fixed (graph-engine §6) and adding
544+
ad-hoc fields would break observers that pattern-match on the
545+
existing shape. Backend mappings know to inspect
546+
``event.pre_state['llm_event']`` when the namespace is
547+
``("openarmature.llm.complete",)``.
548+
"""
549+
payload: dict[str, Any] = {"model": model}
550+
if finish_reason is not None:
551+
payload["finish_reason"] = finish_reason
552+
if usage is not None:
553+
payload["prompt_tokens"] = usage.prompt_tokens
554+
payload["completion_tokens"] = usage.completion_tokens
555+
payload["total_tokens"] = usage.total_tokens
556+
if error is not None:
557+
# The engine's NodeEvent.error type is RuntimeGraphError, but
558+
# llm-provider errors aren't graph-engine §4 categories. Carry
559+
# the exception detail in the payload instead so backends can
560+
# surface it without our needing to wrap as RuntimeGraphError.
561+
payload["error_type"] = type(error).__name__
562+
payload["error_message"] = str(error)
563+
category = getattr(error, "category", None)
564+
if isinstance(category, str):
565+
payload["error_category"] = category
566+
return NodeEvent(
567+
node_name="openarmature.llm.complete",
568+
namespace=("openarmature.llm.complete",),
569+
step=-1,
570+
phase=cast("Any", phase),
571+
# ``pre_state`` is overloaded here as the LLM-event payload
572+
# carrier — see the docstring above.
573+
pre_state=cast("Any", {"llm_event": payload}),
574+
post_state=None,
575+
error=None,
576+
parent_states=(),
577+
)
578+
579+
484580
__all__ = [
485581
"OpenAIProvider",
486582
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""openarmature.observability — cross-backend observability surface.
2+
3+
Two layers:
4+
5+
- **Core** (this module + ``correlation.py``): always available, no
6+
extra dependencies. Exposes :func:`current_correlation_id` and
7+
:func:`current_active_observers` — the spec observability §3
8+
``ContextVar`` primitives that every backend mapping consumes.
9+
- **Backend mappings** (under ``observability.otel`` and future
10+
``observability.langfuse`` etc.): gated behind optional
11+
dependencies (``pip install openarmature[otel]``). Importing the
12+
subpackage without the extras installed raises an informative
13+
``ImportError`` pointing the caller at the install command.
14+
15+
The split mirrors charter §3.1 principle 5: core defines the
16+
contracts; specific backends implement them. At v1.0 launch the
17+
backend mappings will lift into sibling packages
18+
(``openarmature-otel``, ``openarmature-langfuse``) — until then
19+
they live here under per-backend subpackages so the layering is
20+
established up front.
21+
"""
22+
23+
from .correlation import current_active_observers, current_correlation_id
24+
25+
__all__ = [
26+
"current_active_observers",
27+
"current_correlation_id",
28+
]

0 commit comments

Comments
 (0)