Skip to content

Commit a283e62

Browse files
Emit implementation attribution on every invocation (#132)
* Emit implementation attribution on every invocation Implements proposal 0052 (spec observability 5.1 + 8.4.1, v0.44.0). Two new attributes -- openarmature.implementation.name and openarmature.implementation.version -- emit on every OTel invocation span alongside the existing openarmature.graph.spec_version. The Langfuse observer mirrors the rows as trace.metadata.implementation_name and trace.metadata.implementation_version on every Trace. The values let observability-backend operators answer "which library, at which version, produced this trace" without a separate deployment-manifest lookup. The name value is the canonical PyPI package name ("openarmature-python"), pinned as a new __implementation_name__ constant at the package root next to __version__ and __spec_version__. The version value is __version__ itself -- no separate __implementation_version__ constant to avoid the maintenance trap of two values that have to stay in lockstep. Both observers expose configurable implementation_name and implementation_version dataclass fields for test parameterization, defaulting to the package identity via lazy-import helpers that mirror the existing _read_spec_version pattern. The OTel invocation span emits the attributes once per invocation (per 5.1, not the cross-cutting 5.6 family -- inner-node spans do NOT carry them). The Langfuse rows emit on both trace-open paths: the proposal 0043 boundary-event lazy path and the legacy NodeEvent path. Always-emit invariant pinned on both observers: neither disable_state_payload, disable_llm_payload, disable_llm_spans, nor disable_genai_semconv gates the attributes, since they describe runtime identity rather than runtime data. The 3.4 reserved-key set extends from 24 to 26 names: implementation_name and implementation_version reject caller-supplied collision at the invoke() boundary, so a caller passing invocation_metadata={"implementation_name": "spoof"} gets ValueError rather than silently clobbering the implementation-emitted value. Twelve new unit tests pin the contract: 6 reserved-key (validate / set / invoke boundary for both names) + 3 OTel (invocation-span presence + inner-span absence, always-emit under disable_llm_payload, multi-invocation reuse) + 3 Langfuse (trace metadata presence, always-emit under disable_state_payload, multi-invocation reuse). Conformance fixtures 058 (OTel) + 059 (Langfuse) stay deferred from the cross-capability parser pending the upcoming conformance-adapter capability spec. * Document new observer constructor knobs Three documentation consistency fixes from PR review. OTelObserver and LangfuseObserver gained public implementation_name / implementation_version constructor parameters in the prior commit, but each class docstring's "Constructor knobs" list was not updated. Adding entries to both, slotted after spec_version (OTel) and after the trace input/output hooks (Langfuse) so the always-emit knobs group with the other invocation-span / trace-metadata sourcing parameters. The conformance.toml proposal 0052 comment block claimed both observability/058 and /059 were deferred from the cross- capability parser, but later in the same block stated 058 parses cleanly. Tightening the opening sentence so 058 is correctly described as parseable-but-runtime-gated and 059 as parser-deferred.
1 parent 106498a commit a283e62

10 files changed

Lines changed: 414 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
1616

1717
### Added
1818

19+
- **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`.
1920
- **`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.
2021
- **`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.
2122
- **`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.

conformance.toml

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -318,11 +318,80 @@ status = "textual-only"
318318
since = "0.12.0"
319319

320320
# Spec v0.44.0 (proposal 0052). Implementation attribution attributes
321-
# (``openarmature.implementation.name`` + ``openarmature.implementation.version``
322-
# on every invocation span). Queued for v0.12.0 (this cycle); will
323-
# flip to ``implemented`` in the implementation PR.
321+
# on every invocation span (§5.1) + every Langfuse Trace (§8.4.1).
322+
# The OTel observer emits ``openarmature.implementation.name`` +
323+
# ``openarmature.implementation.version`` on the invocation span
324+
# alongside the existing ``openarmature.graph.spec_version``; inner
325+
# spans do NOT carry them (per §5.1 they are invocation-span-only,
326+
# not cross-cutting §5.6). The Langfuse observer mirrors with
327+
# ``trace.metadata.implementation_name`` +
328+
# ``trace.metadata.implementation_version`` rows on both trace-open
329+
# paths (the proposal 0043 boundary-event lazy-open path and the
330+
# legacy NodeEvent path). Always-emit invariant: neither
331+
# ``disable_state_payload`` nor any other privacy knob gates these
332+
# attributes — they describe runtime identity, not runtime data.
333+
#
334+
# Source: ``__implementation_name__`` ("openarmature-python") added
335+
# to ``openarmature/__init__.py`` alongside the existing version
336+
# constants; ``__version__`` is the package version source.
337+
# Configurable via dataclass fields on both observers for test
338+
# parameterization.
339+
#
340+
# §3.4 reserved-key set grows 24 -> 26 names: ``implementation_name``
341+
# and ``implementation_version`` reject caller-supplied collision at
342+
# the ``invoke()`` API boundary.
343+
#
344+
# Conformance fixtures: 058 (OTel) parses cleanly via the existing
345+
# ``span_tree`` + ``attributes_absent`` directive shapes — its
346+
# cross-capability parser test passes; runtime exec is gated by
347+
# ``_SUPPORTED_FIXTURES`` in ``test_observability.py`` until the
348+
# harness wires up the canonical-value parameterization. 059
349+
# (Langfuse) stays deferred from the cross-capability parser
350+
# pending the upcoming conformance-adapter capability — same Path A
351+
# reasoning as the 0048 / 0054 fixtures. Behavior is pinned by unit
352+
# tests:
353+
# - reserved-key rejection (validate / set_invocation_metadata /
354+
# invoke() boundary for both names):
355+
# ``tests/unit/test_observability_metadata.py::
356+
# test_validate_rejects_reserved_implementation_name``,
357+
# ``::test_validate_rejects_reserved_implementation_version``,
358+
# ``::test_set_invocation_metadata_rejects_reserved_implementation_name``,
359+
# ``::test_set_invocation_metadata_rejects_reserved_implementation_version``,
360+
# ``::test_invoke_rejects_reserved_implementation_name_at_boundary``,
361+
# ``::test_invoke_rejects_reserved_implementation_version_at_boundary``;
362+
# - OTel invocation span carries the attributes; inner spans do
363+
# not:
364+
# ``tests/unit/test_observability_otel.py::
365+
# test_invocation_span_carries_implementation_attribution_attributes``;
366+
# - OTel always-emit invariant under ``disable_llm_payload``,
367+
# ``disable_genai_semconv``, ``disable_llm_spans``:
368+
# ``::test_invocation_span_attribution_emits_under_disable_llm_payload``;
369+
# - OTel attributes emit on every invocation span across a
370+
# reused observer (3 sequential invocations):
371+
# ``::test_invocation_span_attribution_emits_on_every_invocation``;
372+
# - Langfuse Trace metadata carries the rows + always-emit
373+
# invariant under ``disable_state_payload=True``:
374+
# ``tests/unit/test_observability_langfuse.py::
375+
# test_trace_metadata_carries_implementation_attribution_rows``,
376+
# ``::test_implementation_attribution_rows_emit_with_disable_state_payload_enabled``;
377+
# - Langfuse rows emit on every Trace across a reused observer (3
378+
# sequential invocations):
379+
# ``::test_implementation_attribution_rows_emit_on_every_trace``.
380+
#
381+
# Fixture 058 case 2 ("detached_subgraph_attribution_propagates_
382+
# _to_child_trace_invocation_span") expects two ``openarmature.invocation``
383+
# spans (parent + detached child trace), but fixture 008 establishes
384+
# that detached subgraphs root in their subgraph-dispatch span — NOT
385+
# in a separate invocation span. The two fixtures appear inconsistent.
386+
# Not deferring 058's parsing (it parses cleanly), but the runtime
387+
# exec contract for the detached-child-invocation-span case needs
388+
# coord-thread resolution before activation. The python implementation
389+
# emits the attribution attributes on every invocation span it opens
390+
# (pinned by the multi-invocation test above), so the 0052 contract
391+
# itself is honored.
324392
[proposals."0052"]
325-
status = "not-yet"
393+
status = "implemented"
394+
since = "0.12.0"
326395

327396
# Spec v0.45.0 (proposal 0053). §3.4 shared-parent boundary
328397
# clarification. Purely textual: tightens the structural-shared-parent

src/openarmature/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,17 @@
2626

2727
__version__ = "0.11.0"
2828
__spec_version__ = "0.46.0"
29+
# Proposal 0052 (spec observability §5.1 / §8.4.1): canonical
30+
# package-registry name for this implementation. Surfaces on every
31+
# OTel invocation span as ``openarmature.implementation.name`` and on
32+
# every Langfuse trace as ``trace.metadata.implementation_name``.
33+
# Matches the PyPI distribution name so operators can paste it
34+
# straight into a registry search box.
35+
#
36+
# No symmetric ``__implementation_version__`` constant — the spec
37+
# requires the implementation_version value to match the package's
38+
# release identity, which is already exposed as ``__version__`` above.
39+
# Both observers source the version from ``__version__`` directly to
40+
# avoid the maintenance trap of two constants that have to stay in
41+
# lockstep across releases.
42+
__implementation_name__ = "openarmature-python"

src/openarmature/observability/langfuse/observer.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ def _read_spec_version() -> str:
6464
return __spec_version__
6565

6666

67+
# Proposal 0052: implementation attribution attributes. Sourced from
68+
# the package identity constants via the same lazy-import discipline
69+
# as ``_read_spec_version``.
70+
def _read_implementation_name() -> str:
71+
from openarmature import __implementation_name__
72+
73+
return __implementation_name__
74+
75+
76+
def _read_implementation_version() -> str:
77+
from openarmature import __version__
78+
79+
return __version__
80+
81+
6782
# In-flight Span observation handle, keyed by the standard span-stack
6883
# key (namespace, attempt_index, fan_out_index, branch_name).
6984
# ``branch_name`` discriminates concurrent same-named inner nodes
@@ -284,6 +299,16 @@ class LangfuseObserver:
284299
``disable_state_payload=False``, minimal stub otherwise).
285300
- ``trace_output_from_state``: same shape for ``trace.output``,
286301
called once per invocation at the ``InvocationCompletedEvent``.
302+
- ``implementation_name``: string surfaced as
303+
``trace.metadata.implementation_name`` on every Trace. Defaults
304+
to the package's ``__implementation_name__``
305+
(``"openarmature-python"``). Configurable for test
306+
parameterization.
307+
- ``implementation_version``: string surfaced as
308+
``trace.metadata.implementation_version`` on every Trace.
309+
Defaults to ``openarmature.__version__``. Always-emit invariant
310+
inherited from §5.1 — not gated by ``disable_state_payload``,
311+
``disable_llm_payload``, or any other privacy knob.
287312
288313
The observer reads the spec version from the package at
289314
construction time. Safe to share across concurrent invocations
@@ -298,6 +323,14 @@ class LangfuseObserver:
298323
detached_subgraphs: frozenset[str] = field(default_factory=_empty_str_frozenset)
299324
detached_fan_outs: frozenset[str] = field(default_factory=_empty_str_frozenset)
300325
spec_version: str = field(default_factory=_read_spec_version)
326+
# Proposal 0052 §8.4.1: implementation attribution rows on every
327+
# Trace. Configurable for test parameterization; defaults to the
328+
# package identity. Always-emit invariant inherited from §5.1 —
329+
# ``disable_state_payload`` and the other privacy knobs do not
330+
# gate these rows because they describe runtime identity, not
331+
# runtime data.
332+
implementation_name: str = field(default_factory=_read_implementation_name)
333+
implementation_version: str = field(default_factory=_read_implementation_version)
301334
# Proposal 0043 §8.4.1 *Trace input/output sourcing*.
302335
disable_state_payload: bool = True
303336
trace_input_from_state: Callable[[Any], Any] | None = None
@@ -682,6 +715,9 @@ def _open_trace_lazy(
682715
metadata: dict[str, Any] = {
683716
"entry_node": entry_node,
684717
"spec_version": self.spec_version,
718+
# Proposal 0052 §8.4.1: implementation attribution rows.
719+
"implementation_name": self.implementation_name,
720+
"implementation_version": self.implementation_version,
685721
}
686722
if correlation_id is not None:
687723
metadata["correlation_id"] = correlation_id
@@ -703,6 +739,9 @@ def _open_trace(self, invocation_id: str, correlation_id: str | None, event: Nod
703739
metadata: dict[str, Any] = {
704740
"entry_node": entry_node,
705741
"spec_version": self.spec_version,
742+
# Proposal 0052 §8.4.1: implementation attribution rows.
743+
"implementation_name": self.implementation_name,
744+
"implementation_version": self.implementation_version,
706745
}
707746
if correlation_id is not None:
708747
metadata["correlation_id"] = correlation_id

src/openarmature/observability/metadata.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@
100100
"branch_name",
101101
"detached",
102102
"detached_from_invocation_id",
103+
# Proposal 0052 (spec v0.44.0): implementation attribution
104+
# attributes emitted on every invocation span / Trace metadata.
105+
# Reserved so a caller passing ``implementation_name`` or
106+
# ``implementation_version`` in ``invocation_metadata`` is
107+
# rejected at the ``invoke()`` boundary rather than silently
108+
# clobbering the implementation-emitted value. The set grows
109+
# from 24 (post-0042) to 26.
110+
"implementation_name",
111+
"implementation_version",
103112
}
104113
)
105114

src/openarmature/observability/otel/observer.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,21 @@ def _read_spec_version() -> str:
155155
return __spec_version__
156156

157157

158+
# Proposal 0052: implementation attribution attributes sourced from
159+
# the package's identity constants. Same lazy-import discipline as
160+
# ``_read_spec_version`` to avoid a load-time cycle.
161+
def _read_implementation_name() -> str:
162+
from openarmature import __implementation_name__
163+
164+
return __implementation_name__
165+
166+
167+
def _read_implementation_version() -> str:
168+
from openarmature import __version__
169+
170+
return __version__
171+
172+
158173
def _apply_caller_metadata(attrs: dict[str, Any], metadata: Mapping[str, Any]) -> None:
159174
"""Merge caller-supplied invocation metadata into a span's
160175
attribute dict as ``openarmature.user.<key>`` entries per
@@ -397,6 +412,15 @@ class OTelObserver:
397412
caught and warned; never propagated.
398413
- ``spec_version``: string surfaced as
399414
``openarmature.graph.spec_version`` on the invocation span.
415+
- ``implementation_name``: string surfaced as
416+
``openarmature.implementation.name`` on the invocation span.
417+
Defaults to the package's ``__implementation_name__``
418+
(``"openarmature-python"``). Configurable for test
419+
parameterization.
420+
- ``implementation_version``: string surfaced as
421+
``openarmature.implementation.version`` on the invocation span.
422+
Defaults to ``openarmature.__version__``. Always-emit invariant:
423+
not gated by any privacy knob.
400424
401425
Safe to share across concurrent invocations and across resumes of
402426
the same correlation_id; every internal span map is outer-keyed by
@@ -440,6 +464,16 @@ class OTelObserver:
440464
# spec submodule + the two version fields automatically updates
441465
# the value reported on every invocation span.
442466
spec_version: str = field(default_factory=_read_spec_version)
467+
# Proposal 0052 (spec v0.44.0): implementation identity emitted on
468+
# every invocation span. ``implementation_name`` is the package
469+
# registry name (``openarmature-python``);
470+
# ``implementation_version`` is ``openarmature.__version__``.
471+
# Configurable for test parameterization but defaults to the
472+
# package-pinned values; the always-emit invariant means neither
473+
# ``disable_state_payload``, ``disable_llm_payload``, nor any
474+
# other privacy knob gates them.
475+
implementation_name: str = field(default_factory=_read_implementation_name)
476+
implementation_version: str = field(default_factory=_read_implementation_version)
443477

444478
# Internal state, populated in __post_init__ and during invocation.
445479
_provider: TracerProvider = field(init=False, repr=False)
@@ -1241,6 +1275,11 @@ def _open_invocation_span(
12411275
attrs: dict[str, Any] = {
12421276
"openarmature.graph.entry_node": event.node_name,
12431277
"openarmature.graph.spec_version": self.spec_version,
1278+
# Proposal 0052 §5.1: implementation attribution attributes.
1279+
# Always-emit on every invocation span; not cross-cutting
1280+
# (§5.6) so inner-node spans don't carry them.
1281+
"openarmature.implementation.name": self.implementation_name,
1282+
"openarmature.implementation.version": self.implementation_version,
12441283
"openarmature.invocation_id": invocation_id,
12451284
}
12461285
if correlation_id is not None:

tests/conformance/test_fixture_parsing.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -362,11 +362,25 @@ def _id(case: tuple[str, Path]) -> str:
362362
"pipeline-utilities/063-failure-isolation-default-predicate-bare-exception": (
363363
"Proposal 0050 failure-isolation middleware; queued for v0.14.0"
364364
),
365-
# Proposal 0052 (implementation attribution attributes, v0.44.0)
366-
# — observability/059 is the Langfuse-side mapping fixture. Lands
367-
# in PR 3 of the v0.12.0 cycle along with the implementation.
365+
# Proposal 0052 (implementation attribution attributes, v0.44.0):
366+
# observability/059 is the Langfuse-side mapping fixture; uses the
367+
# ``langfuse_observer_config`` + ``harness_parameterized`` directive
368+
# shapes the cross-capability parser doesn't model. The python
369+
# implementation ships in v0.12.0 (manifest 0052 = implemented);
370+
# behavior is pinned by unit tests in
371+
# ``tests/unit/test_observability_metadata.py``,
372+
# ``tests/unit/test_observability_otel.py``, and
373+
# ``tests/unit/test_observability_langfuse.py``. Fixture-shape
374+
# activation is queued for a future PR slotted after the upcoming
375+
# spec conformance-adapter capability ratifies the directive
376+
# vocabulary. 058 (the OTel-side mapping fixture) parses cleanly
377+
# against the existing ``span_tree`` + ``attributes_absent``
378+
# directive shapes and is therefore NOT deferred from parsing;
379+
# runtime exec is gated by ``_SUPPORTED_FIXTURES`` in
380+
# ``test_observability.py`` until the harness wires up the
381+
# canonical-value parameterization.
368382
"observability/059-implementation-attribution-langfuse": (
369-
"Proposal 0052 implementation attribution; lands in PR 3 of v0.12.0"
383+
"Proposal 0052 fixture-shape models pending; contract pinned by unit tests"
370384
),
371385
# ----- v0.12.0 cycle spec-pin bump (v0.45.0 -> v0.46.0) -------------
372386
# Proposal 0054 (per-invocation observer event drain, v0.46.0):

0 commit comments

Comments
 (0)