Trace waterfall Bars positioned by start time, not stacked. A span that starts late was waiting; a span that is wide was slow. Those are different problems and only the offset separates them. Tokens this needs: --foreground, --card, --muted-foreground, --border, --status-warn, --status-critical, --chart-1 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // TraceWaterfall.tsx ──────────────────────────────────────────────────────────────────────── /** * The OpenTelemetry shape, reduced to what the drawing needs: a name, when it * started and ended, and who called it. A root is the span with no parent. */ export type Span = { id: string; parentId?: string; name: string; /** Milliseconds from the trace's start. */ start: number; end: number; status?: "ok" | "error" | "unset"; }; /** Empty space to a bar's left longer than this is shaded. A bar that starts * late was waiting, and the wait is the finding, not the bar. */ const GAP_MS = 50; const ROW = 20; const LABEL_W = 180; const ms = (v: number) => (v ? `${Math.round(v)}ms` : "0"); /** Depth-first, so a child always follows its parent, siblings by start time. */ function order(spans: Span[]) { const kids = (id?: string) => spans.filter((s) => s.parentId === id).sort((a, b) => a.start - b.start); const out: { span: Span; depth: number; gap: number }[] = []; const walk = (parent: Span | undefined, depth: number) => { let latest = parent?.start ?? 0; for (const s of kids(parent?.id)) { out.push({ span: s, depth, gap: s.start - latest }); latest = Math.max(latest, s.end); walk(s, depth + 1); } }; walk(undefined, 0); return out; } /** How much of a parent no child covers. Longer than its children means * time went somewhere nobody instrumented, which no summary metric says. */ function uncovered(parent: Span, spans: Span[]) { const kids = spans.filter((s) => s.parentId === parent.id).sort((a, b) => a.start - b.start); let covered = 0, cursor = parent.start; for (const k of kids) { covered += Math.max(0, k.end - Math.max(k.start, cursor)); cursor = Math.max(cursor, k.end); } return parent.end - parent.start - covered; } export function TraceWaterfall({ spans, onSelect, width = 640 }: { spans: Span[]; /** Attributes belong beside the waterfall, not on another page. */ onSelect?: (span: Span) => void; width?: number; }) { const rows = order(spans); const root = rows[0].span; const total = root.end - root.start; const x = (t: number) => LABEL_W + ((t - root.start) / total) * (width - LABEL_W - 20); const axisY = rows.length * ROW + 12; const missing = uncovered(root, spans); return (

{root.name} · {ms(total)} · {rows.length} spans · {ms(missing)} in the root not covered by any child

{[0.5, 0.75].map((f) => )} {rows.map(({ span, depth, gap }, i) => { const y = i * ROW; const waited = gap > GAP_MS; const fill = span.status === "error" ? "fill-status-critical" : waited ? "fill-status-warn" : depth ? "fill-chart-1" : "fill-foreground"; return ( onSelect?.(span)} className={onSelect ? "cursor-pointer" : undefined}> {waited && } {span.name} {`${span.name}: ${ms(span.end - span.start)}${waited ? `, after waiting ${ms(gap)}` : ""}`} ); })} {[0, 0.5, 1].map((f) => ( {ms(f * total)} ))}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { TraceWaterfall, type Span } from "./TraceWaterfall"; /** * One checkout, 800ms, eight spans. pricing.quote and tax.quote overlap, * which is concurrency. The two db.selects step one after the other under * cart.load, which is a loop. payment.charge starts 192ms after anything * else finished, and that wait is shaded because it is the finding. */ const SPANS: Span[] = [ { id: "root", name: "POST /checkout", start: 0, end: 800 }, { id: "auth", parentId: "root", name: "auth.verify", start: 8, end: 95 }, { id: "cart", parentId: "root", name: "cart.load", start: 100, end: 180 }, { id: "items", parentId: "cart", name: "db.select items", start: 108, end: 138 }, { id: "prices", parentId: "cart", name: "db.select prices", start: 140, end: 168 }, { id: "pricing", parentId: "root", name: "pricing.quote", start: 188, end: 298 }, { id: "tax", parentId: "root", name: "tax.quote", start: 200, end: 298 }, { id: "payment", parentId: "root", name: "payment.charge", start: 490, end: 678 }, ]; export default function Demo() { return console.log("span", s.id)} />; }