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.
175 lines
5.1 KiB
Go
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
|
|
}
|