Uh oh!
There was an error while loading. Please reload this page.
Claude OTEL: per-event agent.name and query_source stop becoming session facts - #895
Conversation
philcunliffe
commented
Aug 19, 2026
neutral review round: |
Review round on #895 found the same batch-composition hazard the PR fixes for `agent.name` / `query_source` still open on two more paths: - `mergeSessionFacts` hoisted `model` unguarded, so a subagent answering first in a flush set the exchange's model, and every row without its own (prompt rows, body gap blocks) read the sidechain's. - `mergeBodyFacts` ran for a subagent's `api_request_body`, so its system prompt and narrowed tool list became the exchange's `system_text` / `tools`. Worse, `sessionBodyFacts` remembered them, restating the subagent's prompt on every later batch of that session. Both are now read only off events a subagent did not emit. Gap messages from a subagent body are still projected, attributed to the subagent. Also corrects the comment and type doc that called `facts.querySource` a session-wide value: `bySession` is rebuilt per POST and there is no carry-over map for it, so the borrow reaches one batch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
philcunliffe
commented
Aug 19, 2026
Neutral review round: |
philcunliffe
commented
Aug 19, 2026
Neutral triage at the review-round cap: head |
…ion facts (#881) A Task subagent runs under its parent's `session.id`, so one exporter flush can mix main-loop events with a subagent's. `mergeSessionFacts` hoisted the per-event `agent.name` and `query_source` to session-level facts, and `buildProjection` then stamped `agent_id` / `is_sidechain: true` and `attributes.claude.query_source` on the whole exchange, which every row of the batch inherits. Two consequences, both proven by the new test: - Main-loop `user_prompt` / `assistant_response` rows were labeled sidechain whenever a subagent event happened to share the POST. - A body-derived gap block has no native uuid, so its `message_id` is a content hash scoped by `(thread, agent_id)`. The hoisted `agent_id` moved that scope, so the same block got a different `message_id` / `part_id` depending on batch composition, and the `part_id` dedupe missed: the block was stored twice. Both attributes now ride the message. `attributeMessageToEvent` stamps `agent_id` / `is_sidechain` and `claude.query_source` from the event that produced the message, body gap blocks included (they take their body event's attribution). `query_source` keeps a session-level default for events that carry none of their own (a `user_prompt` event does not), and that default is now taken only from events a subagent did not emit, so a borrowed value is always the main loop's. Main-loop rows keep `is_sidechain` unset rather than an explicit `false`: that matches the proxy path, which also stamps `true` only for a positively identified subagent, so a report still cannot tell the two producers apart. npm test (4486 pass), npm run typecheck, and the claude_telemetry_capture, claude_telemetry_hypignore_drop, claude_telemetry_session_ignore and gateway_claude_capture smokes are green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… rows The per-session `query_source` default is there for main-loop events that carry none of their own (`user_prompt`), and `mergeSessionFacts` already takes it only from events a subagent did not emit, so the value is the main loop's by construction. The stamping loop then applied it to every message without one, including messages the same pass had just labeled a subagent's via `agent.name`: a row could come out `is_sidechain: true, agent_id: 'Explore'` carrying the parent's `query_source: 'user'`, which is the same cross-attribution class of defect #881 exists to remove. Skip messages already attributed to a subagent. A subagent event that carries its own `query_source` is unaffected (it never lacked one); one that does not now reads null instead of the parent's value. Regression test fails on f7f9eda and passes here. npm test (4487 pass) and npm run typecheck are green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round on #895 found the same batch-composition hazard the PR fixes for `agent.name` / `query_source` still open on two more paths: - `mergeSessionFacts` hoisted `model` unguarded, so a subagent answering first in a flush set the exchange's model, and every row without its own (prompt rows, body gap blocks) read the sidechain's. - `mergeBodyFacts` ran for a subagent's `api_request_body`, so its system prompt and narrowed tool list became the exchange's `system_text` / `tools`. Worse, `sessionBodyFacts` remembered them, restating the subagent's prompt on every later batch of that session. Both are now read only off events a subagent did not emit. Gap messages from a subagent body are still projected, attributed to the subagent. Also corrects the comment and type doc that called `facts.querySource` a session-wide value: `bySession` is rebuilt per POST and there is no carry-over map for it, so the borrow reaches one batch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
30f60d2 to
44610cdCompareUh oh!
There was an error while loading. Please reload this page.
Reproduced first
test/plugins/claude-telemetry-subagent-attribution.test.jsis new and fails onmasterin all three cases:user_promptandassistant_responserows come back withagent_id: 'general-purpose'andis_sidechain: true.message_idis609f8b102be68be3with the subagent event in the batch and02ac34a4c318e1fewithout it.query_source: 'agent'.All three pass with the fix.
Root cause
A Task subagent runs under its parent's
session.id.mergeSessionFacts(projection.js) hoisted the per-eventagent.nameandquery_sourceinto session-level facts with??=, andbuildProjectionstampedagent_id/is_sidechain: trueandattributes.claude.query_sourceon the exchange, which every row of the batch inherits. Batch composition is an artifact of the exporter's flush timer, so what else happened to share a POST decided a row's label.The second-order effect is real: a body-derived gap block has no native uuid, so
message_projector.jscomputes itsmessage_idas a content hash scoped by(threadScope, agent_id). The hoistedagent_idmoved that scope, thepart_iddedupe missed, and the same block was stored twice.Fix
Both attributes ride the message now:
attributeMessageToEventstampsagent_id/is_sidechainandclaude.query_sourcefrom the event that produced the message. Body gap blocks get their own body event's attribution.query_sourcekeeps a session-level default for events that carry none (auser_promptevent does not, and theclaude_telemetry_capturesmoke asserts every row carries one). That default is now taken only from events a subagent did not emit, so a borrowed value is always the main loop's.agent_id,is_sidechain, orattributes.claude.query_source.AiGatewayProjectedMessagealready carries all three and the gateway already prefers the per-message value, so nothing downstream changed.ClaudeTelemetrySessionFacts.agentNameis gone.One deviation from the issue's acceptance text
The issue asks that main-loop rows assert
is_sidechain: false. They stay unset (null) instead, and the test asserts that. The proxy path (claude/src/projector.js) also stampsis_sidechainonly for a positively identified subagent, and LLP 0252 turns on the two producers' rows being indistinguishable; stamping an explicitfalseon the OTEL path alone would break that parity and would assert "not a sidechain" from the mere absence of an attribute, which is exactly the silent-drift failure LLP 0262 open question 5 is watching for. The invariant the acceptance actually names, batch composition must not change a row's identity or its label, is what the test pins.Checks
npm test: 4486 pass, 0 fail, 1 skipped.npm run typecheck: clean.claude_telemetry_capture,claude_telemetry_hypignore_drop,claude_telemetry_session_ignore,gateway_claude_capture: all ok.Fixes#881