Feature Request: Aggregate traces by session_id in Jaeger
Summary
Plano already defines a plano.session_id span attribute (crates/brightstaff/src/tracing/constants.rs:142) and sets it on plano(llm) spans when the client sends an X-Model-Affinity header. In practice, agentic clients like Claude Code do not send X-Model-Affinity — they propagate session identity either via a x-claude-code-session-id header (as other open-source gateways have started converging on) or embedded in the Anthropic request body's metadata.user_id JSON. As a result, plano.session_id is effectively never populated for Claude Code traffic, and Jaeger has no first-class way to filter or aggregate traces by session.
This issue requests two things:
- Extract session_id from additional sources (the
x-claude-code-session-id header and the Anthropic body's metadata.user_id.session_id), not just X-Model-Affinity.
- Propagate
plano.session_id to every span in a Plano trace (inbound, routing, llm, outbound), so Jaeger's tag-based search and any future session-grouped views work across the full request lifecycle.
Current Behavior
Source 1: X-Model-Affinity header is the only source Plano reads
crates/brightstaff/src/handlers/routing_service.rs:76-79:
let session_id: Option<String> = request_headers
.get(MODEL_AFFINITY_HEADER) // X-Model-Affinity
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
Same in crates/brightstaff/src/handlers/llm/mod.rs:97-100. Claude Code does not send this header — it's a Plano-internal header intended for the second turn of an agentic loop after Plano returns it in a response.
Source 2: plano.session_id only lands on plano(llm) spans
crates/brightstaff/src/handlers/llm/mod.rs:120-128:
if let Some(ref sid) = session_id {
get_active_span(|span| {
span.set_attribute(opentelemetry::KeyValue::new(
tracing_plano::SESSION_ID,
sid.clone(),
));
});
}
The routing_decision span (routing_service.rs:89-95) only carries request_id — session_id is read but not attached to the span. The plano (inbound) and plano(outbound) (Envoy egress) spans also do not carry plano.session_id.
Source 3: /routing/v1/* debug endpoint never sets plano.session_id
crates/brightstaff/src/main.rs:504-519 routes /routing/v1/messages to routing_decision directly, bypassing llm_chat. So the only place that currently sets plano.session_id is never executed for debug-mode traces.
Result
For a Claude Code session that issues N turns, each turn produces a trace with:
- A
plano inbound span — no plano.session_id
- A
plano(routing) routing_decision span — no plano.session_id
- A
plano(llm) LLM call span — no plano.session_id (because Claude Code doesn't send X-Model-Affinity)
- A
plano(outbound) Envoy egress span — no plano.session_id
To answer "what happened in session X?", a user must manually correlate N traces by request_id and message timestamps. There is no Jaeger tag search that returns all traces for a session, because no span carries the session identifier.
Proposed Solution
Three independent changes. Any subset is useful; together they close the gap.
1. Extract session_id from additional sources
Add a resolution chain (first non-empty wins), in this order:
| Source |
Header / path |
Notes |
X-Model-Affinity |
header |
existing behavior, kept for backward compat |
x-claude-code-session-id |
header |
emerging convention across open-source LLM gateways |
metadata.user_id JSON |
Anthropic body |
parse JSON, extract session_id field (Claude Code format: {"device_id":"...","account_uuid":"...","session_id":"<uuid>"}) |
The Anthropic body extraction should happen during request parsing in crates/hermesllm/src/apis/anthropic.rs (where MessagesRequest is deserialized, lines 104-123) and be threaded into parse_and_validate_request so both llm_chat and routing_decision paths can use it.
2. Set plano.session_id on every span in a Plano trace
Specifically:
routing_decision span in crates/brightstaff/src/handlers/routing_service.rs:89-95 — add session_id = %sid to the info_span! macro (or set_attribute after creation when session_id is Some)
- Inbound
plano span (whatever creates the root request span) — same
plano(llm) span — already done at handlers/llm/mod.rs:120-128, keep as-is
plano(outbound) — propagate via OTel context (the OTLP exporter already inherits parent span attributes through traceparent); no explicit action needed if the upstream spans are children of the Plano trace
3. Auto-extract via header_prefixes config
Document that users can add x-claude-code-session-id to tracing.span_attributes.header_prefixes in their config as a stopgap:
tracing:
span_attributes:
header_prefixes:
- x-request-id
- x-claude-code-session-id # surfaces as span attribute "x-claude-code-session-id"
This is a partial fix — it captures the header as a span attribute but doesn't normalize it to plano.session_id and doesn't help with the Anthropic body case. The code changes in (1) and (2) are still needed for a unified view.
Why This Matters
Agentic loops (Claude Code, Cursor, Continue.dev, Aider, etc.) are the primary use case for model affinity and session pinning. A single "session" can span many turns, each producing a separate trace. Without session-scoped observability:
- Debugging "why did session X route to model Y on turn 5?" requires manually correlating 5+ traces by
request_id and timestamps.
- Cost attribution per session is impossible without joining traces on session_id.
- Signals (misalignment, loops, stagnation) are computed per-request; aggregating them per-session would give a much clearer picture of agent quality over a full task.
With session_id on every span, Jaeger's existing tag search (plano.session_id=<uuid>) immediately becomes a session-scoped trace finder, and downstream tools (PostHog, Grafana, planoai obs TUI from #891) can build session-grouped views on top.
Evidence
A real Claude Code request body (Anthropic /v1/messages):
{
"model": "claude-opus-4-7",
"metadata": {
"user_id": "{\"device_id\":\"xx\",\"account_uuid\":\"\",\"session_id\":\"xx-22c3-4543-xx-xx\"}"
},
"thinking": { "type": "adaptive" },
"messages": [...]
}
The session_id is JSON-encoded inside metadata.user_id. No X-Model-Affinity header is sent. Plano currently extracts neither.
Prior Art
Acceptance Criteria
Related Files
crates/brightstaff/src/handlers/routing_service.rs:76-79, 89-95 — session_id extraction + routing_decision span
crates/brightstaff/src/handlers/llm/mod.rs:97-100, 120-128 — session_id extraction + LLM span attribute
crates/brightstaff/src/tracing/constants.rs:142 — plano.session_id constant
crates/brightstaff/src/main.rs:504-519 — /routing/v1/* dispatch (bypasses llm_chat)
crates/hermesllm/src/apis/anthropic.rs:104-123 — MessagesRequest deserialization (where body parsing would hook in)
crates/brightstaff/src/tracing/init.rs — header_prefixes config wiring
Feature Request: Aggregate traces by session_id in Jaeger
Summary
Plano already defines a
plano.session_idspan attribute (crates/brightstaff/src/tracing/constants.rs:142) and sets it onplano(llm)spans when the client sends anX-Model-Affinityheader. In practice, agentic clients like Claude Code do not sendX-Model-Affinity— they propagate session identity either via ax-claude-code-session-idheader (as other open-source gateways have started converging on) or embedded in the Anthropic request body'smetadata.user_idJSON. As a result,plano.session_idis effectively never populated for Claude Code traffic, and Jaeger has no first-class way to filter or aggregate traces by session.This issue requests two things:
x-claude-code-session-idheader and the Anthropic body'smetadata.user_id.session_id), not justX-Model-Affinity.plano.session_idto every span in a Plano trace (inbound, routing, llm, outbound), so Jaeger's tag-based search and any future session-grouped views work across the full request lifecycle.Current Behavior
Source 1:
X-Model-Affinityheader is the only source Plano readscrates/brightstaff/src/handlers/routing_service.rs:76-79:Same in
crates/brightstaff/src/handlers/llm/mod.rs:97-100. Claude Code does not send this header — it's a Plano-internal header intended for the second turn of an agentic loop after Plano returns it in a response.Source 2:
plano.session_idonly lands onplano(llm)spanscrates/brightstaff/src/handlers/llm/mod.rs:120-128:The
routing_decisionspan (routing_service.rs:89-95) only carriesrequest_id—session_idis read but not attached to the span. Theplano(inbound) andplano(outbound)(Envoy egress) spans also do not carryplano.session_id.Source 3:
/routing/v1/*debug endpoint never setsplano.session_idcrates/brightstaff/src/main.rs:504-519routes/routing/v1/messagestorouting_decisiondirectly, bypassingllm_chat. So the only place that currently setsplano.session_idis never executed for debug-mode traces.Result
For a Claude Code session that issues N turns, each turn produces a trace with:
planoinbound span — noplano.session_idplano(routing)routing_decisionspan — noplano.session_idplano(llm)LLM call span — noplano.session_id(because Claude Code doesn't sendX-Model-Affinity)plano(outbound)Envoy egress span — noplano.session_idTo answer "what happened in session X?", a user must manually correlate N traces by
request_idand message timestamps. There is no Jaeger tag search that returns all traces for a session, because no span carries the session identifier.Proposed Solution
Three independent changes. Any subset is useful; together they close the gap.
1. Extract session_id from additional sources
Add a resolution chain (first non-empty wins), in this order:
X-Model-Affinityx-claude-code-session-idmetadata.user_idJSONsession_idfield (Claude Code format:{"device_id":"...","account_uuid":"...","session_id":"<uuid>"})The Anthropic body extraction should happen during request parsing in
crates/hermesllm/src/apis/anthropic.rs(whereMessagesRequestis deserialized, lines 104-123) and be threaded intoparse_and_validate_requestso bothllm_chatandrouting_decisionpaths can use it.2. Set
plano.session_idon every span in a Plano traceSpecifically:
routing_decisionspan incrates/brightstaff/src/handlers/routing_service.rs:89-95— addsession_id = %sidto theinfo_span!macro (orset_attributeafter creation whensession_idisSome)planospan (whatever creates the root request span) — sameplano(llm)span — already done athandlers/llm/mod.rs:120-128, keep as-isplano(outbound)— propagate via OTel context (the OTLP exporter already inherits parent span attributes throughtraceparent); no explicit action needed if the upstream spans are children of the Plano trace3. Auto-extract via
header_prefixesconfigDocument that users can add
x-claude-code-session-idtotracing.span_attributes.header_prefixesin their config as a stopgap:This is a partial fix — it captures the header as a span attribute but doesn't normalize it to
plano.session_idand doesn't help with the Anthropic body case. The code changes in (1) and (2) are still needed for a unified view.Why This Matters
Agentic loops (Claude Code, Cursor, Continue.dev, Aider, etc.) are the primary use case for model affinity and session pinning. A single "session" can span many turns, each producing a separate trace. Without session-scoped observability:
request_idand timestamps.With session_id on every span, Jaeger's existing tag search (
plano.session_id=<uuid>) immediately becomes a session-scoped trace finder, and downstream tools (PostHog, Grafana,planoai obsTUI from #891) can build session-grouped views on top.Evidence
A real Claude Code request body (Anthropic
/v1/messages):{ "model": "claude-opus-4-7", "metadata": { "user_id": "{\"device_id\":\"xx\",\"account_uuid\":\"\",\"session_id\":\"xx-22c3-4543-xx-xx\"}" }, "thinking": { "type": "adaptive" }, "messages": [...] }The
session_idis JSON-encoded insidemetadata.user_id. NoX-Model-Affinityheader is sent. Plano currently extracts neither.Prior Art
header_prefixesbuilds on. This feature request extends that pattern with structured extraction (JSON parsing) and propagation to all spans.X-Model-Affinityheader and session cache. The session_id concept exists; this issue is about making it observable for clients that don't use Plano's header.planoai obslive LLM observability TUI: natural consumer of a session-grouped view onceplano.session_idis reliably populated.x-claude-code-session-idas a header for exactly this use case; supporting it would improve interop.Acceptance Criteria
POST /v1/messagesproduces a trace where every Plano span (plano,plano(routing),plano(llm),plano(outbound)) carriesplano.session_idmatching thesession_idfrommetadata.user_id.x-claude-code-session-id: <uuid>header produces the same result without body parsing.X-Model-Affinity: <id>continues to work as before (backward compat)./routing/v1/messagesdebug endpoint also setsplano.session_idon itsrouting_decisionspan.plano.session_id=<uuid>returns all traces for that session.docs/source/guides/observability/tracing.rstupdated to describe the session_id sources and Jaeger workflow.Related Files
crates/brightstaff/src/handlers/routing_service.rs:76-79, 89-95— session_id extraction + routing_decision spancrates/brightstaff/src/handlers/llm/mod.rs:97-100, 120-128— session_id extraction + LLM span attributecrates/brightstaff/src/tracing/constants.rs:142—plano.session_idconstantcrates/brightstaff/src/main.rs:504-519—/routing/v1/*dispatch (bypasses llm_chat)crates/hermesllm/src/apis/anthropic.rs:104-123—MessagesRequestdeserialization (where body parsing would hook in)crates/brightstaff/src/tracing/init.rs—header_prefixesconfig wiring