Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Trace waterfall

New

One agent run streaming live as a waterfall of spans: where its time and money go, which call failed and which was slowest.

trace-waterfall/trace-waterfall.tsx
Live · interactive

Trace

Launch the new pricing page by Friday

Live
Duration
0ms
Cost
$0.00
Tokens
0
LLM calls
0
Tool calls
0
Errors
0

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • One agent run, with the agents, model calls and tool calls under it, where the reader asks where the time and the money went.
  • A run that is still going: the spans stream in, the bars grow and the totals tick up until it is done.
  • Debugging a run that failed or got slow: the red span and the Slowest tag point at it, and its card says why.
  • Runs of seconds to a few minutes, with up to thirty or so spans, two or three levels deep.
  • A run page or a side panel at least 600 px wide; it narrows to a phone with shorter rows of names.

When not to use

  • Many runs side by side. Use a table of runs with their totals, and open one into this trace.
  • A team of agents over the last hour, with handoffs. Use agent lanes on a shared clock.
  • Runs of thousands of spans or hours long: the bars get too thin. Use a profiler with zoom and search.
  • Only the answer matters to the reader. Show the result, with a link to the trace.
  • A step-by-step progress view for the person waiting on the run. Use a checklist of steps.

You can add this component with the shadcn CLI or by hand:

Into components/trace-waterfall of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/trace-waterfall.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

run-trace.tsx
'use client';import { useEffect, useState } from 'react';import { TraceWaterfall, type Span } from './trace-waterfall';export function RunTrace({ spans, startedAt }: { spans: Span[]; startedAt: number }) {  // Seconds since the run started, while it streams.  const [now, setNow] = useState(0);  useEffect(() => {    const timer = setInterval(() => setNow((Date.now() - startedAt) / 1000), 250);    return () => clearInterval(timer);  }, [startedAt]);  return (    <TraceWaterfall      spans={spans}      now={now}      goal="Answer the support queue"      onOpenSpan={(span) => console.log('open', span.id)}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A card that traces one agent run as it happens. The header names the goal and says Live, then Done; a strip of totals ticks up: duration, cost, tokens, model calls, tool calls and errors. Below, the spans stream in as a tree beside a timeline: agents as grey summary bars, model calls violet, tool calls teal, failures red, each bar followed by its time and cost, and the slowest call tagged. The axis rescales as the run grows. Fold an agent to see its calls as one multicoloured strip, and press any span for a card with its numbers, its input and its output.

  • The run has a clock in seconds. Every frame while it streams, it writes two custom properties on the waterfall, --t (where the run is) and --range (the seconds the axis shows); each row carries its span’s --s and --e, and a bar sits at s / range of the timeline and is (min(t, e) − s) / range wide. Bars grow and the axis rescales in CSS, with no React render per frame.
  • React catches up about twelve times a second, for what the clock means in words and numbers: which spans have started, the totals, each bar’s label, the Live or Done pill and the slowest call.
  • The axis eases towards a round range a little longer than the run so far (15s, 30s, 45s, 1m, 1m 30s…), with an exponential step each frame, so the bars slide left together as the ticks change; a new tick fades in.
  • Rows are grid items whose single track goes from 0fr to 1fr: a row opens from nothing when its span starts, and folding an agent closes its rows the same way, inert while hidden. A folded agent draws its calls as thin translucent segments at their own times inside its bar, so overlapping calls show darker.
  • Numbers that tick up are drawn a character at a time, keyed by position and value, so only a digit that changed fades up into place.
  • The span card is placed under its row (over it when the stage’s bottom is too near) and follows its bar along the timeline in CSS, clamped inside the card, as the axis rescales.
  • The demo replays the run six times faster than real time, opens a few spans and folds agents as they finish; the second pass folds the whole run into one strip. Any input of the reader’s stops it, and a run in progress carries on to its end.
PropTypeDefaultDescription
spansreadonly Span[]SPANSThe run’s spans: an id, its parent, a kind (agent, llm or tool), a name, a muted detail (the model or the tool), an icon, its start and end in seconds from the start of the run (no end while it runs), and for calls a cost and tokens in and out; an error makes it fail, and an input and output show in its card.
goalstring'Launch the new pricing page by Friday'What the run was asked to do, muted beside the title.
titlestring'Trace'The header’s title.
nownumber—Seconds since the run started, for a run you stream; it also turns the demo off. Without it, the trace plays its own clock: from the start while the demo plays, or resting at the end.
speednumber6How fast the trace’s own clock replays the run: seconds of run per second.
defaultFoldedreadonly string[]—Agents (their ids) folded at first.
defaultOpenSpanstring—Opens with the card of this span showing (its id), for a static view; turns the demo off.
onOpenSpan(span: Span) => void—Called when a span’s card opens.
onFoldChange(span: Span, folded: boolean) => void—Called when an agent is folded or unfolded.
autoplaybooleantrueWhile on screen and untouched, a demo pointer replays the run, opens spans and folds agents; any input hands it to the reader.
colorScheme'light' | 'dark' | 'system'—Light or dark colours, or the reader’s; without it, the page’s color-scheme.
classNamestring—Classes for the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

trace-waterfall.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useMemo,  useRef,  useState,  type CSSProperties,  type Ref,} from 'react';import { GOAL, SPANS, type Span } from './data';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { duration, money, spent, tick, tokens } from './format';import { Chevron, Cross, Sparkle, SpanGlyph, Warning } from './glyphs';import { useAutoplay } from './use-autoplay';import styles from './trace-waterfall.module.css';/*
trace-waterfall.module.css
/* Trace waterfall (trace-waterfall.tsx): a quiet grey stage with the card at its top, or at night   a near-black one with the card a step darker still. The card is laid out for 760 px: a header,   six totals, then the waterfall, a 256 px column of spans beside a timeline. Under 600 px of   card the column narrows to 152 px, the muted details go and the totals stack in two rows.   Time: the script writes --t (seconds into the run) and --range (the seconds the axis shows) on   .waterfall every frame while the run streams; each row carries its span's --s and --e. A bar   sits at s / range of the timeline and is (min(t, e) − s) / range wide, so it grows until its   end, and the label after it follows.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). */.root {  /* The stage, the card, its edge and the lines across it. */  --stage: #f7f7f7;  --card: #fdfdfd;  --edge: #e3e3e3;  --card-shadow: rgb(0 0 0 / 0.03);  --line: #ececec;  --divider: #e0e0e0;  --grid: #f0f0f0;  --guide: #e6e6e6;
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The shapes the trace draws, and the run the lab shows: one agent launching a pricing page with * three sub-agents under it. Times are in seconds from the start of the run; a span shows once * the run's clock reaches its start, and runs until its end. *//** What a span is: an agent (with spans under it), a call to a model, or a call to a tool. */export type SpanKind = 'agent' | 'llm' | 'tool';/** The drawing on a span's tile, in glyphs.tsx: a model family's mark, or a tool's glyph. */export type SpanIcon =  | 'model-a'  | 'model-b'  | 'model-c'  | 'search'  | 'database'  | 'web'  | 'doc'  | 'design'  | 'repo'  | 'payments'  | 'crm'
format.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * How the trace writes its numbers: durations as a person reads them (637ms, 6.9s, 13s, 1m 22s), * money to the cent (or the tenth of a cent under one), tokens in thousands. *//** Seconds as 637ms, 6.9s, 13s or 1m 03s. Rounded down, so a live value never runs ahead. */export function duration(seconds: number) {  const s = Math.max(0, seconds);  if (s < 1) return `${Math.floor(s * 1000 + 1e-6)}ms`;  if (s < 10) return `${(Math.floor(s * 10 + 1e-6) / 10).toFixed(1)}s`;  if (s < 60) return `${Math.floor(s + 1e-6)}s`;  const whole = Math.floor(s + 1e-6);  return `${Math.floor(whole / 60)}m ${String(whole % 60).padStart(2, '0')}s`;}/** The axis's ticks: 0s, 20s, 1m, 1m 20s. */export function tick(seconds: number) {  if (seconds < 60) return `${seconds}s`;  const rest = seconds % 60;  return rest ? `${Math.floor(seconds / 60)}m ${rest}s` : `${seconds / 60}m`;}
glyphs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The small drawings: a mark per model family and a glyph per tool, for the spans' tiles, plus * the header's sparkle, the chevron, the warning sign and the close cross. All on a 16-unit grid, * sized by the stylesheet. Ink-coloured strokes take --glyph-ink, so they turn light at night; * the coloured ones keep their colour on both schemes. */import type { SpanIcon } from './data';const INK = { stroke: 'var(--glyph-ink)' };function IconPath({ icon }: { icon: SpanIcon }) {  switch (icon) {    case 'model-a':      // Four rounded petals around a small open heart.      return (        <g fill="#d9614c">          <ellipse cx="8" cy="4" rx="2.1" ry="3.3" />          <ellipse cx="8" cy="12" rx="2.1" ry="3.3" />          <ellipse cx="4" cy="8" rx="3.3" ry="2.1" />          <ellipse cx="12" cy="8" rx="3.3" ry="2.1" />          <circle cx="8" cy="8" r="1.3" fill="var(--tile)" />
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the * hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(
  • The card is a section named by its title; the totals are a description list, and the spans a list whose rows are buttons named by the span’s name, kind, status, time and cost.
  • Each agent’s chevron is a button with aria-expanded, named Fold or Unfold and the agent. Folded rows are hidden from assistive technology and inert, so the focus skips them.
  • Pressing a span, or Enter on it, opens its card: a non-modal dialog named by the span, which takes the focus when the reader opened it. Escape or the close button closes it and gives the focus back to the span; a press outside closes it too.
  • A failure and the end of the run are announced in a polite live region; statuses are words (Live, Done, Running, Failed), never colour alone.
  • Every control has a visible focus ring. Under reduced motion, the run jumps to its end, and rows, digits, the sweep on running agents and the card appear without moving; the demo does not play.
  • The demo pointer is decorative and hidden from assistive technology.