Skip to content

shusingh/agent-replay

Repository files navigation

Agent Replay

A browser-based time-travel debugger and observability viewer for LLM agent runs - think Chrome DevTools Performance panel × a video-editor timeline, for agent traces.

Drop in a trace and scrub through it like a video: watch tool calls and LLM steps fire on a Canvas waterfall, replay token streams as they type out, inspect any span's real data, and diff two runs side by side. The hard part - and the point - is that the timeline renders 50,000+ spans at 60fps with smooth pan, zoom, and scrub.

Live demo: https://agent-replay.onrender.com · Writeup: How I rendered 50k agent spans at 60fps

Agent Replay: scrubbing the canvas waterfall, replaying tokens in sync with the playhead, and diffing two runs

Why this exists

Trace viewers, eval harnesses, and agent debuggers are the internal tooling every AI lab builds. Rendering tens of thousands of spans at 60fps with smooth interaction is a genuine frontend engineering problem - canvas, culling, level-of-detail, a decoupled animation loop, off-main-thread parsing - the kind of thing that can't be faked and that a reviewer can verify by opening DevTools.

Features

  • Canvas waterfall timeline - nested spans colored by kind on depth rows, a time axis with "nice" ticks, drag-to-pan and wheel zoom-about-cursor, hover, and click-to-select.
  • 60fps at 50k spans via structure-of-arrays geometry, viewport culling, and level-of-detail density bands (see How it works).
  • Time travel - a playhead you can scrub on the ruler or play back (0.25×-8×), with the "world at T" (the live call stack) highlighted. Full keyboard transport (space, ←/→, [/]).
  • Token replay - the selected LLM span's response types out in sync with the playhead, with a tokens/sec readout and an arrival-time sparkline.
  • Inspector - tabs (Overview, Messages, Input, Output, Attributes, Raw) driven entirely by real per-span data.
  • Minimap - a cached full-trace overview with a draggable viewport window and a playhead.
  • Virtualized span tree - a keyboard-navigable, ARIA tree that doubles as the accessible fallback for the canvas, synced both ways with selection.
  • Diff view - align two runs by span path, classify added / removed / changed, and see per-span timing deltas plus a Δ duration / tokens / cost / spans summary.
  • Search & filter - by kind, status, and free text; matches stay bright while the rest dims.

How it works

The timeline is drawn on a single <canvas>, never DOM (DOM dies past ~10k nodes). The techniques that get it to 60fps at 50k spans:

  • Structure-of-arrays geometry. Span geometry lives in flat typed arrays (Float32Array for start/end, small int arrays for depth/kind/status), not an array of objects. The render loop iterates 50k spans per frame cache-friendly and GC-free. Heavy payloads (inputs/outputs, messages) sit in a separate lazy store keyed by index, so the hot path never pulls them into cache.
  • Viewport culling in O(log n + k). A max-end segment tree over the start-sorted spans answers "which spans overlap [t0, t1]?" (and "active at time T?") without scanning all of them.
  • Level of detail. When a bar would render narrower than ~2px, it isn't drawn individually; it accumulates into a per-row, per-column density buffer rendered as a heat band (opacity by count, colour by the most salient kind). This caps draw calls at ~rows×cols regardless of span count, so a zoomed-out 50k trace reads as shape, not mush.
  • A decoupled animation loop. One requestAnimationFrame loop owns the canvas; the viewport, playhead, hover, and drag state live in refs, never React state, so interaction never triggers a React render. The loop is on-demand - a frame is scheduled only while something is dirty, so the page idles when nothing moves. React panels update on settle (selection), not per frame.
  • Off-main-thread ingest. A Comlink Web Worker parses the JSONL and builds the index, then transfers the typed-array buffers back to the main thread zero-copy (transferables), so a multi-MB trace never blocks the UI.

Measured performance

Render time per frame, worst case (fully zoomed out, every span processed), 1440px canvas, dpr 1:

spans draw / frame of the 16.6ms 60fps budget
1,500 0.6 ms 3.7%
10,000 5.0 ms 29.9%
50,000 6.9 ms 41.4%

Ingest (parse JSONL + build index), median of 5 runs on Node 22 (npm run bench):

spans JSONL parse index total
1,000 1.0 MB 6.6 ms 1.5 ms 8.2 ms
10,000 10.4 MB 56.0 ms 11.6 ms 67.6 ms
50,000 52.2 MB 302.1 ms 53.8 ms 355.9 ms
100,000 104.1 MB 861.0 ms 165.9 ms 1026.9 ms

Data model

OpenTelemetry-flavored: a Run with nested Spans (each with kind, timing, status, and a parent link) plus point-in-time SpanEvents, with LLM-specific extras (messages, usage, a token stream) aligned loosely with the OpenTelemetry GenAI semantic conventions. Traces are ingested as JSONL (a run header line, then span lines, then event lines) so large traces stream and parse incrementally. The full model is in src/types/trace.ts.

Local development

Requires Node ≥ 22 (Vite 8 / Rolldown use a Node 22+ API; .nvmrc pins it).

npm install
npm run dev        # http://localhost:5173
Command Description
npm run dev Vite dev server
npm run build Type-check (tsc -b) + production build
npm run preview Preview the production build
npm run typecheck Type-check only
npm run lint / npm run format ESLint / Prettier
npm run test / npm run test:run Tests (watch / once)
npm run gen -- <size> Emit synthetic JSONL (small/medium/large/xl)
npm run bench Benchmark parse + index across sizes

Project structure

src/
  components/   React UI: TimelineCanvas, Minimap, SpanTree, Inspector, TokenReplay, Transport, DiffView
  render/       Canvas renderer + non-React controllers: TimelineRenderer, viewport/playhead controllers, hitTest
  lib/          Pure logic: traceIndex, viewport, ticks, parse, syntheticTrace, diff, filter, perturbTrace, bench
  workers/      ingest.worker.ts - Comlink worker that parses + indexes off the main thread
  store/        Zustand store
  types/        Trace data model
  fixtures/     Curated demo traces
scripts/        gen-trace, bench-ingest

All pure logic in lib/ and render/ is unit-tested (Vitest); the interval queries are checked against a brute-force scan. The canvas/worker glue is verified in the browser.

Tech stack

React 19 · TypeScript · Vite 8 · Canvas 2D · Web Workers (Comlink) · Zustand · TanStack Virtual · Tailwind CSS v4 · Vitest.

Deployment

Static SPA - any static host works. Configured for Render (render.yaml): create a Blueprint from this repo, or a Static Site with build npm ci && npm run build and publish dir dist. A thin Node/Hono + SQLite + WebSocket backend (live mode) is planned and will live alongside on Render.


Built Agent Replay, a browser-based time-travel debugger for LLM agent runs (React/TypeScript, Canvas, Web Workers): renders 50k+ trace spans at 60fps via structure-of-arrays geometry, viewport culling, and level-of-detail aggregation, with scrubbable token-level replay, two-run diffing, and off-main-thread parsing/indexing.

About

Browser-based time-travel debugger for LLM agent runs. Scrub traces like a video, replay token streams, and diff two runs. Renders 50k+ spans at 60fps.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages