Files
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

334 lines
9.9 KiB
Go

package web
import (
"net/http"
"sort"
"strings"
"git.z.gd/zzg/fathom/internal/model"
"git.z.gd/zzg/fathom/internal/store"
)
// TraceSummary is one row of the trace list. The fields beyond identity and
// timing are what make the list scannable without opening anything: an engineer
// looking for the run that went wrong wants to see cost, tool count, and error
// state at a glance.
type TraceSummary struct {
TraceID string `json:"trace_id"`
Name string `json:"name"`
Service string `json:"service"`
StartTS int64 `json:"start_ts"`
EndTS int64 `json:"end_ts"`
DurMicros int64 `json:"duration_micros"`
SpanCount int `json:"span_count"`
Status string `json:"status"`
ErrorCount int `json:"error_count"`
Tokens int64 `json:"tokens"`
Cached int64 `json:"cached_tokens"`
CostUSD float64 `json:"cost_usd"`
ToolCalls int `json:"tool_calls"`
LLMCalls int `json:"llm_calls"`
Model string `json:"model"`
Preview string `json:"preview"`
// Partial marks a trace whose root span has not arrived. Claude Code emits
// the root interaction span when the interaction ends, so a run still in
// flight has every child span and no root. Saying so beats labelling the
// run after whichever child happened to land first, which reads as a
// finished trace with a strange name.
Partial bool `json:"partial"`
}
// tokenAttrs and costAttrs are the attribute names emitters use for the numbers
// the list surfaces. Several are checked because there is no single convention:
// Claude Code uses input_tokens, the OTel GenAI conventions use
// gen_ai.usage.input_tokens, and OpenLLMetry uses llm.usage.*.
var tokenAttrs = []string{
"input_tokens", "output_tokens",
"gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens",
"llm.usage.total_tokens", "gen_ai.usage.total_tokens",
}
// Cache reads are counted separately, not folded into the token total.
//
// A real run measured 16 input and 1,301 output tokens against 280,054 cache
// reads. Summing them would report "281k tokens" and make every cached run look
// enormous; omitting them entirely reports "1.3k" and hides where the context
// actually came from. Both numbers are interesting, so both are kept.
var cacheAttrs = []string{
"cache_read_tokens", "cache_creation_tokens",
"gen_ai.usage.cache_read_input_tokens",
}
var costAttrs = []string{"cost_usd", "gen_ai.usage.cost"}
var modelAttrs = []string{"gen_ai.request.model", "model", "llm.request.model"}
// handleTraceList aggregates spans into per-trace summaries.
//
// This is a full scan of the requested range, by design. There is no trace
// index, because building one would mean a second source of truth to keep
// consistent with the shards — the failure mode being far worse than the scan
// it saves. The response reports rows_scanned so that claim stays checkable
// rather than aspirational.
func (s *Server) handleTraceList(w http.ResponseWriter, r *http.Request) {
minTS, maxTS := timeRange(r)
limit := intParam(r, "limit", 200)
statusFilter := r.URL.Query().Get("status")
search := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
summaries := map[string]*TraceSummary{}
// rootSeen tracks whether a trace's true root span has been found, so a
// later non-root span does not overwrite the name with a child's.
rootSeen := map[string]bool{}
scanned, err := s.store.ScanSpans(minTS, maxTS,
func(sp model.Span, sh *store.Shard, c *store.Chunk, i int) bool {
t := summaries[sp.TraceID]
if t == nil {
t = &TraceSummary{
TraceID: sp.TraceID,
Service: sp.Service,
StartTS: sp.StartTS,
EndTS: sp.EndTS,
Status: "ok",
}
summaries[sp.TraceID] = t
}
mergeSpan(t, &sp, rootSeen)
return true
})
if err != nil {
httpError(w, http.StatusInternalServerError, "scanning spans: %v", err)
return
}
// Cost lives on log records, not spans: Claude Code reports it on the
// api_request event. A spans-only scan therefore shows nothing in the cost
// column, which is how this was caught. Scanning logs for the handful of
// cost-bearing events is cheap next to the span scan already done.
logScan, err := s.store.ScanLogs(minTS, maxTS,
func(rec model.LogRecord, sh *store.Shard, c *store.Chunk, i int) bool {
t := summaries[rec.TraceID]
if t == nil {
return true
}
for _, k := range costAttrs {
if v, ok := rec.Attrs[k]; ok {
if f, ok := v.Float(); ok {
t.CostUSD += f
}
}
}
if rec.EventName == "user_prompt" && t.Preview == "" {
if v, ok := rec.Attrs["prompt"]; ok && v.Str != "" {
t.Preview = truncateRunes(v.Str, 160)
}
}
return true
})
if err != nil {
httpError(w, http.StatusInternalServerError, "scanning logs: %v", err)
return
}
out := make([]TraceSummary, 0, len(summaries))
for id, t := range summaries {
t.Partial = !rootSeen[id]
t.DurMicros = t.EndTS - t.StartTS
if t.DurMicros < 0 {
t.DurMicros = 0
}
if statusFilter != "" && t.Status != statusFilter {
continue
}
if search != "" && !matchesSearch(t, search) {
continue
}
out = append(out, *t)
}
// Newest first: the trace you want is almost always the one that just ran.
sort.Slice(out, func(i, j int) bool { return out[i].StartTS > out[j].StartTS })
total := len(out)
if limit > 0 && len(out) > limit {
out = out[:limit]
}
writeJSON(w, map[string]any{
"traces": out,
"total": total,
"stats": map[string]any{
"shards_scanned": scanned.Shards + logScan.Shards,
"chunks_scanned": scanned.Chunks + logScan.Chunks,
"rows_scanned": scanned.Rows + logScan.Rows,
},
})
}
func matchesSearch(t *TraceSummary, q string) bool {
hay := strings.ToLower(t.Name + " " + t.TraceID + " " + t.Model + " " + t.Preview)
return strings.Contains(hay, q)
}
// mergeSpan folds one span into its trace's summary.
func mergeSpan(t *TraceSummary, sp *model.Span, rootSeen map[string]bool) {
t.SpanCount++
if sp.StartTS < t.StartTS {
t.StartTS = sp.StartTS
}
if sp.EndTS > t.EndTS {
t.EndTS = sp.EndTS
}
if sp.Status == model.StatusError {
t.ErrorCount++
t.Status = "error"
}
// The root span names the trace.
if sp.ParentSpanID == "" && (!rootSeen[sp.TraceID] || sp.StartTS <= t.StartTS) {
t.Name = sp.Name
rootSeen[sp.TraceID] = true
}
switch sp.Kind {
case model.KindToolCall:
// Claude Code emits a `tool` span with `tool.execution` and
// `tool.blocked_on_user` children. Counting all three would treat one
// tool call as three, so only the outermost counts.
if !strings.Contains(sp.Name, ".execution") && !strings.Contains(sp.Name, ".blocked_on_user") {
t.ToolCalls++
}
case model.KindLLMCall:
t.LLMCalls++
}
for _, k := range tokenAttrs {
if v, ok := sp.Attrs[k]; ok {
if f, ok := v.Float(); ok {
t.Tokens += int64(f)
}
}
}
for _, k := range cacheAttrs {
if v, ok := sp.Attrs[k]; ok {
if f, ok := v.Float(); ok {
t.Cached += int64(f)
}
}
}
for _, k := range costAttrs {
if v, ok := sp.Attrs[k]; ok {
if f, ok := v.Float(); ok {
t.CostUSD += f
}
}
}
if t.Model == "" {
for _, k := range modelAttrs {
if v, ok := sp.Attrs[k]; ok && v.Str != "" {
t.Model = v.Str
break
}
}
}
// A short excerpt of the prompt makes the list scannable by what the run
// was about rather than by opaque trace ids.
if t.Preview == "" {
if v, ok := sp.Attrs["user_prompt"]; ok && v.Str != "" {
t.Preview = truncateRunes(v.Str, 140)
}
}
}
func truncateRunes(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// handleTraceDetail rebuilds one trace: its span tree, its correlated log
// records, and the blob text for both.
//
// Blobs are resolved here rather than lazily per span because a trace is opened
// once and read whole; a round trip per span would make the UI feel worse than
// the extra bytes cost.
func (s *Server) handleTraceDetail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
httpError(w, http.StatusBadRequest, "no trace id given")
return
}
minTS, maxTS := traceLookupRange(r)
var spans []model.Span
spanScan, err := s.store.ScanSpans(minTS, maxTS,
func(sp model.Span, sh *store.Shard, c *store.Chunk, i int) bool {
if sp.TraceID != id {
return true
}
if in, err := sh.Blob().Read(store.InputRef(c, i)); err == nil {
sp.Input = in
}
if out, err := sh.Blob().Read(store.OutputRef(c, i)); err == nil {
sp.Output = out
}
spans = append(spans, sp)
return true
})
if err != nil {
httpError(w, http.StatusInternalServerError, "scanning spans: %v", err)
return
}
if len(spans) == 0 {
httpError(w, http.StatusNotFound, "no trace %q in the range searched", id)
return
}
var logs []model.LogRecord
logScan, err := s.store.ScanLogs(minTS, maxTS,
func(rec model.LogRecord, sh *store.Shard, c *store.Chunk, i int) bool {
if rec.TraceID != id {
return true
}
if body, err := sh.Blob().Read(store.BodyRef(c, i)); err == nil {
rec.Body = body
}
logs = append(logs, rec)
return true
})
if err != nil {
httpError(w, http.StatusInternalServerError, "scanning logs: %v", err)
return
}
tr := s.corr.Build(id, spans, logs)
writeJSON(w, map[string]any{
"trace": tr,
"flat": tr.Flatten(),
"log_count": len(logs),
"stats": map[string]any{
"shards_scanned": spanScan.Shards + logScan.Shards,
"rows_scanned": spanScan.Rows + logScan.Rows,
},
})
}
// traceLookupRange widens the default window for a detail lookup.
//
// A trace id carries no timestamp, so finding one means scanning. Defaulting to
// seven days rather than the list's one day means a link to a trace from
// yesterday still resolves, while still bounding the scan — which is the whole
// reason the time partition is the only index.
func traceLookupRange(r *http.Request) (int64, int64) {
if r.URL.Query().Get("from") != "" || r.URL.Query().Get("to") != "" {
return timeRange(r)
}
now := nowMicros()
return now - 7*24*3600*1_000_000, now
}