Fathom ingests OTLP from anything, and ingest already knew emitters disagree about names: classify() keys on gen_ai.operation.name as well as Claude Code's span.type, and the correlator's join keys list gen_ai.tool.call.id alongside tool_use_id. The insights analyzer did not. It read cost_usd, input_tokens, output_tokens, cache_read_tokens, model, stop_reason, tool_name and user_prompt -- Claude Code's private vocabulary, exclusively. A trace from any other emitter therefore rendered correctly in the trace view and reported no cost, no tokens, and every tool named "tool". That is a silent wrong answer, and the kind that presents as "you have no findings" rather than as a bug. Each fact now resolves through an ordered list of names with Claude Code's own first, so a trace carrying both vocabularies resolves to the name the analyzer was written against and no existing answer changes. Resolution is first-match-wins rather than sum-across-matches, so an emitter stamping both names on one span does not get its tokens counted twice -- there is a test pinning that. Cost gets one extra wrinkle: Claude Code reports it on the log records, a GenAI emitter reports it on the span. costOf() checks the span first and short-circuits, so a trace carrying both does not pay twice. This is a read-time alias, not an ingest-time rewrite. What an emitter sent stays on disk verbatim, the same reason classify() keeps the original span name whatever kind it decides on. Verified against a real store, not only in unit tests: a pi run emitted by costslayer's new OTLP layer previously extracted to $0.00000 and zero tokens, and now extracts to $0.04218, 12,560 tokens, 11,000 cached, both tool names, and the correct model and stop reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
5.8 KiB
Go
163 lines
5.8 KiB
Go
package insights
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"git.z.gd/zzg/fathom/internal/model"
|
|
"git.z.gd/zzg/fathom/internal/tracetree"
|
|
)
|
|
|
|
// Fathom ingests OTLP from anything, and classify() and the correlator both
|
|
// already understand OpenTelemetry's GenAI conventions. The insights analyzer
|
|
// did not: it read Claude Code's private attribute names and nothing else, so
|
|
// a trace from any other emitter rendered perfectly in the UI and reported no
|
|
// cost, no tokens and no tool names at all. That is a silent wrong answer, and
|
|
// the kind that looks like "you have no findings" rather than like a bug.
|
|
//
|
|
// These tests use a pi/costslayer-shaped trace -- plain GenAI conventions,
|
|
// nothing Fathom-specific -- because that is the case that was broken.
|
|
|
|
func genAITrace() ([]model.Span, []model.LogRecord) {
|
|
const traceID = "aaaabbbbccccddddeeeeffff00001111"
|
|
spans := []model.Span{
|
|
{
|
|
TraceID: traceID, SpanID: "1111111111111111", Name: "pi agent run",
|
|
Kind: model.KindAgentStep, StartTS: 1_000_000, EndTS: 9_000_000,
|
|
},
|
|
{
|
|
TraceID: traceID, SpanID: "2222222222222222", ParentSpanID: "1111111111111111",
|
|
Name: "chat claude-sonnet-5", Kind: model.KindLLMCall,
|
|
StartTS: 2_000_000, EndTS: 5_000_000,
|
|
Attrs: model.Attrs{
|
|
"gen_ai.request.model": model.StringValue("claude-sonnet-5"),
|
|
"gen_ai.usage.input_tokens": model.IntValue(12043),
|
|
"gen_ai.usage.output_tokens": model.IntValue(517),
|
|
"gen_ai.usage.cache_read_input_tokens": model.IntValue(9800),
|
|
"gen_ai.usage.cache_creation_input_tokens": model.IntValue(1200),
|
|
"gen_ai.usage.cost": model.FloatValue(0.04218),
|
|
"gen_ai.response.finish_reasons": model.StringValue("tool_use"),
|
|
},
|
|
},
|
|
{
|
|
TraceID: traceID, SpanID: "3333333333333333", ParentSpanID: "1111111111111111",
|
|
Name: "execute_tool read", Kind: model.KindToolCall,
|
|
StartTS: 6_000_000, EndTS: 7_000_000,
|
|
Attrs: model.Attrs{
|
|
"gen_ai.operation.name": model.StringValue("execute_tool"),
|
|
"gen_ai.tool.name": model.StringValue("read"),
|
|
"gen_ai.tool.call.id": model.StringValue("toolu_1"),
|
|
},
|
|
},
|
|
}
|
|
logs := []model.LogRecord{
|
|
{
|
|
TraceID: traceID, SpanID: "1111111111111111", EventName: "gen_ai.user.message",
|
|
TS: 1_000_000, Body: "refactor the auth module",
|
|
Attrs: model.Attrs{"gen_ai.prompt": model.StringValue("refactor the auth module")},
|
|
},
|
|
}
|
|
return spans, logs
|
|
}
|
|
|
|
func factsFromGenAI(t *testing.T) TraceFacts {
|
|
t.Helper()
|
|
spans, logs := genAITrace()
|
|
return buildFacts(tracetree.NewCorrelator(), spans[0].TraceID, spans, logs)
|
|
}
|
|
|
|
// The headline failure. Every cost number in every finding is a sum of this.
|
|
func TestGenAICostIsCounted(t *testing.T) {
|
|
f := factsFromGenAI(t)
|
|
if got, want := f.CostUSD, 0.04218; got != want {
|
|
t.Errorf("CostUSD = %v, want %v — a gen_ai emitter's spend must not read as zero", got, want)
|
|
}
|
|
}
|
|
|
|
func TestGenAITokensAreCounted(t *testing.T) {
|
|
f := factsFromGenAI(t)
|
|
if got, want := f.Tokens, int64(12043+517); got != want {
|
|
t.Errorf("Tokens = %d, want %d", got, want)
|
|
}
|
|
if got, want := f.Cached, int64(9800+1200); got != want {
|
|
t.Errorf("Cached = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
// Without this the context-waste detector cannot tell repeated context from
|
|
// fresh context, because everything looks like zero cached tokens.
|
|
func TestGenAILLMCallCarriesItsNumbers(t *testing.T) {
|
|
f := factsFromGenAI(t)
|
|
if len(f.LLMCalls) != 1 {
|
|
t.Fatalf("got %d LLM calls, want 1", len(f.LLMCalls))
|
|
}
|
|
c := f.LLMCalls[0]
|
|
if c.InputTokens != 12043 {
|
|
t.Errorf("InputTokens = %d, want 12043", c.InputTokens)
|
|
}
|
|
if c.OutputToken != 517 {
|
|
t.Errorf("OutputToken = %d, want 517", c.OutputToken)
|
|
}
|
|
if c.CacheRead != 9800 {
|
|
t.Errorf("CacheRead = %d, want 9800", c.CacheRead)
|
|
}
|
|
if c.Model != "claude-sonnet-5" {
|
|
t.Errorf("Model = %q, want claude-sonnet-5", c.Model)
|
|
}
|
|
if c.StopReason != "tool_use" {
|
|
t.Errorf("StopReason = %q, want tool_use", c.StopReason)
|
|
}
|
|
if c.CostUSD != 0.04218 {
|
|
t.Errorf("CostUSD = %v, want 0.04218", c.CostUSD)
|
|
}
|
|
}
|
|
|
|
// A tool call whose name is "tool" collapses every distinct action in a run
|
|
// into one signature, which is precisely what the repeated-sequence detector
|
|
// is looking for. Unnamed tools produce confident nonsense.
|
|
func TestGenAIToolNameIsRead(t *testing.T) {
|
|
f := factsFromGenAI(t)
|
|
if len(f.Calls) != 1 {
|
|
t.Fatalf("got %d tool calls, want 1", len(f.Calls))
|
|
}
|
|
if got := f.Calls[0].Name; got != "read" {
|
|
t.Errorf("tool name = %q, want %q", got, "read")
|
|
}
|
|
}
|
|
|
|
func TestGenAIPromptIsRead(t *testing.T) {
|
|
f := factsFromGenAI(t)
|
|
if got, want := f.Prompt, "refactor the auth module"; got != want {
|
|
t.Errorf("Prompt = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// The aliases must not cost Claude Code anything. Its names stay first in
|
|
// every list, so a trace carrying both vocabularies resolves to the one the
|
|
// analyzer was written against.
|
|
func TestClaudeCodeAttributesStillWin(t *testing.T) {
|
|
const traceID = "11112222333344445555666677778888"
|
|
spans := []model.Span{{
|
|
TraceID: traceID, SpanID: "1111111111111111", Name: "llm_request",
|
|
Kind: model.KindLLMCall, StartTS: 1_000_000, EndTS: 2_000_000,
|
|
Attrs: model.Attrs{
|
|
"input_tokens": model.IntValue(100),
|
|
"gen_ai.usage.input_tokens": model.IntValue(999),
|
|
"model": model.StringValue("claude-opus-5"),
|
|
"gen_ai.request.model": model.StringValue("wrong"),
|
|
},
|
|
}}
|
|
f := buildFacts(tracetree.NewCorrelator(), traceID, spans, nil)
|
|
if len(f.LLMCalls) != 1 {
|
|
t.Fatalf("got %d LLM calls, want 1", len(f.LLMCalls))
|
|
}
|
|
if got := f.LLMCalls[0].InputTokens; got != 100 {
|
|
t.Errorf("InputTokens = %d, want 100 — Claude Code's own name must win", got)
|
|
}
|
|
if got := f.LLMCalls[0].Model; got != "claude-opus-5" {
|
|
t.Errorf("Model = %q, want claude-opus-5", got)
|
|
}
|
|
if got := f.Tokens; got != 100 {
|
|
t.Errorf("Tokens = %d, want 100 — an aliased key must not be summed twice", got)
|
|
}
|
|
}
|