Files
zzg 9bc48467a4 Add the HTTP server, embedded UI, config, and CLI
Three views: a trace list, the trace detail waterfall with its correlated
transcript, and an aggregate query explorer. Vanilla JS in a go:embed'd
directory -- no bundler, no npm, and no way for the API and UI to drift.

Several fixes came directly from screenshotting the running app in Chrome:

- model.Span had no JSON tags, so Go marshalled SpanID while the client read
  span_id. The inspector sat on "Loading..." forever. Tagged the model and
  made Kind/Status marshal as names, so the client keeps no parallel enum.
- replaceChildren stringifies null rather than skipping it, rendering a
  literal "null" in the trace header.
- Cost was empty for every trace: it is reported on the api_request LOG
  record, never on a span, and the list scanned only spans.
- Every tool call rendered three times, because Claude Code emits tool,
  tool.execution, and tool.blocked_on_user for one logical call. Wrapper
  spans that restate their parent are now folded, with a count on the parent.
- The list showed "claude_code.interaction" on all 40 rows while the prompt
  that distinguishes runs sat in a subtitle. The prompt now leads.
- A keyless log record was placed by timestamp containment, filing the user's
  prompt under whichever model call started at the same microsecond. It now
  prefers its own span_id, which for keyless records is the right signal.
- The Explore chart plotted the first numeric column, so a "slowest tools"
  query rendered call counts. Results now carry the ORDER BY measure.
- An unnamed category (the wrapper spans) crushed every named bar; unnamed
  rows are left to the table with a note.
2026-09-15 21:55:49 -07:00

175 lines
5.1 KiB
Go

// Package model defines the records Fathom stores: spans and log records.
package model
import "strconv"
// Kind classifies what a span represents. Agent traces are mostly a few
// recurring shapes, and naming them lets the UI colour and group without
// pattern-matching on span names.
type Kind uint8
const (
KindOther Kind = iota
KindLLMCall
KindToolCall
KindAgentStep
KindRetrieval
KindHook
KindMCP
)
var kindNames = [...]string{"other", "llm_call", "tool_call", "agent_step", "retrieval", "hook", "mcp"}
func (k Kind) String() string {
if int(k) < len(kindNames) {
return kindNames[k]
}
return "other"
}
// ParseKind maps a kind name back to its enum. Unknown names become KindOther.
func ParseKind(s string) Kind {
for i, n := range kindNames {
if n == s {
return Kind(i)
}
}
return KindOther
}
// Status mirrors OTel's span status. Unset is distinct from OK: most emitters
// leave it unset on success, and collapsing the two would make "how many spans
// explicitly reported success" unanswerable.
type Status uint8
const (
StatusUnset Status = iota
StatusOK
StatusError
)
var statusNames = [...]string{"unset", "ok", "error"}
func (s Status) String() string {
if int(s) < len(statusNames) {
return statusNames[s]
}
return "unset"
}
func ParseStatus(s string) Status {
for i, n := range statusNames {
if n == s {
return Status(i)
}
}
return StatusUnset
}
// ValueType tags an attribute's original type. Every value is dictionary-encoded
// as a string regardless; the tag is what lets the UI render 3 rather than "3"
// and lets query comparisons parse numerically. Dictionaries are per-chunk and
// small, so parsing on demand costs nothing worth optimising away.
type ValueType uint8
const (
ValString ValueType = iota
ValInt
ValFloat
ValBool
)
// Value is a single attribute value. The JSON form is what the UI reads, so
// the type tag travels with the string: it is what lets a token count render
// right-aligned as a number rather than left-aligned as text.
type Value struct {
Type ValueType `json:"type"`
Str string `json:"str"`
}
func StringValue(s string) Value { return Value{Type: ValString, Str: s} }
func IntValue(i int64) Value { return Value{Type: ValInt, Str: strconv.FormatInt(i, 10)} }
func FloatValue(f float64) Value {
return Value{Type: ValFloat, Str: strconv.FormatFloat(f, 'g', -1, 64)}
}
func BoolValue(b bool) Value { return Value{Type: ValBool, Str: strconv.FormatBool(b)} }
// Float returns the value as a number, and whether it is numeric at all.
// Bools count as numeric (0/1) so that `WHERE success = 1` behaves.
func (v Value) Float() (float64, bool) {
switch v.Type {
case ValInt, ValFloat:
f, err := strconv.ParseFloat(v.Str, 64)
return f, err == nil
case ValBool:
if v.Str == "true" {
return 1, true
}
return 0, true
}
// A string attribute holding digits is still usefully numeric.
f, err := strconv.ParseFloat(v.Str, 64)
return f, err == nil
}
// Attrs is an attribute map. Ordering is not preserved; the store sorts keys so
// that chunks are deterministic and diffable in tests.
type Attrs map[string]Value
// Span is one node of a trace tree.
type Span struct {
TraceID string `json:"trace_id"`
SpanID string `json:"span_id"`
ParentSpanID string `json:"parent_span_id"` // empty for a root span
Name string `json:"name"`
Service string `json:"service"`
Kind Kind `json:"kind"`
Status Status `json:"status"`
StartTS int64 `json:"start_ts"` // unix micros
EndTS int64 `json:"end_ts"` // unix micros
SampleRate float64 `json:"sample_rate"`
Attrs Attrs `json:"attrs,omitempty"`
Input string `json:"input,omitempty"` // large text, stored out-of-line
Output string `json:"output,omitempty"` // large text, stored out-of-line
}
// DurationMicros is the span's wall time. Emitters occasionally send an end
// before the start on clock adjustment; clamping at zero keeps a bad span from
// rendering as a negative-width bar.
func (s *Span) DurationMicros() int64 {
if s.EndTS < s.StartTS {
return 0
}
return s.EndTS - s.StartTS
}
// LogRecord is a content-bearing event. Fathom keeps these in their own table
// and correlates them onto spans at read time; see internal/tracetree for why
// that correlation is not a span_id join.
type LogRecord struct {
TraceID string `json:"trace_id"`
SpanID string `json:"span_id"`
EventName string `json:"event_name"`
Severity uint8 `json:"severity"`
TS int64 `json:"ts"` // unix micros
Attrs Attrs `json:"attrs,omitempty"`
Body string `json:"body,omitempty"` // large text, stored out-of-line
}
// Table names. These are also the on-disk directory names under the data dir.
const (
TableSpans = "spans"
TableLogs = "logs"
)
// MarshalJSON renders Kind and Status as their names. Sending the integer would
// force the UI to keep a parallel copy of this enum in sync with Go's, which is
// exactly the kind of duplication that silently rots.
func (k Kind) MarshalJSON() ([]byte, error) {
return []byte(`"` + k.String() + `"`), nil
}
func (s Status) MarshalJSON() ([]byte, error) {
return []byte(`"` + s.String() + `"`), nil
}