Per-span Claude Code attribution metadata under cc.* - #14
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
reuses the existing shape from the prior PR.
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.
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.
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.
idempotent (no duplicate Skill-Loaded spans across flushes).
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
`schema_tokens` for tools). Detail arrays separately.
explicitly called these out as noise).
`cc.skills.load` on a Skill tool_use span; `cc.tool.*` on an MCP
tool_use span).
`metadata.cc.*` — same keys, same shape.
Critical fixes (this PR)
#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:
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.
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
matches the actual catalog size (not 191).
on the next trace.
`cc.skills.loaded_tokens` carries the body and
`cc.user_prompts.total_tokens` stays tiny.
`mcp__GitHub__list_branches`); confirm the span appears with
`cc.tool.server = "GitHub"` and the result tokens land in
`cc.tool_results.by_tool`.
checks should all pass.
Out of scope / known gaps
loads schemas via ToolSearch; we measure the addedLines/addedNames
proxy).
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