Skip to content

Commit e2abe86

Browse files
Add caller-supplied invocation_id and 0041 reserved keys (#94)
* Add caller invocation_id + 0041 reserved keys 0039: invoke() accepts a caller-supplied invocation_id (any non-empty URL-safe string, validated at the boundary); resume still mints a fresh id. The Langfuse adapter derives the trace.id from a non-UUID id via SHA-256 (matching create_trace_id) and surfaces the raw id under trace.metadata.invocation_id; a public langfuse_trace_id() helper exposes the mapping. 0041: reject caller metadata keys that exactly match an OA-emitted top-level key (the 8.4 Langfuse set plus invocation_id), at both the invoke() and mid-invocation set_invocation_metadata() boundaries. Conformance fixtures (035/036/057, 0041) follow with the spec submodule bump to v0.31.0. * Move langfuse_trace_id out of [langfuse] gate From PR #94 review: langfuse_trace_id is a pure mapping (uuid + hashlib only) and meant for operator / tooling use (e.g. mapping a logged invocation_id to a Langfuse trace URL), so gating it behind the [langfuse] extra was unnecessary. Move _is_uuid, _to_otel_trace_id, and langfuse_trace_id into a new langfuse/trace_id.py with no SDK import. adapter.py imports the internal helpers from there (so its own use and the existing adapter._to_otel_trace_id test reference are unchanged). langfuse_trace_id is now exported unconditionally from the langfuse subpackage.
1 parent 5c4e999 commit e2abe86

8 files changed

Lines changed: 291 additions & 40 deletions

File tree

src/openarmature/graph/compiled.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
_set_namespace_prefix,
7878
current_active_observer_span,
7979
current_attempt_index,
80+
validate_invocation_id,
8081
)
8182
from openarmature.observability.metadata import (
8283
_reset_invocation_metadata,
@@ -772,6 +773,7 @@ async def invoke(
772773
observers: Iterable[Observer | SubscribedObserver] | None = None,
773774
*,
774775
correlation_id: str | None = None,
776+
invocation_id: str | None = None,
775777
resume_invocation: str | None = None,
776778
metadata: Mapping[str, Any] | None = None,
777779
) -> StateT:
@@ -797,6 +799,12 @@ async def invoke(
797799
- ``correlation_id`` is the per-invocation cross-backend join
798800
key. Caller-supplied or auto-generated UUIDv4 when absent.
799801
Preserved unchanged across ``resume_invocation``.
802+
- ``invocation_id`` (proposal 0039) is the per-attempt id.
803+
Caller-supplied or auto-generated UUIDv4 when absent; a
804+
caller value MAY be any non-empty URL-safe string. Applies
805+
to the fresh-invocation path only — a ``resume_invocation``
806+
mints a fresh id regardless (each attempt is its own
807+
invocation).
800808
- ``resume_invocation`` names a prior ``invocation_id`` to
801809
resume from. Requires a registered Checkpointer; raises
802810
``CheckpointNotFound`` when the backend has no record for
@@ -848,7 +856,13 @@ async def invoke(
848856
# step 3) and pre-populate the skip-set + completed_positions.
849857
starting_state: StateT = initial_state
850858
resolved_correlation_id = correlation_id or str(uuid.uuid4())
851-
invocation_id = str(uuid.uuid4())
859+
# Caller-supplied invocation_id (proposal 0039) applies to the
860+
# fresh-invocation path only; a resume mints a fresh id
861+
# regardless (each attempt is its own invocation, §5.1).
862+
if invocation_id is not None and resume_invocation is None:
863+
invocation_id = validate_invocation_id(invocation_id)
864+
else:
865+
invocation_id = str(uuid.uuid4())
852866
resume_skip_set: frozenset[tuple[str, ...]] = frozenset()
853867
completed_positions: list[NodePosition] = []
854868
pending_resume_states: dict[int, Any] = {}

src/openarmature/observability/correlation.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
from __future__ import annotations
3232

33+
import re
3334
from collections.abc import Callable
3435
from contextvars import ContextVar, Token
3536
from typing import TYPE_CHECKING
@@ -119,6 +120,37 @@ def _reset_invocation_id(token: Token[str | None]) -> None:
119120
_invocation_id_var.reset(token)
120121

121122

123+
# Caller-supplied invocation_id validation (proposal 0039). Per §5.1 a
124+
# caller MAY supply its own id at invoke(); it MAY be any non-empty
125+
# URL-safe string (it need not be a UUID — the Langfuse trace.id
126+
# derivation in §8.4.1 handles non-UUID values). URL-safe here is the
127+
# RFC 3986 unreserved set.
128+
_INVOCATION_ID_RE = re.compile(r"^[A-Za-z0-9._~-]+$")
129+
130+
131+
def validate_invocation_id(value: object) -> str:
132+
"""Validate a caller-supplied ``invocation_id`` and return it.
133+
134+
Per observability §5.1 a caller-supplied id MAY be any non-empty
135+
URL-safe string. Rejects empty / non-string / non-URL-safe values
136+
at the ``invoke()`` boundary so the violation surfaces
137+
synchronously to the caller rather than as a downstream trace-id
138+
derivation failure. Typed ``object`` (like
139+
:func:`validate_invocation_metadata`) so the boundary check guards
140+
against untyped callers. Raises :class:`ValueError`.
141+
"""
142+
if not isinstance(value, str):
143+
raise ValueError(f"invocation_id must be a string; got {type(value).__name__}")
144+
if not value:
145+
raise ValueError("invocation_id must be a non-empty string")
146+
if not _INVOCATION_ID_RE.match(value):
147+
raise ValueError(
148+
f"invocation_id {value!r} is not URL-safe; allowed characters are "
149+
f"A-Z a-z 0-9 and -._~ (RFC 3986 unreserved set)"
150+
)
151+
return value
152+
153+
122154
# ---------------------------------------------------------------------------
123155
# Active observer set — for capability backends emitting from outside the
124156
# engine's per-step path (llm-provider span hook in Phase 6, future
@@ -398,6 +430,7 @@ def _reset_active_observer_span(token: Token[object | None]) -> None:
398430
"current_fan_out_index",
399431
"current_invocation_id",
400432
"current_namespace_prefix",
433+
"validate_invocation_id",
401434
# Engine-internal lifecycle helpers — exported so the engine in
402435
# ``openarmature.graph.compiled`` can drive set/reset without
403436
# pyright's strict ``reportUnusedFunction`` flagging them as

src/openarmature/observability/langfuse/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@
4141
ObservationType,
4242
)
4343
from .observer import LangfuseObserver
44+
from .trace_id import langfuse_trace_id
4445

4546
# LangfuseSDKAdapter requires the [langfuse] optional dependency.
4647
# Surface it when available, but don't force the import on consumers
4748
# who only use the InMemoryLangfuseClient — the adapter module's own
4849
# guard raises an informative ImportError if anyone tries to use it
49-
# without the extras installed.
50+
# without the extras installed. `langfuse_trace_id` is pure
51+
# (uuid+hashlib only), so it's exported unconditionally above.
5052
try:
5153
from .adapter import LangfuseSDKAdapter as LangfuseSDKAdapter
5254

@@ -65,6 +67,7 @@
6567
"LangfuseUsage",
6668
"ObservationLevel",
6769
"ObservationType",
70+
"langfuse_trace_id",
6871
]
6972
if _adapter_available:
7073
__all__.append("LangfuseSDKAdapter")

src/openarmature/observability/langfuse/adapter.py

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@
3434
from __future__ import annotations
3535

3636
import json
37-
import uuid as _uuid
3837
from contextlib import ExitStack
3938
from typing import TYPE_CHECKING, Any, cast
4039

4140
from .client import LangfuseGenerationHandle, LangfuseSpanHandle, LangfuseUsage, ObservationLevel
41+
from .trace_id import _is_uuid, _to_otel_trace_id
4242

4343
if TYPE_CHECKING:
4444
from langfuse import Langfuse
@@ -53,31 +53,6 @@
5353
) from exc
5454

5555

56-
def _to_otel_trace_id(trace_id: str) -> str:
57-
"""Convert OA's UUID4-formatted invocation_id to OTel's 32-char
58-
hex trace_id form (no dashes).
59-
60-
Langfuse v4 is OTel-based: trace IDs are 128-bit integers
61-
serialized as 32 lowercase hex characters. OA's invocation_id is
62-
a standard UUID4 (8-4-4-4-12 dashed hex); same 128 bits, different
63-
representation. Passing the dashed form to Langfuse v4 fails with
64-
``int(..., 16)`` parsing in the SDK's internals.
65-
66-
Non-UUID inputs pass through unchanged so adapter consumers can
67-
pass an already-OTel-formatted trace_id if they have one.
68-
69-
Trade-off: the spec §8.4.1 "trace.id MUST equal invocation_id
70-
verbatim" contract is met content-wise (same 128 bits) but not
71-
representation-wise. Users querying Langfuse for an OA
72-
invocation_id need to strip dashes before searching. Documented
73-
in the adapter's class docstring.
74-
"""
75-
try:
76-
return _uuid.UUID(trace_id).hex
77-
except (ValueError, AttributeError):
78-
return trace_id
79-
80-
8156
def _stringify_metadata(metadata: dict[str, Any] | None) -> dict[str, str]:
8257
"""Coerce metadata values to strings for v4's propagate_attributes,
8358
which only accepts ``Dict[str, str]``. Non-string scalars stringify
@@ -231,10 +206,14 @@ def trace(
231206
# it via propagate_attributes on every observation under this
232207
# trace_id so the trace's display name + metadata stay
233208
# consistent under v4's last-wins semantics.
234-
self._trace_info[id] = {
235-
"name": name,
236-
"metadata": dict(metadata) if metadata is not None else {},
237-
}
209+
md: dict[str, Any] = dict(metadata) if metadata is not None else {}
210+
# Non-UUID invocation_id: the derived trace.id is a hash, not
211+
# reversible to the caller's id, so surface the raw id under
212+
# trace.metadata.invocation_id for lookup (§8.4.1). The key is
213+
# reserved (proposal 0041), so no caller metadata collides.
214+
if not _is_uuid(id):
215+
md.setdefault("invocation_id", id)
216+
self._trace_info[id] = {"name": name, "metadata": md}
238217

239218
def update_trace(
240219
self,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Trace-id derivation helpers, SDK-independent.
2+
3+
Pure functions for mapping an OA ``invocation_id`` to the 32-char hex
4+
Langfuse ``trace.id``. Imports only stdlib (``uuid``, ``hashlib``), so
5+
this module is importable without the ``[langfuse]`` extras —
6+
operators and tooling can compute trace ids without pulling in the
7+
SDK.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import hashlib
13+
import uuid as _uuid
14+
15+
16+
def _is_uuid(value: str) -> bool:
17+
try:
18+
_uuid.UUID(value)
19+
except (ValueError, AttributeError):
20+
return False
21+
return True
22+
23+
24+
# Per observability §8.4.1: Langfuse v4 trace ids are 128-bit values
25+
# rendered as 32 lowercase hex. A UUID invocation_id maps to its hex
26+
# form (dashes stripped). A non-UUID maps to the first 16 bytes of
27+
# SHA-256(invocation_id) as 32 hex (the same derivation as Langfuse's
28+
# create_trace_id(seed), so a consumer can reproduce it); the raw id is
29+
# also written to trace.metadata.invocation_id (see the adapter's
30+
# `trace`) for lookup.
31+
def _to_otel_trace_id(trace_id: str) -> str:
32+
"""Return the 32-char hex Langfuse trace id for an OA invocation_id."""
33+
if _is_uuid(trace_id):
34+
return _uuid.UUID(trace_id).hex
35+
return hashlib.sha256(trace_id.encode("utf-8")).digest()[:16].hex()
36+
37+
38+
def langfuse_trace_id(invocation_id: str) -> str:
39+
"""Return the Langfuse ``trace.id`` for an OA ``invocation_id``.
40+
41+
Public helper for mapping a logged ``invocation_id`` (a dashed UUID
42+
or a caller-supplied non-UUID string) to the 32-char hex
43+
``trace.id`` Langfuse stores, e.g. to build a direct trace URL.
44+
"""
45+
return _to_otel_trace_id(invocation_id)

src/openarmature/observability/metadata.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
- Keys MUST NOT start with ``openarmature.`` or ``gen_ai.`` (reserved
3030
for spec-normative attribute namespaces; collisions would silently
3131
overwrite OA-emitted state at the observer layer).
32+
- Keys MUST NOT exactly match a reserved OA-emitted top-level metadata
33+
key name (the §8.4 Langfuse set plus ``invocation_id``; proposal
34+
0041) for the same collision reason.
3235
- Values MUST be OTel-attribute-compatible scalars: ``str``, ``int``,
3336
``float``, ``bool``, or a homogeneous list/tuple of those types.
3437
``None``, nested objects, and mixed-type arrays are rejected.
@@ -63,6 +66,39 @@
6366
# boundary so observers never see a colliding key.
6467
_RESERVED_PREFIXES: tuple[str, ...] = ("openarmature.", "gen_ai.")
6568

69+
# Reserved exact key NAMES per §3.4 (proposal 0041): the top-level
70+
# metadata keys an OA-emitted §8 backend mapping writes alongside
71+
# caller keys (the §8.4 Langfuse set, plus invocation_id). A caller
72+
# key matching one exactly would silently overwrite an OA field in a
73+
# backend's flat top-level metadata, so it is rejected at the boundary
74+
# the same way as the prefix reservation. Backend-set-independent:
75+
# rejected regardless of which observers are attached.
76+
_RESERVED_KEY_NAMES: frozenset[str] = frozenset(
77+
{
78+
"correlation_id",
79+
"entry_node",
80+
"spec_version",
81+
"detached_child_trace_ids",
82+
"namespace",
83+
"step",
84+
"attempt_index",
85+
"fan_out_index",
86+
"subgraph_name",
87+
"fan_out_item_count",
88+
"fan_out_concurrency",
89+
"fan_out_error_policy",
90+
"fan_out_parent_node_name",
91+
"prompt_group_name",
92+
"request_extras",
93+
"finish_reason",
94+
"system",
95+
"response_model",
96+
"response_id",
97+
"prompt",
98+
"invocation_id",
99+
}
100+
)
101+
66102

67103
def current_invocation_metadata() -> MappingProxyType[str, AttributeValue]:
68104
"""Return the caller-supplied invocation metadata in scope, or the
@@ -144,6 +180,12 @@ def _validate_metadata_key(key: Any) -> None:
144180
f"invocation metadata key {key!r} uses reserved namespace prefix {reserved!r}; "
145181
f"reserved prefixes are for spec-normative attributes (openarmature.*, gen_ai.*)"
146182
)
183+
if key in _RESERVED_KEY_NAMES:
184+
raise ValueError(
185+
f"invocation metadata key {key!r} is reserved: it exactly matches a top-level "
186+
f"metadata key OA emits to observability backends, and would overwrite it. "
187+
f"Rename the key."
188+
)
147189

148190

149191
def _validate_metadata_value(key: str, value: Any) -> None:

tests/unit/test_observability_langfuse_adapter.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ def test_adapter_caches_trace_info() -> None:
8080
assert "trace-1" in adapter._trace_info # noqa: SLF001
8181
cached = adapter._trace_info["trace-1"] # noqa: SLF001
8282
assert cached["name"] == "my-trace"
83-
assert cached["metadata"] == {"correlation_id": "c-1"}
83+
# "trace-1" is a non-UUID id, so the raw id is also surfaced under
84+
# metadata.invocation_id (proposal 0039 / §8.4.1).
85+
assert cached["metadata"] == {"correlation_id": "c-1", "invocation_id": "trace-1"}
8486

8587

8688
def test_adapter_converts_uuid_trace_id_to_otel_hex() -> None:
@@ -96,10 +98,34 @@ def test_adapter_converts_uuid_trace_id_to_otel_hex() -> None:
9698
assert _to_otel_trace_id("b24eda93-d06d-4eaa-9891-ca5e56f35722") == "b24eda93d06d4eaa9891ca5e56f35722"
9799
# Idempotent on already-hex input.
98100
assert _to_otel_trace_id("b24eda93d06d4eaa9891ca5e56f35722") == "b24eda93d06d4eaa9891ca5e56f35722"
99-
# Non-UUID inputs pass through unchanged (consumers passing an
100-
# already-OTel-formatted trace_id from elsewhere don't get
101-
# mangled).
102-
assert _to_otel_trace_id("custom-trace-id") == "custom-trace-id"
101+
102+
103+
def test_to_otel_trace_id_non_uuid_derivation() -> None:
104+
import hashlib
105+
106+
from openarmature.observability.langfuse import langfuse_trace_id
107+
from openarmature.observability.langfuse.adapter import _to_otel_trace_id
108+
109+
# Non-UUID -> first 16 bytes of SHA-256 as 32 hex (== Langfuse's
110+
# create_trace_id(seed)); the public helper is the same mapping.
111+
expected = hashlib.sha256(b"run_abc123").digest()[:16].hex()
112+
assert _to_otel_trace_id("run_abc123") == expected
113+
assert langfuse_trace_id("run_abc123") == expected
114+
# Spec fixture 036 pins this vector.
115+
assert expected == "29b50a6c08dabfeaeb1696301f4fabe1"
116+
# UUID path still strips dashes; the helper agrees.
117+
assert langfuse_trace_id("b24eda93-d06d-4eaa-9891-ca5e56f35722") == "b24eda93d06d4eaa9891ca5e56f35722"
118+
119+
120+
def test_adapter_trace_surfaces_raw_id_for_non_uuid() -> None:
121+
adapter = LangfuseSDKAdapter(_dummy_client())
122+
# Non-UUID id: raw id surfaced under metadata.invocation_id (§8.4.1).
123+
adapter.trace(id="run_abc123", name="t", metadata=None)
124+
assert adapter._trace_info["run_abc123"]["metadata"]["invocation_id"] == "run_abc123" # noqa: SLF001
125+
# UUID id: no invocation_id injected (its trace.id is reversible).
126+
uid = "b24eda93-d06d-4eaa-9891-ca5e56f35722"
127+
adapter.trace(id=uid, name="t", metadata=None)
128+
assert "invocation_id" not in adapter._trace_info[uid]["metadata"] # noqa: SLF001
103129

104130

105131
def test_adapter_update_trace_merges_into_cache() -> None:
@@ -111,7 +137,8 @@ def test_adapter_update_trace_merges_into_cache() -> None:
111137

112138
cached = adapter._trace_info["trace-1"] # noqa: SLF001
113139
assert cached["name"] == "renamed"
114-
assert cached["metadata"] == {"key1": "v1", "key2": "v2"}
140+
# "trace-1" is non-UUID, so trace() also surfaced metadata.invocation_id.
141+
assert cached["metadata"] == {"key1": "v1", "key2": "v2", "invocation_id": "trace-1"}
115142

116143

117144
def test_adapter_force_flush_delegates_to_client() -> None:

0 commit comments

Comments
 (0)