Files
fathom/internal/ingest/otlp_test.go
zzgandClaude Opus 5 79fe343be5 Move the module path to git.z.gd/zzg/fathom
The module was declared as github.com/zzg/fathom, which is not where
this repo lives and never has been. Nothing is published at that path,
so `go get github.com/zzg/fathom` resolves to a 404 while the import
paths in every file assert otherwise.

git.z.gd is a real module path rather than a cosmetic correction:
Gitea serves the go-import meta tag for it,

  <meta name="go-import" content="git.z.gd/zzg/fathom git https://git.z.gd/zzg/fathom.git">

and the repo answers unauthenticated over HTTPS, so `go get
git.z.gd/zzg/fathom` resolves where the old path could not.

Mechanical: the module line plus 46 import paths across 23 files. There
is no go.sum to churn, since fathom has no third-party dependencies.
go build, go vet, gofmt and the full test suite are clean under the new
path, and the built binary still runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:31:18 -07:00

320 lines
10 KiB
Go

package ingest
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
"git.z.gd/zzg/fathom/internal/model"
)
func loadFixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatal(err)
}
return b
}
// TestDecodeClaudeCodeTraces runs against a payload captured from a real
// `claude -p` run rather than a hand-written fixture. Hand-written OTLP tends
// to encode what the author believed the format was; this encodes what actually
// arrived on the wire.
func TestDecodeClaudeCodeTraces(t *testing.T) {
spans, err := NewDecoder().DecodeTraces(loadFixture(t, "claude_code_traces.json"))
if err != nil {
t.Fatal(err)
}
if len(spans) == 0 {
t.Fatal("decoded no spans from a payload that contains them")
}
byName := map[string]model.Span{}
for _, s := range spans {
byName[s.Name] = s
if s.TraceID == "" || s.SpanID == "" {
t.Errorf("span %q is missing an id: trace=%q span=%q", s.Name, s.TraceID, s.SpanID)
}
if s.StartTS == 0 || s.EndTS == 0 {
t.Errorf("span %q has zero timestamps: %d..%d", s.Name, s.StartTS, s.EndTS)
}
if s.Service != "claude-code" {
t.Errorf("span %q service = %q, want claude-code", s.Name, s.Service)
}
// Timestamps must land in a sane range. A nanosecond value mistakenly
// stored as micros would put these ~1000x into the future, and nothing
// else in the test would notice.
if s.StartTS < 1_600_000_000_000_000 || s.StartTS > 2_000_000_000_000_000 {
t.Errorf("span %q start_ts %d is not plausible unix micros", s.Name, s.StartTS)
}
}
// Kinds the classifier must get right on this emitter.
wantKind := map[string]model.Kind{
"claude_code.interaction": model.KindAgentStep,
"claude_code.llm_request": model.KindLLMCall,
"claude_code.tool": model.KindToolCall,
"claude_code.tool.execution": model.KindToolCall,
}
for name, want := range wantKind {
s, ok := byName[name]
if !ok {
continue // not every fixture batch holds every span type
}
if s.Kind != want {
t.Errorf("span %q classified as %v, want %v", name, s.Kind, want)
}
}
}
// TestIdentityAttributesAreDropped is the privacy guarantee: these keys must
// never reach the model, because everything downstream of here writes to disk.
func TestIdentityAttributesAreDropped(t *testing.T) {
banned := []string{"user.email", "user.id", "user.account_uuid", "user.account_id", "organization.id"}
spans, err := NewDecoder().DecodeTraces(loadFixture(t, "claude_code_traces.json"))
if err != nil {
t.Fatal(err)
}
for _, s := range spans {
for _, k := range banned {
if _, ok := s.Attrs[k]; ok {
t.Errorf("span %q kept banned attribute %q", s.Name, k)
}
}
}
recs, err := NewDecoder().DecodeLogs(loadFixture(t, "claude_code_logs.json"))
if err != nil {
t.Fatal(err)
}
for _, r := range recs {
for _, k := range banned {
if _, ok := r.Attrs[k]; ok {
t.Errorf("log %q kept banned attribute %q", r.EventName, k)
}
}
}
// The fixture must actually contain them, or this test proves nothing.
raw := string(loadFixture(t, "claude_code_traces.json"))
if !strings.Contains(raw, "user.email") {
t.Fatal("fixture no longer contains user.email; this test is now vacuous")
}
}
func TestIdentityAttributesKeptWhenConfigured(t *testing.T) {
d := NewDecoder()
d.DropAttrs = nil // the opt-in path
spans, err := d.DecodeTraces(loadFixture(t, "claude_code_traces.json"))
if err != nil {
t.Fatal(err)
}
var found bool
for _, s := range spans {
if _, ok := s.Attrs["user.email"]; ok {
found = true
}
}
if !found {
t.Error("with DropAttrs disabled, user.email should have been retained")
}
}
func TestDecodeClaudeCodeLogs(t *testing.T) {
recs, err := NewDecoder().DecodeLogs(loadFixture(t, "claude_code_logs_content.json"))
if err != nil {
t.Fatal(err)
}
if len(recs) == 0 {
t.Fatal("decoded no log records")
}
seen := map[string]bool{}
for _, r := range recs {
seen[r.EventName] = true
if r.TS == 0 {
t.Errorf("record %q has no timestamp", r.EventName)
}
if r.EventName == "" || r.EventName == "log" {
t.Errorf("record fell back to a useless event name: %+v", r.Attrs)
}
}
// The content-bearing events are the reason logs are ingested at all.
for _, want := range []string{"tool_result", "assistant_response"} {
if !seen[want] {
t.Errorf("fixture should contain a %q record; saw %v", want, keys(seen))
}
}
}
// TestContentIsPreserved asserts the actual payload text survives decoding.
// A decoder that produced correct-looking records with empty content would pass
// every other test here and be useless.
func TestContentIsPreserved(t *testing.T) {
recs, err := NewDecoder().DecodeLogs(loadFixture(t, "claude_code_logs_content.json"))
if err != nil {
t.Fatal(err)
}
var sawToolInput, sawResponse bool
for _, r := range recs {
switch r.EventName {
case "tool_result":
if v, ok := r.Attrs["tool_input"]; ok && v.Str != "" {
sawToolInput = true
}
case "assistant_response":
if v, ok := r.Attrs["response"]; ok && v.Str != "" {
sawResponse = true
}
}
}
if !sawToolInput {
t.Error("no tool_result carried non-empty tool_input")
}
if !sawResponse {
t.Error("no assistant_response carried non-empty response text")
}
}
func TestNormalizeID(t *testing.T) {
raw := []byte{0x19, 0xf0, 0xb7, 0x58, 0xe2, 0xc2, 0x6a, 0x0f}
hexID := hex.EncodeToString(raw)
b64ID := base64.StdEncoding.EncodeToString(raw)
cases := []struct{ in, want string }{
{"", ""},
{hexID, hexID},
{strings.ToUpper(hexID), hexID}, // case must not split identity
{b64ID, hexID}, // base64 emitters normalise to the same id
}
for _, tc := range cases {
if got := normalizeID(tc.in); got != tc.want {
t.Errorf("normalizeID(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestNanoToMicroPrecision(t *testing.T) {
// 1757980800123456789ns exceeds float64's exact integer range. Parsing it
// as a number would silently round; this asserts the string path is used.
const nanos = "1757980800123456789"
got := nanoToMicro(jsonNumber(nanos))
const want = int64(1757980800123456)
if got != want {
t.Errorf("nanoToMicro(%s) = %d, want %d", nanos, got, want)
}
}
func TestDecodeRejectsMalformed(t *testing.T) {
d := NewDecoder()
// Not JSON at all, or JSON of the wrong shape entirely.
for _, bad := range []string{"", "not json", "{", "[]"} {
if _, err := d.DecodeTraces([]byte(bad)); err == nil {
t.Errorf("DecodeTraces(%q) succeeded, want an error", bad)
}
if _, err := d.DecodeLogs([]byte(bad)); err == nil {
t.Errorf("DecodeLogs(%q) succeeded, want an error", bad)
}
}
// A type error in the decoder's own field must be rejected. Each decoder
// only sees its own: a logs payload naming resourceSpans is not malformed,
// just empty, and rejecting it would break exporters that batch signals.
if _, err := d.DecodeTraces([]byte(`{"resourceSpans": 3}`)); err == nil {
t.Error(`DecodeTraces({"resourceSpans": 3}) succeeded, want an error`)
}
if _, err := d.DecodeLogs([]byte(`{"resourceLogs": 3}`)); err == nil {
t.Error(`DecodeLogs({"resourceLogs": 3}) succeeded, want an error`)
}
// A structurally valid but empty export is not an error; exporters send
// these and rejecting them would produce noise in their logs, not ours.
for _, ok := range []string{`{}`, `{"resourceSpans":[]}`} {
if _, err := d.DecodeTraces([]byte(ok)); err != nil {
t.Errorf("DecodeTraces(%q) errored: %v", ok, err)
}
}
}
func TestBlobTruncation(t *testing.T) {
d := NewDecoder()
d.MaxBlobBytes = 10
if got := d.truncate("0123456789abcdef"); len(got) != 10 {
t.Errorf("truncate produced %d bytes, want 10", len(got))
}
if got := d.truncate("short"); got != "short" {
t.Errorf("truncate altered a short string: %q", got)
}
}
func TestClassifyByName(t *testing.T) {
cases := map[string]model.Kind{
"claude_code.mcp.rpc": model.KindMCP,
"claude_code.hook": model.KindHook,
"execute_tool": model.KindToolCall,
"openai.chat": model.KindLLMCall,
"vector_search": model.KindRetrieval,
"agent.step": model.KindAgentStep,
"something.unrecognised": model.KindOther,
}
for name, want := range cases {
if got := classifyByName(name); got != want {
t.Errorf("classifyByName(%q) = %v, want %v", name, got, want)
}
}
}
func TestClassifyPrefersAttributes(t *testing.T) {
// A span named like a tool but attributed as a model call must follow the
// attribute: names are conventions, attributes are declarations.
got := classify("some.tool.thing", model.Attrs{"span.type": model.StringValue("llm_request")})
if got != model.KindLLMCall {
t.Errorf("classify preferred the name over span.type: got %v", got)
}
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// jsonNumber is a test helper for constructing json.Number literals.
func jsonNumber(s string) json.Number { return json.Number(s) }
// TestArrayValueFlattens guards against the OTLP wire format leaking into the
// UI. Marshalling AnyValue structs verbatim renders
// [{"stringValue":"tool_use","boolValue":null,...}] where ["tool_use"] is meant,
// which is what an attribute like gen_ai.response.finish_reasons looks like.
func TestArrayValueFlattens(t *testing.T) {
body := `{"resourceSpans":[{"resource":{"attributes":[]},"scopeSpans":[{"spans":[{
"traceId":"aa","spanId":"bb","name":"x",
"startTimeUnixNano":"1757980800000000000","endTimeUnixNano":"1757980801000000000",
"attributes":[
{"key":"finish_reasons","value":{"arrayValue":{"values":[{"stringValue":"tool_use"},{"stringValue":"stop"}]}}},
{"key":"counts","value":{"arrayValue":{"values":[{"intValue":"3"},{"intValue":"5"}]}}}
]}]}]}]}`
spans, err := NewDecoder().DecodeTraces([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(spans) != 1 {
t.Fatalf("decoded %d spans, want 1", len(spans))
}
if got := spans[0].Attrs["finish_reasons"].Str; got != `["tool_use","stop"]` {
t.Errorf("finish_reasons = %s, want [\"tool_use\",\"stop\"]", got)
}
if got := spans[0].Attrs["counts"].Str; got != `[3,5]` {
t.Errorf("counts = %s, want [3,5]", got)
}
}