> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cerulion.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Record and replay a run

> Record a run to an MCAP bag, inspect it, play it back as a robot substitute, and re-execute your nodes against it.

Cerulion recordings are standard [MCAP](https://mcap.dev) files. One recording is one `.mcap` file, uncompressed, carrying every frame exactly as it was published — wire sequence and timestamp included.

There are three ways to get one, and they answer different questions:

| Command | Use it when |
| - | - |
| `cerulion graph run <name> --record` | You are about to run the graph and want a **replay-grade** bag you can re-execute later. |
| `cerulion bag record` | Something is already running (yours or not) and you want its topics on disk. |
| `cerulion flashback` | Something already went wrong and nobody was recording. |

## Record a run

Add `--record` to the run. The bare flag writes into `./recordings/`; a custom directory needs the `=` form.

```bash theme={null}
cerulion graph run perception --record
```

```text theme={null}
recordings/perception_20260827T234549Z.mcap
messages=722 bytes=87001 frames_lost=0 dropped_unwritten=0
```

The recording keeps the network on — the robot stays viewable while you record — and finalizes on shutdown, including when the run is stopped with `Ctrl+C`. Recording requires the live clock, so it cannot be combined with `--time-source virtual` or `external`, and it is refused together with `--no-rings`. It is also refused together with `--no-validate`: that flag skips the `schema:` checks, and a bag whose channel labels were never checked cannot be honestly replay-verified — a re-execution can refuse a healthy bag, or replay one under the wrong label. Record with validation on, or drop `--record`.

<Note>
  A recorded run captures the graph, the environment, and the run's own record of what fired when. That is what makes it re-executable later — see [Re-execute your nodes](#re-execute-your-nodes).
</Note>

The recorder also picks up live producers the graph never declared and records them alongside your topics. Those extra channels are not replay-grade — the bag says so when it is created, `cerulion bag info` names anything the recorder could not cover, and a re-execution skips them instead of verifying them. Set `CERULION_RECORD_DISCOVERY=off` to record only the graph's declared topics.

By default the environment is captured as names for everything and values only for `CERULION_*`, `RUST_LOG`, and `IOX2_*`, so a bag never leaks a secret you happened to export. Every other value is replaced by its FNV-1a hash, which lets replay detect divergence without carrying the value. `--record-env all` captures every value verbatim for full replay fidelity, and warns loudly that the bag now embeds them.

## Record topics that are already live

`cerulion bag record` taps this machine's shared memory. It never opens the network, so recording a robot's topics means running it **on the robot** and copying the file afterwards.

```bash theme={null}
cerulion bag record -a -o session.mcap --duration 30
```

| Selection | Meaning |
| - | - |
| `TOPIC...` | Exactly these topics. A named topic that is not live here is refused by name. |
| `-a` / `--all` | Every live local topic. |
| `-e PATTERN` | Every live local topic matching a regex. |
| `-x PATTERN` | Narrows any of the above. Repeatable. |

Exactly one selection source is allowed — combining a positional list with `-a` or `-e` is an error, because the merge you would assume ("union? intersection?") is not knowable. Narrow with `-x` instead.

To describe a **run** rather than a topic set, attach to a live one:

```bash theme={null}
cerulion bag record --run=perception
```

The bag then carries that run's effective graph, environment, and recorder manifests, and picks up its scheduler trace from the attach point. A bare `--run` means the sole live run; naming one needs the `=` form, since every word after a bare `--run` is read as a topic.

## Capture the recent past

Every serving graph on Unix already holds a rolling window of roughly the last 30 seconds in memory. `cerulion flashback` writes that window out — plus the following 15 seconds — as an ordinary bag. Nothing has to be armed in advance.

```bash theme={null}
cerulion flashback --note "arm jerked left on the pick"
```

Captures land in `recordings/flashbacks/`. Add `--pin` to keep one out of routine retention, and `--no-wait` to return as soon as the request is accepted instead of waiting for the file. `CERULION_FLASHBACK=off` turns the capture plane off entirely.

A capture also carries the recorder's own health, so `cerulion bag info` reports an absorbance verdict for it — scoped to the recorder that took the capture, over its whole run rather than the captured window:

```text theme={null}
absorbance: 2 topic(s) carry a verdict — 2 absorb the drain stalls the RECORDER that took this capture, over its whole run (not this capture's window — see drive_span_us) measured (0 of them on an OPTIMISTIC floor rate), 0 fall SHORT of them, 0 met a stall the histogram cannot measure, 0 make no claim, 0 fell short earlier in the run and now absorb, and 0 fell short earlier and can no longer be ranked.
```

## Inspect a bag

```bash theme={null}
cerulion bag info recordings/perception_20260827T234549Z.mcap
```

```text theme={null}
bag: recordings/perception_20260827T234549Z.mcap
state: finalized
frames: 722 across 2 topic(s); span 11.880s

TOPIC                                       FRAMES   MAX BYTES  SCHEMA
/perception/detector/detection                 361         126  geometry_msgs/PoseStamped
/perception/sensor/image                       361         115  sensor_msgs/Image
```

`state: finalized` is the one to read first: playback needs the summary footer that a clean recorder shutdown writes, so a bag from a killed recorder is refused by `bag play` while `bag info` still reports what it can read.

### Read the absorbance verdict

A bag that carries a recorder health document — a `graph run --record` recording, or a flashback capture — ends its report with an `absorbance` block:

```text theme={null}
absorbance: 2 topic(s) carry a verdict — 2 absorb the drain stalls this recording measured (0 of them on an OPTIMISTIC floor rate), 0 fall SHORT of them, 0 met a stall the histogram cannot measure, 0 make no claim, 0 fell short earlier in the run and now absorb, and 0 fell short earlier and can no longer be ranked. A verdict is the tap's queue depth divided by that topic's rate, against that run's own drain-gap tail; the rate is a WINDOW AVERAGE, so a bursty topic is worse than its figure, and the gaps are DRIVE-LOOP gaps, so a topic whose own drain was skipped (staging_full_passes) saw a longer stall than the one shown.
```

The verdict answers one question per topic: when the recorder pauses for as long as this run's worst drain gap, does that topic's shared-memory queue hold its frames until the recorder drains again? A topic that absorbs them keeps its frames; a topic that falls short can drop frames at its own queue while the recorder is away, and `frames_lost=0` does not contradict that — the loss happens before the recorder ever sees the frame.

Every topic is counted in the summary, and only the ones you can act on are printed: a topic that fell short, one that makes no claim, one whose stall the drain-gap histogram cannot rank, one that passed on a floor rate rather than a measured one, and one that fell short earlier in the run. A topic that absorbed every measured stall on an exact rate, with nothing else to flag, is counted and not printed.

A printed row names the topic, the depth its recording tap was given, and the rate the verdict used:

```text theme={null}
/perception/sensor/image                 depth 64 (ceiling) @ rate unknown -> absorbance unknown vs no claim (nothing to compare) [no_claim]
```

<Note>
  The per-topic detail behind the block — each topic's own rate, tail, depth and `loss_counting_basis` — is in the bag's health attachment: `__cerulion/record_health.json` on a recording, and `__cerulion/capture_recorder_health.json` on a flashback capture, which carries the recorder's document rather than one of its own.
</Note>

## Play a bag back

Playback republishes the recorded frames onto shared memory, so everything downstream — `topic echo`, `topic hz`, Cerulion Studio, a consumer node — sees what a live robot would.

```bash theme={null}
cerulion bag play session.mcap --loop
```

| Flag | Meaning |
| - | - |
| `-r` / `--rate N` | Rate multiplier: `2` for twice the recorded pace, `0.5` for half. |
| `--loop` | Restart at the beginning until interrupted. |
| `--topics TOPIC` | Play only these topics. A name the bag does not carry is a loud error listing what it does. |
| `-s` / `--start-offset S` | Skip the first S seconds of bag time. |
| `-u` / `--duration D` | Cover only the first D seconds of bag time. |

Two behaviors are worth knowing before you interpret a playback:

* **A topic that a live producer already owns is refused by name**, and the rest of the bag still plays. Topics are single-writer, so a player must not compete with a live producer.
* **Each topic keeps its own recorded rate.** Stamps from different producers share no number line, so playback runs one timeline per topic, anchored on that topic's own first frame.

## Re-execute your nodes

`--resim` is the other half of the verb: instead of republishing recorded frames, it re-executes the bagged graph's nodes against the recorded input, on the recording's own clock.

```bash theme={null}
cerulion bag play recordings/perception_20260827T234549Z.mcap --resim all --verify
```

```text theme={null}
replay PASS: recordings/perception_20260827T234549Z.mcap (11164 tick(s) replayed, 2/2 topic(s) matched byte-for-byte)
```

| | `bag play <bag>` | `bag play <bag> --resim all` |
| - | - | - |
| What runs | The recorded frames are republished | Your current node builds are re-executed |
| Output topics | Replayed verbatim | Recomputed |
| Pacing | Wall-paced from the recorded timeline | Unpaced — it never reads the wall clock |
| Needs the workspace | No | Yes, it loads your node libraries |

`--resim all` re-executes every native node; a node subset is not supported yet and is refused by name rather than quietly widened. A [`ros2:` entry](/cerulion/reference/graph-and-schema-files#ros2-entries) in the bagged graph is never respawned — its topics are recorded inputs — and one warning names the entries it skipped, which `--report` also lists under `ros2_entries_skipped`.

A bare `--resim` makes **no claim** about whether the result matches the recording — divergence is the product, so a completed re-execution exits 0. `--verify` is the verdict: it byte-compares every produced frame and exits non-zero on divergence. Add `--report PATH` for a machine-readable verdict, `--tolerance PATH` for fields that may legitimately differ, and `--strict-state` to refuse the run unless every node's state was restored.

<Check>
  A passing `--verify` is a strong statement: your current code, re-run against real recorded input, produced the same bytes the robot produced. That is the property to put in CI.
</Check>

Two things will make a re-execution refuse or produce nothing, and both are by design:

* **Environment divergence is reported, not ignored.** A variable that was present at record time and absent at replay is a warning naming the variable; the run continues.
* **A bag from `bag record` is not replay-grade.** A standalone capture carries frames and no scheduler trace at all. A `--run` attach does carry the run's graph, environment and a trace from its attach point, but its channel descriptors are still observability-grade, and `--resim` compatibility is not claimed for it — record with `graph run --record` when you know you will re-execute. A Flashback capture is meant to be re-executed and says so itself: one that lost its own resume boundary reports itself as not resimmable. Use `cerulion bag migrate` to bring an older replay-grade bag forward.

## Next steps

<CardGroup cols={2}>
  <Card title="Investigate a recording" icon="magnifying-glass-chart" href="/cerulion/hindsight" color="#0080FF">
    Find the moment a recorded run went sideways with Hindsight (early access).
  </Card>

  <Card title="Visualize a running graph" icon="chart-line" href="/cerulion/guides/visualize-in-studio" color="#0080FF">
    Watch a live run, or a bag playing back, on the Studio stage.
  </Card>

  <Card title="Run a graph across processes" icon="microchip" href="/cerulion/guides/run-multi-process" color="#0080FF">
    The default deployment shape, and what it means for recordings.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.