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>
482 lines
15 KiB
Go
482 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.z.gd/zzg/fathom/internal/ingest"
|
|
"git.z.gd/zzg/fathom/internal/insights"
|
|
"git.z.gd/zzg/fathom/internal/model"
|
|
"git.z.gd/zzg/fathom/internal/store"
|
|
)
|
|
|
|
func testServer(t *testing.T) (*httptest.Server, *store.Store) {
|
|
t.Helper()
|
|
st, err := store.Open(store.Options{
|
|
Dir: t.TempDir(), FlushInterval: time.Hour, FlushRows: 1 << 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
|
|
srv := NewServer(st, ingest.NewDecoder(), nil, log.New(io.Discard, "", 0))
|
|
ts := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(ts.Close)
|
|
return ts, st
|
|
}
|
|
|
|
func post(t *testing.T, url, body string) *http.Response {
|
|
t.Helper()
|
|
res, err := http.Post(url, "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func getJSON(t *testing.T, url string, into any) {
|
|
t.Helper()
|
|
res, err := http.Get(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != 200 {
|
|
b, _ := io.ReadAll(res.Body)
|
|
t.Fatalf("GET %s -> %d: %s", url, res.StatusCode, b)
|
|
}
|
|
if err := json.NewDecoder(res.Body).Decode(into); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestIngestToTraceDetail is the whole pipeline in one test: real captured OTLP
|
|
// goes in over HTTP, and a readable trace comes back out. Every layer in
|
|
// between — decode, store, flush, scan, correlate — has its own tests; this is
|
|
// the one that fails if they stop fitting together.
|
|
func TestIngestToTraceDetail(t *testing.T) {
|
|
ts, st := testServer(t)
|
|
|
|
traceJSON, err := os.ReadFile("../ingest/testdata/claude_code_traces.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logJSON, err := os.ReadFile("../ingest/testdata/claude_code_logs_content.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if res := post(t, ts.URL+"/v1/traces", string(traceJSON)); res.StatusCode != 200 {
|
|
t.Fatalf("POST /v1/traces -> %d", res.StatusCode)
|
|
}
|
|
if res := post(t, ts.URL+"/v1/logs", string(logJSON)); res.StatusCode != 200 {
|
|
t.Fatalf("POST /v1/logs -> %d", res.StatusCode)
|
|
}
|
|
if err := st.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The fixture's timestamps are fixed, so the window has to be wide enough
|
|
// to contain them regardless of when the test runs.
|
|
from, to := int64(1_600_000_000_000_000), int64(2_100_000_000_000_000)
|
|
|
|
var list struct {
|
|
Traces []TraceSummary `json:"traces"`
|
|
Total int `json:"total"`
|
|
}
|
|
getJSON(t, fmt.Sprintf("%s/api/traces?from=%d&to=%d", ts.URL, from, to), &list)
|
|
|
|
if list.Total != 1 {
|
|
t.Fatalf("got %d traces, want 1 (the fixture is a single run)", list.Total)
|
|
}
|
|
summary := list.Traces[0]
|
|
if summary.SpanCount == 0 {
|
|
t.Error("trace summary reports no spans")
|
|
}
|
|
if summary.ToolCalls == 0 {
|
|
t.Error("trace summary reports no tool calls, but the fixture ran Bash and Read")
|
|
}
|
|
if summary.LLMCalls == 0 {
|
|
t.Error("trace summary reports no model calls")
|
|
}
|
|
if summary.Preview == "" {
|
|
t.Error("trace summary has no prompt preview; the list would be unreadable")
|
|
}
|
|
|
|
var detail struct {
|
|
Trace struct {
|
|
TraceID string `json:"trace_id"`
|
|
SpanCount int `json:"span_count"`
|
|
} `json:"trace"`
|
|
Flat []struct {
|
|
Span struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
} `json:"span"`
|
|
Logs []struct {
|
|
EventName string `json:"event_name"`
|
|
} `json:"logs"`
|
|
} `json:"flat"`
|
|
LogCount int `json:"log_count"`
|
|
}
|
|
getJSON(t, fmt.Sprintf("%s/api/traces/%s?from=%d&to=%d", ts.URL, summary.TraceID, from, to), &detail)
|
|
|
|
if detail.Trace.SpanCount != summary.SpanCount {
|
|
t.Errorf("detail reports %d spans, list reported %d", detail.Trace.SpanCount, summary.SpanCount)
|
|
}
|
|
if detail.LogCount == 0 {
|
|
t.Error("detail carries no log records, so the transcript would be empty")
|
|
}
|
|
|
|
// Kind must arrive as a name. If this returns "2" the client would have to
|
|
// keep its own copy of the enum.
|
|
for _, n := range detail.Flat {
|
|
if n.Span.Kind == "" || (n.Span.Kind[0] >= '0' && n.Span.Kind[0] <= '9') {
|
|
t.Fatalf("span kind marshalled as %q, want a name like tool_call", n.Span.Kind)
|
|
}
|
|
}
|
|
|
|
// The correlation must spread content over more than one span.
|
|
withLogs := 0
|
|
for _, n := range detail.Flat {
|
|
if len(n.Logs) > 0 {
|
|
withLogs++
|
|
}
|
|
}
|
|
if withLogs < 2 {
|
|
t.Errorf("only %d span(s) carry log records; correlation collapsed", withLogs)
|
|
}
|
|
}
|
|
|
|
func TestTraceDetailNotFound(t *testing.T) {
|
|
ts, _ := testServer(t)
|
|
res, err := http.Get(ts.URL + "/api/traces/does-not-exist")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNotFound {
|
|
t.Errorf("unknown trace -> %d, want 404", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestMetricsAccepted covers the signal Fathom deliberately discards. Exporters
|
|
// send all three to one endpoint; a 404 here would fill the agent's own logs
|
|
// with errors about a decision Fathom made on purpose.
|
|
func TestMetricsAccepted(t *testing.T) {
|
|
ts, _ := testServer(t)
|
|
res := post(t, ts.URL+"/v1/metrics", `{"resourceMetrics":[]}`)
|
|
if res.StatusCode != 200 {
|
|
t.Errorf("POST /v1/metrics -> %d, want 200", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestIngestRejectsGarbage(t *testing.T) {
|
|
ts, _ := testServer(t)
|
|
for _, path := range []string{"/v1/traces", "/v1/logs"} {
|
|
res := post(t, ts.URL+path, "not json at all")
|
|
if res.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("POST %s with garbage -> %d, want 400", path, res.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryEndpoint(t *testing.T) {
|
|
ts, st := testServer(t)
|
|
traceJSON, err := os.ReadFile("../ingest/testdata/claude_code_traces.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
post(t, ts.URL+"/v1/traces", string(traceJSON))
|
|
if err := st.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
body := fmt.Sprintf(`{"query":"SELECT kind, count(*) FROM spans WHERE time >= %d AND time <= %d GROUP BY kind ORDER BY count(*) DESC"}`,
|
|
1_600_000_000_000_000, 2_100_000_000_000_000)
|
|
res := post(t, ts.URL+"/api/query", body)
|
|
defer res.Body.Close()
|
|
if res.StatusCode != 200 {
|
|
b, _ := io.ReadAll(res.Body)
|
|
t.Fatalf("POST /api/query -> %d: %s", res.StatusCode, b)
|
|
}
|
|
|
|
var out struct {
|
|
Columns []string `json:"columns"`
|
|
Rows [][]any `json:"rows"`
|
|
OrderBy string `json:"order_by"`
|
|
}
|
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out.Rows) == 0 {
|
|
t.Fatal("query returned no rows")
|
|
}
|
|
// The UI charts the ORDER BY measure; without this it charts the wrong one.
|
|
if out.OrderBy != "count(*)" {
|
|
t.Errorf("order_by = %q, want count(*)", out.OrderBy)
|
|
}
|
|
}
|
|
|
|
func TestQueryErrorIsReturnedVerbatim(t *testing.T) {
|
|
ts, _ := testServer(t)
|
|
res := post(t, ts.URL+"/api/query", `{"query":"SELECT name FROM spans"}`)
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("missing time range -> %d, want 400", res.StatusCode)
|
|
}
|
|
var out struct {
|
|
Error string `json:"error"`
|
|
}
|
|
json.NewDecoder(res.Body).Decode(&out)
|
|
// The message must be the parser's, not a generic one: it is the only
|
|
// guidance the person writing the query gets.
|
|
if !strings.Contains(out.Error, "time range") {
|
|
t.Errorf("error = %q, want it to explain the missing time range", out.Error)
|
|
}
|
|
}
|
|
|
|
func TestUIIsServed(t *testing.T) {
|
|
ts, _ := testServer(t)
|
|
for _, path := range []string{"/", "/app.js", "/style.css"} {
|
|
res, err := http.Get(ts.URL + path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
if res.StatusCode != 200 {
|
|
t.Errorf("GET %s -> %d", path, res.StatusCode)
|
|
}
|
|
}
|
|
// A deep link must serve the app rather than 404, or reloading on a trace
|
|
// page breaks.
|
|
res, err := http.Get(ts.URL + "/some/client/route")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != 200 {
|
|
t.Errorf("deep link -> %d, want the SPA shell", res.StatusCode)
|
|
}
|
|
b, _ := io.ReadAll(res.Body)
|
|
if !strings.Contains(string(b), "<title>Fathom</title>") {
|
|
t.Error("deep link did not serve index.html")
|
|
}
|
|
}
|
|
|
|
// TestPartialTraceIsMarked covers a run still in flight. Claude Code emits the
|
|
// root interaction span when the interaction ends, so a list rendered mid-run
|
|
// sees every child and no root — and without this, names the trace after
|
|
// whichever child landed first, which reads as a finished trace with a strange
|
|
// name.
|
|
func TestPartialTraceIsMarked(t *testing.T) {
|
|
ts, st := testServer(t)
|
|
|
|
base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC).UnixMicro()
|
|
// A child span with no root, exactly as an in-flight run looks.
|
|
if err := st.AddSpans([]model.Span{{
|
|
TraceID: "in-flight", SpanID: "child", ParentSpanID: "root-not-here",
|
|
Name: "claude_code.tool.blocked_on_user", StartTS: base, EndTS: base + 1000,
|
|
SampleRate: 1,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var list struct {
|
|
Traces []TraceSummary `json:"traces"`
|
|
}
|
|
getJSON(t, fmt.Sprintf("%s/api/traces?from=%d&to=%d", ts.URL, base-1, base+100000), &list)
|
|
if len(list.Traces) != 1 {
|
|
t.Fatalf("got %d traces, want 1", len(list.Traces))
|
|
}
|
|
if !list.Traces[0].Partial {
|
|
t.Error("a trace with no root span was not marked partial")
|
|
}
|
|
if list.Traces[0].Name != "" {
|
|
t.Errorf("partial trace was named %q; it should carry no name rather than a child's",
|
|
list.Traces[0].Name)
|
|
}
|
|
|
|
// Once the root lands, the trace is complete and correctly named.
|
|
if err := st.AddSpans([]model.Span{{
|
|
TraceID: "in-flight", SpanID: "root-not-here", Name: "claude_code.interaction",
|
|
StartTS: base - 500, EndTS: base + 2000, SampleRate: 1,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list.Traces = nil
|
|
getJSON(t, fmt.Sprintf("%s/api/traces?from=%d&to=%d", ts.URL, base-1000, base+100000), &list)
|
|
if list.Traces[0].Partial {
|
|
t.Error("trace still marked partial after its root arrived")
|
|
}
|
|
if list.Traces[0].Name != "claude_code.interaction" {
|
|
t.Errorf("name = %q, want claude_code.interaction", list.Traces[0].Name)
|
|
}
|
|
}
|
|
|
|
// TestInsightsEmptyStoreIsWellFormed covers the response a brand new
|
|
// installation gets. It shipped with themes marshalling as JSON null, which the
|
|
// client iterated and crashed on — so the insights tab rendered a raw
|
|
// JavaScript error on exactly the store every new user starts with.
|
|
func TestInsightsEmptyStoreIsWellFormed(t *testing.T) {
|
|
_, st := testServer(t)
|
|
analyzer := insights.New(insights.Options{Store: st, WindowDays: 7})
|
|
analyzer.Refresh()
|
|
srv := NewServer(st, ingest.NewDecoder(), nil, log.New(io.Discard, "", 0))
|
|
srv.SetInsights(analyzer)
|
|
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/api/insights", nil))
|
|
if rec.Code != 200 {
|
|
t.Fatalf("GET /api/insights -> %d", rec.Code)
|
|
}
|
|
|
|
// Assert on the raw JSON: a nil slice decodes into an empty Go slice, so
|
|
// checking the decoded form would hide the bug this guards.
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `"themes":[]`) {
|
|
t.Errorf("themes must serialise as [], not null: %s", body)
|
|
}
|
|
|
|
// No field the page dereferences may arrive as null. The client normalises
|
|
// defensively as well, but a server that emits null here is a bug on its
|
|
// own: the two ship in the same binary and should not disagree.
|
|
var loose map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &loose); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, _ := loose["result"].(map[string]any)
|
|
if result == nil {
|
|
t.Fatal("result is missing or null")
|
|
}
|
|
for _, field := range []string{"themes", "window", "local"} {
|
|
if v, ok := result[field]; !ok || v == nil {
|
|
t.Errorf("result.%s is %v; the page dereferences it", field, v)
|
|
}
|
|
}
|
|
|
|
var out struct {
|
|
Ready bool `json:"ready"`
|
|
Result struct {
|
|
Themes []any `json:"themes"`
|
|
TraceCount int `json:"trace_count"`
|
|
MinOccurrences int `json:"min_occurrences"`
|
|
Dismissed int `json:"dismissed"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !out.Ready {
|
|
t.Error("an analysis of an empty store completed, so ready should be true")
|
|
}
|
|
if out.Result.TraceCount != 0 {
|
|
t.Errorf("trace_count = %d, want 0", out.Result.TraceCount)
|
|
}
|
|
// The empty-state copy quotes this threshold, so it has to be present or
|
|
// the wording silently drifts from the detectors' behaviour.
|
|
if out.Result.MinOccurrences < 1 {
|
|
t.Errorf("min_occurrences = %d; the empty state has no threshold to quote",
|
|
out.Result.MinOccurrences)
|
|
}
|
|
if out.Result.Dismissed != 0 {
|
|
t.Errorf("dismissed = %d, want 0", out.Result.Dismissed)
|
|
}
|
|
}
|
|
|
|
// TestInsightsReportsDismissedCount: an empty page because everything was
|
|
// dismissed is a different situation from an empty page because nothing was
|
|
// found, and the UI needs to tell them apart.
|
|
func TestInsightsReportsDismissedCount(t *testing.T) {
|
|
_, st := testServer(t)
|
|
|
|
base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC).UnixMicro()
|
|
var spans []model.Span
|
|
for i := 0; i < 4; i++ {
|
|
tid := fmt.Sprintf("t%d", i)
|
|
spans = append(spans, model.Span{
|
|
TraceID: tid, SpanID: tid + "-root", Name: "claude_code.interaction",
|
|
Kind: model.KindAgentStep, StartTS: base + int64(i)*1000, EndTS: base + int64(i)*1000 + 900,
|
|
SampleRate: 1,
|
|
})
|
|
// A failing command shape, repeated, which the waste detector reports.
|
|
for j := 0; j < 2; j++ {
|
|
spans = append(spans, model.Span{
|
|
TraceID: tid, SpanID: fmt.Sprintf("%s-tool%d", tid, j), ParentSpanID: tid + "-root",
|
|
Name: "claude_code.tool", Kind: model.KindToolCall, Status: model.StatusError,
|
|
StartTS: base + int64(i)*1000, EndTS: base + int64(i)*1000 + 100, SampleRate: 1,
|
|
Attrs: model.Attrs{"tool_name": model.StringValue("Bash")},
|
|
})
|
|
}
|
|
}
|
|
if err := st.AddSpans(spans); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
analyzer := insights.New(insights.Options{
|
|
Store: st, WindowDays: 3650,
|
|
Now: func() time.Time { return time.Date(2026, 9, 15, 13, 0, 0, 0, time.UTC) },
|
|
})
|
|
analyzer.Refresh()
|
|
|
|
res, _ := analyzer.Latest()
|
|
var ids []string
|
|
for _, th := range res.Themes {
|
|
for _, in := range th.Insights {
|
|
ids = append(ids, in.ID)
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
t.Fatal("no findings to dismiss; this test would pass vacuously")
|
|
}
|
|
|
|
for _, id := range ids {
|
|
analyzer.Dismiss(id)
|
|
}
|
|
res, _ = analyzer.Latest()
|
|
|
|
shown := 0
|
|
for _, th := range res.Themes {
|
|
shown += len(th.Insights)
|
|
}
|
|
if shown != 0 {
|
|
t.Errorf("%d findings still shown after dismissing all of them", shown)
|
|
}
|
|
if res.Dismissed != len(ids) {
|
|
t.Errorf("dismissed = %d, want %d", res.Dismissed, len(ids))
|
|
}
|
|
|
|
// Restoring must bring them all back, or a dismissal is effectively
|
|
// permanent and the button lies.
|
|
analyzer.Restore()
|
|
res, _ = analyzer.Latest()
|
|
shown = 0
|
|
for _, th := range res.Themes {
|
|
shown += len(th.Insights)
|
|
}
|
|
if shown != len(ids) {
|
|
t.Errorf("after restore %d findings are shown, want %d", shown, len(ids))
|
|
}
|
|
if res.Dismissed != 0 {
|
|
t.Errorf("dismissed = %d after restore, want 0", res.Dismissed)
|
|
}
|
|
}
|