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>
320 lines
10 KiB
Go
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)
|
|
}
|
|
}
|