Skip to content

Add caller-supplied invocation metadata (proposal 0034)#86

Merged
chris-colinsky merged 2 commits into
mainfrom
feature/caller-supplied-invocation-metadata
May 28, 2026
Merged

Add caller-supplied invocation metadata (proposal 0034)#86
chris-colinsky merged 2 commits into
mainfrom
feature/caller-supplied-invocation-metadata

Conversation

@chris-colinsky
Copy link
Copy Markdown
Member

@chris-colinsky chris-colinsky commented May 28, 2026

Summary

Implements observability §3.4 + §5.6 + §8.4.1/§8.4.2 (proposal 0034): callers pass invoke(metadata={...}) and the framework propagates the entries to every observability backend uniformly. OTel emits each entry as a cross-cutting openarmature.user.<key> span attribute on every span; Langfuse merges each entry as a top-level key into trace.metadata and every observation.metadata.

Public surface

  • compiled.invoke(metadata=...) — optional dict[str, AttributeValue] mapping per the spec's OTel-AnyValue-compatible value contract (str / int / float / bool / homogeneous arrays).
  • openarmature.observability.set_invocation_metadata(**entries) — public augmentation helper; additive merge.
  • openarmature.observability.current_invocation_metadata() — public reader; returns empty MappingProxyType outside an invocation.
  • Synchronous boundary validation: keys under openarmature.* / gen_ai.* raise ValueError BEFORE any work begins; non-OTel-compatible value types raise the same.

Engine internals

  • New module src/openarmature/observability/metadata.py owns the ContextVar, validation, and lifecycle helpers. Engine drives _set_invocation_metadata / _reset_invocation_metadata around the outermost invoke().
  • NodeEvent.caller_invocation_metadata and LlmEventPayload.caller_invocation_metadata carry dispatch-time snapshots. Observers read from the snapshot rather than the live ContextVar because the deliver_loop task's Context is frozen at invoke time and would NOT reflect mid-invocation augmentations made by node bodies running in the main engine task.

Conformance fixtures

  • 026-otel-caller-supplied-metadata — verifies openarmature.user.* on every span. Activated.
  • 027-langfuse-caller-supplied-metadata — verifies trace + every observation propagation. Activated.
  • 028-caller-metadata-namespace-rejection — verifies the API boundary rejects reserved-namespace keys before any work begins. Activated via a dedicated _run_fixture_028 runner that attaches both observers and asserts ValueError + no spans + no Langfuse traces.
  • 029-caller-metadata-fan-out-per-instance and 030-caller-metadata-parallel-branches-per-branchdeferred: they need a new augment_metadata_from_field harness primitive that calls set_invocation_metadata per fan-out instance / per parallel branch. The augmentation surface itself is already exercised by unit tests; the conformance fixtures un-defer in a follow-up.

Test plan

  • 25 new unit tests in tests/unit/test_observability_metadata.py covering boundary validation, ContextVar lifecycle, augmentation merge/overwrite, reserved-namespace rejection, invoke()-boundary rejection, mid-invocation augmentation persisting to subsequent nodes, openarmature.user.* emission on every OTel span, Langfuse top-level merge on trace + every observation.
  • Full unit + conformance suite — 910 passed, 120 skipped.
  • ruff check / ruff format --check clean.
  • pyright clean across changed files.
  • 029 / 030 un-defer once the augment_metadata_from_field harness primitive lands.

Implements observability §3.4 + §5.6 + §8.4.1/§8.4.2: callers pass
``invoke(metadata={...})`` and the framework propagates the entries
to every observability backend.

Public surface:

- ``compiled.invoke(metadata=...)`` accepts a per-invocation
  ``dict[str, AttributeValue]`` mapping where values are OTel
  scalars or homogeneous arrays (str/int/float/bool).
- ``openarmature.observability.set_invocation_metadata(**entries)``
  augments the in-scope mapping mid-invocation (additive merge;
  existing keys overwritten).
- ``openarmature.observability.current_invocation_metadata()``
  reads the in-scope mapping (returns empty MappingProxyType
  outside an invocation).
- Synchronous validation at the API boundary rejects keys under
  the reserved ``openarmature.*`` / ``gen_ai.*`` prefixes and
  rejects non-OTel-compatible value types (``ValueError`` before
  any work begins).

Engine internals:

- ContextVar lifecycle in
  ``openarmature.observability.metadata``; engine drives
  ``_set_invocation_metadata`` / ``_reset_invocation_metadata``
  around the outermost ``invoke()`` call.
- ``NodeEvent.caller_invocation_metadata`` carries a dispatch-time
  snapshot (the deliver_loop task's Context is frozen at invoke
  time, so observers can't re-read the live ContextVar safely).
- ``LlmEventPayload.caller_invocation_metadata`` mirrors the
  pattern for LLM provider events.

OTel observer (§5.6): emits each entry as
``openarmature.user.<key>`` on every span — invocation, node,
subgraph wrapper, fan-out instance dispatch, LLM provider,
detached roots, checkpoint-migrate, checkpoint-save. Cross-cutting
attribute family parallel to ``openarmature.correlation_id``.

Langfuse observer (§8.4.1 + §8.4.2): merges each entry as a
top-level key into ``trace.metadata`` (at trace open) and into
every observation's ``metadata`` bag (leaf nodes, subgraph
wrappers, fan-out instance dispatches, detached-trace wrappers
+ link observations, LLM generations).

Conformance fixtures (proposal 0035's full set is 026 / 027 / 028
/ 029 / 030):

- 026 (OTel cross-cutting) and 027 (Langfuse top-level merge)
  activated.
- 028 (boundary rejection) activated via a dedicated
  ``_run_fixture_028`` runner that attaches both observers and
  asserts ``ValueError`` + no spans + no Langfuse traces.
- 029 (fan-out per-instance) and 030 (parallel-branches per-branch)
  stay deferred — they need an ``augment_metadata_from_field``
  harness primitive that calls ``set_invocation_metadata`` per
  fan-out instance / per parallel branch. The augmentation surface
  is already exercised by unit tests; the conformance fixtures
  un-defer in a follow-up.

Adds 25 focused unit tests covering boundary validation, ContextVar
lifecycle, augmentation merge / overwrite, reserved-namespace
rejection, ``invoke()``-boundary rejection, mid-invocation
augmentation persisting to subsequent nodes,
``openarmature.user.*`` emission on every OTel span, and Langfuse
top-level merge on trace + every observation.

The OTel-side LLM-payload runner was extended to handle multi-node
graphs where ``calls_llm`` is on a non-entry node (fixture 026 has
a ``prep`` step before the LLM call).
Copilot AI review requested due to automatic review settings May 28, 2026 02:22
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements proposal 0034 (observability §3.4 + §5.6 + §8.4.1/§8.4.2): callers may pass metadata={...} to invoke(), and the framework propagates each entry uniformly to OTel (as openarmature.user.<key> span attributes) and Langfuse (top-level trace.metadata and every observation.metadata). The metadata flows via a ContextVar with per-async-context COW semantics; observers read dispatch-time snapshots stashed on NodeEvent / LlmEventPayload because the deliver_loop task's Context is frozen at invoke time.

Changes:

  • New observability/metadata.py module: ContextVar, synchronous boundary validation (reserved-prefix and OTel-AnyValue-compatible types), and engine-internal lifecycle helpers; wired into CompiledGraph.invoke with a metadata= kwarg.
  • OTel and Langfuse observers apply caller metadata at every span / observation emission site; NodeEvent and LlmEventPayload gain a caller_invocation_metadata snapshot field.
  • New unit tests + activated conformance fixtures 026/027/028 (029/030 deferred awaiting an augment_metadata_from_field harness primitive).

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/openarmature/observability/metadata.py New module: ContextVar, validators, public reader/augmenter, engine lifecycle helpers.
src/openarmature/observability/init.py Re-exports the two public helpers.
src/openarmature/graph/events.py NodeEvent gains caller_invocation_metadata snapshot field.
src/openarmature/graph/compiled.py invoke() validates & seeds the ContextVar; dispatch sites pass the snapshot.
src/openarmature/observability/llm_event.py LlmEventPayload gains the dispatch-time snapshot field.
src/openarmature/llm/providers/openai.py LLM provider captures the snapshot when building the event.
src/openarmature/observability/otel/observer.py Emits openarmature.user.<key> on every span.
src/openarmature/observability/langfuse/observer.py Merges entries into trace + every observation metadata.
tests/unit/test_observability_metadata.py New 25-test suite for validation, lifecycle, augmentation, and observer emission.
tests/conformance/test_observability.py Activates fixtures 026 & 028; adds dedicated _run_fixture_028 rejection runner; multi-node LLM-fixture support.
tests/conformance/test_observability_langfuse.py Activates fixture 027; threads metadata= into invoke.
tests/conformance/test_fixture_parsing.py Updates skip rationales for 027/028/029/030.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/openarmature/graph/compiled.py
The synthetic NodeEvent dispatched for a checkpoint_migrated phase
in CompiledGraph.invoke was missed while wiring caller-invocation-
metadata snapshots through the regular _dispatch_started /
_dispatch_completed sites. Result: the
openarmature.checkpoint.migrate span ended up as the only span
missing the cross-cutting openarmature.user.* attribute set,
violating observability §5.6's "every span" invariant.

The metadata ContextVar is set 22 lines earlier in invoke(), so
current_invocation_metadata() returns the right value at this
synthetic dispatch site. Parallel to the three other dispatch
sites updated in PR #86.
@chris-colinsky chris-colinsky merged commit 2aa6efc into main May 28, 2026
6 checks passed
@chris-colinsky chris-colinsky deleted the feature/caller-supplied-invocation-metadata branch May 28, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants