MCAP recording (passthrough)
dc_bridge blesses exactly five Destination types — postgres, s3, file, console,
vector (see Destinations) — and MCAP is not one of them; Vector, the
Shipper, has no MCAP sink at all. This tutorial is the worked example for #210: the
ADR-0003 passthrough plus a
small standalone process, dc_mcap_writer
(ADR-0009),
that consumes the same public dc.<tag> routes a blessed Destination consumes and
writes them as rotated .mcap files, ready to open with ros2 bag info or
Foxglove.
Unlike the Elasticsearch/InfluxDB
passthrough demos, there is no raw Vector TOML to hand-author and no second terminal to
start a companion process in: dc_bringup.launch.py reads a dc_mcap_writer: block
from the same params file every other node's parameters live in, generates the
passthrough sink from it, and starts dc_mcap_writer itself automatically. From the
params file it looks like configuring a Destination — one block, inputs, done —
even though it structurally isn't one (see "Understanding the configuration" below for
why).
Each Record is written as a JSON-schema-encoded MCAP message — one Channel per Tag,
schema {"type": "object"} — the pattern in the
foxglove/mcap jsonschema/writer.cpp
example issue #210 links to. This is not a ros2 bag record capture of typed ROS
messages: Records are DC's own JSON payloads, not (de)serialized ROS message types, so
there is nothing to generate .msg/.idl schemas from. ros2 bag info reads the
file's Channel/Statistics records regardless of encoding; Foxglove additionally
understands jsonschema channels well enough to plot and inspect fields directly.
Run it
Hardware-free: four system Measurements, one dc_mcap_writer: block in the params
file (dc_demos/params/mcap_recording.yaml), one launch command:
colcon build
ros2 launch dc_demos mcap_recording.launch.py
dc_bringup.launch.py starts dc_mcap_writer before the rest of the stack, so its TCP
listener is up before Vector's generated socket sink (re)connects to it — the very
first Record lands instead of relying on Vector's own retry. The records_log file
Destination writes every Record as it is shipped to /tmp/dc/mcap_recording_records.ndjson,
so tail -f on that path doubles as a local view of what dc_mcap_writer is receiving
(an earlier version of this demo used a blessed console Destination for the same job —
see Destinations: Recipes
for why that moved to a passthrough recipe); dc_mcap_writer's own log lines (on this
terminal — it runs alongside dc_bridge, not in the background) show each file it opens
and closes.
Verify the recording
Stop the stack (Ctrl-C) once a few Records have been collected — dc_mcap_writer
finishes the file it has open on shutdown, so every .mcap under the configured
output_dir (~/dc_mcap_out in the demo params) is independently valid, including every
earlier rotation:
ros2 bag info ~/dc_mcap_out/records_<timestamp>_<pid>_0001.mcap
Files: /root/dc_mcap_out/records_20260904T064025Z_40_0001.mcap
Bag size: 3.8 KiB
Storage id: mcap
ROS Distro: unknown
Duration: 9.998903549s
Start: Sep 4 2026 06:40:26.274123868 (1788504026.274123868)
End: Sep 4 2026 06:40:36.273027417 (1788504036.273027417)
Messages: 10
Topic information: Topic: dc.measurement.cpu | Type: dc/record | Count: 3 | Serialization Format: json
Topic: dc.measurement.memory | Type: dc/record | Count: 3 | Serialization Format: json
Topic: dc.measurement.os | Type: dc/record | Count: 1 | Serialization Format: json
Topic: dc.measurement.uptime | Type: dc/record | Count: 3 | Serialization Format: json
Service: 0
Service information:
Type: dc/record — not unknown — because dc_mcap_writer registers its JSON-schema
Channels under that schema name (writer.py's register_schema(name="dc/record", ...));
ros2 bag info shows it verbatim rather than resolving a ROS message type, since there
isn't one. Message counts and per-topic breakdown come from the file's
Statistics/Channel records regardless. Also note the topic name is dc.measurement.cpu,
not dc.dc.measurement.cpu — a single dc. prefix, matching the dc.<tag> routing
contract in Destinations.
Opening the same file in Foxglove Studio (Open local
file…) lists each dc.<tag> Channel and renders its JSON fields (cpu.average,
memory.used, uptime.time, …) in the Raw Messages and Plot panels like any other
topic.
Rotation
dc_mcap_writer rotates to a new .mcap file once the current one hits max_bytes
(default 128 MiB) or has been open max_duration_secs (default 300s), whichever comes
first — the same "whichever limit first" shape used for files.retention
(ADR-0005),
applied here to dc_mcap_writer's own output rather than dc_uploader's intent queue
(ADR-0009 explains why this stays
outside dc_bridge). Filenames are
<prefix>_<UTC timestamp>_<pid>_<rotation index>.mcap; the PID and counter together
guarantee a unique name even across a process restart landing in the same wall-clock
second as the previous process's last rotation — without both, the new process could
compute the identical name and its open(..., "wb") would silently truncate the file
the previous process had already finished.
A .mcap file only becomes readable once its rotation finishes — writing the file's
closing footer is what max_bytes/max_duration_secs triggers, not something every
individual Record write does. A process that never gets to shut down gracefully
(SIGKILL, or a container/orchestrator grace period too short for the clean-shutdown
path to complete) loses whatever is in the file still open at that moment; every
already-finished rotation stays valid and readable regardless. max_duration_secs's
default balances this against not producing too many small files — lower it for
tighter durability, raise it for fewer files, per your own tolerance for that
loss window.
Understanding the configuration
See the Elasticsearch tutorial for the full
ADR-0003 passthrough
mechanics — routing, buffering, the dc.<tag> contract. This section only covers
what's specific to how MCAP recording is wired up.
dc_demos/params/mcap_recording.yaml's dc_mcap_writer: block is not nested
inside dc_bridge's destinations list, and can't be made to look exactly like
postgres/s3/file/console/vector there: destinations is parsed and validated by
dc_bridge itself, in C++, and an unrecognized type is a hard startup error by
design (see Destinations) — teaching it a sixth type would mean
changing dc_bridge, which ADR-0009
explicitly decided against (Vector's own
at-least-once/disk-buffered guarantees already cover what would have justified that).
So it's a sibling top-level block instead, structurally shaped like a Destination
(inputs, a handful of scalar settings) without literally being one.
dc_bringup.launch.py's build_bridge_and_mcap_actions() reads that block at launch
time (not compiled in — editing the params file and relaunching is how you change it)
and, when enabled: true:
- Renders a Vector
socketsink (mode = "tcp",encoding.codec = "json",framing.method = "newline_delimited") frominputs, converting each ROS topic to its publicdc.<tag>route the same way the Bridge itself does, to~/.dc/generated_mcap_sink.toml— regenerated every launch, not meant to be hand-edited — and merges that path into whateverdc_bridge.custom_config_filesthe params file already lists (a secondparameters=[...]entry for a list-valued ROS parameter replaces the file's value rather than appending to it, so this merge has to happen in the launch file's own Python, not by relying onlaunch_ros). - Starts
dc_mcap_writeras a plainExecuteProcess— deliberately notros2 run dc_mcap_writer dc_mcap_writer:ros2 runspawns its target as a child of its own process and does not forward signals to it, solaunch's own respawn/shutdown handling would only ever reach theros2 runwrapper, leaving the realdc_mcap_writerprocess running, orphaned, never getting the chance to finish its currently-open.mcapfile.dc_mcap_writerhas norclpy/ROS-node dependency of its own, so nothing here actually needsros2 run's node-launching machinery — a sourced workspace already puts it onPYTHONPATH, sopython3 -m dc_mcap_writer.cliruns it directly.
Like every passthrough sink, the generated Vector sink gets Vector's default in-memory
buffer, not the disk buffer blessed sinks get — see the "Passthrough:
custom_config_files" section of Destinations.
dc_mcap_writer itself routes each incoming Record to a Channel by its tag field
(falling back to name, then a fixed dc.unknown catch-all), and reads the Record's
normalized timestamp from its own --time-key (default date, matching the Bridge's
own time_key parameter — not currently exposed as its own dc_mcap_writer: field,
set it via ros2 launch's underlying ExecuteProcess if you need a non-default value)
to set the MCAP message's log_time/publish_time — it understands all three
time_format values (epoch_nanos, iso8601, double), same as
documented for Destinations.
Running dc_mcap_writer outside dc_bringup
The launch integration above covers the common case; python3 -m dc_mcap_writer.cli
(see --help) is still the standalone entrypoint underneath it, useful for converting
an existing NDJSON capture (--stdin) or running it against a hand-authored passthrough
snippet the way the Elasticsearch tutorial shows for other sinks.