diff --git a/CHANGELOG.md b/CHANGELOG.md index e11281ad..a2d56844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The ### Added +- **Implementation attribution attributes** (proposal 0052, observability §5.1 + §8.4.1, spec v0.44.0). Every OTel invocation span now carries `openarmature.implementation.name` (`"openarmature-python"`) and `openarmature.implementation.version` (the package's `__version__`) alongside the existing `openarmature.graph.spec_version`. Every Langfuse Trace mirrors the rows as `trace.metadata.implementation_name` / `trace.metadata.implementation_version`. The values let operators triage in their observability backend without a separate deployment-manifest lookup: "which library, at which version, produced this trace" — the first question operators reach for. Always-emit invariant: neither `disable_state_payload`, `disable_llm_payload`, nor any other privacy knob gates these attributes, since they describe runtime identity rather than runtime data. Both observers expose `implementation_name` and `implementation_version` dataclass fields for test parameterization; the defaults read from the package identity via the same lazy-import pattern as `spec_version`. A new `openarmature.__implementation_name__ = "openarmature-python"` constant joins `__version__` and `__spec_version__` at the package root. The §3.4 reserved-key set grows from 24 to 26 names — `implementation_name` and `implementation_version` are reserved against caller-supplied collision, so a caller passing `invocation_metadata={"implementation_name": "spoof"}` is rejected at the `invoke()` boundary with `ValueError`. - **`CompiledGraph.drain_events_for(invocation_id, *, timeout=5.0) -> DrainSummary`** (proposal 0054, spec graph-engine §6 *Per-invocation drain*, v0.46.0). The architectural pair to proposal 0048's §9.4 queryable observer accumulator lifecycle: a terminal node calling `await graph.drain_events_for(state.invocation_id)` blocks until every event dispatched for that invocation has reached every attached observer, typically followed by a read against a queryable observer accumulator whose bucket the drain has now caught up to. Snapshot semantic: the drain awaits the events dispatched as of call time; new emissions after the call are out of scope. Reuses the existing `DrainSummary` shape verbatim — no new `InvocationDrainSummary` variant. **Load-bearing divergence from `drain()`**: a per-invocation drain timeout MUST NOT cancel the delivery worker, in contrast to `drain()`'s shutdown semantics. The graph stays serving other invocations after the timeout fires; the deliver loop keeps processing the queue. Default timeout is `5.0` seconds; `None` waits indefinitely; `0.0` is a non-blocking check. Negative or `NaN` timeout raises `ValueError` at the API boundary. Unknown `invocation_id` (already drained or never started) returns an empty summary, not an error. - **`get_invocation_metadata()` read-symmetric API** (proposal 0048, observability §3.4, spec v0.40.0). The canonical spec-idiomatic public name for the §3.4 read access pairs with `set_invocation_metadata()` on the write side: same function object as the historical `current_invocation_metadata`, exposed for callers wishing to use the symmetric `get_/set_` naming. Returns the `MappingProxyType` snapshot of the current async context's view (caller baseline + in-node augments), or the empty mapping outside any active invocation. Read-only — callers MUST NOT mutate it. Both names are now exported from `openarmature.observability`; existing `current_invocation_metadata` callers continue to work unchanged. - **`docs/concepts/observability.md` §9 *Queryable observer pattern*** documents the convention-only observer-attached read methods that proposal 0048 §9 blesses: how to add a `get_*` read method to a custom observer (§9.1), the async-safety contract for concurrent reads under in-flight delivery (§9.2), the three-channel data-access guidance (typed State / untyped invocation metadata / queryable observer accumulator, §9.3, with a side-by-side table), and the lifecycle / explicit `drop(invocation_id)` discipline (§9.4). No new abstract surface on `Observer` per the spec — the pattern is convention-only and exists to bless the existing observer-state read shape used in production code. diff --git a/conformance.toml b/conformance.toml index 3e207bae..e3bd9903 100644 --- a/conformance.toml +++ b/conformance.toml @@ -318,11 +318,80 @@ status = "textual-only" since = "0.12.0" # Spec v0.44.0 (proposal 0052). Implementation attribution attributes -# (``openarmature.implementation.name`` + ``openarmature.implementation.version`` -# on every invocation span). Queued for v0.12.0 (this cycle); will -# flip to ``implemented`` in the implementation PR. +# on every invocation span (§5.1) + every Langfuse Trace (§8.4.1). +# The OTel observer emits ``openarmature.implementation.name`` + +# ``openarmature.implementation.version`` on the invocation span +# alongside the existing ``openarmature.graph.spec_version``; inner +# spans do NOT carry them (per §5.1 they are invocation-span-only, +# not cross-cutting §5.6). The Langfuse observer mirrors with +# ``trace.metadata.implementation_name`` + +# ``trace.metadata.implementation_version`` rows on both trace-open +# paths (the proposal 0043 boundary-event lazy-open path and the +# legacy NodeEvent path). Always-emit invariant: neither +# ``disable_state_payload`` nor any other privacy knob gates these +# attributes — they describe runtime identity, not runtime data. +# +# Source: ``__implementation_name__`` ("openarmature-python") added +# to ``openarmature/__init__.py`` alongside the existing version +# constants; ``__version__`` is the package version source. +# Configurable via dataclass fields on both observers for test +# parameterization. +# +# §3.4 reserved-key set grows 24 -> 26 names: ``implementation_name`` +# and ``implementation_version`` reject caller-supplied collision at +# the ``invoke()`` API boundary. +# +# Conformance fixtures: 058 (OTel) parses cleanly via the existing +# ``span_tree`` + ``attributes_absent`` directive shapes — its +# cross-capability parser test passes; runtime exec is gated by +# ``_SUPPORTED_FIXTURES`` in ``test_observability.py`` until the +# harness wires up the canonical-value parameterization. 059 +# (Langfuse) stays deferred from the cross-capability parser +# pending the upcoming conformance-adapter capability — same Path A +# reasoning as the 0048 / 0054 fixtures. Behavior is pinned by unit +# tests: +# - reserved-key rejection (validate / set_invocation_metadata / +# invoke() boundary for both names): +# ``tests/unit/test_observability_metadata.py:: +# test_validate_rejects_reserved_implementation_name``, +# ``::test_validate_rejects_reserved_implementation_version``, +# ``::test_set_invocation_metadata_rejects_reserved_implementation_name``, +# ``::test_set_invocation_metadata_rejects_reserved_implementation_version``, +# ``::test_invoke_rejects_reserved_implementation_name_at_boundary``, +# ``::test_invoke_rejects_reserved_implementation_version_at_boundary``; +# - OTel invocation span carries the attributes; inner spans do +# not: +# ``tests/unit/test_observability_otel.py:: +# test_invocation_span_carries_implementation_attribution_attributes``; +# - OTel always-emit invariant under ``disable_llm_payload``, +# ``disable_genai_semconv``, ``disable_llm_spans``: +# ``::test_invocation_span_attribution_emits_under_disable_llm_payload``; +# - OTel attributes emit on every invocation span across a +# reused observer (3 sequential invocations): +# ``::test_invocation_span_attribution_emits_on_every_invocation``; +# - Langfuse Trace metadata carries the rows + always-emit +# invariant under ``disable_state_payload=True``: +# ``tests/unit/test_observability_langfuse.py:: +# test_trace_metadata_carries_implementation_attribution_rows``, +# ``::test_implementation_attribution_rows_emit_with_disable_state_payload_enabled``; +# - Langfuse rows emit on every Trace across a reused observer (3 +# sequential invocations): +# ``::test_implementation_attribution_rows_emit_on_every_trace``. +# +# Fixture 058 case 2 ("detached_subgraph_attribution_propagates_ +# _to_child_trace_invocation_span") expects two ``openarmature.invocation`` +# spans (parent + detached child trace), but fixture 008 establishes +# that detached subgraphs root in their subgraph-dispatch span — NOT +# in a separate invocation span. The two fixtures appear inconsistent. +# Not deferring 058's parsing (it parses cleanly), but the runtime +# exec contract for the detached-child-invocation-span case needs +# coord-thread resolution before activation. The python implementation +# emits the attribution attributes on every invocation span it opens +# (pinned by the multi-invocation test above), so the 0052 contract +# itself is honored. [proposals."0052"] -status = "not-yet" +status = "implemented" +since = "0.12.0" # Spec v0.45.0 (proposal 0053). §3.4 shared-parent boundary # clarification. Purely textual: tightens the structural-shared-parent diff --git a/src/openarmature/__init__.py b/src/openarmature/__init__.py index e817d3a6..dd208399 100644 --- a/src/openarmature/__init__.py +++ b/src/openarmature/__init__.py @@ -26,3 +26,17 @@ __version__ = "0.11.0" __spec_version__ = "0.46.0" +# Proposal 0052 (spec observability §5.1 / §8.4.1): canonical +# package-registry name for this implementation. Surfaces on every +# OTel invocation span as ``openarmature.implementation.name`` and on +# every Langfuse trace as ``trace.metadata.implementation_name``. +# Matches the PyPI distribution name so operators can paste it +# straight into a registry search box. +# +# No symmetric ``__implementation_version__`` constant — the spec +# requires the implementation_version value to match the package's +# release identity, which is already exposed as ``__version__`` above. +# Both observers source the version from ``__version__`` directly to +# avoid the maintenance trap of two constants that have to stay in +# lockstep across releases. +__implementation_name__ = "openarmature-python" diff --git a/src/openarmature/observability/langfuse/observer.py b/src/openarmature/observability/langfuse/observer.py index c150dfc6..6736ae9d 100644 --- a/src/openarmature/observability/langfuse/observer.py +++ b/src/openarmature/observability/langfuse/observer.py @@ -64,6 +64,21 @@ def _read_spec_version() -> str: return __spec_version__ +# Proposal 0052: implementation attribution attributes. Sourced from +# the package identity constants via the same lazy-import discipline +# as ``_read_spec_version``. +def _read_implementation_name() -> str: + from openarmature import __implementation_name__ + + return __implementation_name__ + + +def _read_implementation_version() -> str: + from openarmature import __version__ + + return __version__ + + # In-flight Span observation handle, keyed by the standard span-stack # key (namespace, attempt_index, fan_out_index, branch_name). # ``branch_name`` discriminates concurrent same-named inner nodes @@ -284,6 +299,16 @@ class LangfuseObserver: ``disable_state_payload=False``, minimal stub otherwise). - ``trace_output_from_state``: same shape for ``trace.output``, called once per invocation at the ``InvocationCompletedEvent``. + - ``implementation_name``: string surfaced as + ``trace.metadata.implementation_name`` on every Trace. Defaults + to the package's ``__implementation_name__`` + (``"openarmature-python"``). Configurable for test + parameterization. + - ``implementation_version``: string surfaced as + ``trace.metadata.implementation_version`` on every Trace. + Defaults to ``openarmature.__version__``. Always-emit invariant + inherited from §5.1 — not gated by ``disable_state_payload``, + ``disable_llm_payload``, or any other privacy knob. The observer reads the spec version from the package at construction time. Safe to share across concurrent invocations @@ -298,6 +323,14 @@ class LangfuseObserver: detached_subgraphs: frozenset[str] = field(default_factory=_empty_str_frozenset) detached_fan_outs: frozenset[str] = field(default_factory=_empty_str_frozenset) spec_version: str = field(default_factory=_read_spec_version) + # Proposal 0052 §8.4.1: implementation attribution rows on every + # Trace. Configurable for test parameterization; defaults to the + # package identity. Always-emit invariant inherited from §5.1 — + # ``disable_state_payload`` and the other privacy knobs do not + # gate these rows because they describe runtime identity, not + # runtime data. + implementation_name: str = field(default_factory=_read_implementation_name) + implementation_version: str = field(default_factory=_read_implementation_version) # Proposal 0043 §8.4.1 *Trace input/output sourcing*. disable_state_payload: bool = True trace_input_from_state: Callable[[Any], Any] | None = None @@ -682,6 +715,9 @@ def _open_trace_lazy( metadata: dict[str, Any] = { "entry_node": entry_node, "spec_version": self.spec_version, + # Proposal 0052 §8.4.1: implementation attribution rows. + "implementation_name": self.implementation_name, + "implementation_version": self.implementation_version, } if correlation_id is not None: metadata["correlation_id"] = correlation_id @@ -703,6 +739,9 @@ def _open_trace(self, invocation_id: str, correlation_id: str | None, event: Nod metadata: dict[str, Any] = { "entry_node": entry_node, "spec_version": self.spec_version, + # Proposal 0052 §8.4.1: implementation attribution rows. + "implementation_name": self.implementation_name, + "implementation_version": self.implementation_version, } if correlation_id is not None: metadata["correlation_id"] = correlation_id diff --git a/src/openarmature/observability/metadata.py b/src/openarmature/observability/metadata.py index 0585c040..e2c381b0 100644 --- a/src/openarmature/observability/metadata.py +++ b/src/openarmature/observability/metadata.py @@ -100,6 +100,15 @@ "branch_name", "detached", "detached_from_invocation_id", + # Proposal 0052 (spec v0.44.0): implementation attribution + # attributes emitted on every invocation span / Trace metadata. + # Reserved so a caller passing ``implementation_name`` or + # ``implementation_version`` in ``invocation_metadata`` is + # rejected at the ``invoke()`` boundary rather than silently + # clobbering the implementation-emitted value. The set grows + # from 24 (post-0042) to 26. + "implementation_name", + "implementation_version", } ) diff --git a/src/openarmature/observability/otel/observer.py b/src/openarmature/observability/otel/observer.py index 38a853ec..1c0ef1a0 100644 --- a/src/openarmature/observability/otel/observer.py +++ b/src/openarmature/observability/otel/observer.py @@ -155,6 +155,21 @@ def _read_spec_version() -> str: return __spec_version__ +# Proposal 0052: implementation attribution attributes sourced from +# the package's identity constants. Same lazy-import discipline as +# ``_read_spec_version`` to avoid a load-time cycle. +def _read_implementation_name() -> str: + from openarmature import __implementation_name__ + + return __implementation_name__ + + +def _read_implementation_version() -> str: + from openarmature import __version__ + + return __version__ + + def _apply_caller_metadata(attrs: dict[str, Any], metadata: Mapping[str, Any]) -> None: """Merge caller-supplied invocation metadata into a span's attribute dict as ``openarmature.user.`` entries per @@ -397,6 +412,15 @@ class OTelObserver: caught and warned; never propagated. - ``spec_version``: string surfaced as ``openarmature.graph.spec_version`` on the invocation span. + - ``implementation_name``: string surfaced as + ``openarmature.implementation.name`` on the invocation span. + Defaults to the package's ``__implementation_name__`` + (``"openarmature-python"``). Configurable for test + parameterization. + - ``implementation_version``: string surfaced as + ``openarmature.implementation.version`` on the invocation span. + Defaults to ``openarmature.__version__``. Always-emit invariant: + not gated by any privacy knob. Safe to share across concurrent invocations and across resumes of the same correlation_id; every internal span map is outer-keyed by @@ -440,6 +464,16 @@ class OTelObserver: # spec submodule + the two version fields automatically updates # the value reported on every invocation span. spec_version: str = field(default_factory=_read_spec_version) + # Proposal 0052 (spec v0.44.0): implementation identity emitted on + # every invocation span. ``implementation_name`` is the package + # registry name (``openarmature-python``); + # ``implementation_version`` is ``openarmature.__version__``. + # Configurable for test parameterization but defaults to the + # package-pinned values; the always-emit invariant means neither + # ``disable_state_payload``, ``disable_llm_payload``, nor any + # other privacy knob gates them. + implementation_name: str = field(default_factory=_read_implementation_name) + implementation_version: str = field(default_factory=_read_implementation_version) # Internal state, populated in __post_init__ and during invocation. _provider: TracerProvider = field(init=False, repr=False) @@ -1241,6 +1275,11 @@ def _open_invocation_span( attrs: dict[str, Any] = { "openarmature.graph.entry_node": event.node_name, "openarmature.graph.spec_version": self.spec_version, + # Proposal 0052 §5.1: implementation attribution attributes. + # Always-emit on every invocation span; not cross-cutting + # (§5.6) so inner-node spans don't carry them. + "openarmature.implementation.name": self.implementation_name, + "openarmature.implementation.version": self.implementation_version, "openarmature.invocation_id": invocation_id, } if correlation_id is not None: diff --git a/tests/conformance/test_fixture_parsing.py b/tests/conformance/test_fixture_parsing.py index 61e022fe..c2537b2d 100644 --- a/tests/conformance/test_fixture_parsing.py +++ b/tests/conformance/test_fixture_parsing.py @@ -362,11 +362,25 @@ def _id(case: tuple[str, Path]) -> str: "pipeline-utilities/063-failure-isolation-default-predicate-bare-exception": ( "Proposal 0050 failure-isolation middleware; queued for v0.14.0" ), - # Proposal 0052 (implementation attribution attributes, v0.44.0) - # — observability/059 is the Langfuse-side mapping fixture. Lands - # in PR 3 of the v0.12.0 cycle along with the implementation. + # Proposal 0052 (implementation attribution attributes, v0.44.0): + # observability/059 is the Langfuse-side mapping fixture; uses the + # ``langfuse_observer_config`` + ``harness_parameterized`` directive + # shapes the cross-capability parser doesn't model. The python + # implementation ships in v0.12.0 (manifest 0052 = implemented); + # behavior is pinned by unit tests in + # ``tests/unit/test_observability_metadata.py``, + # ``tests/unit/test_observability_otel.py``, and + # ``tests/unit/test_observability_langfuse.py``. Fixture-shape + # activation is queued for a future PR slotted after the upcoming + # spec conformance-adapter capability ratifies the directive + # vocabulary. 058 (the OTel-side mapping fixture) parses cleanly + # against the existing ``span_tree`` + ``attributes_absent`` + # directive shapes and is therefore NOT deferred from parsing; + # runtime exec is gated by ``_SUPPORTED_FIXTURES`` in + # ``test_observability.py`` until the harness wires up the + # canonical-value parameterization. "observability/059-implementation-attribution-langfuse": ( - "Proposal 0052 implementation attribution; lands in PR 3 of v0.12.0" + "Proposal 0052 fixture-shape models pending; contract pinned by unit tests" ), # ----- v0.12.0 cycle spec-pin bump (v0.45.0 -> v0.46.0) ------------- # Proposal 0054 (per-invocation observer event drain, v0.46.0): diff --git a/tests/unit/test_observability_langfuse.py b/tests/unit/test_observability_langfuse.py index 3f722b4a..59a4697c 100644 --- a/tests/unit/test_observability_langfuse.py +++ b/tests/unit/test_observability_langfuse.py @@ -1121,3 +1121,87 @@ def output_hook(state: Any) -> dict[str, Any]: assert not isinstance(captured_output_state[0], _ParBrBranchXState) assert not isinstance(captured_output_state[0], _ParBrBranchYState) assert captured_output_state[0].outer_a_done is True + + +# Spec §8.4.1 / proposal 0052: implementation attribution rows on +# every Langfuse Trace. The two rows source from the §5.1 +# attributes; the always-emit invariant inherits from §5.1 so the +# privacy knobs do not gate them. + + +async def test_trace_metadata_carries_implementation_attribution_rows() -> None: + from openarmature import __version__ + + graph = ( + GraphBuilder(_S) + .add_node("entry", lambda _s: _record("entry")) + .add_edge("entry", END) + .set_entry("entry") + .compile() + ) + graph, client, _ = _attach(graph) + + await graph.invoke(_S()) + await graph.drain() + + trace = next(iter(client.traces.values())) + assert trace.metadata.get("implementation_name") == "openarmature-python" + assert trace.metadata.get("implementation_version") == __version__ + # Non-empty-string contract per spec §5.1. + assert isinstance(trace.metadata["implementation_name"], str) + assert trace.metadata["implementation_name"] + assert isinstance(trace.metadata["implementation_version"], str) + assert trace.metadata["implementation_version"] + + +async def test_implementation_attribution_rows_emit_with_disable_state_payload_enabled() -> None: + # Always-emit invariant: regardless of disable_state_payload (the + # privacy knob that gates state payloads on trace.input / + # trace.output), the implementation attribution rows MUST appear. + # They describe runtime identity, not runtime data. + client = InMemoryLangfuseClient() + observer = LangfuseObserver(client=client, disable_state_payload=True) + + graph = ( + GraphBuilder(_S) + .add_node("entry", lambda _s: _record("entry")) + .add_edge("entry", END) + .set_entry("entry") + .compile() + ) + graph.attach_observer(observer) + + await graph.invoke(_S()) + await graph.drain() + + trace = next(iter(client.traces.values())) + assert "implementation_name" in trace.metadata + assert "implementation_version" in trace.metadata + assert trace.metadata["implementation_name"] == "openarmature-python" + + +# Spec §8.4.1 / proposal 0052: every Trace carries the attribution +# rows. An observer reused across multiple invocations on the same +# compiled graph MUST emit the rows on every Trace, not just the +# first. Mirrors the OTel-side test_invocation_span_attribution_ +# emits_on_every_invocation contract. +async def test_implementation_attribution_rows_emit_on_every_trace() -> None: + graph = ( + GraphBuilder(_S) + .add_node("entry", lambda _s: _record("entry")) + .add_edge("entry", END) + .set_entry("entry") + .compile() + ) + graph, client, _ = _attach(graph) + + for _ in range(3): + await graph.invoke(_S()) + await graph.drain() + + # Three invocations → three traces. Every one carries the rows. + assert len(client.traces) == 3, f"expected three traces, got {len(client.traces)}" + for trace in client.traces.values(): + assert trace.metadata.get("implementation_name") == "openarmature-python" + assert isinstance(trace.metadata.get("implementation_version"), str) + assert trace.metadata["implementation_version"] diff --git a/tests/unit/test_observability_metadata.py b/tests/unit/test_observability_metadata.py index 90bc8336..63763b9c 100644 --- a/tests/unit/test_observability_metadata.py +++ b/tests/unit/test_observability_metadata.py @@ -426,6 +426,47 @@ async def test_invoke_rejects_reserved_detached_from_invocation_id_at_boundary() await graph.invoke(_SimpleState(), metadata={"detached_from_invocation_id": "p"}) +# --------------------------------------------------------------------------- +# Reserved exact key names extension (proposal 0052) — implementation +# attribution attributes. The 24-name set grows to 26 with +# implementation_name + implementation_version reserved so a caller +# can't clobber the implementation-emitted Trace metadata / OTel +# attribute values by passing the same key in invoke(metadata=...). +# --------------------------------------------------------------------------- + + +def test_validate_rejects_reserved_implementation_name() -> None: + with pytest.raises(ValueError, match="is reserved"): + validate_invocation_metadata({"implementation_name": "spoof"}) + + +def test_validate_rejects_reserved_implementation_version() -> None: + with pytest.raises(ValueError, match="is reserved"): + validate_invocation_metadata({"implementation_version": "9.9.9"}) + + +def test_set_invocation_metadata_rejects_reserved_implementation_name() -> None: + with pytest.raises(ValueError, match="is reserved"): + set_invocation_metadata(implementation_name="spoof") + + +def test_set_invocation_metadata_rejects_reserved_implementation_version() -> None: + with pytest.raises(ValueError, match="is reserved"): + set_invocation_metadata(implementation_version="9.9.9") + + +async def test_invoke_rejects_reserved_implementation_name_at_boundary() -> None: + graph = _build_graph() + with pytest.raises(ValueError, match="is reserved"): + await graph.invoke(_SimpleState(), metadata={"implementation_name": "spoof"}) + + +async def test_invoke_rejects_reserved_implementation_version_at_boundary() -> None: + graph = _build_graph() + with pytest.raises(ValueError, match="is reserved"): + await graph.invoke(_SimpleState(), metadata={"implementation_version": "9.9.9"}) + + # --------------------------------------------------------------------------- # Caller-supplied invocation_id (proposal 0039) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_observability_otel.py b/tests/unit/test_observability_otel.py index 9d65573b..0cbf7c95 100644 --- a/tests/unit/test_observability_otel.py +++ b/tests/unit/test_observability_otel.py @@ -197,6 +197,102 @@ async def test_invocation_span_carries_required_attributes() -> None: assert isinstance(attrs.get("openarmature.graph.spec_version"), str) +# Spec §5.1 / proposal 0052: invocation span MUST carry +# ``openarmature.implementation.name`` and +# ``openarmature.implementation.version`` as non-empty strings; name +# matches the package-registry canonical value (``openarmature-python``). +# Inner-node spans MUST NOT carry them — the attributes live in §5.1, +# not the cross-cutting §5.6 family. +async def test_invocation_span_carries_implementation_attribution_attributes() -> None: + from openarmature import __version__ + + exporter = InMemorySpanExporter() + observer = OTelObserver(span_processor=SimpleSpanProcessor(exporter)) + g, _ = _build_linear_graph(observer) + await g.invoke(_LinearState()) # type: ignore[attr-defined] + await g.drain() # type: ignore[attr-defined] + observer.shutdown() + spans = exporter.get_finished_spans() + + inv = next((s for s in spans if s.name == "openarmature.invocation"), None) + assert inv is not None + inv_attrs = dict(inv.attributes or {}) + assert inv_attrs.get("openarmature.implementation.name") == "openarmature-python" + assert inv_attrs.get("openarmature.implementation.version") == __version__ + assert isinstance(inv_attrs["openarmature.implementation.name"], str) + assert inv_attrs["openarmature.implementation.name"] # non-empty + assert isinstance(inv_attrs["openarmature.implementation.version"], str) + assert inv_attrs["openarmature.implementation.version"] # non-empty + + # Inner-node spans MUST NOT carry the attribution attributes. + inner_spans = [s for s in spans if s.name != "openarmature.invocation"] + assert inner_spans, "expected at least one inner node span" + for span in inner_spans: + span_attrs = dict(span.attributes or {}) + assert "openarmature.implementation.name" not in span_attrs, ( + f"inner span {span.name!r} unexpectedly carries implementation.name" + ) + assert "openarmature.implementation.version" not in span_attrs, ( + f"inner span {span.name!r} unexpectedly carries implementation.version" + ) + + +# Spec §5.1 / proposal 0052: always-emit invariant. The attribution +# attributes describe runtime identity, not runtime data, so the +# privacy knobs that gate payload-shaped attributes (LLM payload, +# state payload, GenAI semconv) MUST NOT gate the attribution. This +# pins the OTel side of the contract; the Langfuse-side equivalent +# lives in test_observability_langfuse.py against +# disable_state_payload=True. +async def test_invocation_span_attribution_emits_under_disable_llm_payload() -> None: + exporter = InMemorySpanExporter() + observer = OTelObserver( + span_processor=SimpleSpanProcessor(exporter), + disable_llm_payload=True, + disable_genai_semconv=True, + disable_llm_spans=True, + ) + g, _ = _build_linear_graph(observer) + await g.invoke(_LinearState()) # type: ignore[attr-defined] + await g.drain() # type: ignore[attr-defined] + observer.shutdown() + spans = exporter.get_finished_spans() + + inv = next((s for s in spans if s.name == "openarmature.invocation"), None) + assert inv is not None + attrs = dict(inv.attributes or {}) + assert attrs.get("openarmature.implementation.name") == "openarmature-python" + assert isinstance(attrs.get("openarmature.implementation.version"), str) + assert attrs["openarmature.implementation.version"] + + +# Spec §5.1 / proposal 0052: every invocation span carries the +# attribution attributes. An observer reused across multiple +# invocations on the same compiled graph MUST emit the attributes on +# every invocation's span — not just the first. The dataclass-field +# defaults are computed once at observer construction, so a regression +# where the values were instance-scoped (read-once) instead of +# emit-each-time would silently break this contract. +async def test_invocation_span_attribution_emits_on_every_invocation() -> None: + exporter = InMemorySpanExporter() + observer = OTelObserver(span_processor=SimpleSpanProcessor(exporter)) + g, _ = _build_linear_graph(observer) + + for _ in range(3): + await g.invoke(_LinearState()) # type: ignore[attr-defined] + await g.drain() # type: ignore[attr-defined] + observer.shutdown() + spans = exporter.get_finished_spans() + + inv_spans = [s for s in spans if s.name == "openarmature.invocation"] + assert len(inv_spans) == 3, f"expected three invocation spans, got {len(inv_spans)}" + for span in inv_spans: + attrs = dict(span.attributes or {}) + assert attrs.get("openarmature.implementation.name") == "openarmature-python" + assert isinstance(attrs.get("openarmature.implementation.version"), str) + assert attrs["openarmature.implementation.version"] + + # --------------------------------------------------------------------------- # §4.2 status mapping # ---------------------------------------------------------------------------