Skip to content

Commit 44cf643

Browse files
Add Langfuse trace.userId / trace.sessionId population (0064) (#171)
* Pin spec v0.62.0 for proposal 0064 Advance the spec submodule pin v0.61.0 -> v0.62.0 to absorb accepted proposal 0064 (Langfuse trace.sessionId / trace.userId population). Updates __spec_version__, the pyproject spec_version, the smoke-test version assertion, and regenerates the bundled AGENTS.md. conformance.toml records 0064 as partial: the trace.userId half ships, while trace.sessionId is dormant until the sessions capability (0020) supplies openarmature.session_id. * Populate Langfuse trace.userId / trace.sessionId (0064) Implement proposal 0064's Langfuse Trace-level grouping fields. The observer recognizes a userId key in the caller-supplied invocation metadata and promotes it to the first-class trace.userId, additively (the key also stays in trace.metadata.userId). trace.sessionId sources from openarmature.session_id; python has no source until the sessions capability (0020), so that half is plumbed but dormant (passes None). LangfuseClient.trace() (the Protocol, the in-memory client, and the SDK adapter) gains session_id / user_id; the observer centralizes the promotion through a _client_trace wrapper so all five trace-open sites apply it uniformly. Unit tests cover the client plumbing and the promotion helper; the live integration test plus the extended end-to-end cloud test assert both fields populate real Langfuse. * Wire fixture 084 and extend conformance harness Activate observability fixture 084 (Langfuse session/user promotion): cases 2/3/4 (not session-bound, userId present additively, userId absent) run now; the session-bound cases 1/5 defer per-case until the sessions capability (0020) supplies a session_id source. Extend the fixture-parsing models for 084's shapes: ObservabilityExpected gains langfuse_trace / langfuse_traces (and the matching discriminator keys), and CaseSpec.invocations widens to int | list to carry case 5's multi-invocation specs alongside the existing run-count usage. * Document 0064: concepts, changelog, example Document the Langfuse trace.userId / trace.sessionId population in the observability concepts page, add the 0.15.0 changelog entry (folding 0064 into the cycle's spec-pin bullet), and demonstrate the userId promotion in the langfuse-observability example (a per-operator userId that surfaces in the captured trace). sessionId is left out of the example since it stays dormant until the sessions capability lands.
1 parent 7224e30 commit 44cf643

18 files changed

Lines changed: 277 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
1010

1111
- **Detached-trace invocation span** (proposal 0061, observability §4.4, spec v0.61.0). The OTel observer now synthesizes an `openarmature.invocation` span at the root of each detached trace (a detached subgraph and each detached fan-out instance), carrying the parent's shared `invocation_id` (detached mode is observer-side trace rendering, not a new run) and the detached unit's own `entry_node`; the detached subgraph / instance span nests under it. A raising detached subgraph surfaces ERROR plus the error category and an OTel exception event on both the parent dispatch span and the detached invocation span. This is observer-side only, with no graph-engine change; the Langfuse observer is unchanged (its Trace entity already plays the invocation-level-container role). Conformance fixtures 008 (rewritten) and 058 (newly wired) run in `test_observability`.
1212
- **Per-attempt LLM spans under call-level retry** (proposal 0050, observability §5.5 / llm-provider §7.1). Completes proposal 0050, which shipped `partial` in v0.14.0 (failure-isolation middleware and the `complete(retry=...)` loop landed then; the per-attempt span surface was deferred). Under call-level retry the OTel observer now emits one `openarmature.llm.complete` span per attempt, each carrying `openarmature.llm.attempt_index` (0-based, 0..N-1, and 0 for a no-retry call). An intermediate failed attempt's span carries ERROR status plus its error category and the request-side attributes; the final attempt's span carries the terminal outcome and, on success, the full response surface. A python-internal `LlmRetryAttemptEvent`, dispatched once per attempt, is the sole source of the OTel span; the terminal `LlmCompletionEvent` / `LlmFailedEvent` stay one per call (payload, latency, Langfuse Generation) and no longer drive the OTel span. Langfuse renders one terminal Generation per call, with the per-attempt detail on the OTel span surface only (a spec-side §8 clarification to pin this is tracked, non-blocking). `conformance.toml` flips proposal 0050 to `implemented`; the call-level fixtures 056-058 are driven through the provider plus OTel observer and the single-attempt observability fixture 057 is wired.
13+
- **Langfuse `trace.userId` / `trace.sessionId` population** (proposal 0064, observability §8.4.1, spec v0.62.0). The Langfuse observer now promotes a recognized `userId` key in the caller-supplied invocation metadata to Langfuse's first-class `trace.userId` field (the Users dashboard), additively: the key also remains at `trace.metadata.userId`. Promotion is automatic and unconditional; an absent key leaves `trace.userId` unset. The `LangfuseClient.trace()` surface (the Protocol, the in-memory client, and the SDK adapter) gains `session_id` / `user_id`. `trace.sessionId` is sourced from `openarmature.session_id`, which the sessions capability (proposal 0020) establishes; that capability is not yet implemented in python, so the `sessionId` plumbing is in place but dormant (no source) and unset in the interim. `conformance.toml` records proposal 0064 `partial` on that basis: fixture 084 cases 2/3/4 (not session-bound, `userId` present additively, `userId` absent) run, and the session-bound cases 1/5 defer until 0020. Langfuse-only: the OTel side already carries `openarmature.session_id` and `openarmature.user.*` as span attributes, and OTel has no trace-level session/user field.
1314

1415
### Changed
1516

16-
- **Pinned spec advances v0.60.0 → v0.61.0** (proposal 0061, the detached-trace invocation span above). A single step this cycle; `conformance.toml` records proposal 0061 as `implemented`. Proposal 0050 needed no pin bump of its own (it was already within the pin from its v0.42.0 acceptance); its v0.14.0 `partial` entry flips to `implemented` with the per-attempt span surface above.
17+
- **Pinned spec advances v0.60.0 → v0.62.0** across the v0.15.0 cycle: v0.61.0 (proposal 0061, the detached-trace invocation span above) and v0.62.0 (proposal 0064, the Langfuse session/user population above). `conformance.toml` records 0061 `implemented` and 0064 `partial` (its `sessionId` half is dormant pending the sessions capability). Proposal 0050 needed no pin bump of its own (it was already within the pin from its v0.42.0 acceptance); its v0.14.0 `partial` entry flips to `implemented` with the per-attempt span surface above.
1718

1819
## [0.14.0] — 2026-06-17
1920

conformance.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,3 +698,10 @@ note = "Descriptive catalog of the failure-mock family (flaky + failure_sequence
698698
status = "implemented"
699699
since = "0.15.0"
700700
note = "The OTel observer synthesizes an openarmature.invocation span at the root of each detached trace (a detached subgraph + each detached fan-out instance), carrying the parent's SHARED invocation_id (detached mode is observer-side trace rendering, not a new run) and the detached unit's own entry_node; the detached subgraph / instance span nests under it. A raising detached subgraph surfaces ERROR + the category + an OTel exception event on BOTH the parent dispatch span and the detached invocation span. Observer-side only -- no graph-engine change; the Langfuse observer is unchanged (its Trace entity already plays the invocation-level-container role). Fixtures 008 (rewritten) and 058 (newly wired) run in test_observability."
701+
702+
# Spec v0.62.0 (proposal 0064). Langfuse trace.sessionId / trace.userId
703+
# population (observability §8.4.1 / §8.10).
704+
[proposals."0064"]
705+
status = "partial"
706+
since = "0.15.0"
707+
note = "The Langfuse observer promotes a recognized userId caller-metadata key to the first-class trace.userId (additive: the key also stays in trace.metadata.userId), and sets trace.sessionId from openarmature.session_id when present. trace.userId is LIVE (sourced from 0034 caller metadata): fixture 084 cases 2/3/4 (not-session-bound, userId present additive, userId absent) pass. partial because trace.sessionId is DORMANT -- openarmature.session_id is established by the sessions capability (0020, observability §5.6), unimplemented in python until v0.19.0, so there is no session_id source yet; the trace(session_id=) plumbing is wired end to end but the observer passes None. Fixture 084 session-bound cases 1 + 5 are deferred (per-case) pending 0020. Langfuse-only: no OTel change (the OTel side already carries openarmature.session_id + openarmature.user.* as span attributes; no trace-level OTel equivalent)."

docs/concepts/observability.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,18 @@ for a runnable demo.
10481048
- **Trace name.** Defaults to the entry-node name (spec §8.6
10491049
fallback). Caller-supplied invocation labels land in PR 4
10501050
(proposal 0034).
1051+
- **Session / user grouping (`trace.sessionId` / `trace.userId`).**
1052+
The observer populates the two cross-trace grouping fields behind
1053+
Langfuse's Sessions and Users dashboards (spec §8.4.1, proposal
1054+
0064). `trace.userId` is promoted from a recognized `userId` key in
1055+
the caller-supplied invocation metadata, automatically and
1056+
additively (the key also stays at `trace.metadata.userId`); an
1057+
absent key leaves it unset. `trace.sessionId` is sourced from
1058+
`openarmature.session_id` (the sessions capability), which is not
1059+
yet implemented, so it is unset for now. There is no OTel
1060+
equivalent (an OTel trace has no trace-level session / user field);
1061+
the same identity already rides as `openarmature.session_id` and the
1062+
`openarmature.user.*` family on the OTel span side.
10511063
- **Per-observation metadata.** Each Span / Generation carries
10521064
`namespace`, `step`, `attempt_index`, optional `fan_out_index` /
10531065
`branch_name`, and the `correlation_id` cross-cutting join key

examples/langfuse-observability/main.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
observation picks that up and links back to the entity, which is how
1717
production Langfuse dashboards thread "this generation came from prompt
1818
v7 of `mission-briefing`" without you having to wire anything up
19-
manually.
19+
manually. It also tags each trace with a ``userId`` (operator identity)
20+
via invocation metadata; the observer promotes that to Langfuse's
21+
first-class user dimension, so the Users dashboard groups and filters
22+
the assistant's traffic by operator.
2023
2124
The example uses the bundled ``InMemoryLangfuseClient`` recorder so the
2225
demo runs without a Langfuse account; at the end we print the captured
@@ -193,6 +196,8 @@ def _format_trace(trace: LangfuseTrace) -> str:
193196
lines: list[str] = []
194197
lines.append(f"Trace id={trace.id}")
195198
lines.append(f" name={trace.name!r}")
199+
if trace.user_id is not None:
200+
lines.append(f" userId={trace.user_id!r} (promoted to the Langfuse Users dimension)")
196201
lines.append(f" metadata={_format_metadata(trace.metadata)}")
197202
for obs in trace.children_of(None):
198203
_format_observation(lines, trace, obs, indent=" ")
@@ -274,7 +279,15 @@ async def main() -> None:
274279
graph.attach_observer(observer)
275280

276281
try:
277-
final = await graph.invoke(BriefingState(question=question))
282+
# metadata={"userId": ...} tags the trace with an operator
283+
# identity. The Langfuse observer promotes a recognized ``userId``
284+
# key to the first-class trace.userId field so the Users dashboard
285+
# can group and filter traces by operator (additive: it also stays
286+
# in trace.metadata.userId).
287+
final = await graph.invoke(
288+
BriefingState(question=question),
289+
metadata={"userId": "flight-controller-gene"},
290+
)
278291
finally:
279292
# Required for short-lived processes: invoke() returns when the
280293
# graph reaches END regardless of whether the observer queue

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec"
6363
openarmature = "openarmature.cli:main"
6464

6565
[tool.openarmature]
66-
spec_version = "0.61.0"
66+
spec_version = "0.62.0"
6767

6868
[dependency-groups]
6969
dev = [

src/openarmature/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OpenArmature — Agent documentation
22

3-
*This is the agent guide bundled with the openarmature Python package, version 0.14.0 (spec v0.61.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
3+
*This is the agent guide bundled with the openarmature Python package, version 0.14.0 (spec v0.62.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
44

55
## TL;DR
66

@@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents:
1010

1111
## Capability contracts
1212

13-
_Sourced from openarmature-spec v0.61.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md` verbatim — including additions from accepted proposals that this Python implementation may not yet ship. For per-proposal implementation status (implemented / partial / textual-only / not-yet), see the `conformance.toml` manifest at the repo root. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
13+
_Sourced from openarmature-spec v0.62.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md` verbatim — including additions from accepted proposals that this Python implementation may not yet ship. For per-proposal implementation status (implemented / partial / textual-only / not-yet), see the `conformance.toml` manifest at the repo root. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
1414

1515
### Capability: `graph-engine`
1616

src/openarmature/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"""
2626

2727
__version__ = "0.14.0"
28-
__spec_version__ = "0.61.0"
28+
__spec_version__ = "0.62.0"
2929
# Proposal 0052 (spec observability §5.1 / §8.4.1): canonical
3030
# package-registry name for this implementation. Surfaces on every
3131
# OTel invocation span as ``openarmature.implementation.name`` and on

src/openarmature/observability/langfuse/adapter.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ def trace(
209209
id: str,
210210
name: str | None = None,
211211
metadata: dict[str, Any] | None = None,
212+
session_id: str | None = None,
213+
user_id: str | None = None,
212214
) -> None:
213215
# v4 has no explicit trace creation; cache the info and apply
214216
# it via propagate_attributes on every observation under this
@@ -221,7 +223,15 @@ def trace(
221223
# reserved (proposal 0041), so no caller metadata collides.
222224
if not _is_uuid(id):
223225
md.setdefault("invocation_id", id)
224-
self._trace_info[id] = {"name": name, "metadata": md}
226+
# Proposal 0064 §8.4.1: cache the session/user grouping fields so
227+
# propagate_attributes can apply them around every observation
228+
# under this trace_id (v4 has no explicit trace-create call).
229+
self._trace_info[id] = {
230+
"name": name,
231+
"metadata": md,
232+
"session_id": session_id,
233+
"user_id": user_id,
234+
}
225235

226236
def update_trace(
227237
self,
@@ -292,6 +302,8 @@ def _emit_trace_output_synthetic(self, trace_id: str, output: Any) -> None:
292302
propagate_attributes(
293303
trace_name=entry["name"],
294304
metadata=_stringify_metadata(entry["metadata"]),
305+
session_id=entry.get("session_id"),
306+
user_id=entry.get("user_id"),
295307
)
296308
)
297309
obs = cast(
@@ -438,6 +450,8 @@ def _start_back_dated_generation(
438450
propagate_attributes(
439451
trace_name=trace_entry["name"],
440452
metadata=_stringify_metadata(trace_entry["metadata"]),
453+
session_id=trace_entry.get("session_id"),
454+
user_id=trace_entry.get("user_id"),
441455
)
442456
)
443457
stack.enter_context(otel_trace_api.use_span(remote_parent_span))
@@ -524,6 +538,8 @@ def _start_observation(
524538
propagate_attributes(
525539
trace_name=trace_entry["name"],
526540
metadata=_stringify_metadata(trace_entry["metadata"]),
541+
session_id=trace_entry.get("session_id"),
542+
user_id=trace_entry.get("user_id"),
527543
)
528544
)
529545
obs = cast("Any", self._client.start_observation(**kwargs))

src/openarmature/observability/langfuse/client.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ class LangfuseTrace:
104104
# invocation-boundary events; absent when no observer wrote them.
105105
input: Any | None = None
106106
output: Any | None = None
107+
# Proposal 0064 §8.4.1: Langfuse's two cross-trace grouping fields.
108+
# ``session_id`` groups traces sharing a session (Sessions dashboard);
109+
# ``user_id`` populates the Users dimension. Each is unset (None) when
110+
# its source is absent.
111+
session_id: str | None = None
112+
user_id: str | None = None
107113
observations: list[LangfuseObservation] = field(default_factory=list[LangfuseObservation])
108114

109115
def find_observation(self, observation_id: str) -> LangfuseObservation | None:
@@ -170,12 +176,18 @@ def trace(
170176
id: str,
171177
name: str | None = None,
172178
metadata: dict[str, Any] | None = None,
179+
session_id: str | None = None,
180+
user_id: str | None = None,
173181
) -> None:
174182
"""Create a new Trace.
175183
176184
The Trace `id` MUST be the OA invocation_id verbatim.
177185
Implementations track Traces internally; observation calls
178186
pass `trace_id` to associate.
187+
188+
`session_id` / `user_id` (proposal 0064 §8.4.1) populate
189+
Langfuse's cross-trace grouping fields (the Sessions / Users
190+
dashboards); each is unset when its source is absent.
179191
"""
180192
# Spec §8.4.1: the Trace id is the OA invocation_id verbatim.
181193
...
@@ -368,11 +380,15 @@ def trace(
368380
id: str,
369381
name: str | None = None,
370382
metadata: dict[str, Any] | None = None,
383+
session_id: str | None = None,
384+
user_id: str | None = None,
371385
) -> None:
372386
self.traces[id] = LangfuseTrace(
373387
id=id,
374388
name=name,
375389
metadata=dict(metadata) if metadata is not None else {},
390+
session_id=session_id,
391+
user_id=user_id,
376392
)
377393

378394
def update_trace(

0 commit comments

Comments
 (0)