Cross-Fleet Tracing Contract
This document specifies the HTTP header contract protoVoice uses to stitch Langfuse traces across the protoLabs agent fleet. Workstacean, ava, and any future fleet agents implement against this so a full user-turn trace spans every service it touches, not just ours.
TL;DR
On every outbound request to another agent, protoVoice attaches two headers:
| Header | Required | Shape | Purpose |
|---|---|---|---|
Langfuse-Session-Id | yes | 32-char hex | The caller's Langfuse session (= our WebRTC session) |
Langfuse-Trace-Id | yes | 32-char hex | The current user-turn trace ID |
Langfuse-Parent-Observation-Id | optional | 32-char hex | If present, the caller wants your spans nested under this specific observation (span) instead of directly under the trace. |
Receivers must honor them: instead of creating a fresh trace, open a new root span attached to the caller's trace via langfuse.start_observation(as_type="span", trace_context=TraceContext(trace_id=trace_id)) (Langfuse v4 SDK) and tag it with the session via span._otel_span.set_attribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, session_id). All spans you open for this request then nest inside the caller's trace.
If the headers are absent, treat the call as an independent trace — normal Langfuse behaviour.
When protoVoice attaches these headers
Every outbound HTTP call made in the context of a live user turn:
- A2A
message/stream/message/send—a2a/client.py::dispatch_message_streamanddispatch_message. - OpenAI-compat
/v1/chat/completionsto a delegate —agent/delegates.py::_dispatch_openai. - Any future fleet-to-fleet RPC that runs inside a user-turn trace.
The values come from the _ACTIVE_TRACER.get_current_trace() in agent/tracing.py. If the TurnTracer has no live trace (pre-session or post-session), the headers are omitted.
What receivers must do
1. Accept the headers
# Python (example; any language works)
trace_id = request.headers.get("Langfuse-Trace-Id")
session_id = request.headers.get("Langfuse-Session-Id")
parent_id = request.headers.get("Langfuse-Parent-Observation-Id")2. Continue, don't create
from langfuse import Langfuse, LangfuseOtelSpanAttributes as A
from langfuse.types import TraceContext
langfuse = Langfuse(...)
def _stamp(span, *, session_id=None, user_id=None):
"""Tag the OTEL span with trace-level session/user so Langfuse aggregations work."""
if session_id: span._otel_span.set_attribute(A.TRACE_SESSION_ID, session_id)
if user_id: span._otel_span.set_attribute(A.TRACE_USER_ID, user_id)
if trace_id and session_id:
# Re-attach: a new root span linked to the caller's existing trace.
root = langfuse.start_observation(
name="ava.handle_request",
as_type="span",
trace_context=TraceContext(trace_id=trace_id, parent_span_id=parent_id),
)
_stamp(root, session_id=session_id)
# New spans for this request nest under the caller's trace; stamp each
# so aggregations by session_id include them too.
child = root.start_observation(name="ava.handle_request.llm", as_type="generation")
_stamp(child, session_id=session_id)
else:
root = langfuse.start_observation(name="ava.standalone_request", as_type="span")Why the direct _otel_span.set_attribute(...) instead of Langfuse's propagate_attributes(...) context manager: propagate_attributes only tags spans created inside an active context. If your receiver creates child spans across async task boundaries (observer callbacks, background workers), the OTEL context doesn't follow automatically — stamping each span at creation time is robust regardless.
3. Propagate
If your agent itself calls further downstream agents (chained delegation), continue propagating the same headers. The trace fans out as a single tree.
4. Flush
Call langfuse.flush() before returning the HTTP response so the caller sees your spans in Langfuse within a second or two, not eventually.
Why session + trace, not just trace
Sessions are Langfuse's unit for "one conversation" — they're what users filter by in the UI. Each WebRTC session is one Langfuse session; every user turn in that session is a trace under it. Including session_id lets the receiver display your spans under the correct conversation in their view even if they also happen to aggregate by session elsewhere.
Security
These headers are identifiers, not secrets. Forging a Langfuse-Trace-Id only lets you append spans to someone else's trace in the Langfuse UI — it doesn't grant access to anything. There's no authentication implied; agent-to-agent authentication happens separately (API keys, bearer tokens, the existing A2A auth model).
Don't log the full header values in high-volume places — they're noisy but otherwise benign.
Versioning
This contract is v1. Future changes (e.g. W3C traceparent interop) bump the version via an additional Langfuse-Contract-Version: 2 header; receivers fall back to v1 behaviour when unset.
protoVoice is on the Langfuse v4 Python SDK. The header contract itself is SDK-agnostic — receivers on v2 or v3 can still read Langfuse-Trace-Id / Langfuse-Session-Id and attach spans via whatever API their SDK offers — but the "Continue, don't create" example above is written against v4.
Implementation status
- protoVoice — writing these headers: K24 (this release). Reading them (for inbound requests via
/a2a, future/api/*): K24 receive side. - workstacean — ava's side: workstacean team implements against this doc.
- Any future fleet agent — same.