Skip to content

Add turn_span_id - #163

Merged
max-braintrust merged 5 commits into
mainfrom
max/turn-span-id
Oct 8, 2026
Merged

max-braintrust merged 5 commits into
mainfrom
max/turn-span-id

Conversation

@max-braintrust

@max-braintrust max-braintrust commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Stamp metadata.turn_span_id on every span under a turn

Why

I'd like to be able to roll up metrics per turn rather than per trace, for example the cost or tokens of turns with a given label across many sessions.

That isn't easily queryable today. span_parents only records the immediate parent, so anything below a turn's direct children can't be tied back to the turn with a filter. That covers subagent calls, their tools, Codex subagent turns and OpenCode child sessions. Some clients write a turn-like id, but the key differs per client (turn_id, prompt_id, generation_id, turn_number, and none for Claude), and it's usually missing from the LLM spans where cost is recorded.

What

Every span that belongs to a turn now carries metadata.turn_span_id: the span id of the user turn that owns it. Nested agent activity rolls up to the user turn that spawned it. That includes subagents, OpenCode child sessions and Claude continuation turns. Spans outside any turn (the session root, session-level compaction) have no key.

Per-turn cost becomes a flat query:

SELECT metadata.turn_span_id AS turn, SUM(estimated_cost()) AS cost
FROM project_logs('<project id>')
WHERE created > now() - interval 7 day
  AND span_attributes.type = 'llm'
  AND metadata.turn_span_id IS NOT NULL
GROUP BY 1

Changes

Translators already track which native turn each operation belongs to, because they need it to choose parents. Each one now records the owning user turn on the row (SpanRow::turn_span_id, #[serde(skip)] like late_merge_key). TurnLineage is a stateless step in Registry that copies that value into metadata.turn_span_id after each translator's metadata allowlist runs.

Translator Where the owner comes from
Cursor The row() helper: every non-root span is parented directly to its turn
Pi, Antigravity, Grok The current turn at each insert
OpenCode The session's current turn; child sessions inherit the parent session's owner when they open
Codex The turn itself in the main scope; subagent scopes take an owner at SubagentStart (via turn_owners, so nested subagents roll up)
Claude Turn.user_turn_id / AgentOrigin.user_turn_id; span_owners maps turn and subagent spans to their user turn, for transcript rows parented through the existing record_turns / request_turns / tool_turns maps

Ownership comes from the same translator state that chooses each row's parent, so it doesn't depend on the order rows are emitted in, and it needs no follow-up merges.

🤖 Generated with Claude Code

Co-authored by StarfolkAI (@starfolkai)[bot]

max-braintrust and others added 3 commits October 7, 2026 13:33
Translators mark the span that opens a user turn (SpanRow::turn_root). A shared TurnLineage wrapper, applied to every translator in Registry, copies that span's id into metadata.turn_span_id on the turn and all of its descendants, including subagent activity. The outermost turn wins, so nested agent turns roll up to the user turn that spawned them. Rows whose ancestry resolves late are re-emitted as late merges.

This lets per-turn cost be aggregated with a flat filter instead of a trace tree walk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A turn span emitted before the span that spawned it used to claim itself as a turn permanently, leaving its subtree attributed to the nested turn. Turns are now derived from current knowledge (parent's turn, else self if a turn root) and any change is propagated to affected descendants as late merges. Late merge keys include the turn id so successive corrections to a span are not deduplicated by the delivery ledger.

Adds regression tests for the out-of-order subagent case and repeated corrections, and lineage checks for Antigravity, OpenCode (including child sessions) and Pi.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uilding it

Translators already track which native turn each operation belongs to in order to parent it, so they now record the owning user turn on each row (SpanRow::turn_span_id). Nested activity records the user turn that spawned it: Claude via Turn/AgentOrigin user_turn_id, Codex via a per-scope owner set at subagent start, and OpenCode via an owner inherited by child sessions. TurnLineage becomes a stateless step that publishes the value as metadata.turn_span_id after each translator's metadata allowlist.

This removes the per-session lineage maps, late-merge corrections and emission-order dependence. New translator state is bounded like the existing native-id caches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AbhiPrasad

Copy link
Copy Markdown
Member

shouldn't we do this in all of our sdks, not just the coding agent plugins? I feel like we should have a more general discussion about this in slack before implementing.

Copy link
Copy Markdown
Collaborator

Probably, yes. Maybe open a PR in the spec repo?

Copy link
Copy Markdown
Member

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay chatted offline. We decided that doing this just for the coding agent plugins is fine for now!

@max-braintrust
max-braintrust marked this pull request as ready for review October 8, 2026 20:09
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T20:13:39.039942Z 2b05f8e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b05f8ebb5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread bt-daemon/src/translate/codex.rs Outdated
Turns that native compaction hooks identify as maintenance (is_turn = false) no longer own themselves; in the main scope they and their synthetic compaction llm carry no turn_span_id, so per-turn cost rollups do not charge a fake turn. Child rows now follow their turn's recorded owner, keeping a span's turn_span_id equal to its parent's. Compaction discovered only during catch-up still opens as a turn, matching is_turn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@max-braintrust
max-braintrust merged commit 14744a0 into main Oct 8, 2026
22 checks passed
@max-braintrust
max-braintrust deleted the max/turn-span-id branch October 8, 2026 22:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants