Files
zzg ac0a068039 Make the insights page robust to a missing or partial result
The empty-store crash was fixed on the server in c09f2cd, but two things
about that fix were incomplete.

refreshInsightBadge still dereferenced result.themes directly. Its own
try/catch swallowed the failure, so the symptom was only a badge that
stopped updating rather than a visible error -- which is worse, because
nothing says it is broken.

And the client trusted every field to be present. The server and the page
ship in the same binary so in principle that holds, but it is precisely what
put a raw JavaScript error on the first page a new install opens, and a guard
costs nothing. Normalising once at the entry point rather than at each use
keeps it from drifting again as call sites are added.

Verified against the shapes that actually crashed -- {themes: null}, {}, and
null -- all three now render the empty state with a clean console.

The server-side test additionally asserts that no field the page dereferences
arrives as null, since the two halves ship together and should not disagree.
2026-09-15 23:54:50 -07:00

1704 lines
60 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Fathom UI.
*
* Vanilla JS, no framework, no build step — the whole app ships inside the Go
* binary. It is small enough that a framework would be more code than the code.
*/
'use strict';
// ---------------------------------------------------------------- state
const state = {
rangeMicros: 86400000000, // 24h, matching the pre-selected button
search: '',
status: '',
traces: [],
trace: null, // the loaded detail payload
selected: null, // selected span id
onlyContent: false,
merge: true, // fold emitter wrapper spans by default
// Live updates. `pending` holds traces that arrived while the reader was
// scrolled away from the top, so nothing shifts under them unannounced.
live: 'connecting', // connecting | live | offline
rows: new Map(), // trace_id -> <tr>, so refreshes patch instead of rebuild
pending: 0,
listLoaded: false, // first paint is not an 'arrival'
};
const KIND = {
llm_call: { label: 'model', color: 'var(--kind-llm)' },
tool_call: { label: 'tool', color: 'var(--kind-tool)' },
agent_step: { label: 'agent', color: 'var(--kind-agent)' },
retrieval: { label: 'retrieval', color: 'var(--kind-retrieval)' },
mcp: { label: 'mcp', color: 'var(--kind-mcp)' },
hook: { label: 'hook', color: 'var(--kind-hook)' },
other: { label: 'other', color: 'var(--kind-other)' },
};
const kindColor = k => (KIND[k] || KIND.other).color;
// ---------------------------------------------------------------- utilities
const $ = sel => document.querySelector(sel);
const $$ = sel => Array.from(document.querySelectorAll(sel));
function el(tag, attrs = {}, ...kids) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v === null || v === undefined || v === false) continue;
if (k === 'class') n.className = v;
else if (k === 'text') n.textContent = v;
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
else if (k === 'style') n.setAttribute('style', v);
else n.setAttribute(k, v);
}
for (const kid of kids.flat()) {
if (kid === null || kid === undefined || kid === false) continue;
n.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
}
return n;
}
/* Durations are formatted to three significant figures with a unit that keeps
* the number small. A waterfall full of "1403000µs" is unreadable; the same
* data as "1.40s" is not. */
function fmtDur(micros) {
if (micros === null || micros === undefined) return '—';
const ms = micros / 1000;
if (ms < 1) return `${micros}µs`;
if (ms < 1000) return `${ms < 10 ? ms.toFixed(1) : Math.round(ms)}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(s < 10 ? 2 : 1)}s`;
const m = Math.floor(s / 60);
return `${m}m ${Math.round(s - m * 60)}s`;
}
function fmtNum(n) {
if (!n) return '0';
if (n < 1000) return String(n);
if (n < 1e6) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`;
return `${(n / 1e6).toFixed(2)}M`;
}
function fmtCost(usd) {
if (!usd) return '—';
if (usd < 0.01) return `$${usd.toFixed(4)}`;
return `$${usd.toFixed(2)}`;
}
function fmtAgo(micros) {
const secs = (Date.now() - micros / 1000) / 1000;
if (secs < 60) return `${Math.max(0, Math.round(secs))}s ago`;
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
return `${Math.round(secs / 86400)}d ago`;
}
function fmtClock(micros) {
const d = new Date(micros / 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function toast(msg) {
const t = $('#toast');
t.textContent = msg;
t.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => { t.hidden = true; }, 1600);
}
async function api(path, opts) {
const res = await fetch(path, opts);
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || body.message || `${res.status} ${res.statusText}`);
return body;
}
function nowMicros() { return Date.now() * 1000; }
// ---------------------------------------------------------------- trace list
async function loadTraces() {
const to = nowMicros();
const from = to - state.rangeMicros;
const params = new URLSearchParams({ from, to, limit: 300 });
if (state.status) params.set('status', state.status);
if (state.search) params.set('q', state.search);
try {
const data = await api(`/api/traces?${params}`);
state.traces = data.traces || [];
renderTraces(data);
} catch (e) {
$('#list-scan').textContent = '';
showListEmpty(`Could not load traces: ${e.message}`);
}
}
function showListEmpty(message) {
$('#trace-rows').replaceChildren();
const box = $('#list-empty');
box.replaceChildren(el('p', { text: message }));
box.hidden = false;
}
function renderTraces(data) {
const tbody = $('#trace-rows');
const s = data.stats || {};
$('#list-scan').textContent =
`${data.total} traces · scanned ${fmtNum(s.rows_scanned || 0)} spans across ${s.shards_scanned || 0} shard${s.shards_scanned === 1 ? '' : 's'}`;
if (!state.traces.length) {
tbody.replaceChildren();
state.rows.clear();
const box = $('#list-empty');
box.replaceChildren(...[
el('p', { text: state.search || state.status
? 'No traces match those filters in this time range.'
: 'No traces in this time range yet.' }),
!state.search && !state.status
? el('p', {}, 'Point an agent at Fathom, or run ',
el('code', { text: 'fathom gen' }), ' for sample data.')
: null,
].filter(Boolean));
box.hidden = false;
return;
}
$('#list-empty').hidden = true;
/* Patch the table rather than rebuild it.
*
* Replacing the whole tbody on every refresh throws away hover state, any
* in-progress text selection, and the identity of every row — which makes a
* "new trace arrived" highlight impossible to express, because every row is
* new. Reusing the element per trace_id means an update touches only the
* cells whose values actually changed. */
const scroller = $('#view-traces .table-wrap');
const atTop = scroller.scrollTop < 8;
const seen = new Set();
let appeared = 0;
const frag = document.createDocumentFragment();
for (const t of state.traces) {
seen.add(t.trace_id);
let row = state.rows.get(t.trace_id);
if (row) {
updateTraceRow(row, t);
} else {
row = traceRow(t);
state.rows.set(t.trace_id, row);
// On first paint every row is new; highlighting all of them is noise.
if (state.listLoaded) {
appeared++;
row.classList.add('is-new');
setTimeout(() => row.classList.remove('is-new'), 2200);
}
}
frag.append(row);
}
for (const [id, row] of state.rows) {
if (!seen.has(id)) {
row.remove();
state.rows.delete(id);
}
}
// Appending existing nodes moves them into the new order without recreating
// them, so the scroll container is untouched.
tbody.append(frag);
state.listLoaded = true;
if (appeared && !atTop) {
// The reader is scrolled into the list. Inserting above them would shift
// everything down mid-read, so announce it instead and let them choose.
state.pending += appeared;
showNewPill();
} else if (atTop) {
state.pending = 0;
hideNewPill();
}
}
function showNewPill() {
const pill = $('#new-pill');
pill.textContent = `${state.pending} new trace${state.pending === 1 ? '' : 's'} ↑`;
pill.hidden = false;
}
function hideNewPill() {
$('#new-pill').hidden = true;
}
function traceRow(t) {
const row = el('tr', { onclick: () => navigate(`#/trace/${t.trace_id}`) },
// The relative-time text node is created up front so updates can patch it
// in place rather than rebuild the cell.
el('td', { class: 'cell-when' }, '', el('span', { class: 'abs' })),
// The prompt leads, not the span name. Every Claude Code trace is named
// "claude_code.interaction", so a name column repeats one string down the
// whole page while the thing that distinguishes runs sits in a subtitle.
el('td', {},
el('div', { class: 'trace-name' },
el('span', { class: 'status-dot' }),
el('span', { class: 'trace-headline' }),
),
el('div', { class: 'trace-id' }),
),
el('td', { class: 'num cell-dur' }),
el('td', {}, el('div', { class: 'shape' })),
el('td', { class: 'num cell-tok' }),
el('td', { class: 'num cell-cost' }),
);
updateTraceRow(row, t);
return row;
}
/* updateTraceRow writes only the cells whose value changed.
*
* A trace still in flight grows on every flush, so these cells are rewritten
* repeatedly. Assigning textContent unconditionally would collapse any text
* selection the reader had made inside the row, which is a strange thing to
* have happen while you are trying to copy a trace id. */
function updateTraceRow(row, t) {
const [whenTd, whatTd, durTd, shapeTd, tokTd, costTd] = row.children;
setText(whenTd.firstChild, fmtAgo(t.start_ts));
setText(whenTd.querySelector('.abs'), fmtClock(t.start_ts));
const dot = whatTd.querySelector('.status-dot');
const wantDot = `status-dot ${t.status}`;
if (dot.className !== wantDot) {
dot.className = wantDot;
dot.title = t.status;
}
setText(whatTd.querySelector('.trace-headline'), t.preview || t.name || '(unnamed)');
const meta = whatTd.querySelector('.trace-id');
const metaText = `${t.trace_id.slice(0, 12)} · ${t.span_count} spans` +
(t.model ? ` · ${t.model}` : '');
if (meta.dataset.text !== metaText || Boolean(t.partial) !== (meta.dataset.partial === '1')) {
meta.dataset.text = metaText;
meta.dataset.partial = t.partial ? '1' : '0';
meta.replaceChildren(...[
metaText,
// A partial trace has no root span yet, so any name here would be an
// arbitrary child's. Say what is actually true instead.
t.partial
? el('span', { class: 'chip-live', text: 'in progress' })
: (t.preview && t.name ? ` · ${t.name}` : ''),
].filter(Boolean).map(x => (x.nodeType ? x : document.createTextNode(String(x)))));
}
setText(durTd, fmtDur(t.duration_micros));
const shapeText = `${t.llm_calls}|${t.tool_calls}|${t.error_count}`;
const shape = shapeTd.firstElementChild;
if (shape.dataset.shape !== shapeText) {
shape.dataset.shape = shapeText;
shape.replaceChildren();
// One pip group per kind present, so the composition of the run is legible
// without opening it.
for (const [kind, n, label] of [
['llm_call', t.llm_calls, 'model call'],
['tool_call', t.tool_calls, 'tool call'],
]) {
if (!n) continue;
shape.append(el('span', { class: 'shape-group', title: `${n} ${label}${n === 1 ? '' : 's'}` },
el('span', { class: 'pip', style: `background:${kindColor(kind)}` }),
String(n),
));
}
if (t.error_count) {
shape.append(el('span', { class: 'badge-err', title: `${t.error_count} span(s) reported an error` },
`${t.error_count} err`));
}
}
const tokText = t.tokens ? fmtNum(t.tokens) : '—';
const cachedText = t.cached_tokens ? `${fmtNum(t.cached_tokens)} cached` : '';
if (tokTd.dataset.tok !== tokText + cachedText) {
tokTd.dataset.tok = tokText + cachedText;
tokTd.title = t.cached_tokens
? `${(t.tokens || 0).toLocaleString()} new tokens, ${t.cached_tokens.toLocaleString()} read from cache`
: `${(t.tokens || 0).toLocaleString()} tokens`;
tokTd.replaceChildren(...[
document.createTextNode(tokText),
cachedText ? el('span', { class: 'sub-num', text: cachedText }) : null,
].filter(Boolean));
}
setText(costTd, fmtCost(t.cost_usd));
}
// setText assigns only when the value differs, so an unchanged cell is left
// completely alone.
function setText(node, value) {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
if (node.nodeValue !== value) node.nodeValue = value;
return;
}
if (node.textContent !== value) node.textContent = value;
}
// ---------------------------------------------------------------- trace detail
async function loadTrace(id) {
$('#detail-name').textContent = 'Loading…';
$('#detail-meta').replaceChildren();
$('#detail-stats').replaceChildren();
$('#waterfall').replaceChildren();
$('#inspector').replaceChildren(el('div', { class: 'inspector-empty', text: 'Loading…' }));
try {
// The detail lookup defaults to a wider window than the list, so a link to
// an older trace still resolves after the list has scrolled past it.
const data = await api(`/api/traces/${encodeURIComponent(id)}`);
state.trace = data;
state.selected = null;
renderTrace(data);
} catch (e) {
$('#detail-name').textContent = 'Not found';
$('#inspector').replaceChildren(el('div', { class: 'inspector-empty', text: e.message }));
}
}
function renderTrace(data, opts = {}) {
const tr = data.trace;
const flat = data.flat || [];
const root = flat[0];
// Every Claude Code trace is named "claude_code.interaction", so the root
// name alone identifies nothing. The prompt is what the reader recognises.
const prompt = tracePrompt(flat);
$('#detail-name').textContent = prompt || (root ? root.span.name : tr.trace_id);
// replaceChildren stringifies null rather than skipping it, which is how a
// literal "null" ended up rendered in the header.
$('#detail-meta').replaceChildren(...[
el('span', { text: tr.trace_id }),
el('span', { text: new Date(tr.start_ts / 1000).toLocaleString() }),
tr.orphan_count ? el('span', { text: `${tr.orphan_count} orphaned`, title:
'These spans name a parent that was never received — usually the exporter was still batching when the process exited.' }) : null,
].filter(Boolean));
// Header stats, chosen to answer "was this slow, expensive, or broken?".
const totals = spanTotals(flat);
$('#detail-stats').replaceChildren(...[
stat(fmtDur(tr.end_ts - tr.start_ts), 'duration'),
stat(String(tr.span_count), 'spans'),
stat(fmtNum(totals.tokens), 'tokens'),
totals.cached ? stat(fmtNum(totals.cached), 'cached') : null,
stat(fmtCost(totals.cost), 'cost'),
stat(String(totals.errors), 'errors'),
].filter(Boolean));
renderLegend(flat);
renderWaterfall(tr, flat);
const restore = opts.restore;
if (restore) {
// A live refresh: put the reader back exactly where they were. If the span
// they had selected has since been folded or replaced, fall back rather
// than leaving nothing selected.
const still = flat.some(n => spanID(n) === restore.keepSelected);
selectSpan(still ? restore.keepSelected : (flat[0] && spanID(flat[0])), { silent: true });
$('#waterfall').scrollTop = restore.keepWaterfall;
$('#inspector').scrollTop = restore.keepInspector;
return;
}
// Opening on the first span that actually carries content puts the reader at
// the prompt rather than at an empty root.
const first = flat.find(n => nodeContent(n).length) || flat[0];
if (first) selectSpan(spanID(first));
}
// tracePrompt digs out the user's prompt, which is the only human-meaningful
// name a trace has.
function tracePrompt(flat) {
for (const n of flat) {
const p = attrStr(spanAttrs(n), 'user_prompt');
if (p) return p;
for (const l of n.logs || []) {
const lp = logAttr(l, 'prompt');
if (lp) return lp;
}
}
return '';
}
function stat(val, label) {
return el('div', { class: 'stat' },
el('div', { class: 'stat-val', text: val }),
el('div', { class: 'stat-label', text: label }),
);
}
function spanTotals(flat) {
let tokens = 0, cached = 0, cost = 0, errors = 0;
const tokenKeys = ['input_tokens', 'output_tokens', 'gen_ai.usage.input_tokens', 'gen_ai.usage.output_tokens'];
const cacheKeys = ['cache_read_tokens', 'cache_creation_tokens', 'gen_ai.usage.cache_read_input_tokens'];
for (const n of flat) {
const a = spanAttrs(n);
for (const k of tokenKeys) tokens += Number(attrStr(a, k)) || 0;
for (const k of cacheKeys) cached += Number(attrStr(a, k)) || 0;
if (spanStatus(n) === 'error') errors++;
for (const l of n.logs || []) cost += Number(logAttr(l, 'cost_usd')) || 0;
}
return { tokens, cached, cost, errors };
}
function renderLegend(flat) {
const present = new Set(flat.map(n => spanKind(n)));
$('#legend').replaceChildren(...Array.from(present).map(k =>
el('span', { class: 'legend-item' },
el('span', { class: 'pip', style: `background:${kindColor(k)}` }),
(KIND[k] || KIND.other).label,
)));
}
// Kind and Status arrive as names, not integers, so there is no enum table to
// keep in sync with Go's.
const spanKind = n => n.span.kind || 'other';
const spanAttrs = n => n.span.attrs || {};
const spanStatus = n => n.span.status || 'unset';
const spanID = n => n.span.span_id;
const attrStr = (attrs, key) => String(attrs[key]?.str ?? '');
const logAttr = (l, key) => String((l.attrs || {})[key]?.str ?? '');
function renderWaterfall(tr, flat) {
const wf = $('#waterfall');
wf.replaceChildren();
for (const n of flat) { n._folded = 0; }
const total = Math.max(1, tr.end_ts - tr.start_ts);
let rows = state.merge ? foldWrappers(flat) : flat;
if (state.onlyContent) rows = rows.filter(n => nodeContent(n).length);
renderAxis(total);
for (const n of rows) {
const kind = spanKind(n);
const left = (n.offset_micros / total) * 100;
const width = Math.max(0.35, (n.duration_micros / total) * 100);
const content = nodeContent(n);
// The tree glyph is drawn rather than nested with indentation so that every
// row shares one grid and the bars all start from the same x origin.
const indent = state.onlyContent ? 0 : n.depth;
const glyph = indent ? '│ '.repeat(Math.max(0, indent - 1)) + '├─' : '';
const row = el('div', {
class: 'span-row',
'data-span': spanID(n),
onclick: () => selectSpan(spanID(n)),
},
el('div', { class: 'span-label', title: n.span.name },
glyph ? el('span', { class: 'span-tree', text: glyph }) : null,
el('span', { class: 'span-kind-dot', style: `background:${kindColor(kind)}` }),
el('span', { class: 'span-name' }, spanLabel(n)),
n._folded ? el('span', { class: 'fold-count', title:
`${n._folded} nested wrapper span(s) folded into this row` , text: `+${n._folded}` }) : null,
content.length ? el('span', { class: 'content-dot', title: `${content.length} content record(s)` }) : null,
),
el('div', { class: 'span-track' },
el('div', {
class: `span-bar${spanStatus(n) === 'error' ? ' is-error' : ''}`,
style: `left:${left}%;width:${width}%;background:${kindColor(kind)}`,
}),
),
el('div', { class: 'span-dur', text: fmtDur(n.duration_micros) }),
);
wf.append(row);
}
}
/* foldWrappers hides spans that restate their parent rather than adding to it.
*
* Claude Code emits every tool call as three spans: `claude_code.tool`, and
* beneath it `tool.execution` and `tool.blocked_on_user`, all sharing one
* tool_use_id. Rendered literally that triples the height of the tree and says
* the same thing three times — in a 33-span trace, 20 rows were pure restatement.
*
* A child is folded when it is a dotted extension of its parent's name, shares
* the parent's tool call id, and carries no content of its own. Anything that
* fails those tests — a child that took materially longer, that errored on its
* own, that holds a payload — is left visible, because then it is telling the
* reader something the parent does not. The count of folded children is shown on
* the parent so nothing disappears silently.
*/
function foldWrappers(flat) {
const byId = new Map(flat.map(n => [spanID(n), n]));
const folded = new Set();
for (const n of flat) {
const parent = byId.get(n.span.parent_span_id);
if (!parent) continue;
const isExtension = n.span.name.startsWith(parent.span.name + '.');
// Contained in the parent's window: a child that outlasts its parent is
// describing something the parent's bar does not already show.
const contained = n.duration_micros <= parent.duration_micros;
const hasOwnContent = nodeContent(n).length > 0;
const ownError = spanStatus(n) === 'error' && spanStatus(parent) !== 'error';
const hasChildren = flat.some(c => c.span.parent_span_id === spanID(n));
// Matching tool_use_id was the original test, but it is too strict: Claude
// Code stamps it on `tool` and `tool.execution` and omits it on
// `tool.blocked_on_user`, so permission-wait rows survived folding and the
// tree still carried a row per tool that said nothing.
if (isExtension && contained && !hasOwnContent && !ownError && !hasChildren) {
folded.add(spanID(n));
parent._folded = (parent._folded || 0) + 1;
}
}
for (const n of flat) if (!folded.has(spanID(n))) n._foldedShown = true;
return flat.filter(n => !folded.has(spanID(n)));
}
/* Span names from real emitters are long and repetitive
* ("claude_code.tool.execution"). Showing the distinguishing tail in full and
* dimming the shared prefix keeps the column scannable. A tool span is labelled
* by the tool it ran, which is what the reader is actually looking for. */
function spanLabel(n) {
const attrs = spanAttrs(n);
const name = n.span.name || '';
const tool = attrStr(attrs, 'tool_name');
const model = attrStr(attrs, 'gen_ai.request.model') || attrStr(attrs, 'model');
const parts = name.split('.');
const tail = parts.length > 1 ? parts.slice(1).join('.') : name;
const frag = document.createDocumentFragment();
frag.append(el('span', { text: tail }));
if (tool) frag.append(el('span', { class: 'qualifier', text: ` ${tool}` }));
else if (model) frag.append(el('span', { class: 'qualifier', text: ` ${model}` }));
return frag;
}
function renderAxis(totalMicros) {
const axis = $('#timeaxis');
axis.replaceChildren();
const steps = 5;
for (let i = 0; i <= steps; i++) {
const pct = (i / steps) * 100;
if (i === steps) continue; // the last label would overflow the container
axis.append(el('div', {
class: 'tick',
style: `left:${pct}%`,
text: i === 0 ? '0' : fmtDur((totalMicros * i) / steps),
}));
}
}
function selectSpan(spanId, opts = {}) {
if (!spanId) return;
state.selected = spanId;
for (const row of $$('.span-row')) {
row.classList.toggle('is-selected', row.dataset.span === spanId);
}
const node = (state.trace?.flat || []).find(n => (spanID(n)) === spanId);
if (node) renderInspector(node);
// A live refresh restores scroll itself; scrolling here would fight it.
if (!opts.silent) {
const sel = $(`.span-row[data-span="${CSS.escape(spanId)}"]`);
if (sel) sel.scrollIntoView({ block: 'nearest' });
}
// Keep the URL pointing at exactly what is on screen, so a link shares the
// span, not just the trace.
const traceId = state.trace?.trace?.trace_id;
if (traceId) history.replaceState(null, '', `#/trace/${traceId}/${spanId}`);
}
// ---------------------------------------------------------------- inspector
/* nodeContent turns a span's correlated log records into ordered transcript
* entries. This is where the correlation done on the server pays off: each
* span carries the prompt, tool payload, or response that belongs to it. */
function nodeContent(n) {
const out = [];
for (const l of n.logs || []) {
const a = l.attrs || {};
const ev = l.event_name || '';
const ts = l.ts || 0;
const get = k => String(a[k]?.str ?? '');
if (ev === 'user_prompt' && get('prompt')) {
out.push({ kind: 'prompt', title: 'User prompt', ts, body: get('prompt') });
} else if (ev === 'tool_result') {
const ok = get('success') !== 'false';
out.push({
kind: 'tool', failed: !ok, ts,
title: `${get('tool_name') || 'Tool'} ${ok ? '' : '— failed'}`.trim(),
input: get('tool_input') || get('tool_parameters'),
body: l.body || '',
meta: {
duration: get('duration_ms') ? `${get('duration_ms')}ms` : '',
bytes: get('tool_result_size_bytes'),
},
});
} else if (ev === 'assistant_response' && get('response')) {
out.push({ kind: 'response', title: 'Assistant', ts, body: get('response'),
meta: { model: get('model') } });
} else if (ev === 'api_request') {
out.push({ kind: 'api', title: 'Model call', ts, body: '', meta: {
model: get('model'),
in: get('input_tokens'), out: get('output_tokens'),
cacheRead: get('cache_read_tokens'),
cost: get('cost_usd'), duration: get('duration_ms') ? `${get('duration_ms')}ms` : '',
}});
}
}
// Span input/output, for emitters that put payloads on the span itself
// rather than in log records.
const inText = n.span.input || '';
const outText = n.span.output || '';
if (inText) out.push({ kind: 'prompt', title: 'Input', ts: 0, body: inText });
if (outText) out.push({ kind: 'response', title: 'Output', ts: 0, body: outText });
out.sort((a, b) => a.ts - b.ts);
return out;
}
function renderInspector(n) {
const insp = $('#inspector');
const attrs = spanAttrs(n);
const kind = spanKind(n);
const status = spanStatus(n);
const content = nodeContent(n);
const head = el('div', { class: 'insp-head' },
el('div', { class: 'insp-title' },
el('span', { class: 'span-kind-dot', style: `background:${kindColor(kind)}` }),
el('span', { text: n.span.name }),
status === 'error' ? el('span', { class: 'badge-err', text: 'error' }) : null,
),
el('div', { class: 'insp-sub' },
el('span', { text: fmtDur(n.duration_micros) }),
el('span', { text: `+${fmtDur(n.offset_micros)} into trace` }),
el('span', { text: `depth ${n.depth}` }),
el('span', { text: (spanID(n) ?? '').slice(0, 12) }),
),
);
const sections = [];
if (content.length) {
sections.push(section('Transcript', content.length, true,
el('div', { class: 'transcript' }, content.map(renderMessage))));
}
const attrKeys = Object.keys(attrs).sort();
if (attrKeys.length) {
const grid = el('div', { class: 'attrs' });
for (const k of attrKeys) {
const v = attrs[k];
const type = v.type ?? 0;
const str = String(v.str ?? '');
grid.append(
el('div', { class: 'attr-k', text: k }),
el('div', { class: `attr-v${type === 1 || type === 2 ? ' is-num' : type === 3 ? ' is-bool' : ''}`, text: str }),
);
}
sections.push(section('Attributes', attrKeys.length, content.length < 2, grid));
}
if (!content.length && !attrKeys.length) {
sections.push(el('div', { class: 'inspector-empty', text: 'This span carries no attributes or content.' }));
}
insp.replaceChildren(head, ...sections);
insp.scrollTop = 0;
}
function section(title, count, open, body) {
return el('details', { class: 'insp-section', open: open ? 'open' : null },
el('summary', {}, title, el('span', { class: 'insp-count', text: String(count) })),
el('div', { class: 'insp-body' }, body),
);
}
function renderMessage(m) {
const chips = el('div', { class: 'kv-inline' });
const meta = m.meta || {};
const chip = (label, val) => val
? chips.append(el('span', { class: 'chip' }, `${label} `, el('b', { text: String(val) })))
: null;
chip('model', meta.model);
chip('in', meta.in && fmtNum(Number(meta.in)));
chip('out', meta.out && fmtNum(Number(meta.out)));
chip('cached', meta.cacheRead && Number(meta.cacheRead) ? fmtNum(Number(meta.cacheRead)) : '');
chip('cost', meta.cost && fmtCost(Number(meta.cost)));
chip('took', meta.duration);
chip('bytes', meta.bytes);
const bodyParts = [];
if (chips.childNodes.length) bodyParts.push(chips);
if (m.input) {
bodyParts.push(el('pre', { class: 'payload', text: prettyJSON(m.input) }));
}
if (m.body) {
if (m.input) bodyParts.push(el('div', { style: 'height:8px' }));
bodyParts.push(el('pre', { class: 'payload', text: m.body }));
}
return el('div', { class: `msg ${m.kind}${m.failed ? ' failed' : ''}` },
el('div', { class: 'msg-head' },
el('span', { text: m.title }),
m.ts ? el('span', { class: 'msg-when', text: fmtClock(m.ts) }) : null,
el('button', {
class: 'copy-btn', text: 'copy',
onclick: e => {
e.stopPropagation();
navigator.clipboard.writeText(m.input ? `${m.input}\n\n${m.body}` : m.body).then(
() => toast('Copied'), () => toast('Copy failed'));
},
}),
),
el('div', { class: 'msg-body' }, bodyParts.length ? bodyParts : el('span', { class: 'attr-k', text: '(no payload)' })),
);
}
/* Tool inputs arrive as JSON strings. Pretty-printing them is the difference
* between a wall of escapes and something readable, but a non-JSON payload must
* pass through untouched rather than being mangled. */
function prettyJSON(s) {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
// ---------------------------------------------------------------- explore
const EXAMPLES = [
['Slowest tools', `SELECT tool_name, count(*), p95(duration), max(duration)
FROM spans
WHERE time >= {from} AND time <= {to} AND kind = tool_call
GROUP BY tool_name
ORDER BY p95(duration) DESC
LIMIT 20`],
['Tokens by model', `SELECT model, count(*), sum(input_tokens), sum(output_tokens)
FROM spans
WHERE time >= {from} AND time <= {to} AND kind = llm_call
GROUP BY model
ORDER BY sum(input_tokens) DESC`],
['Spans by kind', `SELECT kind, count(*), avg(duration)
FROM spans
WHERE time >= {from} AND time <= {to}
GROUP BY kind
ORDER BY count(*) DESC`],
['Failures', `SELECT name, tool_name, count(*)
FROM spans
WHERE time >= {from} AND time <= {to} AND status = error
GROUP BY name, tool_name
ORDER BY count(*) DESC
LIMIT 20`],
['Event volume', `SELECT event_name, count(*)
FROM logs
WHERE time >= {from} AND time <= {to}
GROUP BY event_name
ORDER BY count(*) DESC`],
];
function fillRange(sql) {
const to = nowMicros();
return sql.replace('{from}', String(to - state.rangeMicros)).replace('{to}', String(to));
}
function renderExamples() {
$('#examples').replaceChildren(...EXAMPLES.map(([label, sql]) =>
el('button', {
class: 'example', text: label,
onclick: () => {
$('#query-input').value = fillRange(sql);
state.autosize?.();
runQuery();
},
})));
}
async function runQuery() {
const sql = $('#query-input').value;
$('#query-error').hidden = true;
try {
const res = await api('/api/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: sql }),
});
renderResults(res);
} catch (e) {
$('#query-error').textContent = e.message;
$('#query-error').hidden = false;
$('#result-head').replaceChildren();
$('#result-rows').replaceChildren();
$('#chart-wrap').hidden = true;
$('#query-scan').textContent = '';
}
}
function renderResults(res) {
const s = res.stats || {};
$('#query-scan').textContent =
`${res.rows.length} rows · scanned ${fmtNum(s.rows_scanned || 0)} across ${s.shards_scanned || 0} shards in ${fmtDur(s.elapsed_micros || 0)}`;
$('#result-head').replaceChildren(
el('tr', {}, res.columns.map((c, i) =>
el('th', { class: isNumericColumn(res, i) ? 'col-num' : '', text: c }))));
const tbody = $('#result-rows');
tbody.replaceChildren();
for (const row of res.rows) {
tbody.append(el('tr', {}, row.map((v, i) => {
const numeric = typeof v === 'number';
const isTrace = res.columns[i] === 'trace_id' && typeof v === 'string' && v;
return el('td', {
class: `${numeric ? 'is-num' : ''}${isTrace ? ' is-link' : ''}`,
text: formatCell(v, res.columns[i]),
onclick: isTrace ? () => navigate(`#/trace/${v}`) : null,
});
})));
}
drawChart(res);
}
function isNumericColumn(res, i) {
return res.rows.some(r => typeof r[i] === 'number');
}
function formatCell(v, col) {
if (v === null || v === undefined) return '—';
if (typeof v === 'object') return JSON.stringify(v);
if (typeof v === 'number') {
// Duration columns are micros; rendering them raw is unreadable.
if (/duration|_ts$|^ts$/.test(col)) return fmtDur(v);
if (Number.isInteger(v)) return v.toLocaleString();
return v.toFixed(3);
}
return String(v);
}
// ---------------------------------------------------------------- chart
/* The chart answers one question: how do these categories compare on one
* measure. That makes it a horizontal bar chart with a single series, so every
* bar takes the same hue — colouring bars by their own value would double-encode
* length as lightness and spend the only free channel on information the bar
* already shows. Category labels sit outside the plot so they are never clipped
* by a short bar, and the results table below is the table-view twin, so no
* value is reachable only by hovering.
*/
const CHART_MAX_BARS = 15;
const BAR_H = 15;
const ROW_H = 26;
function drawChart(res) {
const wrap = $('#chart-wrap');
const svg = $('#chart');
svg.replaceChildren();
const plan = chartPlan(res);
if (!plan) { wrap.hidden = true; return; }
wrap.hidden = false;
const { labelIdx, valueIdx, rows } = plan;
const max = Math.max(...rows.map(r => Number(r[valueIdx]) || 0), 0);
if (max <= 0) { wrap.hidden = true; return; }
// Reserve space from the actual label widths rather than a guess, so a long
// model name neither overflows nor squeezes the plot to nothing.
const labelW = Math.min(240, Math.max(90,
...rows.map(r => String(formatCell(r[labelIdx], res.columns[labelIdx]) || '—').length * 6.6 + 12)));
const valueW = 76;
const padT = 6, padB = 20;
const width = svg.clientWidth || wrap.clientWidth || 800;
const plotW = Math.max(40, width - labelW - valueW);
const height = padT + rows.length * ROW_H + padB;
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.style.height = `${height}px`;
const ns = 'http://www.w3.org/2000/svg';
const mk = (tag, attrs) => {
const n = document.createElementNS(ns, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
return n;
};
// Recessive gridlines: solid hairlines one shade off the surface, never dashed.
const ticks = niceTicks(max, 4);
for (const t of ticks) {
const x = labelW + (t / max) * plotW;
svg.append(mk('line', {
x1: x, x2: x, y1: padT, y2: padT + rows.length * ROW_H,
stroke: 'var(--border)', 'stroke-width': 1, 'shape-rendering': 'crispEdges',
}));
const lab = mk('text', {
x, y: height - 6, 'text-anchor': t === 0 ? 'start' : 'middle',
fill: 'var(--text-faint)', 'font-size': 10, 'font-family': 'var(--mono)',
});
lab.textContent = compactNumber(t, res.columns[valueIdx]);
svg.append(lab);
}
rows.forEach((row, i) => {
const y = padT + i * ROW_H;
const value = Number(row[valueIdx]) || 0;
// The 2px inset is the surface gap that separates adjacent bars without
// drawing a border around them.
const barW = Math.max(1, (value / max) * plotW);
const label = formatCell(row[labelIdx], res.columns[labelIdx]) || '—';
const g = mk('g', { class: 'bar-g', tabindex: 0, role: 'listitem' });
// A full-row hit area, so the hover target is the row rather than a 15px bar.
g.append(mk('rect', {
x: 0, y, width, height: ROW_H, fill: 'transparent',
}));
const cat = mk('text', {
x: labelW - 10, y: y + ROW_H / 2 + 4, 'text-anchor': 'end',
fill: 'var(--text-dim)', 'font-size': 11.5,
});
cat.textContent = label.length > 34 ? label.slice(0, 33) + '\u2026' : label;
g.append(cat);
g.append(mk('rect', {
x: labelW, y: y + (ROW_H - BAR_H) / 2,
width: barW, height: BAR_H,
rx: 4, ry: 4,
fill: 'var(--accent)',
}));
const val = mk('text', {
x: labelW + barW + 8, y: y + ROW_H / 2 + 4,
fill: 'var(--text)', 'font-size': 11, 'font-family': 'var(--mono)',
});
val.textContent = formatCell(row[valueIdx], res.columns[valueIdx]);
g.append(val);
const title = mk('title', {});
title.textContent = `${label}\n${res.columns[valueIdx]}: ${formatCell(row[valueIdx], res.columns[valueIdx])}`;
g.append(title);
svg.append(g);
});
const notes = [];
if (plan.truncated) notes.push(`top ${CHART_MAX_BARS} of ${plan.total}`);
if (plan.dropped) notes.push(`${plan.dropped} unnamed row${plan.dropped === 1 ? '' : 's'} charted only in the table`);
if (notes.length) {
// Right-aligned: left-aligned it sat on top of the zero tick label.
const note = mk('text', {
x: width - 2, y: height - 6, fill: 'var(--text-faint)', 'font-size': 10,
'text-anchor': 'end',
});
note.textContent = notes.join(' · ');
svg.append(note);
}
}
/* chartPlan decides whether these results are chartable at all. A chart of a raw
* row listing, or of a result with no label column, is noise — the table already
* says it better. */
function chartPlan(res) {
if (!res.rows.length || res.columns.length < 2) return null;
let labelIdx = -1, valueIdx = -1;
for (let i = 0; i < res.columns.length; i++) {
const anyNum = res.rows.some(r => typeof r[i] === 'number');
if (!anyNum && labelIdx < 0) labelIdx = i;
if (anyNum && valueIdx < 0) valueIdx = i;
}
// The measure the query sorted by is the measure the query is about. Falling
// back to the first numeric column is what made a "slowest tools" query
// render a chart of call counts.
if (res.order_by) {
const i = res.columns.findIndex(c => c.toLowerCase() === res.order_by.toLowerCase());
if (i >= 0 && res.rows.some(r => typeof r[i] === 'number')) valueIdx = i;
}
if (labelIdx < 0 || valueIdx < 0) return null;
// More than a screenful of categories is a table, not a bar chart.
if (res.rows.length > 60) return null;
// A category with no name cannot be interpreted, and an unlabelled bucket is
// often the largest one — it crushes every named bar into the margin. The row
// stays in the table below, which is where an unnamed total belongs.
const named = res.rows.filter(r => String(r[labelIdx] ?? '').trim() !== '');
const dropped = res.rows.length - named.length;
if (!named.length) return null;
const sorted = [...named].sort((a, b) => (Number(b[valueIdx]) || 0) - (Number(a[valueIdx]) || 0));
return {
labelIdx, valueIdx, dropped,
rows: sorted.slice(0, CHART_MAX_BARS),
truncated: sorted.length > CHART_MAX_BARS,
total: sorted.length,
};
}
// niceTicks picks round axis values rather than dividing the max evenly, so the
// gridlines land on numbers a reader recognises.
function niceTicks(max, count) {
const raw = max / count;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * mag;
const out = [];
for (let v = 0; v <= max * 1.0001; v += step) out.push(v);
return out;
}
function compactNumber(v, col) {
if (v === 0) return '0';
if (/duration|_ts$|^ts$/.test(col || '')) return fmtDur(v);
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (v >= 1000) return `${(v / 1000).toFixed(v < 10000 ? 1 : 0)}k`;
return Number.isInteger(v) ? String(v) : v.toFixed(1);
}
// ---------------------------------------------------------------- live updates
/* The server pushes a one-line event whenever a flush makes new rows
* queryable; the client decides what, if anything, to refetch.
*
* Two rules keep this from being annoying. Events are coalesced, because a busy
* agent flushes every second and a refetch per flush is pointless churn. And a
* refresh never moves what the reader is looking at: the list patches rows in
* place and holds new arrivals behind a pill when scrolled, and the detail view
* restores the selected span and both scroll positions.
*/
let liveSource = null;
let liveTimer = null;
function setLiveState(next) {
if (state.live === next) return;
state.live = next;
const dot = $('#live-dot');
dot.className = `live-dot is-${next}`;
dot.title = {
live: 'Connected — traces appear as they arrive',
connecting: 'Connecting to the trace stream…',
offline: 'Disconnected from the trace stream; retrying',
}[next];
}
function connectLive() {
if (liveSource) liveSource.close();
setLiveState('connecting');
liveSource = new EventSource('/api/events');
liveSource.onopen = () => setLiveState('live');
liveSource.onerror = () => {
// EventSource reconnects on its own; this only reflects that in the UI.
setLiveState(liveSource.readyState === EventSource.CLOSED ? 'offline' : 'connecting');
};
liveSource.onmessage = (e) => {
let ev;
try {
ev = JSON.parse(e.data);
} catch {
return;
}
if (ev.type === 'hello') {
setLiveState('live');
return;
}
if (ev.type !== 'flush') return;
scheduleRefresh(ev);
};
}
// scheduleRefresh coalesces a burst of flushes into one refetch.
let pendingTraceIDs = new Set();
function scheduleRefresh(ev) {
for (const id of ev.trace_ids || []) pendingTraceIDs.add(id);
if (liveTimer) return;
liveTimer = setTimeout(() => {
liveTimer = null;
const ids = pendingTraceIDs;
pendingTraceIDs = new Set();
applyRefresh(ids);
}, 400);
}
function applyRefresh(traceIDs) {
if (!$('#view-insights').hidden) {
loadInsights();
return;
}
// Keep the tab badge current even when the tab is closed, so a new finding is
// discoverable without going to look for one.
refreshInsightBadge();
if (!$('#view-traces').hidden) {
loadTraces();
return;
}
if (!$('#view-trace').hidden && state.trace) {
const open = state.trace.trace?.trace_id;
// Only refetch the trace actually on screen, and only when this flush
// touched it. Reloading an unrelated trace would be pure cost.
if (open && traceIDs.has(open)) refreshOpenTrace();
}
}
/* refreshOpenTrace re-renders a trace that is still being written without
* disturbing the reader: the selected span stays selected and both panes keep
* their scroll position. Watching a run unfold is the point; having the view
* jump every second would make it unusable. */
async function refreshOpenTrace() {
const id = state.trace?.trace?.trace_id;
if (!id) return;
const waterfall = $('#waterfall');
const inspector = $('#inspector');
const keepSelected = state.selected;
const keepWaterfall = waterfall.scrollTop;
const keepInspector = inspector.scrollTop;
try {
const data = await api(`/api/traces/${encodeURIComponent(id)}`);
if (state.trace?.trace?.trace_id !== id) return; // navigated away mid-flight
state.trace = data;
renderTrace(data, { restore: { keepSelected, keepWaterfall, keepInspector } });
} catch {
// A refresh that fails leaves the last good render on screen, which is
// better than blanking a trace someone is reading.
}
}
// ---------------------------------------------------------------- insights
/* The insights tab reads what the background analyzer last produced. Opening it
* never triggers analysis, so it cannot make the server do work proportional to
* the store.
*
* Every finding leads with its cost and ends with the traces it came from. That
* ordering is deliberate: the number is the argument for caring, and the traces
* are what make the claim checkable rather than something to take on faith. */
/* normalizeResult fills in anything the server left out.
*
* The server is the same binary as this page, so in principle its shape is
* guaranteed — but a nil Go slice marshals to JSON null, and relying on every
* field being present is what put a raw JavaScript error on the first page a
* new install opens. One normalisation point is cheaper than a guard at every
* use, and it means a partial or older response degrades instead of crashing.
*/
function normalizeResult(res) {
const r = res || {};
return {
...r,
themes: Array.isArray(r.themes) ? r.themes : [],
window: r.window || { from: 0, to: 0 },
trace_count: r.trace_count || 0,
total_cost_usd: r.total_cost_usd || 0,
addressable_cost_usd: r.addressable_cost_usd || 0,
min_occurrences: r.min_occurrences || 3,
dismissed: r.dismissed || 0,
local: r.local || { available: false },
};
}
function countInsights(res) {
return res.themes.reduce((n, t) => n + (t.insights ? t.insights.length : 0), 0);
}
async function refreshInsightBadge() {
try {
const data = await api('/api/insights');
if (!data.ready || !data.result) return;
const total = countInsights(normalizeResult(data.result));
const badge = $('#insight-count');
badge.textContent = String(total);
badge.hidden = total === 0;
} catch {
// The badge is decoration; a failure here must not surface as an error.
}
}
async function loadInsights() {
const body = $('#insights-body');
try {
const data = await api('/api/insights');
if (data.disabled) {
showInsightsState({
title: 'Insights are turned off',
lines: ['Set enabled = true under [insights] in fathom.ini to switch them on.'],
});
return;
}
if (!data.ready) {
// "Not looked yet" and "nothing found" are different claims, and an empty
// page would assert the second when the first is true.
showInsightsState({ title: 'Looking through recent traces…', quiet: true });
return;
}
renderInsights(data.result);
} catch (e) {
showInsightsState({
title: 'Could not load insights',
lines: [e.message],
});
}
}
/* showInsightsState renders the page when there is nothing to list.
*
* Empty is not one situation, and collapsing them into a single message throws
* away the most useful thing the page could say. A store with no traces needs
* to be told how to get some; a store with three traces is not yet able to show
* a pattern and should say so rather than imply everything is fine; a busy store
* with nothing flagged is genuinely good news and should read that way. */
function showInsightsState({ title, lines = [], hint, action, quiet = false }) {
$('#insights-summary').replaceChildren();
$('#local-status').hidden = true;
$('#restore-insights').hidden = true;
$('#insight-count').hidden = true;
$('#insights-body').replaceChildren(
el('div', { class: `insights-state${quiet ? ' is-quiet' : ''}` },
el('div', { class: 'state-mark' }),
el('h2', { class: 'state-title', text: title }),
...lines.map(t => el('p', { class: 'state-line', text: t })),
hint ? el('pre', { class: 'state-hint', text: hint }) : null,
action || null,
),
);
}
function renderInsights(raw) {
const res = normalizeResult(raw);
const themes = res.themes;
const total = countInsights(res);
if (!total) {
renderInsightsEmpty(res);
return;
}
const badge = $('#insight-count');
badge.textContent = String(total);
badge.hidden = false;
const days = windowDays(res);
$('#insights-summary').replaceChildren(
document.createTextNode(`Last ${days} ${days === 1 ? 'day' : 'days'}: `),
el('b', { text: String(res.trace_count) }),
document.createTextNode(` ${res.trace_count === 1 ? 'trace' : 'traces'}, `),
el('b', { text: fmtCost(res.total_cost_usd) }),
document.createTextNode(' total. These findings cover '),
el('b', { text: fmtCost(res.addressable_cost_usd) }),
document.createTextNode(' of it.'),
);
$('#restore-insights').hidden = !res.dismissed;
renderLocalStatus(res.local);
$('#insights-body').replaceChildren(...themes.map(themeSection));
}
function windowDays(res) {
const w = res.window || {};
return Math.max(1, Math.round(((w.to || 0) - (w.from || 0)) / 86400e6));
}
// renderInsightsEmpty picks the message that is actually true.
function renderInsightsEmpty(res) {
const days = windowDays(res);
const min = res.min_occurrences || 3;
// Everything was found and then hidden. Offering the restore is the only
// useful thing to say.
if (res.dismissed > 0) {
showInsightsState({
title: `${res.dismissed} finding${res.dismissed === 1 ? '' : 's'} hidden`,
lines: [`Nothing else stood out in the last ${days} ${days === 1 ? 'day' : 'days'}.`],
action: el('button', {
class: 'primary', text: 'Show them again',
onclick: restoreInsights,
}),
});
return;
}
// A brand new store. The useful thing is how to get traces into it.
if (res.trace_count === 0) {
showInsightsState({
title: 'No traces yet',
lines: [
'Insights come from traces, so there is nothing to look at until an agent sends some.',
'Point Claude Code at this server, or write a batch of sample traces to try the page out.',
],
hint: 'fathom gen -traces 40',
action: el('a', { class: 'primary', href: '#/traces', text: 'Go to traces' }),
});
return;
}
// Some traces, but not enough for anything to have repeated yet. This is not
// the same as "nothing is wrong" and must not be dressed up as it.
if (res.trace_count < min) {
showInsightsState({
title: 'Not enough to go on yet',
lines: [
`${res.trace_count} ${res.trace_count === 1 ? 'trace' : 'traces'} in the last ${days} ${days === 1 ? 'day' : 'days'}. ` +
`A pattern has to repeat at least ${min} times before it is worth reporting, so there is not yet enough here to tell.`,
'Keep working and check back — this fills in on its own.',
],
});
return;
}
// A real body of work with nothing flagged. That is a good result, and the
// page should say so rather than look broken.
showInsightsState({
title: 'Nothing worth flagging',
lines: [
`${res.trace_count} traces and ${fmtCost(res.total_cost_usd)} over the last ${days} ${days === 1 ? 'day' : 'days'}, ` +
`with no repeated sequence, duplicated context, or recurring failure above the ${min}-time threshold.`,
'Findings appear here on their own when a habit starts to form.',
],
quiet: true,
});
}
/* renderLocalStatus says whether a model on this machine was used, and for
* what. "Available" and "used" are different claims — a model can be present
* and still have failed — so the last run's actual usage is reported rather
* than the configuration. */
function renderLocalStatus(local) {
const box = $('#local-status');
if (!local || !local.available) {
box.hidden = true;
return;
}
box.hidden = false;
const used = local.used_for || [];
const detail = !local.enabled ? 'off'
: used.length ? `used for ${used.join(' and ')}`
: 'available, not needed this run';
box.replaceChildren(
el('span', {}, 'Local model: ', el('code', { text: local.chat_model || local.embed_model || local.kind })),
el('span', { class: 'ev-when', text: detail, title: `${local.kind} at ${local.endpoint}` }),
el('label', { class: 'local-switch', title:
'Use the model running on this machine to group prompts by meaning and name the groups. ' +
'Findings and every number stay heuristic either way.' },
el('input', {
type: 'checkbox',
...(local.enabled ? { checked: 'checked' } : {}),
onchange: e => setLocalEnabled(e.target.checked),
}),
el('span', { text: 'use it' }),
),
);
}
async function setLocalEnabled(on) {
try {
const data = await api('/api/insights/local', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: on }),
});
if (data.result) renderInsights(data.result);
toast(on ? 'Using the local model' : 'Local model off');
} catch (e) {
toast(`Could not change that: ${e.message}`);
}
}
function themeSection(group) {
return el('section', { class: 'theme' },
el('div', { class: 'theme-title', text: group.title }),
el('p', { class: 'theme-sub', text: group.subtitle }),
...group.insights.map(insightCard),
);
}
function insightCard(ins) {
const bodyEl = el('div', { class: 'insight-body', hidden: true },
el('p', { text: ins.body }),
ins.suggestion ? el('div', { class: 'suggestion', text: ins.suggestion }) : null,
ins.steps && ins.steps.length > 1 ? stepsRow(ins.steps) : null,
ins.evidence && ins.evidence.length
? el('div', {},
// Evidence is capped, so say so rather than appear to contradict the
// occurrence count in the title.
el('div', { class: 'evidence-title', text: ins.occurrences > ins.evidence.length
? `${ins.evidence.length} of ${ins.occurrences} traces`
: `Seen in ${ins.evidence.length} ${ins.evidence.length === 1 ? 'trace' : 'traces'}` }),
el('div', { class: 'evidence' }, ins.evidence.map(evidenceRow)))
: null,
el('div', { class: 'insight-actions' },
el('button', {
class: 'dismiss', text: 'Not useful',
title: 'Hide this until the pattern recurs after today',
onclick: e => { e.stopPropagation(); dismissInsight(ins.id); },
})),
);
const title = el('div', { class: 'insight-title' }, ins.title);
if (ins.method) {
// Semantic grouping can find what lexical grouping cannot, and can also
// group things a reader would have kept apart. Marking which was used lets
// the finding be judged on the right terms.
title.append(el('span', {
class: `method-tag ${ins.method === 'semantic' ? 'is-semantic' : ''}`,
title: ins.method === 'semantic'
? 'Grouped by meaning, using the local model'
: 'Grouped by shared wording',
text: ins.method,
}));
}
const head = el('div', { class: 'insight-head', onclick: () => { bodyEl.hidden = !bodyEl.hidden; } },
title,
el('div', { class: 'insight-cost' },
fmtCost(ins.cost_usd),
el('span', { class: 'sub', text: `${ins.occurrences}×${ins.wall_micros ? ' · ' + fmtDur(ins.wall_micros) : ''}` }),
),
);
return el('div', { class: `insight sev-${ins.severity}` }, head, bodyEl);
}
function stepsRow(steps) {
const row = el('div', { class: 'steps' });
steps.forEach((st, i) => {
if (i) row.append(el('span', { class: 'step-arrow', text: '→' }));
row.append(el('span', { class: 'step', text: st }));
});
return row;
}
function evidenceRow(ev) {
return el('div', {
class: 'ev-row',
title: 'Open this trace',
onclick: () => navigate(`#/trace/${ev.trace_id}`),
},
el('span', { class: 'ev-when', text: fmtAgo(ev.when) }),
el('span', { class: 'ev-prompt', text: ev.prompt || ev.trace_id.slice(0, 16) }),
el('span', { class: 'ev-detail', text: ev.detail || '' }),
el('span', { class: 'ev-cost', text: fmtCost(ev.cost_usd) }),
);
}
async function dismissInsight(id) {
try {
const data = await api('/api/insights/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (data.result) renderInsights(data.result);
toast('Hidden until it recurs');
} catch (e) {
toast(`Could not dismiss: ${e.message}`);
}
}
async function restoreInsights() {
try {
const data = await api('/api/insights/restore', { method: 'POST' });
if (data.result) renderInsights(data.result);
toast('Dismissed findings restored');
} catch (e) {
toast(`Could not restore: ${e.message}`);
}
}
// ---------------------------------------------------------------- routing
function navigate(hash) {
if (location.hash === hash) route();
else location.hash = hash;
}
function route() {
const hash = location.hash || '#/traces';
const parts = hash.slice(2).split('/');
const show = id => {
for (const v of $$('.view')) v.hidden = (v.id !== id);
};
const tab = parts[0] === 'explore' ? 'explore'
: parts[0] === 'insights' ? 'insights'
: 'traces';
for (const a of $$('.tabs a')) {
a.classList.toggle('is-active', a.dataset.tab === tab);
}
if (parts[0] === 'trace' && parts[1]) {
show('view-trace');
const wanted = parts[2];
if (state.trace?.trace?.trace_id === parts[1]) {
if (wanted) selectSpan(wanted);
} else {
loadTrace(parts[1]).then(() => { if (wanted) selectSpan(wanted); });
}
} else if (parts[0] === 'insights') {
show('view-insights');
loadInsights();
} else if (parts[0] === 'explore') {
show('view-explore');
if (!$('#query-input').value.trim()) {
$('#query-input').value = fillRange(EXAMPLES[0][1]);
}
state.autosize?.();
} else {
show('view-traces');
loadTraces();
}
}
// ---------------------------------------------------------------- wiring
function initTheme() {
const saved = localStorage.getItem('fathom-theme');
if (saved) document.documentElement.dataset.theme = saved;
else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
document.documentElement.dataset.theme = 'light';
}
}
function init() {
initTheme();
renderExamples();
$('#theme-toggle').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('fathom-theme', next);
});
$('#range-picker').addEventListener('click', e => {
const btn = e.target.closest('.range-btn');
if (!btn) return;
state.rangeMicros = Number(btn.dataset.range);
for (const b of $$('.range-btn')) b.classList.toggle('is-active', b === btn);
if (!location.hash.startsWith('#/trace/')) route();
});
let searchTimer;
$('#search').addEventListener('input', e => {
state.search = e.target.value.trim();
state.listLoaded = false;
state.pending = 0;
hideNewPill();
clearTimeout(searchTimer);
searchTimer = setTimeout(loadTraces, 180);
});
$('#status-filter').addEventListener('click', e => {
const btn = e.target.closest('button');
if (!btn) return;
// Changing a filter swaps the whole visible set; those rows are not
// arrivals and must not be announced as such.
state.listLoaded = false;
state.pending = 0;
hideNewPill();
state.status = btn.dataset.status;
for (const b of $('#status-filter').children) b.classList.toggle('is-active', b === btn);
loadTraces();
});
$('#back-btn').addEventListener('click', () => navigate('#/traces'));
const rerenderTree = () => {
if (state.trace) renderWaterfall(state.trace.trace, state.trace.flat);
if (state.selected) selectSpan(state.selected);
};
$('#only-content').addEventListener('change', e => {
state.onlyContent = e.target.checked;
rerenderTree();
});
$('#merge-wrappers').addEventListener('change', e => {
state.merge = e.target.checked;
rerenderTree();
});
const autosize = () => {
const ta = $('#query-input');
ta.style.height = 'auto';
ta.style.height = `${Math.min(300, ta.scrollHeight + 2)}px`;
};
$('#query-input').addEventListener('input', autosize);
state.autosize = autosize;
$('#restore-insights').addEventListener('click', restoreInsights);
refreshInsightBadge();
$('#run-query').addEventListener('click', runQuery);
$('#query-input').addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); runQuery(); }
});
// Keyboard navigation through the waterfall. An engineer reading a trace
// moves span to span constantly; reaching for the mouse each time is the
// difference between skimming a run and slogging through it.
document.addEventListener('keydown', e => {
if (e.target.matches('input, textarea')) return;
if (!state.trace || $('#view-trace').hidden) return;
const rows = $$('.span-row');
const idx = rows.findIndex(r => r.dataset.span === state.selected);
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
if (idx < rows.length - 1) selectSpan(rows[idx + 1].dataset.span);
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault();
if (idx > 0) selectSpan(rows[idx - 1].dataset.span);
} else if (e.key === 'Escape') {
navigate('#/traces');
}
});
window.addEventListener('hashchange', route);
route();
connectLive();
// Relative timestamps ("2m ago") go stale on their own, with no event to
// prompt a redraw. This only rewrites those cells; it does not refetch.
setInterval(() => {
if ($('#view-traces').hidden) return;
for (const [id, row] of state.rows) {
const t = state.traces.find(x => x.trace_id === id);
if (t) setText(row.children[0].firstChild, fmtAgo(t.start_ts));
}
}, 15000);
$('#new-pill').addEventListener('click', () => {
$('#view-traces .table-wrap').scrollTo({ top: 0, behavior: 'smooth' });
state.pending = 0;
hideNewPill();
});
// Scrolling back to the top clears the backlog notice on its own.
$('#view-traces .table-wrap').addEventListener('scroll', () => {
if ($('#view-traces .table-wrap').scrollTop < 8 && state.pending) {
state.pending = 0;
hideNewPill();
}
}, { passive: true });
}
document.addEventListener('DOMContentLoaded', init);