Record & replay

A recording is not a video and not a log of what a test did. It is the whole event stream, appended raw, plus periodic graph keyframes — so replaying it rebuilds the identical graph through the identical reducer, and every consumer surface works on it unchanged. And when something goes wrong, Studio records one by itself, starting five seconds earlier.

Loading the live Studio…
A recorded session, on one timeline. Live, in your browser.Open in Studio

What a recording is

A .sqrec file is an append-only container holding the raw protocol frames a session produced, interleaved with periodic graph keyframes so a scrubber can seek without folding from the beginning. It is a black-box recording: everything the app emitted, not a curated subset chosen at record time. That is the premise — you cannot know at record time which event will turn out to matter.

Replay feeds those frames into a normal client. Not a special replay-mode client — the client, with the same fold path a live session uses. Which is why the inspector, the analytics screen and the performance center all work on a recording without knowing they are looking at one.

Inside the container

Preamble
A SQREC magic number and a format version. Truncated tails — a crash mid-append — are tolerated on read, and unknown record types are skipped, so a file written by a newer build is diagnosable rather than a parse error.
Header
Session identity, the wall-clock anchor, the protocol version, and the surface that armed it — cli, desktop or mcp. Provenance is first-class here rather than buried in a metadata bag, because three front doors share one directory.
Frames
The raw protocol frames, verbatim. No re-encode, so there is no second schema to drift from the first one. Each carries its offset on the session-monotonic event clock, never on wall-clock — which is what makes two replays of the same file identical.
Keyframes
A faithful snapshot of the consumer graph plus the index of the last frame folded into it, cut every five seconds and at stop. A seek restores the nearest one and folds forward from exactly the next frame, which is why scrubbing a long session is instant.

Recording

Terminal
squiggle record                      # until Ctrl-Csquiggle record checkout --duration 60squiggle record -o ./repro.sqrec

The desktop’s ○ Record button in Simulator Studio arms the same recorder on the live session, and record_start does it from an agent. All three write to .squiggle/recordings/ and stamp the producing surface into the file header, so a shared directory stays honest about who recorded what.

Recording is relay-first, like everything else — it coexists with an open Studio rather than fighting it for the port. When idle, the tap that watches for frames is a single no-op branch, so an armed recorder costs nothing until it is recording.

Replaying

Terminal
squiggle replay                         # list available recordingssquiggle replay checkout                # by namesquiggle replay ./repro.sqrec --speed 2squiggle replay checkout --assert flows/checkout.yaml

Default speed is max — there is no device to wait for, so the only reason to pace a replay is that a human is watching it.

Replay Studio

The desktop app opens the same file on a timeline: scrub (keyframe-restore then fold forward), play/pause, ×1/×2/×4/max, and navigation markers on the track — all driving the reused inspector graph canvas rather than a second viewer written for recordings.

Everything hangs off that one playhead. Under the track sits a metric lane per series the recording carries — renders per second first, then CPU, GPU, memory footprint, JS heap, UI and JS frame rate, frame time, event-loop lag — each reading its value at the playhead, so a spike sits directly under the marker that explains it. Beside the graph, an event list — navigation, taps and gestures, errors, logs, network calls, long tasks — highlights the row you are on and seeks when you click one: it answers “what did the user do just before this”. The graph rings every component that rendered, changed props or mounted in the second before the playhead, and every marker says what it is on hover and lands exactly on itself when clicked.

Click a component in the graph and the event list gives way to what the recording says about it at the playhead: where it is defined and written, its test ID and label, how many times it has rendered so far, why it last rendered and which props, state or context keys changed — flagging a memo miss, where a prop was recreated with the same meaning — and its recent history, each row a moment to seek to. A recording keepswhich keys changed, not their values, and the panel says so.

Its playhead is two-way scrub-synced with the Performance Center’s unified timeline: drag one and the other follows, so a frame drop in a chart and the interaction that caused it are one gesture apart. Recordings name the run they were made in, so Open in Performance and Open in Analytics land on exactly that run, and Copy report puts the recording on the clipboard as Markdown — cause, stack, the window’s numbers and the steps that led up to it — ready for a ticket. Make flow turns the taps and navigation that led up to it into a draft flow (the same fold as squiggle flow --from-recording), files it under incidents/ in your flows and opens it in Flow Studio: add the assertions that should hold, and the bug has a regression test.

Incident capture

The recording you most need is the one nobody started. So while an app is connected, Studio keeps the last five seconds of its event stream in memory, and when something goes wrong it records two seconds more and saves the lot as an ordinary .sqrec. It opens on the seconds before the failure, replays, scrubs and diffs like any other recording, and costs nothing to keep: no video is encoded, and a graph snapshot is only taken when the graph has changed.

What counts as an incident

Crash
Only when a fresh crash report for that simulator appears after the app’s connection closes. A relaunch, a --fresh flow run and a Metro reload all close the connection too, and none of them is an incident.
Error
A thrown JS error, fatal or not, an unhandled promise rejection, or an error boundary catching one. The stack travels with it.
Stall
The injected agent’s watchdog saw the main thread stop.
Slowdown
A JS task of a second or more, event-loop lag of 500 ms or more, or frames taking 100 ms or more at the 95th percentile.
CPU, GPU or memory spike
CPU or GPU at 95% or more for two samples running; memory footprint or the JS heap jumping sharply within ten seconds; or the system reporting critical memory pressure.

The thresholds sit well past the Performance strip’s warnings: a strip may nag, an incident writes a file and raises a notification. Each kind then waits 30 seconds before it can fire again, and a second trigger inside a capture joins it rather than writing a second file of the same seconds.

What is in an incident

Everything the app streamed — the graph, navigation, renders, errors and logs, and its own JS heap, frame-rate, event-loop and long-task samples — and Studio’s own measurements alongside it: process CPU, memory footprint and thread counts from the host sampler, GPU when it is enabled, and frame timing, dropped frames and watchdog stalls from the injected agent. They ride the same file on the same clock, so the curve a trigger was read from is there to scrub through.

Replay Studio lists incidents with what set them off and opens each one on an incident report: the trigger and its stack, then the window’s numbers — CPU and GPU peak, how memory and the JS heap moved up to the trigger, the lowest frame rate, the worst frame time and event-loop lag, the longest JS task, and the errors thrown. Anything past an incident threshold is marked, and a number the file never carried is left out rather than shown as zero. The timeline marks the trigger and every exception, and Jump to incident seeks straight to it. Beside the report is the screen as it looked at the trigger: Studio takes one still from the simulator the moment a rule fires (not after the post-roll), so you see what the user saw. A crash has none — the app is gone before anyone knows.

Repeats fold. Incidents of the same kind whose titles match once their numbers are taken out — memory jumped 115 MB and memory jumped 98 MB — are one entry with a count and when it was first and last seen, which opens into its members. In the player, the title is a menu of the other recordings, the open incident’s repeats first, so the next one is a click away rather than a trip back to the list.

Where incidents go

The file lands in .squiggle/recordings/ as incident_<kind>_<time>.sqrec, a row is written to perf.db’s incidents table — which retention never prunes, because an incident is exactly the evidence a sweep must not take — and a History row and a notification with Open in Replay follow. Capture is on by default; turn it off under Settings → Replay → Capture incidents.

Terminal
squiggle perf sql "select kind, title, datetime(triggered_wall / 1000, 'unixepoch') as at, path                   from incidents order by triggered_wall desc limit 10"

Asserting against a recording

--assert re-runs a flow’s assertions against the recorded timeline. Action steps are skipped, because a recording is read-only. This is a genuinely different kind of check from a live run:

Two evaluation windows

Positive assertions are evaluated over the whole timeline
route, visible, exists, store and query pass if they held at any point, and the report names the first event-time where they did. So a multi-screen journey validates even though no single frame satisfies every assertion at once.
Negative and window assertions stay end-state
notVisible, rerenders and droppedFrames are evaluated at the end, because “never happened” and “happened fewer than N times” are not point-in-time questions.

${VAR} resolves from the flow’s env:, overridable with --env. The process exits non-zero on a failed assertion, so a recording can be a CI gate that needs no simulator.

Diffing two of them

squiggle diff a b answers “what changed between these two builds” with something other than a screenshot, and it needs no app running:

Terminal
squiggle diff before.sqrec after.sqrecsquiggle diff before.sqrec after.sqrec --json | jq '.renders'

It compares renders, routes, network calls, spans, actions and store mutations. Because both sides fold through the same reducer, a difference in the output is a difference in what the app did — not a difference in how two runs were observed.

What replay connects to

This is the part that is easy to miss: a recording is not a feature on its own, it is the substrate the rest of the product reuses.

Authoring
squiggle flow --from-recording derives a draft flow from a journey you drove by hand — including a comment listing the taps nothing durable identified.
Behavioural diff
squiggle diff a b compares two recordings — renders, routes, network, spans, actions, mutations — with no app running. This is how “what changed between these two builds” gets an answer that is not a screenshot.
Agent gating
Before an agent actuates anything, its program is checked against the newest relevant recording: do these assertions hold in territory we have already seen? A program aiming at a screen that does not exist is stopped at that tier, with zero live actions. If no recording covers the territory, the gate degrades to static-only and says so — it never silently passes.
Inferred contracts
What held across every green recording of a screen becomes a contract nobody wrote and nobody maintains. A violation is reported as contract_drift next to the goal, with its confidence (“held in 3 of 3” reads differently from “40 of 40”), and never as a hard failure — these are inferred, so they can be wrong.
Re-projection
squiggle reproject folds recordings back into the perf store and checks the store agrees with the canonical log. The projection is derived, so it can be rebuilt — and a mismatch is a bug worth catching.
Deterministic sessions
deterministic_session pins the conditions that make two replays comparable, and named fixtures restore app-data preconditions, so a comparison is not confounded by the app starting from somewhere else.

Where they live

Recordings land in .squiggle/recordings/ (gitignored — they are machine-local evidence, not source), and each finished file is indexed into perf.db’s recordings table so the desktop, the CLI and an agent can list them without walking the directory. Incidents are indexed in their own incidents table, with the trigger, its time and the recording’s path.

Related: Performance reads the same store, and Agents & MCP explains the gate pyramid recordings feed.