Skip to content

Per-span Claude Code attribution metadata under cc.* - #14

Merged
collincunn merged 7 commits into
mainfrom
collinc/cc-metadata-consolidation
Jun 5, 2026
Merged

Per-span Claude Code attribution metadata under cc.*#14
collincunn merged 7 commits into
mainfrom
collinc/cc-metadata-consolidation

Conversation

@collincunn

Copy link
Copy Markdown
Contributor

Summary

Stand up a Go engine that scans Claude Code transcripts and emits
per-span structured token-attribution metadata to Opik. Every span on
every trace now carries the same domain snapshots under
`metadata.cc.{skills, tools, memory, thinking, tool_results,
user_prompts, file_attachments, prior_assistant, assistant_text,
llm_call, git, identity}` — so analytics can drill into "how much of
this LLM call's context was skills vs MCP catalog vs prior assistant
output" without re-parsing the transcript.

Design

  • thread = session, trace = turn, span = message-content-block
    reuses the existing shape from the prior PR.
  • cc.llm_call tags every span with `{message_id, block_index,
    block_kind, measured_output_tokens, attributed_output_tokens}` so
    multi-block messages (thinking + text + tool_use sharing one
    `message.usage`) can be reconstructed without naive `GROUP BY
    span.name` looking like 100% thinking. Σattributed over a message_id
    always equals `anchor.usage.completion_tokens` exactly.
  • Per-block attribution: text and tool_use blocks are directly
    measured (chars→tokens with calibrated per-type ratios); thinking
    blocks get the leftover. Edge cases (measured > total,
    no-thinking-blocks) are clamped/scaled to preserve the sum
    invariant.
  • Calibrated tokenizer under `src/attribution.go::tokEstimateAs`
    with per-content-type chars/token ratios drawn from 643 real
    Claude Code transcript samples vs Anthropic count_tokens. Median
    error 19.3% (chars/4) → 9.9%. count_tokens is only called offline in
    `tools/calibration/`, not at runtime.
  • Deterministic span IDs via `toV7(messageUUID)` so flushes are
    idempotent (no duplicate Skill-Loaded spans across flushes).
  • Skill body resolution walks Skill tool_use → tool_result →
    next user-text to get the canonical body, hashes it (per-skill
    SHAs, not the listing-blob hash), and tracks loaded vs available
    separately. On-disk `resolveSkillBody` runs project → user → plugin
    paths.

Domain snapshot conventions

  • One `summary` block per domain, with at least `total_tokens` (or
    `schema_tokens` for tools). Detail arrays separately.
  • No timestamps anywhere under `cc.*` (deliberate — the user
    explicitly called these out as noise).
  • Enums over booleans (`source: "listing"|"bundled"`).
  • Per-event hook only when not already named on the span (e.g.
    `cc.skills.load` on a Skill tool_use span; `cc.tool.*` on an MCP
    tool_use span).
  • Trace metadata mirrors the span metadata shape exactly under
    `metadata.cc.*` — same keys, same shape.

Critical fixes (this PR)

# Fix Before After
1 MCP toggle honored union-of-all-deltas, removals ignored catalog replays in order; toggling shrinks `cc.tools.available_count`
2 Real `schema_tokens` `tool_count × 15` everywhere per-server from real `addedLines` text
3 Skill bodies not double-counted 146K skill body inflated `cc.user_prompts` to 119K excluded via `skillBodyTextSet`
4 `cc.*` namespace cleanup 10+ top-level git + identity keys grouped under `cc.git.` and `cc.identity.`
5 ToolSearch results counted normal tool_result path missed the tool_reference shape; delta path not paired both shapes counted via `pendingToolSearchID` pairing
6 `cc.thinking` matches attribution recomputed per-entry, diverged by 2× on multi-block messages shares `DeduplicateUsage` source so sums are guaranteed equal
7 MCP span display names `mcp__server__tool` bare tool name + `cc.tool.{name, server, full, source}`
8 MCP primitive tool_results no longer silently dropped `toolUseResult` written as a string failed `json.Unmarshal`; `ReadTranscript` skipped the entry custom `UnmarshalJSON` tolerates both string and object shapes

#8 is the worst kind — silent. Any MCP tool that returns a primitive
(jira_get_all_projects, list_branches, etc.) was completely invisible
to the engine across all traces before this PR.

Verification

Validated on two end-to-end sessions on the user's account:

  • Pre-fix thread `b4c5e4c2…` exhibited every bug: 191 tools every
    trace despite the user toggling MCPs off, `schema_tokens = count × 15`
    uniform, `cc.user_prompts.total_tokens = 119,361` on the
    claude-api turn, `thinking ≠ attribution sums`, ToolSearch
    uncounted.
  • Post-fix thread `356252c2…` (fresh session, new binary): 82 → 131 →
    45 `available_count` as the user toggled servers, real per-server
    schema_tokens, `cc.user_prompts.total_tokens = 7` on the same
    claude-api load, attribution math green on every LLM call.

`tools/verify.py` is a permanent smoke script:

```bash
SID= OPIK_KEY= python3 tools/verify.py
```

Runs B1 (required domains), C7 (tools sum), D3 (attribution invariant),
D4 (per-category non-degenerate), H1–H4 (no legacy keys / timestamps /
Skill Loaded spans). Returns non-zero on regression.

`src/dryrun_test.go` is an offline test harness — point it at a
captured transcript via `OPIK_DRY_TRANSCRIPT` and it dumps every
domain snapshot without touching Opik.

Test plan

  • Start a fresh Claude Code session; confirm `cc.tools.summary.available_count`
    matches the actual catalog size (not 191).
  • Toggle an MCP off mid-session; confirm `available_count` shrinks
    on the next trace.
  • Load a large skill (`/claude-api`); confirm
    `cc.skills.loaded_tokens` carries the body and
    `cc.user_prompts.total_tokens` stays tiny.
  • Invoke an MCP that returns a primitive (e.g.
    `mcp__GitHub__list_branches`); confirm the span appears with
    `cc.tool.server = "GitHub"` and the result tokens land in
    `cc.tool_results.by_tool`.
  • Run `tools/verify.py` against the resulting trace — headline
    checks should all pass.

Out of scope / known gaps

  • Real per-tool JSON schema cost (only available when Claude actually
    loads schemas via ToolSearch; we measure the addedLines/addedNames
    proxy).
  • ToolSearch re-add deltas with empty addedNames/addedLines contribute
    0 tokens — re-enabling already-known tools doesn't create new model
    context cost, so this is probably correct, but worth a follow-up.

🤖 Generated with Claude Code

collincunn and others added 7 commits June 4, 2026 15:10
Validated against two live test sessions. Headline fixes:

1. MCP toggle honored. Attachment struct now captures removedNames,
   readdedNames, addedLines, pendingMcpServers (was only addedNames).
   extractToolsSnapshot replays the deltas in order so toggling an MCP
   off mid-session shrinks cc.tools.available_count. Previously every
   trace reported the union-of-all-deltas, ignoring removals.

2. schema_tokens reflect reality. Was tool_count * 15 across every
   server (just the names list length, prorated). Now driven by the
   real addedLines text per server. GitHub 37 tools = 448 tokens (was
   555); claude_ai_Gamma 2 tools = 32 tokens (was 30 by coincidence,
   meaningless before).

3. Skill bodies no longer double-counted. Loading claude-api used to
   put the 146K-token body in BOTH cc.skills.loaded_tokens AND
   cc.user_prompts.total_tokens. extractUserPromptsSnapshot now skips
   user-text entries that match a buildSkillBodyMap value.

4. Top-level cc namespace cleaned up. 10 git keys → cc.git.*, 5
   identity keys → cc.identity.*. cc.* now dominated by domain
   snapshots as intended.

5. ToolSearch results counted. Two delivery shapes:
   - normal tool_result with content=[{type:"tool_reference",...}]
   - deferred_tools_delta attachment following the tool_use
   extractToolResultsSnapshot handles both via a pendingToolSearchID
   pairing mechanism.

6. cc.thinking.summary matches cc.llm_call attribution. Both derive
   from the same DeduplicateUsage pass now, so Σattributed[thinking]
   over the trace == cc.thinking.summary.total_tokens exactly.

7. MCP span display names. Spans for mcp__server__tool now render as
   the bare tool name; cc.tool.{name, server, full, source} carries
   the routing info for analytics that need it.

Plus a critical parse fix discovered during verification:

8. MCP tool_results with primitive payloads write toolUseResult as a
   STRING ("{\"result\":\"[]\"}") at the entry top-level. Original
   ToolUseResult struct unmarshal failed on this, and ReadTranscript
   silently dropped the entire entry — so jira_get_all_projects and
   any other primitive-returning MCP tool was completely invisible.
   Added a custom UnmarshalJSON that tolerates both string and object
   shapes.

Also includes the calibrated tokenizer (median error 19.3% → 9.9% on
643 samples vs Anthropic count_tokens), the testing-plan.md +
tools/verify.py executable smoke script, and a dryrun_test.go gated
on OPIK_DRY_TRANSCRIPT for offline schema validation on captured
transcripts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
M1: Parse all content blocks per assistant entry. Was reading only
    Content[0], which silently dropped blocks past index 0 when Claude
    Code emits the multi-block `[thinking, text, tool_use]` shape (~0.02%
    of entries — recovered 2 of 8 thinking blocks on the test transcript).
    Subsequent blocks get a uuid#N suffix so toV7-derived span IDs stay
    unique. BuildToolResults / BuildTaskResults updated identically.

M2: Remove `skillBodyForToolUse` package-level mutable global. Threaded
    through processToolUse → enrichSkillSpan as a function parameter.

M3: Fix pendingToolSearchID race. Was overwriting if two ToolSearches
    fired back-to-back; could mis-attribute an unrelated delta if one
    arrived between turns. Now uses a FIFO queue with strict adjacency
    drain rules: any intervening user message / non-ToolSearch tool_use /
    non-delta attachment empties the queue.

M4: Document the `mcp__server__tool` parse assumption in main.go and
    tools_extract.go — SplitN(3) assumes server names have no `__`;
    tool names CAN contain `__` (absorbed into the third part).

M5: Stop shipping domain snapshots on every span. Snapshots now land
    on trace.metadata.cc only (postTraceMetrics). Spans keep cc.llm_call
    + per-event hooks (cc.skills.load on Skill spans, cc.tool on MCP
    spans). Removes redundant upload payload that scaled O(N×M) with
    span count × domain count.

L1: Match skill bodies by sha256, not exact string. Prevents the
    theoretical case where a small user prompt equals a short skill
    body and gets dropped from cc.user_prompts.

L2: Reject path-traversal in resolveSkillBody. Skill names with `/`,
    `\\`, `.`, or `..` no longer resolve — defensive against a future
    API quirk or typo letting us read arbitrary files.

L3: Cache the parsed + deduped slice in domainSnapshotsFromEntries so
    extractThinkingSnapshot doesn't re-parse + re-dedup. Same data,
    one pass instead of two.

L4: Remove pagination cap in tools/verify.py (was 4×200=800 spans).
    Loops until empty page.

Verification on the live test transcripts (b4c5e4c2 + 356252c2):
- TestAttributionInvariant: 19/19 + 20/20 LLM-call groups
  Σ AttributedOutputTokens == anchor.usage.OutputTokens
- TestToolResultDebug: 14/14 + 17/17 tool_uses paired with results
- Wider check across all 30 transcripts in ~/.claude/projects/:
  30 attribution-invariant passes, 0 failures.

verify.py updated to read trace.metadata.cc for the B1 and C7 checks
(span metadata no longer carries snapshots after M5). H1/H2/H3 still
check both for hygiene.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ipts

Claude Code 2.1.150 dropped the per-entry `slug` field; only a dedicated
`type:"ai-title"` event carries `aiTitle` (per-session human-readable
title). The old findSlug returned empty on new transcripts, so every
trace defaulted to "claude-code" — making the thread view useless.

Fix: set Trace.Name to the truncated user prompt at trace creation in
onPrompt. This gives every trace a meaningful per-turn label without
needing any transcript field at all. Compaction traces (which have no
user prompt) fall back to the session-level aiTitle via findSlug, which
now also recognizes the new event shape.

Removed the slug→name PATCH from flush() / onStop() — overwriting the
per-turn prompt-derived name with the session-level aiTitle would
collapse every trace name in a thread to the same string. The model
PATCH path is preserved.

Verified on 74412ce0-...b6dac that traces would now read e.g.:
- "hello this conversation is solely for testing purposes. I am going…"
- "load the opik FE skill"
- "ok now can you read some of the references for that skill?"
- "run a random MCP"
- "ok now create a sub agent that runs three bash command in sequence"
instead of all reading "claude-code".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
getLastOutput() picked only the LAST assistant text block in a turn. For
merged or interrupted turns with multiple assistant responses, earlier
text was hidden from trace.output entirely.

Example: a turn where the user typed "run a random MCP" then interrupted
with "now run three bash commands" produced two assistant text responses
("Notion needs auth, running bash now" + "Ran in parallel: …"). The
trace.output only showed the bash summary, making it look like the MCP
call result disappeared. The MCP result was always visible on the
authenticate span; the first text response just wasn't surfacing on the
trace-level output.

Concatenate every assistant text block in the turn (joined with blank
lines) so trace.output is a full record of what Claude said. Also
iterates every block per entry instead of just Content[0] — matches the
M1 fix in ParseAssistantMessages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude Code's `claude -p --resume` path fires UserPromptSubmit twice
within ~2ms with identical input. Each fire was creating its own trace
via uuid7(), so every prompt produced two traces in Opik — one empty
(losing the race for state ownership) and one with all the spans.

Defense in depth, both layers needed:

  1. Deterministic trace ID — toV7(session:promptHash:bucket5s) so
     both concurrent calls compute the same ID. Duplicate POST either
     409s on the server or upserts onto the same row, never produces
     a second trace.
  2. State-based dedup — when the second call DOES read the first's
     saved state, return early without re-POSTing. Adds PromptHash +
     StartUnix to State so the check is correct across consecutive
     prompts in the same session.

5-second bucket: a user who deliberately retypes the same prompt
after >5s gets a fresh trace; the spurious double-fire (<3ms apart)
collapses onto one trace.

Also adds tools/run-test-conversation.sh — drives a fresh Claude Code
session through the standard 7-prompt test scenario non-interactively.
Useful for regression testing without manually retyping prompts.

  ./tools/run-test-conversation.sh          # fresh UUID
  ./tools/run-test-conversation.sh <uuid>   # specific UUID
  ./tools/run-test-conversation.sh --verify # auto-runs verify.py

Verified on test thread 86a7763f-...: 7 prompts → 7 traces (was 14
before the dedup fix), correct span counts on each.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…onsolidation

# Conflicts:
#	bin/opik-logger-darwin-arm64
@collincunn
collincunn merged commit 964589c into main Jun 5, 2026
1 check passed
@jverre
jverre deleted the collinc/cc-metadata-consolidation branch June 12, 2026 21:15
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.

1 participant