Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 73 additions & 4 deletions conformance.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/openarmature/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
39 changes: 39 additions & 0 deletions src/openarmature/observability/langfuse/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Comment thread
chris-colinsky marked this conversation as resolved.
# Proposal 0043 §8.4.1 *Trace input/output sourcing*.
disable_state_payload: bool = True
trace_input_from_state: Callable[[Any], Any] | None = None
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/openarmature/observability/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
)

Expand Down
39 changes: 39 additions & 0 deletions src/openarmature/observability/otel/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>`` entries per
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
chris-colinsky marked this conversation as resolved.

# Internal state, populated in __post_init__ and during invocation.
_provider: TracerProvider = field(init=False, repr=False)
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 18 additions & 4 deletions tests/conformance/test_fixture_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading