Files
zzg 47fbe583dc Make the Insights tab respect the selected time range
The Insights tab ignored the time-range picker in the upper right: clicking
'15 minutes' still showed findings from the analyzer's fixed background
window (WindowDays, e.g. 7 days), because /api/insights always returned the
cached background result regardless of what range the UI requested.

- internal/insights/analyzer.go: split Refresh()'s logic into a shared
  analyze(from, to) core, and add a public AnalyzeRange(from, to) that runs
  the detectors on demand for an arbitrary window without disturbing the
  cached background result used by Latest() and the Stop-hook/surfacing
  path.
- internal/web/insights.go: handleInsights now honors optional from/to query
  params, calling AnalyzeRange for that window instead of Latest() when
  present.
- internal/web/static/app.js: loadInsights() sends the current
  state.rangeMicros as from/to, and the window label now renders
  minutes/hours/days appropriately (windowLabel()) instead of always
  rounding up to whole days, so a 15-minute selection reads as '15 minutes'
  rather than '1 day'.
2026-09-19 21:15:47 -07:00

589 lines
16 KiB
Go

package insights
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"git.z.gd/zzg/fathom/internal/model"
"git.z.gd/zzg/fathom/internal/store"
"git.z.gd/zzg/fathom/internal/tracetree"
)
// Analyzer runs the detectors in the background and publishes the latest result.
//
// It is deliberately a reader. It scans the store through the same public API
// the UI uses, holds its findings in memory, and writes nothing to the data
// directory except the reader's own dismissals. Nothing else in Fathom depends
// on it, and switching it off removes a tab and changes nothing else.
//
// Results are recomputed rather than persisted because recomputing is
// milliseconds and a cached analysis is a second source of truth that can
// disagree with the traces it claims to describe.
type Analyzer struct {
store *store.Store
detectors []Detector
// WindowDays bounds how far back an analysis looks. Habits worth capturing
// are recent by definition; a pattern from six weeks ago is history.
WindowDays int
// Interval is how often the analysis is refreshed when traces are arriving.
Interval time.Duration
now func() time.Time
mu sync.RWMutex
latest Result
hasRun bool
running bool
dismissPath string
dismissed map[string]int64 // insight id -> when dismissed
// local is a model server found on this machine, if any. Nil is the
// ordinary case and every feature that would use it degrades silently.
local Labeler
embedder Embedder
judge Judge
status LocalStatus
surfacedPath string
surfaced *surfacedState
crystallize *Crystallize
trigger chan struct{}
stop chan struct{}
stopped sync.Once
wg sync.WaitGroup
}
// Options configure an Analyzer.
type Options struct {
Store *store.Store
DataDir string
WindowDays int
Interval time.Duration
Now func() time.Time
// Local is an optional model server on this machine.
Local Labeler
Embedder Embedder
Judge Judge
Status LocalStatus
}
// Labeler names a cluster from examples.
type Labeler interface {
Label(ctx context.Context, prompts []string) (string, error)
}
// LocalStatus describes what was found on this machine, for the UI.
//
// Reported rather than hidden: a finding that came from semantic clustering can
// differ from one produced on a machine with no model, and a reader should be
// able to see why rather than wonder.
type LocalStatus struct {
Available bool `json:"available"`
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint,omitempty"`
Kind string `json:"kind,omitempty"`
EmbedModel string `json:"embed_model,omitempty"`
ChatModel string `json:"chat_model,omitempty"`
// UsedFor says what the last analysis actually used it for, which is not
// the same as what is available: a model can be present and still fail.
UsedFor []string `json:"used_for,omitempty"`
}
func New(opts Options) *Analyzer {
if opts.WindowDays <= 0 {
opts.WindowDays = 7
}
if opts.Interval <= 0 {
opts.Interval = 2 * time.Minute
}
if opts.Now == nil {
opts.Now = time.Now
}
crystallize := NewCrystallize()
a := &Analyzer{
store: opts.Store,
WindowDays: opts.WindowDays,
Interval: opts.Interval,
now: opts.Now,
local: opts.Local,
embedder: opts.Embedder,
judge: opts.Judge,
status: opts.Status,
detectors: []Detector{
crystallize,
NewContextWeight(),
NewWaste(),
},
crystallize: crystallize,
dismissed: map[string]int64{},
trigger: make(chan struct{}, 1),
stop: make(chan struct{}),
}
a.status.Enabled = opts.Status.Available
a.applyLocal()
if opts.DataDir != "" {
a.dismissPath = filepath.Join(opts.DataDir, "insights-dismissed.json")
a.surfacedPath = filepath.Join(opts.DataDir, "insights-surfaced.json")
a.loadDismissed()
}
return a
}
// Start begins background analysis.
func (a *Analyzer) Start() {
a.wg.Add(1)
go a.loop()
}
func (a *Analyzer) Close() {
a.stopped.Do(func() { close(a.stop) })
a.wg.Wait()
}
// Notify asks for a refresh soon. Wired to the store's flush hook so an
// analysis follows new traces rather than a clock.
//
// Non-blocking and coalescing: a burst of flushes schedules one run, and a run
// already in flight is not queued behind another.
func (a *Analyzer) Notify() {
select {
case a.trigger <- struct{}{}:
default:
}
}
func (a *Analyzer) loop() {
defer a.wg.Done()
// An initial pass so the tab has something the first time it is opened.
a.Refresh()
// The ticker is a floor, not the primary trigger: it catches the case where
// traces stopped arriving but the window has moved on and old findings have
// aged out.
tick := time.NewTicker(a.Interval)
defer tick.Stop()
// Debounce flush notifications. Ingest flushes about once a second while an
// agent is working, and re-analysing on each one would burn CPU to produce
// the same answer.
var debounce <-chan time.Time
for {
select {
case <-a.stop:
return
case <-a.trigger:
if debounce == nil {
debounce = time.After(10 * time.Second)
}
case <-debounce:
debounce = nil
a.Refresh()
case <-tick.C:
a.Refresh()
}
}
}
// Refresh runs the detectors once, synchronously, over the analyzer's default
// window, and publishes the result for Latest.
func (a *Analyzer) Refresh() {
a.mu.Lock()
if a.running {
a.mu.Unlock()
return
}
a.running = true
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.running = false
a.mu.Unlock()
}()
now := a.now()
to := now.UnixMicro()
from := now.AddDate(0, 0, -a.WindowDays).UnixMicro()
res, err := a.analyze(from, to)
if err != nil {
return // leave the previous result in place rather than blanking the tab
}
a.mu.Lock()
a.latest = res
a.hasRun = true
a.mu.Unlock()
}
// AnalyzeRange runs the detectors once over an explicit window and returns the
// result directly, without touching the cached background result that Latest
// and the surfacing/hook path read.
//
// This is what lets the UI's time-range picker apply to the Insights tab: the
// background Analyzer keeps its own fixed window (recent habits, checked on a
// schedule), while a request for a specific range gets its own on-demand
// analysis. Recomputing is milliseconds, so there is no need to cache it.
func (a *Analyzer) AnalyzeRange(from, to int64) (Result, error) {
return a.analyze(from, to)
}
// analyze is the shared core behind Refresh and AnalyzeRange: extract facts,
// run every detector, drop what has been dismissed and not recurred, and group
// the rest for display.
func (a *Analyzer) analyze(from, to int64) (Result, error) {
traces, err := a.extract(from, to)
if err != nil {
return Result{}, err
}
var all []Insight
for _, d := range a.detectors {
all = append(all, d.Detect(traces)...)
}
a.mu.RLock()
dismissed := make(map[string]int64, len(a.dismissed))
for k, v := range a.dismissed {
dismissed[k] = v
}
a.mu.RUnlock()
kept := all[:0]
for _, in := range all {
// A dismissal applies until the pattern recurs after it. Suppressing
// forever would mean a habit you consciously accepted last month stays
// invisible when it comes back at ten times the cost.
if when, ok := dismissed[in.ID]; ok && in.LastSeen <= when {
continue
}
kept = append(kept, in)
}
res := group(kept, Window{From: from, To: to}, traces, to)
res.MinOccurrences = a.crystallize.MinRuns
res.Dismissed = len(all) - len(kept)
used := []string{}
if a.crystallize.LastMethod() == MethodEmbedding {
used = append(used, "clustering")
}
if a.enabled() && a.local != nil {
if n := a.labelFindings(&res); n > 0 {
used = append(used, "naming")
}
}
a.mu.RLock()
status := a.status
status.UsedFor = used
a.mu.RUnlock()
res.Local = status
return res, nil
}
// labelFindings asks the local model to name clustered findings.
//
// Strictly additive: the model sees prompts that are already grouped and writes
// a phrase. It is never given a number, never asked to decide what is a
// finding, and a failure leaves the heuristic title in place. Returns how many
// labels were applied.
func (a *Analyzer) labelFindings(res *Result) int {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
applied := 0
for ti := range res.Themes {
for ii := range res.Themes[ti].Insights {
in := &res.Themes[ti].Insights[ii]
if !strings.HasPrefix(in.ID, "crystallize/ask/") || len(in.Evidence) < 2 {
continue
}
var prompts []string
for _, e := range in.Evidence {
if e.Prompt != "" {
prompts = append(prompts, e.Prompt)
}
}
if len(prompts) < 2 {
continue
}
label, err := a.local.Label(ctx, prompts)
if err != nil || label == "" {
continue // keep the heuristic title
}
in.Label = label
in.Title = fmt.Sprintf("%s — asked %d times", label, in.Occurrences)
applied++
}
}
return applied
}
// applyLocal wires the embedder into the detector that can use it.
func (a *Analyzer) applyLocal() {
if a.status.Enabled {
a.crystallize.Embedder = a.embedder
return
}
a.crystallize.Embedder = nil
}
func (a *Analyzer) enabled() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.status.Enabled
}
// SetLocalEnabled turns local-model use on or off and re-analyses.
func (a *Analyzer) SetLocalEnabled(on bool) {
a.mu.Lock()
a.status.Enabled = on && a.status.Available
a.mu.Unlock()
a.applyLocal()
a.Refresh()
}
// Status returns what was found on this machine.
func (a *Analyzer) Status() LocalStatus {
a.mu.RLock()
defer a.mu.RUnlock()
return a.status
}
// Latest returns the most recent analysis and whether one has completed.
func (a *Analyzer) Latest() (Result, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
return a.latest, a.hasRun
}
// Dismiss hides an insight until the underlying pattern recurs.
func (a *Analyzer) Dismiss(id string) {
a.mu.Lock()
a.dismissed[id] = a.now().UnixMicro()
a.mu.Unlock()
a.saveDismissed()
a.Refresh()
}
// Restore un-dismisses everything.
func (a *Analyzer) Restore() {
a.mu.Lock()
a.dismissed = map[string]int64{}
a.mu.Unlock()
a.saveDismissed()
a.Refresh()
}
func (a *Analyzer) loadDismissed() {
b, err := os.ReadFile(a.dismissPath)
if err != nil {
return
}
var m map[string]int64
if err := json.Unmarshal(b, &m); err != nil {
return // a corrupt dismissal file costs the dismissals, nothing else
}
a.dismissed = m
}
func (a *Analyzer) saveDismissed() {
if a.dismissPath == "" {
return
}
a.mu.RLock()
b, err := json.MarshalIndent(a.dismissed, "", " ")
a.mu.RUnlock()
if err != nil {
return
}
// Write and rename, so an interrupted save cannot leave a half-written file
// that fails to parse on the next start.
tmp := a.dismissPath + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return
}
os.Rename(tmp, a.dismissPath)
}
// extract turns stored spans and logs into per-trace facts.
//
// One pass over each table, then correlation per trace, so every detector reads
// the same prepared view and none of them touch the store.
func (a *Analyzer) extract(from, to int64) ([]TraceFacts, error) {
spansByTrace := map[string][]model.Span{}
if _, err := a.store.ScanSpans(from, to, func(sp model.Span, sh *store.Shard, c *store.Chunk, i int) bool {
spansByTrace[sp.TraceID] = append(spansByTrace[sp.TraceID], sp)
return true
}); err != nil {
return nil, err
}
logsByTrace := map[string][]model.LogRecord{}
if _, err := a.store.ScanLogs(from, to, func(rec model.LogRecord, sh *store.Shard, c *store.Chunk, i int) bool {
logsByTrace[rec.TraceID] = append(logsByTrace[rec.TraceID], rec)
return true
}); err != nil {
return nil, err
}
corr := tracetree.NewCorrelator()
out := make([]TraceFacts, 0, len(spansByTrace))
for id, spans := range spansByTrace {
out = append(out, buildFacts(corr, id, spans, logsByTrace[id]))
}
sort.SliceStable(out, func(i, j int) bool { return out[i].StartTS < out[j].StartTS })
return out, nil
}
// buildFacts flattens one correlated trace into the detectors' input.
func buildFacts(corr *tracetree.Correlator, id string, spans []model.Span, logs []model.LogRecord) TraceFacts {
tr := corr.Build(id, spans, logs)
f := TraceFacts{
TraceID: id,
StartTS: tr.StartTS,
EndTS: tr.EndTS,
SpanCount: tr.SpanCount,
}
for _, n := range tr.Flatten() {
sp := n.Span
if f.Prompt == "" {
f.Prompt = firstStr(sp.Attrs, promptKeys)
}
if f.Model == "" {
f.Model = firstStr(sp.Attrs, modelKeys)
}
switch sp.Kind {
case model.KindLLMCall:
f.LLMCalls = append(f.LLMCalls, llmCallFrom(sp, n.Logs))
case model.KindToolCall:
// Only the outermost span of a tool call. Claude Code emits
// `tool`, `tool.execution`, and `tool.blocked_on_user` for one
// logical call, and counting all three would treble every sequence.
if isWrapperSpan(sp.Name) {
continue
}
f.Calls = append(f.Calls, toolCallFrom(sp, n.Logs))
}
f.CostUSD += costOf(sp, n.Logs)
for _, l := range n.Logs {
if f.Prompt == "" && isPromptEvent(l.EventName) {
f.Prompt = firstStr(l.Attrs, promptKeys)
}
}
if f.Prompt == "" {
for _, l := range n.Logs {
if isPromptEvent(l.EventName) && l.Body != "" {
f.Prompt = l.Body
break
}
}
}
f.Tokens += firstNum(sp.Attrs, inputTokenKeys) + firstNum(sp.Attrs, outputTokenKeys)
f.Cached += firstNum(sp.Attrs, cacheReadKeys) + firstNum(sp.Attrs, cacheWriteKeys)
}
sort.SliceStable(f.LLMCalls, func(i, j int) bool { return i < j })
return f
}
func isWrapperSpan(name string) bool {
return len(name) > 0 &&
(hasSuffix(name, ".execution") || hasSuffix(name, ".blocked_on_user"))
}
func hasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
func toolCallFrom(sp model.Span, logs []model.LogRecord) ToolCall {
c := ToolCall{
Name: "tool",
Succeeded: sp.Status != model.StatusError,
DurMicros: sp.DurationMicros(),
}
if n := firstStr(sp.Attrs, toolNameKeys); n != "" {
c.Name = n
}
// The arguments live on the correlated tool_result record, not the span —
// which is the same reason the trace view needs log correlation at all.
for _, l := range logs {
if l.EventName != "tool_result" && l.EventName != "tool_decision" {
continue
}
if n := firstStr(l.Attrs, toolNameKeys); n != "" {
c.Name = n
}
for _, k := range []string{"tool_input", "tool_parameters"} {
if v, ok := l.Attrs[k]; ok && v.Str != "" && c.Input == "" {
c.Input = v.Str
}
}
if v, ok := l.Attrs["tool_result_size_bytes"]; ok {
if n, ok := v.Float(); ok && int64(n) > c.OutputSize {
c.OutputSize = int64(n)
}
}
if v, ok := l.Attrs["success"]; ok && v.Str == "false" {
c.Succeeded = false
}
}
if c.OutputSize == 0 {
for _, l := range logs {
if l.EventName == "tool_result" && l.Body != "" {
c.OutputSize = int64(len(l.Body))
}
}
}
c.Signature, c.Target = normalizeCall(c.Name, c.Input)
return c
}
func llmCallFrom(sp model.Span, logs []model.LogRecord) LLMCall {
c := LLMCall{DurMicros: sp.DurationMicros()}
c.InputTokens = firstNum(sp.Attrs, inputTokenKeys)
c.OutputToken = firstNum(sp.Attrs, outputTokenKeys)
c.CacheRead = firstNum(sp.Attrs, cacheReadKeys)
c.Model = firstStr(sp.Attrs, modelKeys)
c.StopReason = firstStr(sp.Attrs, stopReasonKeys)
c.CostUSD = costOf(sp, logs)
return c
}
// DebugExtract exposes fact extraction for diagnostics. It exists so the
// extraction step can be inspected against a real store without standing up an
// analyzer, and is not used by the server.
func DebugExtract(s *store.Store, windowDays int) ([]TraceFacts, error) {
a := New(Options{Store: s, WindowDays: windowDays})
now := a.now()
return a.extract(now.AddDate(0, 0, -windowDays).UnixMicro(), now.UnixMicro())
}