DC

Source code: https://github.com/minipada/ros2_data_collection

ROS 2 python C++

codecov tests

Rolling
prek
Documentation
Github Pages
CI

For detailed instructions, see the navigation sidebar, or browse doc/src/dc on GitHub. Security policy.

Introduction

The DC (Data Collection) project aims at integrating data collection pipelines into ROS 2. The goal is to integrate data collection pipelines with existing APIs to enable data analytics, rather than live monitoring, which already has excellent tools available. As companies increasingly turn to autonomous robots, the ability to understand and improve operations for any type of machine in any environment has become crucial. This involves mostly pick and drop and inspection operations. This framework aims at helping collecting, validating (through JSON schemas) and sending reliably the data to create such APIs and dashboards.

DC uses a modular approach, based on pluginlib and greatly inspired by Nav2 for its architecture. Pluginlib is used to configure which Measurements are collected. Data leaves the robot through the Bridge (dc_bridge), a thin ROS 2 node that renders and supervises an external Shipper, Vector: Vector is a fast, lightweight observability data pipeline, distributed as a single static binary, with native sinks for PostgreSQL, S3-compatible storage, and many more. DC gets its performance, reliability, and data integrity (backpressure handling and disk buffering) without embedding or forking it. Five Destination types are configured natively from ROS parameters; every other Vector sink is reachable by passing raw Shipper configuration through, with no DC code.

Why collect data from robots?

  1. Performance Monitoring: Collecting data from a robot allows you to monitor its performance and identify areas for improvement. For example, you can use data to analyze the robot's motion and identify areas where it may be experiencing issues or inefficiencies.
  2. Fault Diagnosis: Data collection can also be used to diagnose faults and troubleshoot issues with the robot. By collecting data on various aspects of the robot's behavior, you can identify patterns or anomalies that may indicate problems with the system.
  3. Machine Learning: Data collected from robots can be used to train machine learning models, which can be used to improve the robot's performance and behavior. For example, you can use data collected from sensors to train models for object detection or path planning.
  4. Research and Development: Data collection is important for research and development in robotics. By collecting data on the behavior of robots in different scenarios, researchers can gain insights into how robots can be designed and optimized for different applications.
  5. Inventory Management: Data collection can be used to monitor inventory levels and track the movement of goods within a warehouse. This can help managers identify which products are in high demand and optimize the placement of products to improve order fulfillment times.
  6. Resource Allocation: Data collection can also help managers allocate resources more efficiently. For example, by monitoring the movement of people and goods within a warehouse, managers can identify bottlenecks and areas of congestion and adjust staffing and equipment allocation to address these issues.
  7. Process Improvement: Data collection can be used to monitor and analyze the performance of various processes within a warehouse. By identifying areas of inefficiency or errors, managers can develop strategies for improving these processes and increasing productivity.
  8. Predictive Maintenance: Data collection can be used to monitor the performance of equipment and identify potential maintenance issues before they occur. This can help managers schedule maintenance more effectively and avoid costly downtime due to equipment failure.

Main features

  • Open source: Currently all tools on the market are not open source. This project is in MPL-2.0 license, in summary you can use without asking permission and without paying
  • Modular approach: based on pluginlib and greatly inspired by Nav2 for its architecture
  • Reliable data collection: validate and send Records to create APIs and dashboards
  • Flexible data collection: set polling interval for each Measurement or collect every Measurement with StringStamped messages
  • Customizable validation: validate Records using existing or customized JSON schemas
  • Easy to extend: add new Measurements by writing a plugin; add new Destinations with configuration alone
  • Flexible data collection conditions: collect data based on conditions such as whether the robot is moving or if a field is equal to a value
  • Condition-based data collection: collect data when a defined set of combination of all, any, or no condition are met
  • Customizable record collection: configure the number of records to collect at the start and when a condition is activated.
  • Data inspection: inspect data from camera input including barcode and QR codes
  • Fast and efficient: high performance, using an external Shipper for delivery, and designed to minimize code duplication and reduce human errors
  • Grouped Measurements: Records can be merged into Groups using the group node, based on the ApproximateTimeSynchronizer
  • File uploads: Files — map_server maps, camera images, videos, anything a Measurement produces — are uploaded to object storage with verified, resumable transfers, and their metadata is recorded as a Record
  • Easy to use: designed to be easy to learn and use
  • No C++ 3rd party library required: all 3rd party libraries have a vendor package in the repository

And inherited from the Vector shipper:

  • Backpressure handling
  • Disk buffering, persisting Records across Destination outages and reboots

Here is an example of a pipeline, for an AGV doing pick-and-drop and inspection work:

AGV data pipeline: Operations and Pick/drop & inspect Measurements through the Bridge + Shipper to PostgreSQL and object storage

Security

Found a vulnerability? Do not open a public issue — report it privately through GitHub private vulnerability reporting. The security policy covers the supported branches, the response targets, and what is in scope.

License

This program is under the terms of the Mozilla Public License Version 2.0.

About and Contact

For any inquiry, please contact David (d.bensoussan@proton.me). If your inquiry relates to bugs or open-source feature requests, consider posting a ticket on our GitHub project. If your inquiry relates to configuration support or private feature development, reach out and we will be able to support you in your projects.

Setup

DC 2.0 ships as published container images by default — podman compose or podman run, no ROS 2 toolchain to install locally. It is also an ordinary ROS 2 workspace if you'd rather build natively: rosdep install, colcon build, done — no forked shipper to compile, no Go toolchain, nothing needs root.

Containerized

The default, recommended way to run DC — nothing to build, nothing but Podman required.

Quick run

No build needed — the published :rolling images run the all-in-one shape (every ROS node, the Bridge and the Shipper in one dc-ros container) directly against a local Postgres, with RustFS available alongside it for a files/S3 Destination. Isolated network by default — dc-ros reaches the stores by container name, not the host's network — so the commands below run from deploy/robot/.

podman compose, all three containers, one command — the fastest path if you don't need to watch each container's own output separately:

podman compose -f compose.aio.yaml -f compose.isolated-network.yaml -f compose.local-destinations.yaml up

podman run, one step at a time — same three containers, split so each command can be run and checked before the next, in its own terminal:

  1. Network and volumes:

    podman network create dc_robot_net
    podman volume create dc_robot_pgdata
    podman volume create dc_robot_rustfs_data
    podman volume create dc_robot_aio_buffer
    
  2. Postgres, pinned by digest — bump deliberately, check https://hub.docker.com/_/postgres/tags?name=13 for a newer one:

    # PostgreSQL 13.23
    podman run --rm -it --network dc_robot_net --name dc_robot_postgres \
      -e POSTGRES_USER=dc -e POSTGRES_PASSWORD=password -e POSTGRES_DB=dc \
      -p 5432:5432 -v dc_robot_pgdata:/var/lib/postgresql/data \
      docker.io/library/postgres@sha256:4689940c683801b4ab839ab3b0a0a3555a5fe425371422310944e89eca7d8068
    

    Wait for database system is ready to accept connections before moving on.

  3. RustFS, pinned by digest (matches compose.local-destinations.yaml — bump deliberately, check https://hub.docker.com/r/rustfs/rustfs/tags for the new one):

    # RustFS v1.0.0-beta.11
    podman run --rm -it --network dc_robot_net --name dc_robot_rustfs \
      -p 9000:9000 -v dc_robot_rustfs_data:/data \
      docker.io/rustfs/rustfs@sha256:84ce557a0245a06a9aae5516f55ee0f007fca78d41df356f419306fdc0cb168c
    
  4. dc-ros — on the isolated network, 127.0.0.1 no longer reaches Postgres, so this needs a params override pointed at the hostname postgres instead of the image's own baked-in 127.0.0.1 default:

    podman run --rm -it --network dc_robot_net --name dc_robot_aio \
      -v dc_robot_aio_buffer:/root/.dc/buffer \
      -v "$(pwd)/params/aio_params_local.yaml:/opt/dc/dc_params.yaml:ro" \
      ghcr.io/minipada/ros2_data_collection/dc-ros:rolling \
      dc_params_file:=/opt/dc/dc_params.yaml
    
  5. Check Records are landing, from a fourth terminal:

    psql -h 127.0.0.1 -U dc -d dc -c 'select * from dc order by date desc limit 5;'
    

    Password password.

See deploy/robot/README.md for the three-container split topology, the host-network variant, and trying it against a real store before fleet rollout.

Building the workspace image

The repository builds a full workspace image with Podman — the same one CI uses:

IMAGE_TAG=dc-workspace:local ./tools/e2e/scripts/build.sh

tools/e2e/scripts/test.sh runs colcon test against that image, and tools/e2e/scripts/run.sh drives the zero-loss end-to-end harness. See tools/e2e/README.md.

Deployment renderings and a local Kubernetes loop

deploy/robot/ describes the three-container robot tier (dc-ros, vector, dc-uploader — see Deployment modes) as Compose, Podman Quadlet and Kubernetes manifests, for whichever a site already runs. For iterating on the Kubernetes rendering itself, a loop of plain podman build, k3d and kubectl commands brings up a disposable local cluster in seconds — no wrapper script, no registry, just the commands themselves. See deploy/robot/README.md for the full command sequence and what each step is for.

Development loop, not production parity

k3d's default CNI does not enforce NetworkPolicy, so it cannot validate the fleet's network-isolation claims. It is the fast inner loop only, deliberately not the production-parity check.

Native

For developing DC itself, or wherever containers aren't an option.

Requirements

  • ROS 2 Rolling (ros-rolling-ros-base or larger), on Ubuntu 26.04 or a Debian equivalent
  • colcon, rosdep, git, vcstool (python3-vcstool), a C++17 compiler
  • x86-64 or aarch64 — the architectures vector_vendor has a pinned Vector binary for

Build

  1. Clone into a workspace:

    mkdir -p ~/ws/src && cd ~/ws/src
    git clone https://github.com/minipada/ros2_data_collection.git
    
  2. Pull in vector_vendor, aws_sdk_vendor and nlohmann_json_schema_validator_vendor (each its own repo — see ADR-0002's amendment, ADR-0012 and ros2_data_collection.repos), register DC's local rosdep rules (header-only C++ libraries upstream rosdistro has no key for), then resolve dependencies:

    cd ~/ws
    vcs import src < src/ros2_data_collection/ros2_data_collection.repos
    echo "yaml file://$PWD/src/ros2_data_collection/rosdep/dc.yaml" \
      | sudo tee /etc/ros/rosdep/sources.list.d/10-dc.list
    rosdep update
    rosdep install --from-paths src --ignore-src -r -y
    
  3. Build:

    source /opt/ros/rolling/setup.bash
    colcon build
    

That is the whole install. colcon build also runs vector_vendor, which fetches a pinned, checksummed Vector release tarball live — the external Shipper the Bridge supervises at runtime (ADR-0002) — and aws_sdk_vendor, which fetches and builds the AWS SDK for C++ (core + s3) dc_uploader uses (ADR-0014) live from github.com/aws/aws-sdk-cpp at a pinned tag; both steps need network access (ADR-0002, ADR-0012), and aws_sdk_vendor's takes several minutes the first time.

Python dependencies

Only some Measurement plugins (camera inspection, QR code detection) need Python packages beyond what ROS 2 installs. rosdep covers the ones with rosdistro keys; for the rest, uv installs pyproject.toml's pins into a project virtualenv:

uv sync --no-dev   # drop --no-dev to add the tooling and the demo dashboard's packages

Run

source install/setup.bash
ros2 launch dc_bringup dc_bringup.launch.py

The default parameters file (dc_bringup/params/dc_params.yaml) collects uptime and writes it to a local PostgreSQL Destination. To run your own:

ros2 launch dc_bringup dc_bringup.launch.py dc_params_file:=/path/to/my_params.yaml

See Configuration examples for configurations you can copy, and Destinations for the full Bridge configuration contract.

Useful launch arguments

ArgumentDefaultDescription
dc_params_filedc_params.yamlParameters file for every DC node
group_nodeFalseStart the Group node (needed by any group_server config)
namespace""Top-level namespace
log_levelinfoLog level for the DC nodes
autostartTrueLet the lifecycle manager configure and activate the nodes
use_sim_timeFalseUse simulation (Gazebo) clock — set True against a simulator, or TF lookups run on the wall clock while the sim publishes on its own clock and drift into "extrapolation" errors
run_uploaderTrueLaunch dc_uploader (ADR-0014) from this process. Set False when it runs in its own container instead (the three-container split)

What starts, in what order

dc_bringup.launch.py brings the pipeline up deterministically (ADR-0006): the Bridge and its Shipper first, then a readiness gate, and only then the collection nodes. If the Shipper never becomes ready, the launch shuts down loudly instead of collecting data nowhere. Data Pipeline describes this in full.

Advanced build options

Air-gapped or distro-packaged Vector

Point the build at a Vector binary you already have instead of downloading one:

colcon build --cmake-args -Dvector_path=/usr/bin/vector

The VECTOR_PATH environment variable does the same thing.

Infrastructure

DC delivers to systems you run yourself. tools/infrastructure/docker/ has compose files that bring up PostgreSQL and RustFS (S3-compatible object storage) preconfigured for the demos; see Infrastructure setup.

Issues

If you run into problems building DC, search the issue tracker on GitHub and feel free to open a ticket.

Requirements

Requirements are tracked using Strictdoc.

The goal is to list tasks and do test based requirements.

Demos

We will go together through some demos to get started with DC. You shall find them in the dc_demos package

The demos are grouped in three tiers. Start at Beginner and work down: each demo assumes the concepts explained in the ones before it.

How the tiers work

A demo's tier is the heavier of the two things it asks of you: the infrastructure you have to run alongside DC, and the DC machinery you have to understand or write. Neither axis alone is enough — Custom plugin needs no infrastructure at all but has you writing a C++ Measurement plugin, and MCAP recording is a single ros2 launch away but is built on the ADR-0003 passthrough.

TierInfrastructure to runDC machinery involved
BeginnerNone. Nothing but the built workspaceMeasurements and the console Destination (via the ADR-0003 passthrough), configured in YAML
IntermediateAt most one stack from tools/infrastructure/docker/, that you start yourselfPostgreSQL/RustFS reached via the ADR-0003 passthrough, or a store with no blessed type at all
AdvancedThe full inspection stack — PostgreSQL, RustFS and Grafana at onceCode you write yourself, or Measurements, Conditions, Groups, Files and dashboards wired end to end

When adding a demo, find the heaviest thing it asks of the reader — a service to stand up, or code to write — and file it under the matching tier.

Beginner

Everything prints to your terminal: nothing to install, start or clean up afterwards, and nothing to go and look at in another tool.

Prerequisites: a built and sourced workspace, and nothing else — no containers, no databases. Roughly 5 minutes each, 15 for the Turtlebot3 one including simulator startup.

TitleDescriptionAlso needs
UptimeCollect how long the system has been running and print it on Stdout. Minimal example—
Group memory and uptimeCollect both memory and uptime and group them in a dictionary—
Turtlebot3 StdoutCollect command velocity, map, position and speed and print it in stdoutThe Nav2 Turtlebot3 simulation (see Setup)

Uptime to stdout

This is the most minimal example to run DC, it collects the system uptime every 5 seconds and sends it to Stdout.

Copy the passthrough sink into place, then run it:

mkdir -p ~/.dc && cp "$(ros2 pkg prefix dc_demos)/share/dc_demos/config/uptime_stdout_sink.toml" ~/.dc/
ros2 launch dc_demos uptime_stdout.launch.py

At the end, the data is displayed. Every Destination — the passthrough console sink below included — goes through the external Vector Shipper (ADR-0002, Destinations), so this is Vector's own event object after ingesting the Record over the Fluent-forward protocol: source_type, tag, host and timestamp are Vector's, not the Bridge's:

[dc_bridge-2] {"custom_keys":["robot_name","time"],"date":1788476609.152106,"flattened":false,"host":"127.0.0.1","name":"uptime","nested":false,"robot_name":"C3PO","run_id":"169","source_type":"fluent","tag":"dc.measurement.uptime","time":1608993,"timestamp":"2026-09-03T23:03:29.152105868Z"}
[dc_bridge-2] {"custom_keys":["robot_name","time"],"date":1788476614.1494331,"flattened":false,"host":"127.0.0.1","name":"uptime","nested":false,"robot_name":"C3PO","run_id":"169","source_type":"fluent","tag":"dc.measurement.uptime","time":1608998,"timestamp":"2026-09-03T23:03:34.149433151Z"}
[dc_bridge-2] {"custom_keys":["robot_name","time"],"date":1788476619.149286,"flattened":false,"host":"127.0.0.1","name":"uptime","nested":false,"robot_name":"C3PO","run_id":"169","source_type":"fluent","tag":"dc.measurement.uptime","time":1609003,"timestamp":"2026-09-03T23:03:39.149286154Z"}

This launchfile is a wrapper of dc_bringup/launch/dc_bringup.launch.py which loads a custom yaml configuration

Configuration

Measurement

measurement_server:
  ros__parameters:
    measurement_plugins: ["uptime"]
    uptime:
      plugin: "dc_measurements/Uptime"
      topic_output: "/dc/measurement/uptime"
      polling_interval: 5000
      enable_validator: true
      debug: true
      init_collect: true
    custom_key_str_list: ["robot_name", "id"]
    custom_keys_str:
      robot_name:
        name: robot_name
        value: C3PO
      id:
        name: id
        value_from_file: /etc/machine-id
    run_id:
      enabled: true
      counter: true
      counter_path: "$HOME/run_id"
      uuid: false

measurement_plugins (Mandatory): List all the plugins to enable. This is a custom string that is equal to the measurement plugin dictionary present in the same level. If not listed, will not be loaded.

uptime.plugin (Mandatory): Name of the plugin, if you are not sure which plugin is available, use the CLI tool to list them

uptime.polling_interval (Optional): Interval to which data is collected in milliseconds

uptime.enable_validator (Optional): Will validate the data against a JSON schema. This file is located in the dc_measurements package. You can provide your own using the json_schema_path parameter, which we will explore later on

uptime.debug (Optional): More verbose output

uptime.init_collect (Optional): Collect when the node starts instead of waiting for the polling_interval time to pass

run_id.enabled (Optional): Identify which run the robot is. A new one is generated at every start of the node. Uses either a counter that increment at each restart of the node or UUID

run_id.counter (Optional): Enable counter for the run_id

run_id.counter_path (Optional): Path to store the last run. It is expanded with environment variables id

run_id.uuid (Optional): Generate a new run ID by using a random UUID

This will collect the uptime every 5 seconds (including when the node starts), will forward it to the console passthrough sink below.

Inject custom data for each record

Here, we want to append some content in every record: the robot name and its ID. While the robot name comes from a fixed variable in the parameter file, the id comes from the machine-id file.

custom_key_str_list (Optional): Look for those keys in this configuration to add them as keys and values in each record.

custom_keys_str.robot_name (Optional): This parameter is loaded since it is mentioned in custom_key_str_list

custom_keys_str.robot_name.name (Optional): Key in the dictionary to add

custom_keys_str.robot_name.value (Optional): Value associated to the key in the dictionary to add

custom_keys_str.id.name (Optional): Key in the dictionary to add

custom_keys_str.id.value_from_file (Optional): Value associated to the key in the dictionary to add taken from the content of a file

Info

Note that this configuration alone will not display the JSON on stdout since it requires the dc_bridge configuration below

Find the complete measurements documentation here

Destination

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/uptime"]
      path: "/tmp/dc/uptime_stdout_records.ndjson"
      time_key: "date"
      time_format: "double"
    custom_config_files: ["$HOME/.dc/uptime_stdout_sink.toml"]
    vector_forward_host: "127.0.0.1"
    vector_forward_port: 24224
# ~/.dc/uptime_stdout_sink.toml
[sinks.debug_console]
type = "console"
inputs = ["dc.dc.measurement.uptime"]
target = "stdout"

[sinks.debug_console.encoding]
codec = "json"

Destinations

Let's analyze piece by piece. dc_bridge is the single C++ node that owns every Destination; each entry in the destinations list names a section, defined below it, that describes where data goes. We need the topic list on each Destination because the Bridge subscribes to those topics itself and forwards what it receives to an external Vector process over the shipper ingest protocol.

destinations (Mandatory): List all the Destinations to enable. Each name must have a matching section at the same level.

records_log.type (Mandatory): One of the blessed Destination types (postgres, s3, file, console, vector). file writes each Record as a JSON line to path, and is the cheapest anchor to give destinations when the actual output you want comes from a custom_config_files passthrough sink below — dc_bridge derives its ROS subscriptions and dc.<tag> routes from destinations alone, never from a passthrough snippet's inputs. (Printing straight to stdout used a blessed console Destination in earlier versions of this demo; per ADR-0003, console — along with postgres and s3 — has moved to the passthrough recipe below, since it's a pure Vector-sink wrapper with no DC-specific logic. See Destinations: Recipes.)

records_log.receives (Optional): records (default) or files.

records_log.inputs (Mandatory): Topics to which to listen to get the data.

records_log.path (Mandatory for file): Absolute path Vector writes each JSON line to (Vector, not the Bridge, expands this — no $HOME).

records_log.time_format (Optional): Format the data's timestamp will be printed as (epoch_nanos (default), iso8601 or double).

records_log.time_key (Optional): Dictionary key the timestamp is written under.

custom_config_files (Optional): Raw Vector config snippets, merged as-is alongside what dc_bridge itself renders. The uptime_stdout_sink.toml above defines a plain Vector console sink consuming the public dc.dc.measurement.uptime route that records_log's inputs created — this is what actually prints to stdout; records_log itself just writes the same Records to disk as the passthrough's required anchor. See Destinations: Passthrough.

dc_bridge itself needs no engine tuning of the kind the old embedded Fluent Bit shipper required (buffering, scheduler backoff, HTTP stats server, …) — Vector, the external shipper process it forwards to, owns its own on-disk buffering and is configured from the dc_bridge/destinations block above; see ADR-0002 for why that split exists.

Inject run id at each record

Finally, we set the run id. This is used later on when fetching data for a run. It can come from a counter which is incremented at each start of the node or from a random UUID generated. The counter mechanism writes and read on a file on the system (take care of not deleting it), you can set its path as a parameter.

Find the complete destinations documentation here

Console output

Now that the node started, let us see what's displayed in the console.

Measurement server and dc_bridge are started in the Lifecycle, you can read more about it here. Per ADR-0006, the lifecycle manager waits on a bridge_ready_gate before activating the collection nodes:

[INFO] [bridge_ready_gate-3]: process started with pid [31]
[dc_bridge-2] [INFO] [1788478636.751773322] [dc_bridge]: dc_bridge up: 1 subscribed topic(s), supervising /root/ws/install/vector_vendor/lib/vector_vendor/vector
[bridge_ready_gate-3] [INFO] [1788478637.212874385] [bridge_ready_gate]: Bridge is ready: vector is accepting connections
[INFO] [bridge_ready_gate-3]: process has finished cleanly [pid 31]
[INFO] [launch.user]: dc_bridge reports ready; activating collection nodes.

dc_bridge renders the destinations block above into a Vector config and launches (or reloads) the external Vector process pointed at it; bridge_ready_gate only lets the launch continue once Vector is actually accepting connections — see ADR-0002 for why Vector runs as its own process rather than embedded in the Bridge.

Finally, we see the data, now printed by the passthrough console sink rather than by the Bridge itself:

[dc_bridge-2] {"custom_keys":["robot_name","time"],"date":1788476609.152106,"flattened":false,"host":"127.0.0.1","name":"uptime","nested":false,"robot_name":"C3PO","run_id":"169","source_type":"fluent","tag":"dc.measurement.uptime","time":1608993,"timestamp":"2026-09-03T23:03:29.152105868Z"}
[dc_bridge-2] {"custom_keys":["robot_name","time"],"date":1788476614.1494331,"flattened":false,"host":"127.0.0.1","name":"uptime","nested":false,"robot_name":"C3PO","run_id":"169","source_type":"fluent","tag":"dc.measurement.uptime","time":1608998,"timestamp":"2026-09-03T23:03:34.149433151Z"}
[dc_bridge-2] {"custom_keys":["robot_name","time"],"date":1788476619.149286,"flattened":false,"host":"127.0.0.1","name":"uptime","nested":false,"robot_name":"C3PO","run_id":"169","source_type":"fluent","tag":"dc.measurement.uptime","time":1609003,"timestamp":"2026-09-03T23:03:39.149286154Z"}

So...what happened?

  1. The measurement plugin starts publishing data to /dc/measurement/uptime, which contains the JSON and timestamp of the message
  2. Run ID and robot_name is appended in the JSON
  3. dc_bridge, which subscribes to this topic directly, receives the data and forwards it to Vector over the shipper ingest protocol
  4. Vector's generated config applies a remap transform that writes the configured time_key in the requested time_format, and routes the Record onto its public dc.dc.measurement.uptime route
  5. The passthrough console sink from uptime_stdout_sink.toml, consuming that route, prints the JSON to stdout — records_log's own file sink writes the same Record to disk in parallel

Group memory and uptime

This demo will introduce the group node. It subscribes to multiple nodes and group for each its data and republishes on a new topic.

Let's run it:

ros2 launch dc_demos group_memory_uptime_stdout.launch.py
[dc_bridge-3] {"date":1788476662.509069,"host":"127.0.0.1","memory":{"flattened":false,"name":"memory","nested":false,"run_id":"169","used":95.77983856201172},"name":"memory_uptime","source_type":"fluent","tag":"dc.group.memory_uptime","tags":[""],"timestamp":"2026-09-03T23:04:22.509068935Z","uptime":{"flattened":false,"name":"uptime","nested":false,"run_id":"169","time":1609047}}
[dc_bridge-3] {"date":1788476665.5074596,"host":"127.0.0.1","memory":{"flattened":false,"name":"memory","nested":false,"run_id":"169","used":95.87796020507812},"name":"memory_uptime","source_type":"fluent","tag":"dc.group.memory_uptime","tags":[""],"timestamp":"2026-09-03T23:04:25.507459531Z","uptime":{"flattened":false,"name":"uptime","nested":false,"run_id":"169","time":1609050}}
[dc_bridge-3] {"date":1788476668.5055394,"host":"127.0.0.1","memory":{"flattened":false,"name":"memory","nested":false,"run_id":"169","used":96.04102325439453},"name":"memory_uptime","source_type":"fluent","tag":"dc.group.memory_uptime","tags":[""],"timestamp":"2026-09-03T23:04:28.505539347Z","uptime":{"flattened":false,"name":"uptime","nested":false,"run_id":"169","time":1609053}}

This launchfile is a wrapper of dc_bringup/launch/dc_bringup.launch.py which loads a custom yaml configuration

Info

Note that here the group node is started. It is one parameter in the launchfile to enable it. In the uptime demo, it is disabled by default because it is not used.

Configuration

Measurement

We collect data from 2 plugins: memory and uptime. The first every second and the latter every 3.

measurement_server:
  ros__parameters:
    measurement_plugins: ["memory", "uptime"]
    memory:
      plugin: "dc_measurements/Memory"
      group_key: "memory"
      topic_output: "/dc/measurement/memory"
      polling_interval: 1000
    uptime:
      plugin: "dc_measurements/Uptime"
      group_key: "uptime"
      topic_output: "/dc/measurement/uptime"
      polling_interval: 3000

Data is now published on 2 ROS topics: /dc/measurement/uptime and /dc/measurement/memory.

The group_key mentioned will be used by the group node to assign a key in the new dictionary

Group

This create a memory_uptime group, subscribes to /dc/measurement/memory and /dc/measurement/uptime topics and republish the result on /dc/group/memory_uptime. The sync_delay allows to wait in a 5 seconds window timeframe the data from each topic before throwing away the data if one topic does not publish it

group_server:
  ros__parameters:
    groups: ["memory_uptime"]
    memory_uptime:
      inputs: ["/dc/measurement/memory", "/dc/measurement/uptime"]
      output: "/dc/group/memory_uptime"
      sync_delay: 5.0
      group_key: "memory_uptime"

include_group_name (Optional, default true): Includes the name of the group in the JSON as a top-level name field — visible in the console output below — which makes it easier later on to fetch the data from your API. Left at its default here.

You can also notice that the group also has a "group_key". It means a group can be part of another.

Destination

Here, we only subscribe to the /dc/group/memory_uptime topic

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["console"]
    console:
      type: console
      receives: records
      inputs: ["/dc/group/memory_uptime"]
      time_key: "date"
      time_format: "double"
    vector_forward_host: "127.0.0.1"
    vector_forward_port: 24224

Console output

In the terminal, you can see the result, published every 3 seconds (see the date field), which the is timeframe defined by sync_delay and the maximum polling_interval of the measurements.

Finally, note the new dictionary uses the key defined in the group_key measurement_server plugin configuration. They are transferred through the ROS message.

[dc_bridge-3] {"date":1788476662.509069,"host":"127.0.0.1","memory":{"flattened":false,"name":"memory","nested":false,"run_id":"169","used":95.77983856201172},"name":"memory_uptime","source_type":"fluent","tag":"dc.group.memory_uptime","tags":[""],"timestamp":"2026-09-03T23:04:22.509068935Z","uptime":{"flattened":false,"name":"uptime","nested":false,"run_id":"169","time":1609047}}
[dc_bridge-3] {"date":1788476665.5074596,"host":"127.0.0.1","memory":{"flattened":false,"name":"memory","nested":false,"run_id":"169","used":95.87796020507812},"name":"memory_uptime","source_type":"fluent","tag":"dc.group.memory_uptime","tags":[""],"timestamp":"2026-09-03T23:04:25.507459531Z","uptime":{"flattened":false,"name":"uptime","nested":false,"run_id":"169","time":1609050}}
[dc_bridge-3] {"date":1788476668.5055394,"host":"127.0.0.1","memory":{"flattened":false,"name":"memory","nested":false,"run_id":"169","used":96.04102325439453},"name":"memory_uptime","source_type":"fluent","tag":"dc.group.memory_uptime","tags":[""],"timestamp":"2026-09-03T23:04:28.505539347Z","uptime":{"flattened":false,"name":"uptime","nested":false,"run_id":"169","time":1609053}}

Turtlebot3

In this example, we add a robot and start collecting robot data to Stdout.

You will also need 2 terminal windows, to:

  1. Run the Nav2 turtlebot3 launchfile: it starts localization, navigation and RViz
  2. Run DC

Since RViz is pretty verbose, using 2 terminal windows will help reading the JSON printed on the terminal window.

Setup the environment

In each, terminal, source your environment and setup turtlebot configuration:

source /opt/ros/rolling/setup.bash
source install/setup.bash

Nothing else has to be exported. Nav2's own nav2_minimal_tb3_sim ships the world, the robot and its ros_gz_bridge config, and puts them on GZ_SIM_RESOURCE_PATH itself — the Gazebo Classic GAZEBO_MODEL_PATH and TURTLEBOT3_MODEL variables are gone along with Classic.

Start Navigation

Then, start the Turtlebot launchfile:

ros2 launch nav2_bringup tb3_simulation_launch.py headless:=False

RViz and Gazebo will start: you should now see the robot in Gazebo, and the map on RViz.

Set the robot position using the "2D Pose Estimate" button.

Info

If any problem occur, please take a look at the nav2 official documentation which covers the case.

Start DC

Execute

ros2 launch dc_demos tb3_simulation_stdout.launch.py

At the end, the data is displayed. Every Destination — console included — goes through the external Vector Shipper (ADR-0002, Destinations), so each line below is Vector's own event object, one bare JSON object per line (not an array):

[dc_bridge-3] {"custom_keys":["robot_name","id"],"date":1788479117.6297202,"flattened":false,"height":384,"host":"127.0.0.1","id":"e110a88ba1c24602bd2c116daf5b8287","local_paths":{"pgm":"/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.pgm","png":"/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.png","yaml":"/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.yaml"},"name":"map","nested":false,"origin":{"x":-10,"y":-10},"resolution":0.05000000074505806,"robot_name":"C3PO","run_id":"170","source_type":"fluent","tag":"dc.measurement.map","timestamp":"2026-09-03T23:45:17.629720410Z","width":384}
[dc_bridge-3] {"cmd_vel":{"angular":{"x":0,"y":0,"z":0.0299867},"computed":0.42023804783821106,"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","linear":{"x":0.420238,"y":0,"z":0},"name":"cmd_vel","nested":false,"robot_name":"C3PO","run_id":"170"},"date":1788479127.1009243,"host":"127.0.0.1","name":"robot","position":{"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","name":"position","nested":false,"robot_name":"C3PO","run_id":"170","x":-1.0834591164924419,"y":0.5133007443427651,"yaw":-0.008814692701341621},"source_type":"fluent","speed":{"angular":{"x":0,"y":0,"z":0.027356281873875128},"computed":0.33021257209388344,"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","linear":{"x":0.33021257209388344,"y":0,"z":0},"name":"speed","nested":false,"robot_name":"C3PO","run_id":"170"},"tag":"dc.group.robot","tags":[""],"timestamp":"2026-09-03T23:45:27.100924197Z"}

Given the JSON is quite large, let's analyze 2 different records:

The first one being the data published on the robot group. Note it's deeply nested now — each of cmd_vel, position and speed carries its own copy of the custom keys (robot_name, id, run_id), since those are applied per-measurement before the group node merges them, not once at the top level:

{
  "cmd_vel": {
    "angular": {
      "x": 0,
      "y": 0,
      "z": 0.0299867
    },
    "computed": 0.42023804783821106,
    "custom_keys": [
      "robot_name",
      "id"
    ],
    "flattened": false,
    "id": "e110a88ba1c24602bd2c116daf5b8287",
    "linear": {
      "x": 0.420238,
      "y": 0,
      "z": 0
    },
    "name": "cmd_vel",
    "nested": false,
    "robot_name": "C3PO",
    "run_id": "170"
  },
  "date": 1788479127.1009243,
  "host": "127.0.0.1",
  "name": "robot",
  "position": {
    "custom_keys": [
      "robot_name",
      "id"
    ],
    "flattened": false,
    "id": "e110a88ba1c24602bd2c116daf5b8287",
    "name": "position",
    "nested": false,
    "robot_name": "C3PO",
    "run_id": "170",
    "x": -1.0834591164924419,
    "y": 0.5133007443427651,
    "yaw": -0.008814692701341621
  },
  "source_type": "fluent",
  "speed": {
    "angular": {
      "x": 0,
      "y": 0,
      "z": 0.027356281873875128
    },
    "computed": 0.33021257209388344,
    "custom_keys": [
      "robot_name",
      "id"
    ],
    "flattened": false,
    "id": "e110a88ba1c24602bd2c116daf5b8287",
    "linear": {
      "x": 0.33021257209388344,
      "y": 0,
      "z": 0
    },
    "name": "speed",
    "nested": false,
    "robot_name": "C3PO",
    "run_id": "170"
  },
  "tag": "dc.group.robot",
  "tags": [
    ""
  ],
  "timestamp": "2026-09-03T23:45:27.100924197Z"
}

This record contains the speed, cmd_vel and position from the group "robot".

{
  "custom_keys": [
    "robot_name",
    "id"
  ],
  "date": 1788479117.6297202,
  "flattened": false,
  "height": 384,
  "host": "127.0.0.1",
  "id": "e110a88ba1c24602bd2c116daf5b8287",
  "local_paths": {
    "pgm": "/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.pgm",
    "png": "/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.png",
    "yaml": "/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.yaml"
  },
  "name": "map",
  "nested": false,
  "origin": {
    "x": -10,
    "y": -10
  },
  "resolution": 0.05000000074505806,
  "robot_name": "C3PO",
  "run_id": "170",
  "source_type": "fluent",
  "tag": "dc.measurement.map",
  "timestamp": "2026-09-03T23:45:17.629720410Z",
  "width": 384
}

This record contains the map data from the measurement. There's no remote_paths key — this demo's dc_bridge block only declares the console Destination below, not a rustfs one, so nothing actually matches map.remote_keys: ["rustfs"] and the Bridge never populates it.

Configuration

Measurement

measurement_server:
  ros__parameters:
    custom_keys_str: ["robot_name"]
    robot_name: "C3PO"
    measurement_plugins: ["cmd_vel", "map", "position", "speed"]
    run_id:
      enabled: true
      counter: true
      counter_path: "$HOME/run_id"
      uuid: false
    save_local_base_path: "$HOME/dc_data/"
    all_base_path: "=robot_name/%Y/%m/%d/%H"
    cmd_vel:
      plugin: "dc_measurements/CmdVel"
      group_key: "cmd_vel"
      enable_validator: true
      topic_output: "/dc/measurement/cmd_vel"
    position:
      plugin: "dc_measurements/Position"
      group_key: "position"
      topic_output: "/dc/measurement/position"
      polling_interval: 1000
      enable_validator: true
      init_collect: true
      global_frame: "map"
      robot_base_frame: "base_link"
      transform_timeout: 0.1
    speed:
      plugin: "dc_measurements/Speed"
      group_key: "speed"
      odom_topic: "/odom"
      topic_output: "/dc/measurement/speed"
    map:
      plugin: "dc_measurements/Map"
      group_key: "map"
      polling_interval: 5000
      save_path: "map/%Y-%m-%dT%H:%M:%S"
      topic_output: "/dc/measurement/map"
      save_map_timeout: 4.0
      remote_prefixes: [""]
      remote_keys: ["rustfs"]

save_local_base_path (Optional): Used as a common base for all saved files from measurement plugins. all_base_path is concatenated to it afterwards for defining the path where files are saved.

all_base_path (Optional): Used as a common base for some measurements to save files. Is concatenated to save_local_base_path. Note the =robot_name, which is later replaced by C3PO (the variable defined in custom_keys_str)

map.remote_keys: creates a dictionary inside remote_paths which is named by the strings in this field — each name must match a receives: files Destination in the dc_bridge block below, so dc_uploader (a separate process from the Bridge, see ADR-0014) knows where to send the file.

Group

group_server:
  ros__parameters:
    groups: ["robot"]
    robot:
      inputs:
        [
          "/dc/measurement/cmd_vel",
          "/dc/measurement/position",
          "/dc/measurement/speed",
        ]
      output: "/dc/group/robot"
      sync_delay: 5.0
      group_key: "robot"
      include_group_name: true

Create a group with data from cmd_vel, position and speed. Even though it appears there is nothing new here, I shall like to precise something important. In the previous demo, we mentioned that if all messages are not received by the group, it will drop it. It matters in this case because cmd_vel is not published all the time in this example (not when it is not moving), this means the data will be collected only when the robot moves (when a controller sends a command).

If you wished to collect the position and the speed constantly, you could take cmd_vel out of this group and add it in the destination.

Destinations

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["console"]
    console:
      type: console
      receives: records
      inputs: ["/dc/group/robot", "/dc/measurement/map"]
      time_key: "date"
      time_format: "double"
    vector_forward_host: "127.0.0.1"
    vector_forward_port: 24224

measurement_server:
  ros__parameters:
    custom_key_str_list: ["robot_name", "id"]
    custom_keys_str:
      robot_name:
        name: robot_name
        value: "C3PO"
      # Requires systemd package
      id:
        name: id
        value_from_file: /etc/machine-id

Nothing new here, we simply edited the console Destination's inputs to ["/dc/group/robot", "/dc/measurement/map"] to get the data from the robot group and the map.

Console output

Now that the node started, let us see what's displayed in the console. Measurement server and dc_bridge are started in the Lifecycle, you can read more about it here.

"Base save path" and "All Base path" are also saved and expanded. Note "=robot_name" has been replaced by C3PO. measurement_server runs composed inside the same process as every other DC node here, so the prefix is the container's, not the node's own name:

[component_container_isolated-1] [INFO] [1788479023.671013064] [measurement_server]: Base save path expanded to /root/dc_data/
[component_container_isolated-1] [INFO] [1788479023.671095239] [measurement_server]: All Base path expanded to C3PO/%Y/%m/%d/%H

Once dc_bridge reports ready (per ADR-0006's bridge_ready_gate), the measurement plugins and the "robot" group start publishing, and we see the data on Vector's console sink:

[dc_bridge-3] {"custom_keys":["robot_name","id"],"date":1788479117.6297202,"flattened":false,"height":384,"host":"127.0.0.1","id":"e110a88ba1c24602bd2c116daf5b8287","local_paths":{"pgm":"/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.pgm","png":"/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.png","yaml":"/root/dc_data/C3PO/2026/09/03/23/map/2026-09-03T23:45:17.yaml"},"name":"map","nested":false,"origin":{"x":-10,"y":-10},"resolution":0.05000000074505806,"robot_name":"C3PO","run_id":"170","source_type":"fluent","tag":"dc.measurement.map","timestamp":"2026-09-03T23:45:17.629720410Z","width":384}
[dc_bridge-3] {"cmd_vel":{"angular":{"x":0,"y":0,"z":0.0131854},"computed":0.17549346387386322,"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","linear":{"x":0.175493,"y":0,"z":0},"name":"cmd_vel","nested":false,"robot_name":"C3PO","run_id":"170"},"date":1788479126.1020856,"host":"127.0.0.1","name":"robot","position":{"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","name":"position","nested":false,"robot_name":"C3PO","run_id":"170","x":-1.1893664880015578,"y":0.5148037648787771,"yaw":-0.02119602699838526},"source_type":"fluent","speed":{"angular":{"x":0,"y":0,"z":0},"computed":0,"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","linear":{"x":0,"y":0,"z":0},"name":"speed","nested":false,"robot_name":"C3PO","run_id":"170"},"tag":"dc.group.robot","tags":[""],"timestamp":"2026-09-03T23:45:26.102085677Z"}
[dc_bridge-3] {"cmd_vel":{"angular":{"x":0,"y":0,"z":0.0299867},"computed":0.42023804783821106,"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","linear":{"x":0.420238,"y":0,"z":0},"name":"cmd_vel","nested":false,"robot_name":"C3PO","run_id":"170"},"date":1788479127.1009243,"host":"127.0.0.1","name":"robot","position":{"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","name":"position","nested":false,"robot_name":"C3PO","run_id":"170","x":-1.0834591164924419,"y":0.5133007443427651,"yaw":-0.008814692701341621},"source_type":"fluent","speed":{"angular":{"x":0,"y":0,"z":0.027356281873875128},"computed":0.33021257209388344,"custom_keys":["robot_name","id"],"flattened":false,"id":"e110a88ba1c24602bd2c116daf5b8287","linear":{"x":0.33021257209388344,"y":0,"z":0},"name":"speed","nested":false,"robot_name":"C3PO","run_id":"170"},"tag":"dc.group.robot","tags":[""],"timestamp":"2026-09-03T23:45:27.100924197Z"}

So...what happened?

  1. The Nav2 turtlebot3 simulation starts, a robot is able to localize and move (once you use the 2-D pose estimate on RViz)
  2. The measurement plugins start publishing data to /dc/measurement/map, /dc/measurement/cmd_vel, /dc/measurement/position and /dc/measurement/speed, which contain the JSON and timestamp of the message
  3. In parallel, each time the map plugin sends a ROS message, it also saves the files on the filesystem. Open a file browser to the path you set in the configuration to a path mentioned in the map JSON
  4. The "robot" group node subscribes to /dc/measurement/cmd_vel, /dc/measurement/position and /dc/measurement/speed and publish on /dc/group/robot when it collects data from all 3 topics
  5. Run ID and robot_name is appended in the JSON of each
  6. dc_bridge, which subscribes to /dc/group/robot and /dc/measurement/map directly, receives the data and forwards it to the external Vector process over the shipper ingest protocol
  7. Vector's generated config applies a remap transform that writes the configured time_key in the requested time_format
  8. Vector's console sink, the only one matching the console Destination we configured, prints the JSON to stdout

Intermediate

The Records leave the terminal: they land in a database, a search index, a bucket or a file, and you go and read them back there. This is the tier where Destinations — blessed and passthrough — are introduced.

Prerequisites: the Beginner tier, plus the one service each demo sends to, started from tools/infrastructure/docker/ as described on its Infrastructure setup page. The two Turtlebot3 demos also need the AWS warehouse world on top of the simulator. Roughly 20 to 30 minutes each, plus a one-off container image pull the first time you bring a stack up.

TitleDescriptionAlso needs
MCAP recordingRecord system data as .mcap via the passthrough Destination and dc_mcap_writer, and open it with ros2 bag info/Foxglove. No robot or simulator neededNo service — just an MCAP viewer (ros2 bag info, Foxglove) to read the result
ElasticsearchSend system data to Elasticsearch via the passthrough Destination, and look at it in Kibana. No robot or simulator neededElasticsearch + Kibana
Turtlebot3 AWS Warehouse RustFS PostgreSQLCollect system, robot, environment and infrastructure data and send it to RustFS and PostgreSQLPostgreSQL + RustFS, and the AWS warehouse world
Turtlebot3 AWS Warehouse InfluxDBCollect system, robot, environment and infrastructure data and send it to InfluxDB via the passthrough DestinationInfluxDB, and the AWS warehouse world

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).

Info

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.

Warning

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

Info

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:

  1. Renders a Vector socket sink (mode = "tcp", encoding.codec = "json", framing.method = "newline_delimited") from inputs, converting each ROS topic to its public dc.<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 whatever dc_bridge.custom_config_files the params file already lists (a second parameters=[...] 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 on launch_ros).
  2. Starts dc_mcap_writer as a plain ExecuteProcess — deliberately not ros2 run dc_mcap_writer dc_mcap_writer: ros2 run spawns its target as a child of its own process and does not forward signals to it, so launch's own respawn/shutdown handling would only ever reach the ros2 run wrapper, leaving the real dc_mcap_writer process running, orphaned, never getting the chance to finish its currently-open .mcap file. dc_mcap_writer has no rclpy/ROS-node dependency of its own, so nothing here actually needs ros2 run's node-launching machinery — a sourced workspace already puts it on PYTHONPATH, so python3 -m dc_mcap_writer.cli runs 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.

Elasticsearch (passthrough)

dc_bridge blesses exactly five Destination types — postgres, s3, file, console, vector (see Destinations) — and Elasticsearch is not one of them. This tutorial is the worked example for reaching everything else: the ADR-0003 passthrough, a raw Vector config snippet loaded through custom_config_files that consumes the same public dc.<tag> routes a blessed Destination consumes. Nothing in dc_bridge knows what Elasticsearch is, and no DC code was written to support it.

Elasticsearch is the example because it is the one most often asked for, but the shape generalises: swap the sink type and you have Kafka, Loki, ClickHouse, Datadog, or anything else in Vector's catalog. The InfluxDB demo is the same mechanism against a Turtlebot3 simulation; this page needs no simulator and no robot — four system Measurements, one compose file, one terminal.

Setup the infrastructure

Start Elasticsearch and Kibana by following the steps:

podman compose -f tools/infrastructure/docker/docker-compose.elasticsearch.yaml up -d

Confirm the cluster is up before launching DC — the sink retries a store that isn't there yet, but a green cluster makes the first Record land immediately:

curl -s http://localhost:9200/_cluster/health

One-time: install the passthrough sink config

Because there is no Elasticsearch Destination to configure through ROS parameters, the Vector sink itself ships as a plain file in this package, dc_demos/config/elasticsearch_sink.toml, installed to the package's share directory. Copy it into place once before the first launch:

mkdir -p ~/.dc
cp "$(ros2 pkg prefix dc_demos)/share/dc_demos/config/elasticsearch_sink.toml" ~/.dc/

The params file's custom_config_files points at that path. Editing the copy in ~/.dc/ is how you change the sink — it is read at Bridge startup, not compiled in.

Run it

colcon build
ros2 launch dc_demos elasticsearch.launch.py

The file Destination writes every Record as it is shipped to /tmp/dc/elasticsearch_records.ndjson, so tail -f on that path doubles as a local view of what Elasticsearch is receiving:

{"cpu":{"average":0,"processes":9,"sorted":[]},"custom_keys":["robot_name"],"date":1788504548.4015868,"flattened":false,"host":"127.0.0.1","name":"cpu","nested":true,"robot_name":"C3PO","run_id":"170","source_type":"fluent","tag":"dc.measurement.cpu","timestamp":"2026-09-04T06:49:08.401586806Z"}
{"custom_keys":["robot_name"],"date":1788504548.404685,"flattened":false,"host":"127.0.0.1","memory":{"used":96.89765167236328},"name":"memory","nested":true,"robot_name":"C3PO","run_id":"170","source_type":"fluent","tag":"dc.measurement.memory","timestamp":"2026-09-04T06:49:08.404685157Z"}
{"custom_keys":["robot_name"],"date":1788504548.4057963,"flattened":false,"host":"127.0.0.1","name":"uptime","nested":true,"robot_name":"C3PO","run_id":"170","source_type":"fluent","tag":"dc.measurement.uptime","timestamp":"2026-09-04T06:49:08.405796359Z","uptime":{"time":1636933}}

Visualize the data

With curl

The sink writes one index per UTC day, so dc-records-* is every day's data:

curl -s 'http://localhost:9200/dc-records-*/_count'
{"count":25,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0}}

Count the documents per Measurement — cpu, memory and uptime poll every 5 seconds, while os is configured to collect once (init_max_measurements: 1), which is exactly what shows up:

curl -s -H 'Content-Type: application/json' 'http://localhost:9200/dc-records-*/_search' \
  -d '{"size":0,"aggs":{"by_name":{"terms":{"field":"name.keyword"}}}}'
"buckets": [
  { "key": "cpu",    "doc_count": 8 },
  { "key": "memory", "doc_count": 8 },
  { "key": "uptime", "doc_count": 8 },
  { "key": "os",     "doc_count": 1 }
]

And one document in full:

curl -s -H 'Content-Type: application/json' 'http://localhost:9200/dc-records-*/_search' \
  -d '{"size":1,"query":{"term":{"name.keyword":"os"}}}'
{
  "@timestamp": "2026-08-07T15:47:09Z",
  "custom_keys": ["robot_name"],
  "date": 1786117629.0,
  "flattened": false,
  "host": "127.0.0.1",
  "name": "os",
  "nested": true,
  "os": {
    "cpus": 8,
    "kernel": "6.12.96+deb13-amd64",
    "memory": 23.23,
    "os": "Ubuntu 24.04.4 LTS"
  },
  "robot_name": "C3PO",
  "run_id": "1",
  "source_type": "fluent",
  "tag": "dc.measurement.os",
  "timestamp": "2026-08-07T15:47:09Z"
}

The Record's own fields (os.*, date, robot_name, run_id) are joined by four the Shipper adds: tag — the Tag the Record was routed under, useful as a filter — plus host, source_type and timestamp from Vector's ingest source. @timestamp is added by the snippet; see below.

With Kibana

Open http://localhost:5601, then Stack Management → Data Views → Create data view:

FieldValue
Namedc
Index patterndc-records-*
Timestamp field@timestamp

Discover then shows Records as they arrive, and cpu.average, memory.used and uptime.time are all numeric fields ready to chart in Lens — no mapping to declare, because Elasticsearch inferred it from the first document of each index.

Understanding the configuration

Info

The full configuration file can be found here, and the passthrough sink config here.

Measurements

Four system Measurements, none of which need hardware, a simulator, or a running robot:

  1. CPU
  2. Memory
  3. OS
  4. Uptime
measurement_server:
  ros__parameters:
    measurement_plugins: ["cpu", "memory", "os", "uptime"]
    cpu:
      plugin: "dc_measurements/Cpu"
      topic_output: "/dc/measurement/cpu"
      polling_interval: 5000
      include_measurement_name: true
      init_collect: true
      max_processes: 5
      cpu_min: 5.0
      nested: true
      flatten: false

The one destination-specific choice here is flatten: false. With nested: true the Measurement wraps its values under its own key; flatten then decides whether that structure is collapsed into keys like /cpu/average (what the InfluxDB and PostgreSQL demos use, because a line-protocol or column-shaped store wants flat fields) or left as real nested JSON. Elasticsearch stores JSON natively and maps a nested object to dotted field names on its own, so leaving it nested gives you cpu.average — a field Kibana can aggregate — instead of a literal field named /cpu/average, which every KQL query would have to escape.

Destination: what actually creates the routes

This is the part of the passthrough that surprises people:

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs:
        [
          "/dc/measurement/cpu",
          "/dc/measurement/memory",
          "/dc/measurement/os",
          "/dc/measurement/uptime",
        ]
      path: "/tmp/dc/elasticsearch_records.ndjson"
      time_key: "date"
      time_format: "double"
    custom_config_files: ["$HOME/.dc/elasticsearch_sink.toml"]
    vector_forward_host: "127.0.0.1"
    vector_forward_port: 24224

destinations still names a blessed Destination, and it is not decoration. dc_bridge derives two things from destinations and nothing else:

  • which ROS topics it subscribes to, and
  • which dc.<tag> route branches exist in the Vector config it renders.

It never reads a passthrough snippet's inputs. So a topic that appears only in the snippet is a topic the Bridge never subscribes to and never routes — the snippet's inputs would resolve to routes that don't exist. Every topic you want in Elasticsearch must therefore appear in some blessed Destination's inputs too.

file is the anchor here — it needs no infrastructure and no credentials, and writes path to disk rather than your terminal. An earlier version of this demo used a blessed console Destination for the same job (console also gave you a local view of what was landing in Elasticsearch), but per ADR-0003 console — along with postgres and s3 — has since moved to the passthrough recipe itself, being a pure Vector-sink wrapper with no DC-specific logic; see Destinations: Recipes for that recipe if you still want a terminal echo alongside Elasticsearch.

Warning

destinations: [] does not work. rclcpp cannot load an empty YAML sequence (it has no inferable element type), so the Bridge dies at startup with parameter_value_from failed for parameter 'destinations': No parameter value set and never gets as far as reading custom_config_files. A passthrough always accompanies at least one blessed Destination.

The passthrough snippet

[transforms.elasticsearch_prepare]
type = "remap"
inputs = [
  "dc.dc.measurement.cpu",
  "dc.dc.measurement.memory",
  "dc.dc.measurement.os",
  "dc.dc.measurement.uptime",
]
source = '''
."@timestamp" = .timestamp

.doc_id = sha2(
  to_string!(.tag) + "|" + to_string!(.date) + "|" + to_string!(.run_id),
  variant: "SHA-256"
)
'''

[sinks.elasticsearch]
type = "elasticsearch"
inputs = ["elasticsearch_prepare"]
endpoints = ["http://127.0.0.1:9200"]
api_version = "v8"
mode = "bulk"
bulk.index = "dc-records-%Y.%m.%d"
bulk.action = "index"
id_key = "doc_id"

[sinks.elasticsearch.buffer]
type = "disk"
max_size = 268435488

Each inputs entry is one of the stable dc.<tag> routes. The Tag is the topic name with the leading / dropped and the remaining / turned into . (/dc/measurement/cpu → dc.measurement.cpu), and the route is dc.<tag> — so dc.dc.measurement.cpu. These names are public API; see Destinations.

Note that a snippet is not limited to sinks. elasticsearch_prepare is a transform, and it is merged into the topology like any other component — the only rule is that a snippet must not define a component id the generated config owns (dc_bridge_in, dc_bridge_normalize, dc, or a configured Destination's name). Consuming those ids is fine; redefining one is a loud Bridge startup error naming the file.

Four things in there are worth explaining, because three of them are what separate a snippet that works from a snippet that works and survives contact with a real deployment:

."@timestamp" = .timestamp — Elasticsearch's convention for a document's time field is @timestamp, and Kibana offers it as the default when you create a data view. Vector calls it timestamp. This copies it across. Copy, not move: bulk.index is a strftime template rendered from the event's own timestamp, and deleting that field makes every document fail to render an index name and get dropped.

bulk.index = "dc-records-%Y.%m.%d" — one index per UTC day, the usual shape for time-series data. dc-records-* is then a single Kibana data view, and expiring old data is deleting whole indices rather than running delete-by-query.

id_key + a deterministic doc_id — the Shipper is at-least-once (ADR-0002). After an outage, Records that were in flight when it started are re-sent on recovery, and with Elasticsearch's default auto-generated _id every re-send becomes a new document. Hashing (Tag, Record timestamp, run id) into a stable id makes the write an upsert instead, so a re-delivery overwrites its earlier copy. Vector moves the doc_id field into the document's _id rather than storing it in the body.

The disk buffer — dc_bridge gives every blessed sink a disk buffer, but a passthrough sink gets Vector's default, which is in-memory and 500 events deep. That is not a correctness problem (see below), but a shallow buffer starts applying backpressure within seconds of a store going away, which is what turns a short outage into a large burst of re-deliveries. max_size is in bytes and Vector rejects anything below ~256 MiB; the buffer lives under the Bridge's own shipper.data_dir.

What happens when Elasticsearch goes away

Worth doing once, because it is the question every passthrough raises — the Bridge's durability guarantees are documented for blessed Destinations, and it is reasonable to wonder whether a sink DC knows nothing about still gets them.

Leave DC running and stop the store for two minutes:

podman stop elasticsearch
# ...wait...
podman start elasticsearch

Vector logs the failure and retries with exponential backoff, so the terminal fills with:

WARN sink{component_id=elasticsearch component_type=elasticsearch}: vector::sinks::util::retries: Retrying after error. error=Failed to make HTTP(S) request: error trying to connect: tcp connect error: Connection refused (os error 111)
WARN sink{component_id=elasticsearch component_type=elasticsearch}: vector::sinks::util::service::health: Endpoint is unhealthy. endpoint=http://127.0.0.1:9200

uptime.time increases by exactly 5 every Record, which makes gaps and duplicates countable. Across a 2-minute outage, with the sink above, all 110 Records spanning the outage arrive: none missing, none duplicated.

Two things produced that result, and both are choices in the snippet rather than anything DC does for you:

  • No loss comes from the Shipper's end-to-end acknowledgements. dc_bridge holds a Record until Vector confirms it, so a full buffer means the Bridge retries rather than drops — this part you get for free, disk buffer or not.
  • No duplicates comes from the deterministic _id. Running the same outage with Vector's default in-memory buffer and Elasticsearch's auto-generated ids also loses nothing, but indexes each Record up to a dozen times: backpressure hits within seconds, the Bridge re-sends unacknowledged Records, and every re-send lands as its own document.

Info

Recovery is not instant. Vector's retry backoff grows to roughly a minute between attempts, so after a long outage the backlog starts draining up to a minute after the store comes back, then catches up quickly.

Adapting it

  • Another store. Replace [sinks.elasticsearch] with any other type from Vector's sink catalog. The inputs, the blessed-Destination requirement, and the at-least-once caveat are the same; only the sink block changes.
  • A real robot's data. Add the topics to both the blessed Destination's inputs and the snippet's. The Turtlebot3 AWS Warehouse params file is a good source of Measurement configuration to copy from.
  • A secured cluster. Add an auth block to the sink — there is a commented example at the bottom of elasticsearch_sink.toml. Vector expands $VAR references in its own config at startup, so the password can come from the environment.

Cleanup

podman compose -f tools/infrastructure/docker/docker-compose.elasticsearch.yaml down -v

Turtlebot3

In this example, we add a robot and start collecting robot data to Stdout.

You will also need 3 terminal windows, to:

  1. Run the Nav2 turtlebot3 launchfile: it starts localization, navigation and RViz
  2. Run navigation inspection demo
  3. Run DC

Using a different terminal window for DC helps reading its information.

Setup the environment

Python dependencies

For this tutorial, we will need to install all dependencies, the demo dashboard's included (uv owns them; see Setup):

uv sync

Setup Infrastructure

RustFS

RustFS will be used as storage for images and other files. To start it, follow the steps — a single RustFS container plus a one-shot container that bootstraps the dc-files bucket, using the default rustfsadmin/rustfsadmin credentials this demo's params file already assumes. No manual bucket or key setup is needed, unlike the old 4-node MinIO cluster + nginx console this replaces.

PostgreSQL

PostgreSQL will be used as database storage for our JSON. Later on, backend engineers can make requests on those JSON based on measurement requested and time range. To start it, follow the steps

The default yaml configuration file does not need change as it also uses default values.

Info

tools/infrastructure/docker/config/postgresql/init.sql pre-creates the dc and dc_files tables Vector's postgres sink writes into — it maps each Record's top-level JSON keys onto existing columns of the same name rather than creating them itself. See the file's header comment for the full column reference.

Setup simulation environment

In the terminal 1, source your environment:

source /opt/ros/rolling/setup.bash
source install/setup.bash

Nothing else has to be exported. dc_simulation puts its own models and worlds on GZ_SIM_RESOURCE_PATH through its environment hook.

Info

This demo used to run aws_robomaker_small_warehouse_world under Gazebo Classic. That package has no Jazzy release — its own jazzy branch still hard-depends on gazebo_ros, which was never published for Jazzy. The warehouse now comes from dc_simulation instead, which vendors the same AWS RoboMaker props (shelves, clutter, trash cans) into a gz-sim world of its own.

Terminal 1: Start Navigation

Then, in the same terminal (1), start the simulation and Nav2. DC comes up separately in terminal 2, so turn it off here:

ros2 launch dc_demos tb3_qrcodes.launch.py \
    headless:=False \
    use_dc:=False

RViz and gz-sim will start: now you see the robot in the warehouse, and the map on RViz. AMCL sets the initial pose itself, so there is no need to click "2D Pose Estimate".

Warning

The warehouse world is heavy — 317 model instances. Expect a slow start and a real-time factor well under 1 without a GPU; see dc_simulation/README.md for measured numbers.

RViz Gazebo

Terminal 2: Start DC

Run colcon build to compile the workspace:

colcon build

Now, start the demo:

ros2 launch dc_demos tb3_simulation_pgsql_minio.launch.py use_sim_time:=True

Warning

use_sim_time defaults to False — without the override above, DC's Position measurement looks up TF transforms on the wall clock while the simulation publishes them on Gazebo's sim clock, which throws "extrapolation into the past"/"transform does not exist" errors and can crash measurement_server outright (std::overflow_error in tf2::Duration) once the two clocks drift far enough apart.

The robot will start collecting data.

Terminal 3: Start autonomous navigation

Execute

ros2 run nav2_simple_commander demo_security

The robot will start moving and you will be able to see all visualizations activated in RViz:

RViz-moving

Visualize the data

In the database

Navigate to localhost:8080

  1. Select dc database
  2. Select dc table
  3. Click on Select data

You will see rows filling the database. You can click on one to see its content:

Adminer-gif

With Grafana

Four Grafana dashboards ship with the DC 2.0 infrastructure now — Home, Robot, KPI and Fast DDS statistics (the System/Environment/Infrastructure dashboards were dropped as out of scope for this minimal rework). Open http://localhost:3000 (admin/admin) and pick the Robot dashboard: its panels are backed by SQL queries against the dc/dc_files PostgreSQL tables — the Grafana datasource is PostgreSQL (uid dc_postgres) now, not InfluxDB. It shows, among other things, speed/command velocity over time and a table of uploaded inspection files with their RustFS + PostgreSQL upload status:

SELECT to_timestamp(updated_at) AS "time", group_name, robot_name, storage_type, remote_path, content_type, size, uploaded
FROM dc_files WHERE kind = 'file_status' ORDER BY updated_at DESC LIMIT 100

See tools/infrastructure/docker/config/grafana/dashboards/robot.json for every panel's exact query.

The KPI dashboard is the operations view of the same data: availability and uptime per robot over whatever time range the dashboard is set to, computed by the KPI views rather than by any panel query of its own. It populates from this demo's uptime Measurement with no extra configuration.

That's it! Now you can collect your data!

Understanding the configuration

Info

The full configuration file can be found here.

Measurement server

Measurements

measurement_plugins sets which plugin to load. We collect

System measurements:

  1. CPU
  2. OS
  3. Memory
  4. Uptime

Robot measurements:

  1. Camera images
  2. Command velocities
  3. Distance traveled
  4. Driving type
  5. Positions
  6. Speed

Environment measurements:

  1. Map

Infrastructure measurements:

  1. RustFS health
  2. PostgreSQL health

Each has their own configuration: polling interval, source topic, destination paths, topics used as input etc. Going through each of them would be too long here but you can check for each measurement its documentation and the general documentation of measurements

Info

The old MinIO setup needed two separate TCP health checks — minio_api_health (port 9000) and minio_dashboard_health (port 9001) — because it ran a 4-node MinIO cluster fronted by an nginx console. This demo's single RustFS container exposes only its API port, so it collapses to one rustfs_health check on port 9000; there is no separate console port to probe here.

measurement_server:
  ros__parameters:
    ...
    camera:
      plugin: "dc_measurements/Camera"
      cam_topic: "/intel_realsense_r200_depth/image_raw"
      cam_name: "Intel Realsense"
      save_raw_img: true
      save_raw_path: "camera/raw/%Y-%m-%dT%H-%M-%S"
      remote_prefixes: [""]
      remote_keys: ["rustfs"]
      ...
    map:
      plugin: "dc_measurements/Map"
      save_path: "map/%Y-%m-%dT%H-%M-%S"
      topic_output: "/dc/measurement/map"
      remote_prefixes: [""]
      remote_keys: ["rustfs"]
      ...
    rustfs_health:
      plugin: "dc_measurements/TCPHealth"
      topic_output: "/dc/measurement/rustfs_health"
      polling_interval: 5000
      host: "127.0.0.1"
      port: 9000
      name: "RustFS"
      include_measurement_plugin: true
    pgsql_health:
      plugin: "dc_measurements/TCPHealth"
      topic_output: "/dc/measurement/pgsql_health"
      polling_interval: 5000
      host: "127.0.0.1"
      port: 5432
      name: "PostgreSQL"
      include_measurement_plugin: true

Conditions

We also initialize conditions:

  1. min_distance_traveled
  2. max_distance_traveled

They are used in the distance traveled measurement to only take values in a certain range.

Destination server

Here we enable the records_log and rustfs Destinations, plus a passthrough for PostgreSQL:

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["records_log", "rustfs"]
    records_log:
      type: file
      receives: records
      inputs: [
          # System
          "/dc/measurement/cpu",
          "/dc/measurement/memory",
          "/dc/measurement/os",
          "/dc/measurement/uptime",
          # Robot
          "/dc/measurement/camera",
          "/dc/measurement/cmd_vel",
          "/dc/measurement/distance_traveled",
          "/dc/measurement/driving_type",
          "/dc/measurement/position",
          "/dc/measurement/speed",
          # Environment
          "/dc/measurement/map",
          # Infrastructure
          "/dc/measurement/rustfs_health",
          "/dc/measurement/pgsql_health",
        ]
      path: "/tmp/dc/tb3_simulation_pgsql_minio_records.ndjson"
      time_key: "date"
    rustfs:
      type: s3
      receives: files
      inputs: ["/dc/measurement/map", "/dc/measurement/camera"]
      bucket: "dc-files"
      endpoint: "http://127.0.0.1:9000"
      region: "us-east-1"
      access_key_id: "rustfsadmin"
      secret_access_key: "rustfsadmin"
      force_path_style: true
    files:
      delete_when_sent: true
      metadata_destination: "records_log"
    custom_config_files: ["$HOME/.dc/tb3_simulation_pgsql_minio_sink.toml"]
# ~/.dc/tb3_simulation_pgsql_minio_sink.toml
[sinks.pgsql]
type = "postgres"
inputs = [
  "dc.dc.measurement.cpu", "dc.dc.measurement.memory", "dc.dc.measurement.os",
  "dc.dc.measurement.uptime", "dc.dc.measurement.camera", "dc.dc.measurement.cmd_vel",
  "dc.dc.measurement.distance_traveled", "dc.dc.measurement.driving_type",
  "dc.dc.measurement.position", "dc.dc.measurement.speed", "dc.dc.measurement.map",
  "dc.dc.measurement.rustfs_health", "dc.dc.measurement.pgsql_health",
]
endpoint = "postgres://dc:password@127.0.0.1:5432/dc"
table = "dc"

[sinks.pgsql.buffer]
type = "disk"
max_size = 268435488

[sinks.pgsql_files]
type = "postgres"
inputs = ["dc.dc.files"]
endpoint = "postgres://dc:password@127.0.0.1:5432/dc"
table = "dc_files"

[sinks.pgsql_files.buffer]
type = "disk"
max_size = 268435488

Copy the snippet into place before launching:

mkdir -p ~/.dc && cp "$(ros2 pkg prefix dc_demos)/share/dc_demos/config/tb3_simulation_pgsql_minio_sink.toml" ~/.dc/

PostgreSQL Destinations

PostgreSQL is reached through the ADR-0003 passthrough now, not a blessed postgres Destination — see Destinations: Recipes. The passthrough pgsql sink carries almost every measurement and the two infrastructure health checks as plain Records. Note that not all data needs to go to PostgreSQL — only topics whose dc.<tag> route exists (i.e. listed in records_log's inputs) reach it.

pgsql_files is a second, dedicated postgres sink in the same passthrough snippet for dc_uploader's status Records — it has no ROS topic inputs of its own; it consumes the dc.dc.files route instead, which records_log gains from being named as files.metadata_destination (that parameter must name a configured receives: records Destination, so it can't point at a passthrough-only sink id directly). See Destinations, ADR-0005 and ADR-0014 for the full split, and the QR codes demo for a worked example of the same pattern.

RustFS Destination

We list only map and camera in rustfs's inputs since those are the only measurements referencing Files. rustfs's type: s3 and receives: files mark it as owned by dc_uploader — a separate process from dc_bridge (ADR-0014) — rather than a Vector sink target: it uploads whatever File the measurement's remote_keys: ["rustfs"] pointed at it, verifies the object landed, and (with files.delete_when_sent: true) deletes the local copy only once that's confirmed. Unlike PostgreSQL, rustfs stays a blessed Destination: receives: files is served entirely by dc_uploader reading these same ROS params, never by a Vector sink, so there is no passthrough equivalent for it to migrate to.

Turtlebot3 AWS Warehouse InfluxDB

dc_bridge blesses exactly five Destination types — postgres, s3, file, console, vector (see Destinations) — and InfluxDB is not one of them. This demo is not a peer of the PostgreSQL/RustFS demos: it exists to show how to reach a destination dc_bridge doesn't bless directly, via the ADR-0003 passthrough escape hatch — a raw Vector sink config loaded through custom_config_files, consuming the same public dc.<tag> routes a blessed Destination would. Read Destinations's "Passthrough" section first if you haven't already; this page only covers what's specific to InfluxDB.

You will also need 3 terminal windows, to:

  1. Run the Nav2 turtlebot3 launchfile: it starts localization, navigation and RViz
  2. Run navigation inspection demo
  3. Run DC

Using a different terminal window for DC helps reading its information.

Setup the environment

Python dependencies

For this tutorial, we will need to install all dependencies, the demo dashboard's included (uv owns them; see Setup):

uv sync

Setup the infrastructure

InfluxDB

InfluxDB will be used to store our data and timestamps. To start it, follow the steps (tools/infrastructure/docker/docker-compose.influxdb.yaml, unchanged by the DC 2.0 rework — this demo still needs a real InfluxDB instance to point the passthrough sink at).

One-time: install the passthrough sink config

dc_bridge has no InfluxDB Destination to configure through ROS parameters, so the Vector sink itself ships as a plain file in this package, dc_demos/config/tb3_simulation_influxdb_sink.toml, installed to the package's share directory. Copy it into place once before the first launch:

mkdir -p ~/.dc
cp "$(ros2 pkg prefix dc_demos)/share/dc_demos/config/tb3_simulation_influxdb_sink.toml" ~/.dc/

dc_params_file's custom_config_files (see below) points at this path.

Setup simulation environment

In the terminal 1, source your environment:

source /opt/ros/rolling/setup.bash
source install/setup.bash

Nothing else has to be exported. dc_simulation puts its own models and worlds on GZ_SIM_RESOURCE_PATH through its environment hook.

Info

This demo used to run aws_robomaker_small_warehouse_world under Gazebo Classic. That package has no Jazzy release — its own jazzy branch still hard-depends on gazebo_ros, which was never published for Jazzy. The warehouse now comes from dc_simulation instead, which vendors the same AWS RoboMaker props (shelves, clutter, trash cans) into a gz-sim world of its own.

Terminal 1: Start Navigation

Then, in the same terminal (1), start the simulation and Nav2. DC comes up separately in terminal 2, so turn it off here:

ros2 launch dc_demos tb3_qrcodes.launch.py \
    headless:=False \
    use_dc:=False

RViz and gz-sim will start: now you see the robot in the warehouse, and the map on RViz. AMCL sets the initial pose itself, so there is no need to click "2D Pose Estimate".

Warning

The warehouse world is heavy — 317 model instances. Expect a slow start and a real-time factor well under 1 without a GPU; see dc_simulation/README.md for measured numbers.

RViz Gazebo

Terminal 2: Start DC

Run colcon build to compile the workspace:

colcon build

Now, start the demo:

ros2 launch dc_demos tb3_simulation_influxdb.launch.py use_sim_time:=True

Warning

use_sim_time defaults to False — without the override above, DC's Position measurement looks up TF transforms on the wall clock while the simulation publishes them on Gazebo's sim clock, which throws "extrapolation into the past"/"transform does not exist" errors and can crash measurement_server outright once the two clocks drift far enough apart.

The robot will start collecting data.

Known issue: a field can silently stop landing

InfluxDB 1.x's line protocol locks a field's type from its first write for the life of the database. cpu.average is 0 (a bare JSON integer, not 0.0) on its very first sample — before the plugin has two polls to compute a delta from — so Vector's influxdb_logs sink writes it as an integer; every later float sample then fails with field type conflict: ... is type float, already exists as type integer and is silently dropped, forever, for that field in that database. Verified: after a full demo run, SELECT count("/cpu/average") FROM dc returns nothing at all. This isn't DC-specific — it's InfluxDB 1.x line protocol meeting JSON's 0/0.0 ambiguity — and the only fix is dropping and recreating the dc database (influx -execute "DROP DATABASE dc; CREATE DATABASE dc") before a run where you need that field.

Terminal 3: Start autonomous navigation

Execute

ros2 run nav2_simple_commander demo_security

The robot will start moving and you will be able to see all visualizations activated in RViz:

RViz-moving

Visualize the data

Grafana's own datasource is PostgreSQL now (see PostgreSQL/RustFS demo), and every dashboard shipped with the DC 2.0 infrastructure — Home, Robot, KPI and Fast DDS statistics — is backed by it: there is no Grafana dashboard for this demo's data, since it never touches PostgreSQL. To look at what landed in InfluxDB, use InfluxDB's own tooling instead:

influx -database dc -execute "SELECT * FROM dc ORDER BY time DESC LIMIT 20"

or query its HTTP API directly:

curl -G 'http://127.0.0.1:8086/query' --data-urlencode "db=dc" --data-urlencode "q=SELECT * FROM dc ORDER BY time DESC LIMIT 20"

Understanding the configuration

Info

The full configuration file can be found here, and the passthrough sink config here.

Measurement server

Measurements

measurement_plugins sets which plugin to load. We collect

System measurements:

  1. CPU
  2. OS
  3. Memory
  4. Uptime

Robot measurements:

  1. Camera images
  2. Command velocities
  3. Distance traveled
  4. Positions
  5. Speed

Environment measurements:

  1. Map

Infrastructure measurements:

  1. InfluxDB health, a TCPHealth check against InfluxDB's own port (8086) — unrelated to the passthrough mechanism, this is the same kind of infrastructure health-check measurement the PostgreSQL/RustFS demo uses for its own destinations.

None of this changed from the destination-agnostic measurement configuration used elsewhere in DC 2.0 — nested/flatten still shape the JSON for InfluxDB's line-protocol-oriented storage, and images (map, camera) are still stored as base64 strings since that's the only field type Grafana (or any consumer reading straight out of InfluxDB) can render from a database column. What changed is only how the Records reach InfluxDB in the first place — see the Destination section below.

measurement_server:
  ros__parameters:
    ...
    camera:
      plugin: "dc_measurements/Camera"
      topic_output: "/dc/measurement/camera"
      save_raw_base64: true
      nested: true
      flatten: true
      ...
    influxdb_health:
      plugin: "dc_measurements/TCPHealth"
      topic_output: "/dc/measurement/influxdb_health"
      polling_interval: 5000
      host: "127.0.0.1"
      port: 8086
      name: "InfluxDB"
      include_measurement_plugin: true
      nested: true
      flatten: true

An example camera Record, now without a tags field (that mechanism no longer exists — a Destination's inputs list decides routing instead). nested: true + flatten: true together produce /-prefixed dotted-path keys such as /camera/camera_name, which is also exactly what lands as the InfluxDB column name, since nothing downstream renames them:

{
  "/camera/base64/raw": "/9j/4AAQSkZJRgABAQAA...",
  "/camera/camera_name": "Intel Realsense",
  "custom_keys": ["robot_name", "id"],
  "date": 1677668926.700422,
  "flattened": true,
  "host": "127.0.0.1",
  "id": "be781e5ffb1e7ee4f817fe7b63e92c32",
  "name": "camera",
  "nested": true,
  "robot_name": "Turtlebot",
  "run_id": "218",
  "source_type": "fluent",
  "tag": "dc.measurement.camera",
  "timestamp": "2026-09-04T00:00:40.252Z"
}

Conditions

We also initialize conditions:

  1. min_distance_traveled
  2. max_distance_traveled

They are used in the distance traveled measurement to only take values in a certain range.

Destination: the passthrough

There is no InfluxDB Destination to bless, but destinations is not empty — it names a file Destination whose inputs are what put these topics on the dc.<tag> routes the snippet consumes:

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/cpu", "/dc/measurement/memory", ...]
      path: "/tmp/dc/tb3_simulation_influxdb_records.ndjson"
      time_key: "date"
      time_format: "double"
    custom_config_files: ["$HOME/.dc/tb3_simulation_influxdb_sink.toml"]
    vector_forward_host: "127.0.0.1"
    vector_forward_port: 24224

dc_bridge derives both its ROS subscriptions and its dc.<tag> route branches from destinations, and never reads a passthrough snippet's inputs — so a passthrough always accompanies at least one blessed Destination covering the same topics. file is the anchor in every passthrough demo in this repo now, including this one — blessed console moved to a passthrough recipe of its own (ADR-0003, Destinations: Recipes) — but it would have been the wrong anchor for this demo regardless, even back when it was still blessed: this demo collects base64 camera and map images, which make for unreadable terminal output. The Elasticsearch tutorial explains the file anchor mechanics in more detail.

Warning

destinations: [] does not work as a way to say "passthrough only". rclcpp cannot load an empty YAML sequence (no inferable element type), so the Bridge dies at startup with parameter_value_from failed for parameter 'destinations': No parameter value set and never reads custom_config_files at all.

custom_config_files lists raw Vector config snippets that are merged as-is alongside whatever dc_bridge itself renders (here, nothing) — see Destinations's passthrough section for the full contract (naming collisions, vector validate as a startup backstop, etc.). The snippet installed above:

# ~/.dc/tb3_simulation_influxdb_sink.toml
[sinks.influxdb]
type = "influxdb_logs"
inputs = [
  "dc.dc.measurement.cpu",
  "dc.dc.measurement.memory",
  "dc.dc.measurement.os",
  "dc.dc.measurement.uptime",
  "dc.dc.measurement.camera",
  "dc.dc.measurement.cmd_vel",
  "dc.dc.measurement.distance_traveled",
  "dc.dc.measurement.position",
  "dc.dc.measurement.speed",
  "dc.dc.measurement.map",
  "dc.dc.measurement.influxdb_health",
]
endpoint = "http://127.0.0.1:8086"
measurement = "dc"

[sinks.influxdb.influxdb1_settings]
database = "dc"
username = "dc"

Each inputs entry is one of the stable dc.<tag> routes dc_bridge exposes for every topic that appears in any Destination's inputs or, as here, any custom_config_files snippet's inputs — the Tag is the topic name with the leading / dropped and the rest of the /s turned into . (/dc/measurement/cpu → dc.measurement.cpu), and the route is dc.<tag> (so dc.dc.measurement.cpu). Vector's influxdb_logs sink type is what actually understands InfluxDB 1.x's write API; nothing in this snippet is DC-specific beyond the dc.<tag> inputs.

Warning

Set real InfluxDB credentials (or an auth token, depending on your InfluxDB version) in the snippet before pointing this at anything but the demo's own local, unauthenticated-by-default instance.

Advanced

Either you run the whole inspection pipeline at once — Measurements, Conditions, Groups, File uploads and the Grafana dashboards reading them back — or you leave YAML behind and write DC code yourself.

Prerequisites: the Intermediate tier, plus several services running together for the QR codes pipeline, and a C++ toolchain for the custom plugin. Roughly 45 minutes for the QR codes pipeline, 30 for the custom plugin including the rebuild.

TitleDescriptionAlso needs
Turtlebot3 QR codesCollect QR codes and images, upload them as Files and read them back in GrafanaPostgreSQL, RustFS and Grafana running together, plus the simulator
Fast DDS statisticsCollect Fast DDS's own network statistics and read them back in Grafana. No robot or simulator neededPostgreSQL and Grafana running together, plus Fast DDS built with its Statistics Module enabled
Custom pluginCreate an external pluginNo service — a colcon build of your own plugin package

Note that each demo assumes concepts explained in previous demos will be acknowledged.

QRCodes

In this example, we add a robot and start collecting robot data to PostgreSQL, and maps and scanned QR codes to RustFS as image files.

You will also need 3 terminal windows, to:

  1. Run the simulation + Nav2 launchfile: it starts gz-sim, localization, navigation and RViz
  2. Run DC
  3. Drive the robot past the QR codes

Keeping them separate helps reading the JSON printed on the DC terminal, which RViz and gz-sim would otherwise drown out.

TerminalDescription
Nav2gz-sim, localization, navigation and RViz
DCData collection
RunWaypoint follower driving past every QR-coded pallet

Setup RustFS and PostgreSQL

RustFS

RustFS will be used as storage for the map and camera image Files. To start it, follow the steps — a single RustFS container plus a one-shot container that bootstraps the dc-files bucket the Destinations below upload into, using the default rustfsadmin/rustfsadmin credentials this demo's params file already assumes.

PostgreSQL

PostgreSQL will be used as database storage for our JSON. Later on, backend engineers can make requests on those JSON based on measurement requested and time range. To start it, follow the steps

Info

Vector's postgres sink maps each top-level key of a Record's JSON payload onto an existing column of the same name — it does not create tables or columns itself. tools/infrastructure/docker/config/postgresql/init.sql pre-creates the dc and dc_files tables this demo writes into; see its header comment for the full column reference if you add a measurement whose fields aren't already columns.

Setup the ROS environment

In each terminal, source your environment:

source /opt/ros/rolling/setup.bash
source install/setup.bash

Nothing else has to be exported. dc_simulation's environment hook puts its own models/ and worlds/ on GZ_SIM_RESOURCE_PATH, and warehouse.launch.py adds nav2_minimal_tb3_sim's models (where the TurtleBot3 meshes live now — the Gazebo Classic turtlebot3_gazebo package has no Jazzy release).

Start Navigation

dc_demos' own launch file brings up gz-sim with the QR-code warehouse world, spawns dc_simulation's TurtleBot3-Waffle with its ros_gz_bridge, and starts Nav2 (map_server + AMCL + planners) against dc_simulation/maps/qrcodes.yaml. It also starts DC, which this demo drives separately, so turn that off here:

ros2 launch dc_demos tb3_qrcodes.launch.py \
    headless:=False \
    use_dc:=False

gz-sim and RViz will start: you should see the robot in the warehouse, and the map in RViz. AMCL sets the demo's initial pose itself (set_initial_pose in qrcodes_nav.yaml), so there is no need to click "2D Pose Estimate" — wait until the laser scan lines up with the map before starting the run below.

Warning

The warehouse world is heavy: 238 model instances, most of them the QR-coded pallets and the props stacked on them. Expect a slow start on a machine without a GPU: gz-sim, the robot and Nav2 come up in well under a minute, but with the real-time factor well under 1 (~0.11-0.2, see dc_simulation's README for the measured breakdown), reaching the first QR-coded pallet and getting the first Record out of the demo can take several minutes of wall clock, and the full 60-waypoint pass over an hour. See #52.

Start DC

Execute

ros2 launch dc_demos tb3_qrcodes_minio_pgsql.launch.py use_sim_time:=True

Warning

use_sim_time defaults to False — without the override above, DC's Position measurement looks up TF transforms on the wall clock while the simulation publishes them on Gazebo's sim clock, which throws "extrapolation into the past"/"transform does not exist" errors and can crash measurement_server outright once the two clocks drift far enough apart (same issue as the PostgreSQL/RustFS and InfluxDB demos).

With this, all data will be transmitted

Drive the robot past the QR codes

In a fourth terminal, run the waypoint follower. It sends the robot down each aisle, stopping in front of every QR-coded pallet so both cameras can read them, and exits once the pass is complete:

ros2 run dc_demos qrcodes_waypoint_follower

Info

The waypoints are camera stations, and the aisles are narrow enough that positioning accuracy matters: a code is only readable while

|lateral error| + standoff * tan(|yaw error|) + 0.181 <= standoff * tan(30°)

where 0.181 m is half a rendered QR symbol and 30° is half the cameras' field of view. The tightest station has a 0.73 m standoff, which is why qrcodes_nav.yaml stops the robot within 0.1 m and 0.1 rad rather than Nav2's more usual tolerances. If you move the waypoints, the pallets or the cameras, re-run ./tools/sim/scripts/run.sh — its lint stage re-derives that inequality from the world, the robot model, the waypoints and the nav params, and its detect stage checks that codes really do come back. See dc_simulation's README for the measured geometry.

Understanding the configuration

Info

The full configuration file can be found here.

For this demo, we will reconstruct the yaml configuration element by element, given how large it is. Go through the explanation to understand how it works.

Collect command velocity, position and speed to PostgreSQL as a group

Similarly to the previous tutorial:

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["records_log", "rustfs"]
    records_log:
      type: file
      receives: records
      inputs:
        [
          "/dc/measurement/map",
          "/dc/measurement/right_camera",
          "/dc/measurement/left_camera",
          "/dc/group/robot",
        ]
      path: "/tmp/dc/qrcodes_minio_pgsql_records.ndjson"
      time_key: "date"
    custom_config_files: ["$HOME/.dc/qrcodes_minio_pgsql_sink.toml"]

group_server:
  ros__parameters:
    groups: ["robot"]
    robot:
      inputs:
        [
          "/dc/measurement/cmd_vel",
          "/dc/measurement/position",
          "/dc/measurement/speed",
        ]
      output: "/dc/group/robot"
      sync_delay: 5.0
      group_key: "robot"

measurement_server:
  ros__parameters:
    custom_keys_str: ["robot_name"]
    robot_name: "C3PO"
    measurement_plugins: ["cmd_vel", "position", "speed"]
    custom_key_str_list: ["robot_name", "id"]
    custom_keys_str:
      robot_name:
        name: robot_name
        value: "C3PO"
      # Requires systemd package
      id:
        name: id
        value_from_file: /etc/machine-id
    run_id:
      enabled: true
      counter: true
      counter_path: "$HOME/run_id"
      uuid: false
    moving:
      plugin: "dc_conditions/Moving"
    cmd_vel:
      plugin: "dc_measurements/CmdVel"
      group_key: "cmd_vel"
      enable_validator: true
      topic_output: "/dc/measurement/cmd_vel"
      include_measurement_name: true
    position:
      plugin: "dc_measurements/Position"
      group_key: "position"
      topic_output: "/dc/measurement/position"
      enable_validator: true
      global_frame: "map"
      robot_base_frame: "base_link"
      transform_timeout: 0.1
      include_measurement_name: true
    speed:
      plugin: "dc_measurements/Speed"
      group_key: "speed"
      odom_topic: "/odom"
      topic_output: "/dc/measurement/speed"
      include_measurement_name: true

In the measurement server, we set 3 measurements: cmd_vel, position and speed being collected once per second, are validated with their respective JSON schemas and publish on their own topics.

Note the include_measurement_name which include measurement name in the JSON, which is used when grouping. The group collects the data from those 3 measurement and republishes it on the group topic /dc/group/robot.

dc_bridge owns every Destination this demo uses. records_log's inputs list already names every topic this demo produces (/dc/group/robot for this section, plus /dc/measurement/map, /dc/measurement/right_camera and /dc/measurement/left_camera for the sections below): unlike the retired per-measurement tags: [...] mechanism, a Destination's inputs is the single place that decides what reaches it, so we declare it once and simply grow the measurements that feed those topics as we go.

records_log is a file Destination, not postgres — PostgreSQL is reached through the ADR-0003 passthrough instead. The actual postgres sink lives in qrcodes_minio_pgsql_sink.toml, a raw Vector config snippet loaded via custom_config_files:

# ~/.dc/qrcodes_minio_pgsql_sink.toml
[sinks.pgsql]
type = "postgres"
inputs = [
  "dc.dc.measurement.map",
  "dc.dc.measurement.right_camera",
  "dc.dc.measurement.left_camera",
  "dc.dc.group.robot",
]
endpoint = "postgres://dc:password@127.0.0.1:5432/dc"
table = "dc"

[sinks.pgsql.buffer]
type = "disk"
max_size = 268435488

dc_bridge derives its ROS subscriptions and dc.<tag> routes from destinations alone, never from a passthrough snippet's inputs — that's why records_log still lists every topic even though the snippet above is what actually reaches PostgreSQL. Copy the snippet into place before launching:

mkdir -p ~/.dc && cp "$(ros2 pkg prefix dc_demos)/share/dc_demos/config/qrcodes_minio_pgsql_sink.toml" ~/.dc/

Warning

Be sure to change the login and password to your current infrastructure configuration. Do it in production setup!

You can find more about the postgres sink recipe here

To take a look at records, go to Adminer. It is by default started at http://localhost:8080, it is a database GUI.:

Adminer

You can then click on a record, to take a look, edit or delete it:

Adminer

Send the map image and YAML from nav2_map_server to RustFS

First, we add the map measurement. Its remote_keys names the rustfs Destination, which is what actually uploads the pgm/yaml Files — the map measurement only records where they are, locally and (once uploaded) remotely:

measurement_server:
  ros__parameters:
  ...
  measurement_plugins: ["map"]
  map:
    plugin: "dc_measurements/Map"
    group_key: "map"
    polling_interval: 5000
    save_path: "map/%Y-%m-%dT%H:%M:%S"
    topic_output: "/dc/measurement/map"
    save_map_timeout: 4.0
    remote_prefixes: [""]
    remote_keys: ["rustfs"]
    enable_validator: true
    include_measurement_name: true
  ...

Then, the Destinations that make this work:

dc_bridge:
  ros__parameters:
    ...
    destinations: ["records_log", "rustfs"]
    rustfs:
      type: s3
      receives: files
      inputs:
        [
          "/dc/measurement/map",
          "/dc/measurement/right_camera",
          "/dc/measurement/left_camera",
        ]
      bucket: "dc-files"
      endpoint: "http://127.0.0.1:9000"
      region: "us-east-1"
      access_key_id: "rustfsadmin"
      secret_access_key: "rustfsadmin"
      force_path_style: true
    files:
      delete_when_sent: true
      metadata_destination: "records_log"
# ~/.dc/qrcodes_minio_pgsql_sink.toml (continued)
[sinks.pgsql_files]
type = "postgres"
inputs = ["dc.dc.files"]
endpoint = "postgres://dc:password@127.0.0.1:5432/dc"
table = "dc_files"

[sinks.pgsql_files.buffer]
type = "disk"
max_size = 268435488

This introduces the two-way PostgreSQL split this demo relies on:

  • pgsql (the passthrough sink declared above) carries the map's own metadata Record (dimensions, resolution, local/remote paths) like any other measurement.
  • pgsql_files is a second postgres sink in the same passthrough snippet, dedicated to the Uploader's own bookkeeping. It has no ROS topic inputs of its own — it consumes the dc.dc.files route instead, which records_log gains from being named as files.metadata_destination below. files.metadata_destination must name a configured receives: records Destination (dc_bridge rejects anything else at startup), which is why it names records_log rather than the passthrough sink directly — a passthrough-only sink id isn't eligible.
  • rustfs is the s3 Destination that actually uploads the pgm/yaml bytes. receives: files marks it as owned by dc_uploader — a separate process from dc_bridge (ADR-0014) — rather than a Vector sink: dc_bridge subscribes to rustfs's inputs, and durably enqueues an intent for any Record with remote_paths entries whose key matches a Destination name — here rustfs, matching the map measurement's remote_keys above. dc_uploader reads that intent, uploads the referenced Files, verifies they landed, and emits a status Record under dc.files, routed to records_log (and, from there, consumed by the pgsql_files passthrough sink). Unlike pgsql/pgsql_files, rustfs stays blessed: receives: files is served entirely by dc_uploader reading these same ROS params, never by a Vector sink, so there is no passthrough equivalent for it to migrate to.

See Destinations for the full files:/Uploader contract, and ADR-0005 / ADR-0014 for why file uploads are a DC responsibility rather than a Vector sink, and why that logic now runs as its own process.

files.delete_when_sent: true means the local pgm/yaml are removed only once RustFS confirms the upload — never before.

You can check upload status and completion in Grafana's Robot dashboard, whose "Uploaded inspection files" and "Group completion status" panels query the dc_files table pgsql_files writes into:

SELECT to_timestamp(updated_at) AS "time", group_name, robot_name, storage_type, remote_path, content_type, size, uploaded
FROM dc_files WHERE kind = 'file_status' ORDER BY updated_at DESC LIMIT 100

Then, similarly, on Adminer, you can browse the dc table's map rows.

Send QR code images to RustFS

We want to collect pictures taken by the cameras

measurement_server:
  ros__parameters:
  ...
  measurement_plugins: ["cmd_vel", "position", "speed", "map", "right_camera", "left_camera"]
  condition_plugins: ["moving", "inspected_exists"]
  custom_key_str_list: ["robot_name", "id"]
  custom_keys_str:
    robot_name:
      name: robot_name
      value: "C3PO"
    # Requires systemd package
    id:
      name: id
      value_from_file: /etc/machine-id
  run_id:
    enabled: true
    counter: true
    counter_path: "$HOME/run_id"
    uuid: false
  moving:
    plugin: "dc_conditions/Moving"
  inspected_exists:
    plugin: "dc_conditions/Compare"
    key: "inspected"
    comparison: "exists"
  right_camera:
    plugin: "dc_measurements/Camera"
    group_key: "right_camera"
    if_none_conditions: ["moving"]
    if_all_conditions: ["inspected_exists"]
    topic_output: "/dc/measurement/right_camera"
    init_collect: false
    init_max_measurements: -1
    condition_max_measurements: 1
    node_name: "dc_measurement_camera"
    cam_topic: "/right_intel_realsense_r200_depth/image_raw"
    cam_name: right_camera
    enable_validator: true
    draw_det_barcodes: true
    save_raw_img: false
    save_rotated_img: false
    save_detections_img: true
    save_inspected_path: "right_camera/inspected/%Y-%m-%dT%H-%M-%S"
    rotation_angle: 0
    detection_modules: ["barcode"]
    remote_prefixes: [""]
    remote_keys: ["rustfs"]
    include_measurement_name: true
  left_camera:
    plugin: "dc_measurements/Camera"
    group_key: "left_camera"
    if_none_conditions: ["moving"]
    if_all_conditions: ["inspected_exists"]
    topic_output: "/dc/measurement/left_camera"
    init_collect: true
    init_max_measurements: -1
    condition_max_measurements: 1
    node_name: "dc_measurement_camera"
    cam_topic: "/left_intel_realsense_r200_depth/image_raw"
    cam_name: left_camera
    enable_validator: true
    draw_det_barcodes: true
    save_raw_img: false
    save_rotated_img: false
    save_detections_img: true
    save_inspected_path: "left_camera/inspected/%Y-%m-%dT%H-%M-%S"
    rotation_angle: 0
    detection_modules: ["barcode"]
    remote_prefixes: [""]
    remote_keys: ["rustfs"]
    include_measurement_name: true
  ...

Taking a look at the cameras, we can understand that:

  1. Data is only collected when the robot is not moving: if_none_conditions: ["moving"]
  2. Data is only collected when there is inspected data, so only when a QR code is detected: if_all_conditions: ["inspected_exists"]
  3. Data is not collected constantly: init_max_measurements: -1
  4. Only one record is collected when conditions are triggered: condition_max_measurements: 1
  5. Only images with inspected data are collected:
    1. save_raw_img: false
    2. save_rotated_img: false
    3. save_detections_img: true
  6. Barcodes are scanned in each image: detection_modules: ["barcode"]

include_measurement_name matters here too: the Uploader relies on it to know which top-level field of the Record holds the local_paths/remote_paths it should act on.

No new Destination block is needed for the cameras: records_log and rustfs already list /dc/measurement/right_camera and /dc/measurement/left_camera in their inputs (see the first section above), and the passthrough pgsql sink already consumes the matching dc.<tag> routes those inputs create — a Destination's inputs is a single, global list of topics rather than something declared per measurement, so adding a measurement that feeds an already-configured Destination requires no dc_bridge change at all.

Here, we collect images with the rustfs Destination, and their metadata (which record they belong to, remote path once uploaded, image dimensions where relevant) through the passthrough pgsql sink. pgsql_files, fed by the Uploader, tracks when each image is sent to RustFS and — with files.delete_when_sent: true — is deleted locally once confirmed. Note that a Record's remote_paths can name several Destinations at once; the Uploader sends to every one whose name appears there.

An example Record for right_camera, once the image is inspected:

{
  "camera_name": "right_camera",
  "date": 1677668926.700422,
  "flattened": false,
  "id": "be781e5ffb1e7ee4f817fe7b63e92c32",
  "nested": false,
  "robot_name": "C3PO",
  "run_id": "218",
  "local_img_paths": {
    "inspected": "/root/dc_data/C3PO/2023/03/01/17/right_camera/inspected/2023-03-01T17-12-57.jpg"
  },
  "remote_paths": {
    "rustfs": {
      "inspected": "C3PO/2023/03/01/17/right_camera/inspected/2023-03-01T17-12-57.jpg"
    }
  },
  "inspected": {
    "barcode": [
      {
        "data": [81, 82, 99, 111, 100, 101, 45, 49],
        "height": 40,
        "width": 40,
        "top": 120,
        "left": 200,
        "type": "QRCODE"
      }
    ]
  }
}

Fast DDS statistics to PostgreSQL/Grafana

Fast DDS-specific

This demo only produces data when Fast DDS is the RMW in use and it was built with its Statistics Module enabled (-DFASTDDS_STATISTICS=ON), plus fastdds_statistics_backend installed against it — pin a v2.x tag, the line built against the Fast-DDS 3.x that ROS 2 Rolling ships. A third, runtime-only prerequisite is easy to miss: the FASTDDS_STATISTICS environment variable must be set on every process before it creates its first DomainParticipant, or latency_ns_mean and every throughput/RTPS field stay permanently absent even though the plugin itself runs fine. See the Fast DDS statistics Measurement doc for the full build recipe and all three prerequisites. Without the first two, dc_measurements still builds and every other demo still runs — dc_measurements/CMakeLists.txt finds fastdds_statistics_backend optionally and skips only this one plugin — but launching this demo fails: measurement_server can't load a dc_measurements/FastddsStats plugin that was never built.

This is the smallest hardware-free way to see Fast DDS's own Statistics Module land in DC: one Measurement (Fast DDS statistics), a passthrough postgres sink (ADR-0003), and a Grafana dashboard provisioned automatically — the same convention #304 established for the KPI dashboard.

Setup Infrastructure

PostgreSQL

Follow the steps to start it. The default yaml configuration file does not need change.

Grafana

Follow the steps to start it.

Run the demo

colcon build
mkdir -p ~/.dc && cp "$(ros2 pkg prefix dc_demos)/share/dc_demos/config/fastdds_stats_pgsql_grafana_sink.toml" ~/.dc/
export FASTDDS_STATISTICS="HISTORY_LATENCY_TOPIC;PUBLICATION_THROUGHPUT_TOPIC;SUBSCRIPTION_THROUGHPUT_TOPIC;RTPS_SENT_TOPIC;RTPS_LOST_TOPIC"
ros2 launch dc_demos fastdds_stats_pgsql_grafana.launch.py

A Record captured from a real run, echoed straight off /dc/measurement/fastdds_stats (no PostgreSQL needed to see this — it's what dc_bridge forwards on):

{
  "custom_keys": ["robot_name"],
  "datareader_count": 1,
  "datawriter_count": 11,
  "domain_id": 0,
  "event": "sample",
  "flattened": false,
  "hosts": ["d:14058711922191368192"],
  "name": "fastdds_stats",
  "nested": false,
  "participant_count": 3,
  "participants": [
    { "guid": "01.0f.4d.26.9c.1d.72.46.00.00.00.00|0.0.1.c1", "name": "/" },
    { "guid": "01.0f.4d.26.13.27.ae.cb.00.00.00.00|0.0.1.c1", "name": "/" },
    { "guid": "01.0f.4d.26.25.27.c4.fd.00.00.00.00|0.0.1.c1", "name": "/" }
  ],
  "process_names": ["7580", "10003", "10021"],
  "robot_name": "C3PO",
  "run_id": "172",
  "users": ["root"]
}

latency_ns_mean and the throughput/RTPS fields are absent in this particular sample: nothing exchanged data on a matched DataWriter/DataReader pair within that 5-second poll window. See the Measurement's own page for why absence is what "nothing to report" looks like here.

Visualize the data

Open http://localhost:3000 (admin/admin) and pick the ROS 2 Data Collection - Fast DDS statistics dashboard. Its panels are backed by SQL queries against the dc PostgreSQL table (datasource uid dc_postgres), the same one every other PostgreSQL demo writes to — filtered to WHERE name = 'fastdds_stats':

  • Write-to-notification latency, mean across matched DataWriter/DataReader pairs
  • Publication and subscription throughput, mean
  • Discovered participant/DataWriter/DataReader counts over time
  • RTPS packets sent and lost
  • The most recent sample's physical-layer data — which hosts, users and processes are behind the discovered participants

See tools/infrastructure/docker/config/grafana/dashboards/fastdds_stats.json for every panel's exact query.

Understanding the configuration

Info

The full configuration file can be found here.

measurement_server:
  ros__parameters:
    measurement_plugins: ["fastdds_stats"]
    fastdds_stats:
      plugin: "dc_measurements/FastddsStats"
      topic_output: "/dc/measurement/fastdds_stats"
      polling_interval: 5000
      domain_id: 0
      include_measurement_name: true
      init_collect: true

dc_bridge:
  ros__parameters:
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/fastdds_stats"]
      path: "/tmp/dc/fastdds_stats_pgsql_grafana_records.ndjson"
      time_key: "date"
      time_format: "double"
    custom_config_files: ["$HOME/.dc/fastdds_stats_pgsql_grafana_sink.toml"]
# ~/.dc/fastdds_stats_pgsql_grafana_sink.toml
[sinks.pgsql]
type = "postgres"
inputs = ["dc.dc.measurement.fastdds_stats"]
endpoint = "postgres://dc:password@127.0.0.1:5432/dc"
table = "dc"

[sinks.pgsql.buffer]
type = "disk"
max_size = 268435488

postgres — along with s3 and console — moved from the blessed ROS-param form to this passthrough recipe (ADR-0003); records_log is the cheap file anchor the passthrough still needs, since dc_bridge derives its ROS subscriptions and dc.<tag> routes from destinations alone. See Destinations: Recipes for the full recipe. domain_id is the DDS domain to monitor — the same value ROS_DOMAIN_ID would use for every other node in the deployment. include_measurement_name: true writes "name": "fastdds_stats" onto every Record, which every panel's WHERE name = 'fastdds_stats' clause relies on to tell this Measurement's rows apart from any other demo sharing the same dc table.

Custom uptime to Stdout

In this demo, we will go through a new use case. You want to create your own measurement or use an existing measurement provided by dc_measurements but not exactly, you want to add a field and also modify the JSON schema. That is what we will do here: create a new plugin, inside dc_demos, another package, create a plugin and load it

We are going to take the uptime measurement, change it slightly and collect the data.

To test it, run:

ros2 launch dc_demos uptime_custom_stdout.launch.py

JSON schema

Located in dc_demos/plugins/measurements/json/uptime_custom.json:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Uptime Custom",
  "description": "Time the system has been up. Intentionally failing to demonstrate customization and callback",
  "properties": {
    "time": {
      "description": "Time the system has been up",
      "type": "integer",
      "maximum": 0
    }
  },
  "type": "object"
}

It is almost the same as the standard uptime, but for the sake of the example, we will set the maximum value to 0, which will certainly make the validation fail!

CPP code

First, we create a hpp file in dc_demos/include/dc_demos/plugins/measurements:

#ifndef DC_DEMOS__PLUGINS__MEASUREMENTS__UPTIME_CUSTOM_HPP_
#define DC_DEMOS__PLUGINS__MEASUREMENTS__UPTIME_CUSTOM_HPP_

#include <nlohmann/json-schema.hpp>
#include <nlohmann/json.hpp>

#include "dc_measurements/measurement.hpp"
#include "dc_measurements/plugins/measurements/uptime.hpp"

namespace dc_demos
{
using json = nlohmann::json;

class UptimeCustom : public dc_measurements::Uptime
{
protected:
  void onFailedValidation(json data_json) override;
};

}  // namespace dc_demos

#endif  // DC_DEMOS__PLUGINS__MEASUREMENTS__UPTIME_CUSTOM_HPP_

We create a new class UptimeCustom, which inherits from dc_measurements::Uptime.

Info

If we wanted to start a new measurement from scratch, it would inherit from dc_core::Measurement.

The method onFailedValidation is not mandatory but it is here to show it is possible to trigger a custom function when the validation fails.

Then the cpp code, currently located in dc_demos/plugins/measurements/uptime_custom.cpp:

#include "dc_demos/plugins/measurements/uptime_custom.hpp"

namespace dc_demos
{

void UptimeCustom::onFailedValidation(json data_json)
{
  (void)data_json;
  RCLCPP_INFO(logger_, "Callback! Validation failed for uptime custom");
}

}  // namespace dc_demos

#include "pluginlib/class_list_macros.hpp"
PLUGINLIB_EXPORT_CLASS(dc_demos::UptimeCustom, dc_core::Measurement)

We include the uptime_custom file header. Then, define the onFailedValidation function (triggered when validation fails).

The schema is picked up without any code: a Measurement validates against plugins/measurements/json/<plugin type>.json from the package registering it, so dc_demos/UptimeCustom reads dc_demos/plugins/measurements/json/uptime_custom.json. Set json_schema_path to point elsewhere, or enable_validator: false to turn validation off.

Info

Do not forget to include the pluginlib statements at the end to export the plugin class.

Plugin file

Create the xml file, here will be measurement_plugin.xml for us, at the source of the package. It defines the plugins of the package.

<class_libraries>
    <library path="dc_uptime_custom_measurement">
        <class name="dc_demos/UptimeCustom" type="dc_demos::UptimeCustom" base_class_type="dc_core::Measurement">
            <description>
                dc_measurement_uptime_custom
            </description>
        </class>
    </library>
</class_libraries>

CMakeLists.txt

Now that you have all files set up, you can add the build process to the CMakeLists.txt:

# Measurement plugins
add_library(dc_uptime_custom_measurement SHARED
  plugins/measurements/uptime_custom.cpp
)
list(APPEND dc_measurement_plugin_libs dc_uptime_custom_measurement)

foreach(measurement_plugin ${dc_measurement_plugin_libs})
  ament_target_dependencies(${measurement_plugin} ${dependencies})
  target_link_libraries(
    ${measurement_plugin}
    nlohmann_json::nlohmann_json
    nlohmann_json_schema_validator
  )
  target_compile_definitions(${measurement_plugin} PRIVATE BT_PLUGIN_EXPORT)
endforeach()

pluginlib_export_plugin_description_file(dc_core measurement_plugin.xml)

install(FILES measurement_plugin.xml
  DESTINATION share/${PROJECT_NAME}
)

install(DIRECTORY plugins/measurements/
  DESTINATION share/${PROJECT_NAME}/plugins/measurements/
)

It creates the library, installs and exports it.

Console output

In the measurement server log, the plugin is detected properly

[component_container_isolated-1] [INFO] [1788506243.714587305] [measurement_server]: Creating measurement plugin uptime_custom: Type dc_demos/UptimeCustom, Group key: uptime, Polling interval: 5000, Debug: 0, Validator enabled: 1, Schema path: , Tags: [], Init collect: 1, Init Max measurement: 0, Include measurement name: 1, Include measurement plugin name: 0, Remote keys: , Remote prefixes: , Nest: 0, Flatten: 0, Include measurement plugin name: 0, Max measurement on condition: 0, If all condition: , If any condition: , If none condition: , Gate condition: , Buffer duration sec: 0, Post roll duration sec: 0, Cooldown sec: 0, Max flush rate hz: 0, Flush topic: /dc/flush
[component_container_isolated-1] [INFO] [1788506243.724443694] [measurement_server]: Done configuring uptime_custom
[component_container_isolated-1] [INFO] [1788506243.724997657] [measurement_server]: Looking for schema at /root/ws/install/dc_demos/share/dc_demos/plugins/measurements/json/uptime_custom.json
[component_container_isolated-1] [INFO] [1788506243.725045689] [measurement_server]: schema: {"$schema":"http://json-schema.org/draft-07/schema#","description":"Time the system has been up. Intentionally failing to demonstrate customization and callback","properties":{"time":{"description":"Time the system has been up","maximum":0,"type":"integer"}},"title":"Uptime Custom","type":"object"}

Then, it fails as expected:

[component_container_isolated-1] [ERROR] [1788506243.726002578] [measurement_server]: Validation failed: At /time of 1638628 - instance exceeds maximum of 0
[component_container_isolated-1] data={"time":1638628}
[component_container_isolated-1] [INFO] [1788506243.726050184] [measurement_server]: Callback! Validation failed for uptime custom
[component_container_isolated-1] [ERROR] [1788506248.725121430] [measurement_server]: Validation failed: At /time of 1638633 - instance exceeds maximum of 0
[component_container_isolated-1] data={"time":1638633}
[component_container_isolated-1] [INFO] [1788506248.725329122] [measurement_server]: Callback! Validation failed for uptime custom

Even though every Record fails validation, dc_bridge still ships the raw data to the console Destination — validation failure only triggers onFailedValidation, it doesn't drop the Record:

[dc_bridge-2] {"date":1788506243.7255764,"flattened":false,"host":"127.0.0.1","name":"uptime_custom","nested":false,"source_type":"fluent","tag":"dc.measurement.uptime_custom","time":1638628,"timestamp":"2026-09-04T07:17:23.725576334Z"}

Concepts

DC has a small, fixed vocabulary. Using it precisely makes the rest of the documentation — and the configuration files — unambiguous.

Glossary

TermDefinition
MeasurementA source of sampled data (CPU, position, camera inspection…) that emits Records.
RecordOne timestamped JSON document flowing through the pipeline.
ConditionA boolean predicate on robot state that gates whether a Measurement's Records are collected.
TriggerA plugin that fires a one-shot signal on the false→true edge of a Condition composition, releasing a Measurement's pre-event buffer. Distinct from a Condition, which gates continuously.
FlushEventThe message a Trigger broadcast node publishes when its Trigger fires, carrying the incident_id subscribed Measurements adopt.
IncidentOne flush cycle — the pre-event window a Trigger released plus its post-roll — identified by the incident_id every Record and File of that cycle carries.
GroupA merge of Records from several Measurements into one Record, based on time proximity.
FileA binary artifact produced by a Measurement (image, video, map), uploaded to object storage as-is; only its metadata travels as a Record.
DestinationAn external system that receives Records or Files (PostgreSQL, S3-compatible storage, console…).
Blessed DestinationA Destination DC configures natively from ROS parameters: postgres, s3, file, console, vector.
Passthrough DestinationA Destination configured by handing raw Shipper configuration through DC, unlocking the Shipper's full catalog without DC code.
BridgeThe DC component (dc_bridge) that receives Records from ROS topics and hands them to the Shipper.
ShipperThe external process (Vector by default) that buffers, transforms, and reliably delivers Records to Destinations.
TagA label carried by a Record naming the route it is delivered on.

Relationships between them:

  • A Measurement emits Records, optionally gated by one or more Conditions
  • A Group merges Records from several Measurements into one Record
  • The Bridge forwards every Record to the Shipper
  • The Shipper delivers Records to one or more Destinations
  • A File is uploaded to an object-storage Destination; its metadata becomes a Record
  • A Record carries a Tag; each Tag names a route Destinations subscribe to
  • A Trigger fires a FlushEvent; every Measurement listening for it releases its buffered window as one Incident

Words that mean something specific here

"Sink" is Vector's own term for an output configuration unit; DC's term for the same idea is Destination. "Route" is the Shipper-side path a Tag selects. A camera image is a File; the Record is the separate JSON document describing where that File went. A Condition is a level that gates collection while true; a Trigger is an edge that fires once, when a Condition composition flips from false to true.

ROS 2

ROS 2 is the core middleware used for DC. If you are unfamiliar with it, visit the ROS 2 documentation before continuing.

Records

A Record is a single data unit in JSON, published on a ROS topic as a dc_interfaces/msg/StringStamped message. For example, a Record from the Memory Measurement:

{
    "date": "2022-12-04T14:16:06.810999008",
    "flattened": false,
    "memory": {
        "used": 76.007431
    },
    "nested": true
}

flattened and nested are added to every Record unconditionally by the base Measurement class — they report whether nested/flatten were configured for this plugin, not something you opt out of showing.

The StringStamped message carries:

  1. header: ROS timestamp as std_msgs/Header
  2. data: the Record, as a JSON string
  3. group_key: the key this Record is nested under when merged into a Group
  4. incident_id: the Incident a released Record belongs to; empty outside an incident

The data string must be valid JSON:

"Robot_X:1.5, Robot_Y:1.8" # Not a valid Record
"{'x': 1.5, 'y': 1.8}"     # Valid Record

Timestamps

The timestamp is the time the Record was created. Measurements convert ROS time to a UTC timestamp. Each Destination normalizes it into one field before delivery, controlled by time_key (default date) and time_format (epoch_nanos, the default, for exact integer nanoseconds since the epoch; iso8601 for a string; double for fractional seconds, which rounds — see Destinations).

JSON validation

Each Record is validated against a JSON schema by default, following the JSON Schema Validation specification. Each Measurement ships its own schema, which can be overridden from a custom package or disabled per Measurement with enable_validator: false. See Data validation.

Bridge and Shipper

DC's data plane is an external Shipper process, Vector, fed by the Bridge (dc_bridge) over a local socket. The Bridge renders the Shipper's entire configuration from plain ROS parameters — see Destinations — spawns and supervises the Shipper process, and forwards every Record it receives on its configured input topics.

DC gets the Shipper's disk buffering, backpressure handling, retries, and native Destination support (PostgreSQL, S3-compatible storage, and many more) without embedding or forking it (ADR-0001, ADR-0002).

Shipper ingest protocol

The wire format on the local Bridge↔Shipper socket (default port 24224) is Fluentd's open "Forward" specification — chosen as the cheapest Shipper-native listener with built-in receipt acknowledgement (ADR-0002). No Fluentd or Fluent Bit software runs anywhere in DC 2.0: the Bridge implements the sender side itself in a few hundred lines of msgpack. The word "fluent" only appears in the generated Shipper config (type = "fluent").

Tags

Every Record carries a Tag, derived mechanically from the topic it was published on: the leading / is dropped and the remaining / become ..

TopicTagPublic Shipper route
/dc/measurement/uptimedc.measurement.uptimedc.dc.measurement.uptime
/dc/group/robotdc.group.robotdc.dc.group.robot
(Uploader-internal)dc.filesdc.dc.files

Tags are what the Shipper routes on, and the dc.<tag> route names are stable public API that passthrough Destinations consume. A Destination selects what it receives through its own inputs list of topics — Tags are derived from that, not configured directly.

Destinations

A Destination is where data ends up: a file is blessed (rendered from plain ROS parameters), and so is another Shipper, and (only for File uploads, not Records) S3-compatible storage. PostgreSQL, S3-compatible storage for Records, and the console are reached through a passthrough recipe instead of the ROS-param form (per ADR-0003); any other Vector sink is reachable through the passthrough too. See Destinations for the full contract.

dc_bridge:
  ros__parameters:
    destinations: ["records_log"]             # Destination names to enable
    records_log:                              # a name you choose
      type: file                              # a blessed type
      receives: records
      inputs: ["/dc/measurement/uptime"]      # the topics this Destination receives
      path: "/tmp/dc/records.ndjson"

measurement_server:
  ros__parameters:
    measurement_plugins: ["uptime"]
    uptime:
      plugin: "dc_measurements/Uptime"
      topic_output: "/dc/measurement/uptime"  # matched by the Destination's `inputs`

Conditions

A Condition enables or disables one or more Measurements. For example, collect camera images only when the robot is stopped. A Measurement can require that all, any, or none of a set of Conditions are active. See Conditions.

Triggers and incidents

A Condition can only give you data from the moment it turned true. When what matters is the run-up to an event — the seconds before an emergency brake — a Trigger is the mechanism: a Measurement holds its recent output in a rolling buffer instead of publishing it, and a Trigger firing on the false→true edge of a Condition composition broadcasts a FlushEvent that releases that buffer. Everything released by one firing, across every Measurement listening, shares one incident_id. See Triggers.

Lifecycle Nodes and Bond

(Source: Nav2 documentation)

Lifecycle (or Managed, more correctly) nodes are unique to ROS 2. More information can be found here. They are nodes that contain state machine transitions for bringup and teardown of ROS 2 servers. This helps in deterministic behavior of ROS systems in startup and shutdown. It also helps users structure their programs in reasonable ways for commercial uses and debugging.

When a node is started, it is in the unconfigured state, only processing the node's constructor which should not contain any ROS networking setup or parameter reading. By the launch system, or the supplied lifecycle manager, the nodes need to be transitioned to inactive by configuring. After, it is possible to activate the node by transitioning through the activating stage.

This state will allow the node to process information and be fully setup to run. The configuration stage, triggering the on_configure() method, will setup all parameters, ROS networking interfaces, and for safety systems, all dynamically allocated memory. The activation stage, triggering the on_activate() method, will active the ROS networking interfaces and set any states in the program to start processing information.

To shutdown, we transition into deactivating, cleaning up, shutting down and end in the finalized state. The networking interfaces are deactivated and stop processing, deallocate memory, exit cleanly, in those stages, respectively.

The lifecycle node framework is used extensively through out this project and all servers utilize it. It is best convention for all ROS systems to use lifecycle nodes if it is possible.

Within DC, we use a wrapper of LifecycleNodes, nav2_util LifecycleNode from Nav2. This wrapper wraps much of the complexities of LifecycleNodes for typical applications. It also includes a bond connection for the lifecycle manager to ensure that after a server transitions up, it also remains active. If a server crashes, it lets the lifecycle manager know and transition down the system to prevent a critical failure. See Eloquent to Foxy for details.

Info

The Bridge is deliberately not a lifecycle node and not under the lifecycle manager (ADR-0006): it must be up and its Shipper ready before the collection nodes are allowed to activate. See Data Pipeline.

See Lifecycle Manager for the managed-node list, the bond heartbeat and state-transition diagrams, and the autostart flow that ties into the Bridge readiness gate above.

Buffering and data persistence

The Shipper manages its own disk buffer (shipper.data_dir on the Bridge's parameters) with a documented minimum size (shipper.buffer_max_bytes). This buffer persists across reboots: Records accepted by the Bridge but not yet delivered to a Destination survive an outage of that Destination, a robot reboot, or a Bridge restart, and are delivered — with end-to-end acknowledgements — once the Destination is reachable again.

Scheduling and retries

The Shipper retries delivery to a Destination on failure with its own backoff, independent of DC code; see Vector's documentation for details. The Bridge itself is supervised by DC (launch respawn) and, in turn, supervises its own Shipper child process — including a Linux parent-death signal so the Shipper can never outlive the Bridge across a crash or SIGKILL.

Data Pipeline

This page follows one piece of data from the sensor that produced it to the external system that stores it. The vocabulary — Measurement, Record, Group, File, Bridge, Shipper, Destination, Tag — is defined in Concepts and used consistently throughout.

C4 model

Three levels, each zooming further into the pipeline: DC as a single system among external actors and destinations (Context), the processes that make it up (Container), and the pieces inside the Bridge (Component). The flowchart in the next section stays as the at-a-glance narrative view of a single Record's journey; these diagrams complement it rather than replace it.

DC and the systems it exchanges data with (C1)

DC runs as one system on the robot. A robot operator configures it; analytics and dashboard consumers read from whatever Destinations it was configured to write to — PostgreSQL, S3-compatible object storage, and (via passthrough, ADR-0003) any other Shipper-supported sink.

System context for DC (Data Collection)

Inside DC (C2)

Inside DC, measurement_server is the node lifecycle-managed by dc_lifecycle_manager (see Lifecycle Manager for the managed-node list and why it's just the one node today). group_server runs as a plain node alongside it, not under lifecycle management. The Bridge (dc_bridge) and its supervised Shipper child are deliberately outside that boundary too (ADR-0006) — the Bridge has no meaningful deactivated state, so its readiness comes from launch ordering (bridge_ready_gate) instead of a lifecycle transition. See Deterministic startup ordering for the sequence this diagram's bridge_ready_gate → dc_lifecycle_manager relationship summarizes.

dc_uploader is a fourth, independent process dc_bringup.launch.py starts alongside the rest of the pipeline when a receives: files Destination is configured (ADR-0014) — it uploads Files and reports their status directly to the Shipper over its own connection, so an Uploader crash or restart never touches Record collection.

Container diagram for DC (Data Collection)

Inside dc_bridge and dc_uploader (C3)

The pieces added across #244–#267, now invisible from the outside: BridgeNode wires a Forwarder (Records → Shipper), a Supervisor (owns the Vector child process), a Config renderer (ADR-0003's shipper/destinations params → Vector TOML, including passthrough snippet validation), Readiness (backs ~/ready), and — for receives: files Destinations (ADR-0005) — a durable on-disk IntentQueue it enqueues into and forgets.

That queue is where the Bridge's responsibility for a File ends. dc_uploader (ADR-0014) is a separate process — its own executable, no rclcpp/rclpy dependency — that rescans the same on-disk queue, uploads Files against an S3-compatible ObjectStore, and reports status Records over its own Forwarder/Shipper connection under the dc.files Tag, entirely independent of the Bridge's own Forwarder. Killing or restarting dc_uploader never touches Record collection, since there is no shared address space left for it to take down.

Component diagram for dc_bridge and dc_uploader

The path of a Record

flowchart LR
    subgraph ros["ROS 2 graph"]
        meas["Measurement plugins<br/>(measurement_server)"]
        cond["Conditions"]
        group["Group node<br/>(group_server)"]
    end
    subgraph bridge["Bridge (dc_bridge)"]
        fwd["Forwarder"]
        iq[("Intent queue<br/>(disk)")]
    end
    subgraph uploader["dc_uploader (own process)"]
        upl["Uploader"]
        ufwd["Forwarder<br/>(own connection)"]
    end
    subgraph shipper["Shipper (Vector)"]
        route["dc.&lt;tag&gt; routes"]
        buf[("Disk buffer")]
    end
    subgraph dest["Destinations"]
        pg["PostgreSQL"]
        s3["S3-compatible storage"]
        other["Any Vector sink<br/>(passthrough)"]
    end

    cond -- gate --> meas
    meas -- "Records (StringStamped)" --> fwd
    meas -- "Records" --> group
    group -- "merged Records" --> fwd
    meas -. "Files on disk" .-> fwd
    fwd -. "enqueues intent" .-> iq
    iq -. "rescans (poll)" .-> upl
    fwd -- "shipper ingest protocol" --> route
    route --> buf
    buf --> pg
    buf --> other
    upl -- "File bytes" --> s3
    upl -- "status Record" --> ufwd
    ufwd -- "dc.files, its own connection" --> route
  1. A Measurement produces a Record. Each Measurement plugin samples its source on a timer (or on an input topic) and publishes one timestamped JSON document as a dc_interfaces/msg/StringStamped on its topic_output. Conditions can gate whether the Measurement collects at all.
  2. Optionally, a Group merges Records. The Group node subscribes to several Measurement topics and publishes one merged Record on /dc/group/<name> once their timestamps line up (sync_delay).
  3. The Bridge forwards every Record. dc_bridge subscribes to every topic listed in any Destination's inputs, derives that topic's Tag, and hands the Record to the Shipper over the local shipper ingest socket (default 127.0.0.1:24224), with receipt acknowledgement.
  4. The Shipper routes, buffers and delivers. Vector normalizes the Record's timestamp field, exposes it on the public dc.<tag> route, writes it to a persistent disk buffer, and delivers it to each Destination wired to that route — retrying with its own backoff until it succeeds.
  5. Files take a different path. A File (camera image, map, video) is never sent through the Shipper. dc_bridge parses the local_paths/remote_paths references embedded in the Record and durably enqueues an intent to a shared on-disk queue — then forgets it. dc_uploader, a separate process (ADR-0014), rescans that same queue, uploads the bytes to object storage, verifies them, and emits a status Record under the dc.files Tag over its own Shipper connection — which then travels the ordinary Record path. See File uploads.

Where each piece is configured

StageNodeParameters
Producing Recordsmeasurement_serverMeasurements
Gating collectionmeasurement_serverConditions
Merging Recordsgroup_serverGroups
Routing, buffering, deliveringdc_bridgeDestinations
Uploading Files, reporting statusdc_uploaderFile uploads

Routing is decided in exactly one place: a Destination's inputs list names the topics it receives. Nothing on the producing side selects a Destination.

Deterministic startup ordering

dc_bringup.launch.py brings the pipeline up in a fixed order (ADR-0006), so no Record can be emitted before the pipeline is able to accept it:

  1. Bridge first. dc_bridge starts as a plain node (outside the lifecycle manager) and spawns the Vector Shipper as a supervised child process. dc_uploader starts alongside it at this same step, as its own process, unless the run_uploader launch argument is False (ADR-0014) — it isn't gated by readiness the way the collection nodes are, since it has nothing to wait for beyond the on-disk intent queue it rescans. The measurement server also starts here, but stays unconfigured and inactive — its publishers cannot emit anything yet.
  2. Readiness gate. A bridge_ready_gate process blocks, polling the Bridge's ~/ready service (std_srvs/Trigger), which answers success=True only once the Shipper is accepting connections on its ingest socket. The gate's service, timeout_s (default 120 s), and poll_interval_s parameters are configurable from the params file under bridge_ready_gate:.
  3. Activation. Only when the gate exits successfully does the launch start lifecycle_manager_dc, which configures and then activates the collection nodes. If the Bridge never becomes ready before the gate's deadline, the whole launch shuts down loudly instead of leaving a half-started pipeline running.

See Lifecycle Manager for the diagrammed version of this sequence, plus the state transitions and bond-heartbeat recovery behavior it drives once activated.

Durability and supervision

  • Disk buffering. The Shipper owns a persistent disk buffer at shipper.data_dir. A Record the Bridge has handed over survives a Destination outage, a Bridge restart, and a robot reboot; delivery resumes — with end-to-end acknowledgements — once the Destination is reachable again.
  • Backpressure. When a Destination is slow, the Shipper stops acknowledging, and the Bridge propagates that backwards rather than dropping data silently.
  • Supervision. The launch file respawns dc_bridge unconditionally (independent of use_respawn), and the Bridge supervises its Shipper child — including a Linux parent-death signal, so the Shipper can never outlive the Bridge even across a SIGKILL or crash.
  • The one lossy window. Records published while the Bridge is down are dropped: ROS topics are fire-and-forget and nothing buffers upstream of the Bridge. Delivery resumes as soon as the respawned Bridge is ready.
  • Delivery semantics. At-least-once. After a crash or an induced outage, a boundary Record may be re-sent; deduplicate on read if that matters to you.
  • File uploads survive an Uploader crash. An intent is only removed from the disk queue after a successful upload is acknowledged; killing dc_uploader mid-upload loses nothing; the next start replays the same intent from disk.

Data validation

Model

Each Measurement can validate the Records it emits against a model.

We use use JSON schema validator for JSON for Modern C++.

Schemas follow the JSON 7 model:

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Uptime",
    "description": "Time the system has been up",
    "properties": {
        "time": {
            "description": "Time the system has been up",
            "type": "integer",
            "minimum": 0
        }
    },
    "type": "object"
}

Validation is enabled by default (enable_validator: true); disable it per plugin with enable_validator: false when fields are filtered out or added dynamically (e.g. remote paths) in a way the shipped schema doesn't account for.

Failed validation callback

You might want to trigger some actions when a validation fails, e.g send the data to another database to later on debug it.

In this case, you will need to write your own plugin (inherit from an existing one or start from scratch) and define the onFailedValidation function in the class.

This case is covered by the custom plugin demo

Use a different Schema

For each plugin, a default path is provided but this can be changed by passing the json_schema_path parameter in the measurement plugin parameter to the absolute path of your schema.

Groups

Description

A Group merges the Records of several Measurements into one Record, based on time proximity. For example, grouping the cpu and position Measurements publishes a single merged Record on /dc/group/my_group:

{
    "cpu": ...,
    "position": ...,
}

A merged Record is an ordinary Record from there on: it carries the Tag derived from its output topic, and a Destination receives it by listing that topic in inputs.

Envelope fields do not get nested under a member's key: a member's tags is replaced by the Group's own, and the incident_id on its envelope — the id a Measurement stamps on a Record it released as part of an incident — is carried onto the merged Record's own envelope (first non-empty wins, so a partial Record built from a mix of released and live members still carries it). The Bridge lifts that envelope field into the payload's top level, where a postgres Destination has a column for it; buried under <group_key>.incident_id it would simply be dropped by that sink. A member payload that is not a JSON object — a bare scalar or an array — has no such fields to lift, and is merged as it is under its group_key.

Info

The Group node is written in Python: allocating and passing a variable number of inputs to the ApproximateTimeSynchronizer is straightforward there and awkward in C++.

Node parameters

ParameterDescriptionTypeDefault
groupsGroups to enablelist[str]N/A
group_measurement_pluginsCollect the members' plugin fields into the merged Record's plugins listbooltrue

Group parameters

ParameterDescriptionTypeDefault
inputsName of the input topics to grouplist[str]N/A
outputOutput topic to send the data tostr"/dc/group/{group}"
sync_delayDelay to wait during all subscriber data need to reach before being published againfloat5.0
sync_timeoutSeconds an incomplete set may wait before on_sync_timeout applies. 0.0 disables itfloat0.0
on_sync_timeoutWhat to do with an incomplete set once sync_timeout elapses: drop/emit_partialstr"drop"
sync_timeout_log_throttleSeconds between two sync-timeout warnings for this group. 0.0 logs every onefloat60.0
group_keyDictionary key under which data is groupedstr{group_name}
exclude_keysList of keys to exclude from the published data. Data depth is separated by a dot. An entry holding glob syntax (*, ?, [seq]) is a glob over the flattened key, one without is a key prefixlist[str]N/A
nested_dataWhether measurements are nested dictionaries or flatbooltrue
include_group_nameInclude group name in the JSON as key="name" and value=<group_key>booltrue

Incomplete sets

A Group only publishes once every one of its inputs has produced a Record close enough in time. If one Measurement stops publishing — a camera unplugged, a plugin crashed, a gate_condition that never opens — the whole Group goes silent, and the Records of the inputs that are working never reach a Destination.

sync_timeout puts a deadline on that wait. It is measured from the arrival of the first Record of a set, and it is a different thing from sync_delay: sync_delay is the synchroniser's slop, the maximum spread between the timestamps of Records that may be merged together, whereas sync_timeout is real time spent waiting for a set to complete.

sync_timeouton_sync_timeoutBehaviour
0.0(ignored)No deadline. The Group waits indefinitely and the synchroniser evicts stale Records on its own
> 0.0dropThe incomplete set is discarded when the deadline elapses. Nothing is published, a warning is logged
> 0.0emit_partialThe Records that did arrive are published as one partial Record, then the set is discarded

drop is the default, so a Group that sets nothing new behaves exactly as it did before. emit_partial needs a positive sync_timeout to have a deadline to fire on; configured without one, the Group logs an error and falls back to drop.

Info

The deadline is on the set as a whole, not on each input: it starts with the first Record of a set and later Records do not push it back. After a set times out, the Group forgets every Record it was holding, so a Record already published in a partial set is never merged a second time when its late partner finally arrives.

Warnings and throttling

Every timeout logs a warning naming the group, the deadline it missed, and the input topics that produced nothing — including under drop, where the warning is the only trace left since nothing is published:

[WARN] [group_server]: Group 'memory_cpu': no complete set after 10.0s, dropping the
incomplete set (no Record from: ['/dc/measurement/memory'])

A Measurement that dies stays dead, so the group keeps timing out and an unthrottled warning would repeat every sync_timeout for as long as the robot runs. sync_timeout_log_throttle caps this at one warning per group per window (60s by default); set it to 0.0 to log every timeout. The first timeout after a quiet window always logs immediately, and reports how many warnings the throttle swallowed since the last one:

[WARN] [group_server]: Group 'memory_cpu': no complete set after 10.0s, dropping the
incomplete set (no Record from: ['/dc/measurement/memory']) [+5 more in the last 60.0s]

The throttle is per group, so a permanently broken group does not silence the warnings of a healthy one that starts failing. Only the log line is rate-limited — under emit_partial every timeout still publishes its partial Record, throttled or not.

What a partial Record looks like

A partial Record has the same shape as a complete one, minus the inputs that did not report, plus two keys that mark it as partial:

{
    "cpu": { "used": 12.0 },
    "partial": true,
    "missing_inputs": ["/dc/measurement/memory"],
    "tags": [""],
    "name": "memory_cpu"
}
  • A missing input's key is omitted, not present-and-null. The Group node knows a missing input's topic and nothing else: a Record's group_key and its fields both travel inside the Record itself, so there is no correctly shaped null placeholder the Group could invent for a Measurement that never published. Consumers with a flat column schema — the postgres sink maps a Record's top-level keys onto existing columns — see those columns stay NULL for that row, the same as any other column a Record does not populate.
  • partial and missing_inputs appear only on partial Records. A complete Record is byte-for-byte what the Group published before this option existed, so nothing downstream has to change to keep consuming complete Records. Treat the absence of partial as false.
  • missing_inputs lists the input topics that contributed no Record, in inputs order.
  • tags, name and plugins behave as they do on a complete Record — plugins only lists the plugins that actually reported.
  • incident_id behaves the same way: if any member that did arrive was released as part of an incident, the merged Record carries its incident_id, so a partial Record of an incident is still queryable as one.

Add the columns before charting a partial Record

Vector's postgres sink silently drops top-level keys that have no matching column, so partial and missing_inputs are discarded unless you add partial boolean and missing_inputs jsonb to the destination table yourself.

Example

group_server:
  ros__parameters:
    groups: ["memory_cpu", "memory_uptime", "cameras", "map"]
    memory_cpu:
      inputs: ["/dc/measurement/memory", "/dc/measurement/cpu"]
      output: "/dc/group/memory_cpu"
      sync_delay: 5.0
      group_key: "memory_cpu"
    memory_uptime:
      inputs: ["/dc/measurement/memory", "/dc/measurement/uptime"]
      output: "/dc/group/memory_uptime"
      sync_delay: 5.0
      # Publish whatever arrived rather than nothing if one of the two is 10s late
      sync_timeout: 10.0
      on_sync_timeout: "emit_partial"
      group_key: "memory_uptime"
    cameras:
      inputs: ["/dc/measurement/camera"]
      output: "/dc/group/cameras"
      sync_delay: 5.0
      group_key: "cameras"
    map:
      inputs: ["/dc/measurement/map"]
      output: "/dc/group/map"
      sync_delay: 5.0
      group_key: "map"

Lifecycle Manager

dc_lifecycle_manager is DC's nav2-style manager for the nodes that have a meaningful deactivated state. It walks each managed node through the ROS 2 managed-node state machine (configure, activate, deactivate, cleanup, shutdown), watches a bond heartbeat on every node it has activated, and can bring the whole managed set up automatically at launch. See Concepts for the general nav2_util LifecycleNode background this page builds on.

Info

The Bridge (dc_bridge) is deliberately not one of the managed nodes (ADR-0006): it has no meaningful deactivated state, so its readiness is a launch-ordering problem instead — see the boundary and autostart flow below.

What it manages

The set of managed nodes is the node_names parameter, walked in list order for bring-up (configure then activate, each transition applied to every node before the next one starts) and in reverse order for teardown. In every params file this repo ships — dc_bringup.launch.py's inline lifecycle_manager_params and both E2E harness params files — that list is:

lifecycle_manager_dc:
  ros__parameters:
    node_names: ["measurement_server"]
    transitions: [configure, activate]

measurement_server is the only node under lifecycle management today. group_server, when enabled, is launched as a plain Node alongside it — not added to node_names — so it is not lifecycle-managed. Nothing stops a future node_names entry from adding it if it ever needs a deactivated state; the manager is already list-driven for exactly that reason.

The boundary and the autostart flow

dc_bringup.launch.py brings the pipeline up in a fixed order (ADR-0006) so that no Record can be emitted before the Bridge can accept it. The Bridge, its Vector Shipper child, and the bridge_ready_gate process that polls the Bridge's ~/ready (std_srvs/Trigger) service all run outside lifecycle_manager_dc — only once the gate exits 0 does the lifecycle manager exist at all in the launch graph.

flowchart TB
    subgraph outside["Outside the lifecycle manager (ADR-0006)"]
        direction TB
        vector["Vector<br/>(Shipper child process)"]
        bridge["dc_bridge<br/>(plain node, launch respawn)"]
        gate["bridge_ready_gate<br/>(polls ~/ready)"]
        bridge -- "spawns and supervises" --> vector
        bridge -- "~/ready (std_srvs/Trigger)" --> gate
    end
    subgraph managed["Managed by lifecycle_manager_dc"]
        direction TB
        lm["lifecycle_manager_dc<br/>(autostart=true by default)"]
        meas["measurement_server<br/>(only entry in node_names)"]
        lm -- "configure, then activate" --> meas
        meas -- "bond heartbeat (10 Hz)" --> lm
    end

    gate -- "exit 0: Bridge ready" --> lm
    gate -. "exit != 0: never ready" .-> abort["Shutdown(reason=...)<br/>whole launch aborts"]
  1. Bridge first. dc_bridge starts as a plain node and spawns Vector as a supervised child process.
  2. Readiness gate. bridge_ready_gate blocks until ~/ready answers success=True (Vector accepting connections on its ingest socket), or its own timeout_s (default 120 s) expires.
  3. Activation, only on success. dc_bringup.launch.py registers an OnProcessExit handler on the gate: exit 0 starts lifecycle_manager_dc; any other exit code shuts the whole launch down (Shutdown(reason=...)) instead of leaving collection nodes running against a Bridge that never came up.
  4. Autostart. dc_bringup.launch.py's autostart launch argument defaults to True and is always passed through explicitly — overriding the code's own default of false for a manager launched some other way. With autostart=true, lifecycle_manager_dc calls its own startup() as soon as it has constructed lifecycle service clients for every managed node, with no external service call needed. autostart=false leaves the managed set in Unconfigured until something calls the manager's ~/manage_nodes service (nav2_msgs/srv/ManageLifecycleNodes, command STARTUP).

State transitions and bond heartbeats

Each managed node moves through the same five primary states nav2 uses. The manager's five service-level operations (startup, shutdown, reset, pause, resume) are each just a sequence of these per-node transitions applied across every managed node — pause/resume reuse the same deactivate/activate edges startup uses, not separate ones.

A bond (10 Hz heartbeat, bond_timeout — 10 s in dc_bringup.launch.py, 4 s default otherwise) is created for a node the moment it activates and torn down the moment it deactivates. The manager polls every bond every 200 ms; a missed heartbeat past bond_timeout is treated as that node having crashed.

stateDiagram-v2
    [*] --> Unconfigured
    Unconfigured --> Inactive: configure
    Inactive --> Active: activate / resume
    Active --> Inactive: deactivate / pause
    Inactive --> Unconfigured: cleanup
    Unconfigured --> Finalized: shutdown
    Finalized --> [*]

    Active --> Unconfigured: bond heartbeat lost -> reset(hard_reset=true)
    Unconfigured --> Active: node reachable again within\nbond_respawn_max_duration -> startup()

A lost heartbeat does not just deactivate the one node that crashed — checkBondConnections hard-resets every managed node (deactivate then cleanup, continuing past per-node failures since hard_reset=true) and clears all bonds, on the principle that a half-alive managed set is worse than a fully torn-down one. If attempt_respawn_reconnection (default true) is set, a 1 s-period timer then polls whether every managed node's lifecycle service is reachable again:

  • Reachable within bond_respawn_max_duration (default 10 s): the manager calls startup() again — a full configure + activate pass — and resumes normal operation, bonds included.
  • Still unreachable once bond_respawn_max_duration elapses: the manager gives up and leaves the managed set Unconfigured. Recovery from there needs an explicit STARTUP call to ~/manage_nodes (or a relaunch).

~/is_active (std_srvs/srv/Trigger) reports whether the managed set is currently Active, and a diagnostic_updater entry surfaces the same status on /diagnostics.

Measurements

Description

A Measurement is a source of sampled data that emits Records — timestamped JSON documents — on its own ROS topic. For example, a Record from the Memory Measurement:

{
    "flattened": false,
    "memory": {
        "used": 76.007431
    },
    "nested": true,
    "run_id": "358"
}

flattened/nested/run_id are added unconditionally by this node — see below. Fields like robot_name only appear once configured as a custom key (see custom_key_str_list above), and date/host/source_type/tag/ timestamp are added later, by Vector once the Bridge forwards the Record — see a captured example on any demo page for the full shape a Destination actually receives.

Node parameters

This node collects data and publishes it as Records. Each Measurement is a pluginlib plugin loaded into this node and publishes on its own topic_output; the Bridge (dc_bridge) subscribes to those topics and forwards the Records to the Destinations that list them in inputs (see Destinations). Conditions are pluginlib plugins loaded here too — optional predicates that gate whether a Measurement collects, e.g. only when the robot is not moving.

Parameter nameDescriptionType(s)Default
measurement_pluginsName of the measurement plugins to loadlist[str]N/A (mandatory)
condition_pluginsName of the condition plugins to loadlist[str][]
save_local_base_pathPath where files will be saved locally (e.g camera images). Expands $X to environment variables and =Y to custom string parametersstr"$HOME/ros2/data/%Y/%M/%D/%H"
all_base_pathPath where files will be saved at their destination (S3, RustFS...). Expands $X to environment variables and =Y to custom string parametersstr""
custom_key_str_listCustom strings to use in other parameters. They are also appended in the json sent to the destination, and to the File metadata Records of the same Measurementlist[str]N/A
custom_keys_str.force_overrideOverride values if the keys are already present in the measurement. Applies to all and can be overridden by custom_keys_str.<param_name>.force_overrideboolfalse
custom_keys_str.<param_name>.nameKey to add in the serialized datastrN/A (optional)
custom_keys_str.<param_name>.valueValue to set for the key as a fixed stringstrN/A (optional)
custom_keys_str.<param_name>.value_from_filePath to a file containing the value to setstrN/A (optional)
custom_keys_str.<param_name>.force_overrideOverride value if the key is already present in the measurementboolfalse
run_id.enabledIdentify which run the robot is. A new one is generated at every start of the node. Uses either a counter or UUIDbooltrue
run_id.counterEnable counter for the run_idbooltrue
run_id.counter_pathPath to store the last run. It is expanded with environment variables idstr"$HOME/run_id"
run_id.uuidGenerate a new run ID by using a random UUIDboolfalse

robot_name resolution

robot_name is a custom key like any other, but when it appears in custom_key_str_list its value resolves in a fixed order rather than always being a literal string, so a fleet does not need one hand-edited params file per robot:

  1. custom_keys_str.robot_name.value — a literal string, unchanged from before.
  2. custom_keys_str.robot_name.value_from_file — the contents of a file, e.g. one written by the provisioning process.
  3. The machine's hostname — the default when neither of the above is set.

A value_from_file that names a file that cannot be read, or any source that resolves to an empty string, fails node configuration with a clear error rather than shipping Records with a missing or blank robot_name.

Custom keys on Files

The keys listed in custom_key_str_list label a Measurement's Files as well as its Records: dc_uploader (ADR-0014, a separate process from the Bridge) writes them into the file_status and group_complete Records it emits for that Measurement's Files, so both sides of a Destination carry the same labelling. A Record names its custom keys in a custom_keys field for that purpose.

Two limits are worth knowing. A custom key whose name is one the Uploader computes itself (group_name, local_path, remote_path, storage_type, uploaded, size, …) is not written — the Uploader's own value is kept and dc_uploader logs the collision. The keys the rows already carry, robot_name and id (as robot_id), are likewise not repeated, and are not reported: those values are in the row either way. And the column still has to exist in the Destination: the PostgreSQL sink maps JSON keys onto existing columns 1:1, so a new custom key needs an ALTER TABLE on dc_files the same way it needs one on dc_records.

Plugin parameters

Each measurement is collected through a node and has these configuration parameters:

Parameter nameDescriptionType(s)Default
buffer_duration_secSeconds of history to buffer instead of publishing live; 0 disables buffering and preserves normal live publishingfloat0
condition_max_measurementsCollect a maximum of n measurements when conditions are activated (-1 = never, 0 = infinite)int0
cooldown_secSeconds to ignore further FlushEvents once post-roll ends, before buffering re-arms itself; 0 re-arms immediatelyfloat0
debugMore verbose outputboolfalse
enable_validatorWill validate the data against a JSON schemabooltrue
flush_topicTopic to receive the FlushEvent (see Triggers) that releases the buffered window, tagging each Record with the event's incident_idstr"/dc/flush"
gate_conditionName of a Condition that must become true once before any collection is published; then latches open permanently and is never consulted againstrN/A (optional)
group_keyValue of the key used when groupedstr"" (Optional)
if_all_conditionsCollect only if all conditions are activatedlist[str]N/A (optional)
if_any_conditionsCollect if any conditions is activatedlist[str]N/A (optional)
if_none_conditionsCollect only if all conditions are not activatedlist[str]N/A (optional)
include_measurement_nameInclude measurement name in the JSON databooltrue
include_measurement_pluginInclude measurement plugin name in the JSON databoolfalse
init_collectCollect when the node starts instead of waiting the first tickbooltrue
init_max_measurementsCollect a maximum of n measurements when starting the node (-1 = never, 0 = infinite)int0
json_schema_pathPath to the JSON schema, ignored if empty stringstrN/A (optional)
max_flush_rate_hzCeiling on how fast the buffered window is emitted once a flush releases it; 0 releases the whole window in one burstfloat0
pluginName of the plugin to loadstrN/A (mandatory)
polling_intervalInterval to which data is collected in millisecondsint (>=100)1000
post_roll_duration_secSeconds to keep publishing live after a flush, still tagged with the same incident_id; 0 means pre-roll onlyfloat0
remote_keysDestination names the Files this Measurement produces are uploaded to; each becomes a key under the Record's remote_pathslist[str]N/A (optional)
remote_prefixesPrefixes to apply to the remote paths of the Files this Measurement producesstrN/A (optional)
topic_outputTopic where result will be publishedstr"/dc/measurement/<measurement_name>"

buffer_duration_sec and friends: pre-event circular-buffer capture

When buffer_duration_sec is set above 0, this Measurement stops publishing live: each collected sample is instead pushed into an in-memory ring buffer covering the last buffer_duration_sec seconds. A FlushEvent on flush_topic (published by a dc_triggers broadcast node when its Trigger fires) then drives one incident-capture cycle:

  1. Buffering — the default, armed state: samples accumulate in the ring buffer and nothing is published. Only in this state does a FlushEvent start a cycle.
  2. Flushing — the buffered window is published oldest first, each Record tagged with the event's incident_id and stamped with when it was collected, not when it was released. With max_flush_rate_hz left at 0 the whole window goes out in one burst; set above 0, it is emitted at no more than that many Records per second, so a robot recovering from an incident does not also have to absorb the entire window at once. The window is consumed, so the next incident releases its own history rather than replaying this one. Samples collected while a rate-limited release is still draining are buffered, not published, and so become part of the next incident's pre-roll.
  3. PostRoll — for post_roll_duration_sec after the release finishes, samples are published live as they are collected, still tagged with the same incident_id, so the aftermath of the incident is captured too. It runs from the end of the release, not from the FlushEvent, so a rate-limited release does not eat into it. Left at its default 0, this phase is skipped entirely: pre-roll only.
  4. Cooldown — for cooldown_sec after post-roll ends, further FlushEvents are ignored, so a flapping Trigger cannot produce a flood of overlapping incidents. Samples are buffered again during this phase, so the next incident still gets a full pre-roll window.

Files follow their Records. A Measurement that produces Files (camera, map, …) normally leaves them under save_local_base_path for the Bridge to upload as soon as the Record naming them is published — but an armed Measurement publishes nothing, so every File it produces while buffering (also during cooldown, and while a rate-limited release is still draining) is instead moved into a scratch directory beside the save path, <save_local_base_path>/.dc_incident_scratch/<measurement_name>/, and the buffered Record is rewritten to reference the staged copy. That scratch directory rolls on the same buffer_duration_sec window as the Records themselves: a staged File is deleted from disk at the same moment its Record ages out of the ring buffer, so an armed Measurement's Files stay bounded instead of accumulating images no Record will ever carry to the Bridge. On release the staged copies are handed on with the Records referencing them — remote_paths is untouched, so each File uploads to exactly the Destination key it was collected under — and the scratch ring stops tracking them, leaving the Bridge's usual retention sweep and delete_when_sent to clean them up. The Bridge needs no configuration for any of this: a released Record is an ordinary Files Record that happens to be older than usual. Files collected during PostRoll are published live and never staged at all.

The Measurement then re-arms itself back to Buffering with no manual intervention — a second incident is captured exactly like the first. With both post_roll_duration_sec and cooldown_sec left at 0, a flush releases the pre-roll window and the Measurement is armed again immediately.

incident_id is a top-level field of the Record envelope, beside tags, run_id and name — not a key nested inside the measurement's own data — so a postgres Destination stores it in its own incident_id column and "everything from this one event" is a plain WHERE incident_id = '…'. See Destinations for the column the table needs. A Record collected outside an incident carries no incident_id at all, leaving the column NULL. A Group lifts a member's incident_id onto the merged Record the same way it does tags, so grouping does not bury it.

gate_condition vs. if_all/if_any/if_none_conditions

gate_condition is a one-shot arming latch: it names a single Condition plugin (any type under dc_measurements/plugins/conditions/) that suppresses every collection — including the init_collect Record normally published on activation — until that Condition becomes true for the first time. Once armed, the Condition is never consulted again for the lifetime of the node, even if it later becomes false again; re-arming does not happen. This is unlike if_all_conditions/ if_any_conditions/if_none_conditions, which are re-evaluated on every collection and can suppress publishing again once their Conditions change. If the named Condition doesn't exist among condition_plugins, collection is held back permanently and an error is logged.

How if_all/if_any/if_none_conditions combine

The three lists are evaluated on every collection and ANDed together: if_all_conditions objects unless every Condition it names is active, if_any_conditions unless at least one of its Conditions is active, and if_none_conditions unless every Condition it names is inactive. A list left empty never objects, so a Measurement naming no Condition at all always collects. A name that is not among condition_plugins reads as inactive and an error is logged: it blocks collection when listed in if_all_conditions or if_any_conditions, and is accepted by if_none_conditions.

Available plugins:

NameDescription
BatteryCharge percentage, voltage and current of a pack, plus charging sessions and completed cycles
CameraCamera images, images can be rotated and inspected to detect content in images. They are saved as files
Command velocityCommand velocity: navigation commands
CPUCPU statistics
Diagnostics/diagnostics DiagnosticStatus entries, converted to Records so hardware/driver health reaches Destinations
Distance traveledTotal distance traveled by the robot
Driving typeCurrent operating mode — autonomous, manual, teleop or unknown
DummyDummy event, for testing and debugging
Fast DDS statisticseProsima Fast DDS's own Statistics Module: latency, throughput, RTPS packets, physical-layer data. Fast-DDS-specific
FaultComponent diagnostic level transitions: one Record per raise, change or clear, a source for MTBF/MTTR
InterventionHuman takeovers: how often, how long autonomous beforehand, how long the takeover lasted
IP CameraIP camera videos as files
ManipulationOne MoveIt MoveGroup goal's lifecycle — start and end Records with outcome and timing
MapROS map files (yaml and pgm) and metadata used by the robot to localize and navigate
MemorySystem memory usage
Mission Nav2 (NavigateToPose)Nav2 adapter of the Mission Measurement for a single-pose NavigateToPose goal
Mission (nav2 FollowWaypoints)Outcome of a nav2 FollowWaypoints patrol/waypoint-following run, including per-waypoint failures
Mission Nav2 (NavigateThroughPoses)Nav2 adapter of the Mission Measurement for a NavigateThroughPoses goal
Mission (Open-RMF)Open-RMF adapter of the Mission Measurement, reading rmf-web's per-task TaskState
NetworkNetwork interfaces, availability
OSOperating System information
PermissionsPermissions of a file or directory
PositionRobot position
RandomA randomly generated value every polling interval — for exercising the pipeline without robot infrastructure
ROS2 control statusWhen a ros2_control controller or hardware component crosses into or out of the active state
Serial interfaceLine-delimited data off a configurable serial port, parsed into named Record fields
slam_toolbox qualityLocalization quality from slam_toolbox's /pose and loop-closure topics
SpeedRobot speed
StorageAvailable and used space in a directory
String stampedRepublish a string stamped message, can be used for external data
TCP HealthHealth status of a TCP Server
ThermalTemperatures (CPU, GPU, board, …) from the kernel's thermal sysfs interface
UptimeHow long the machine has been turned on

Battery

Description

Records the state of one battery pack from a sensor_msgs/BatteryState topic: charge percentage, voltage and current on the polling interval, plus a Record when a charging session starts and another when it ends. Charging is unavailable time, so the session boundaries are what turn battery data into a shift-utilisation number downstream.

The Measurement emits facts, not metrics: "charging started at 14:02:11 after a discharge of 62 %", never "battery availability is 87 %". Aggregation belongs in SQL views, where the time window is a query parameter.

Every Record carries an event field naming which of the three it is:

eventWhenCarries
sampleEvery polling interval, once the topic publishedpercentage, voltage, current, power_supply_status, completed_cycles, whatever else the pack reports
charge_session_startThe pack starts chargingsession_id, and the depth of the discharge that preceded it
charge_session_endThe pack stops chargingsession_id, duration_sec, and the percentage points gained

Sessions are delimited by the pack's power_supply_status, never by a percentage threshold, so a noisy percentage cannot open and close sessions repeatedly; a status of unknown carries no information and leaves an open session open. Completed cycles are accumulated from discharge depth rather than counted as full discharges — two half discharges are one cycle, not two — so a robot topped up at every dock still reports the wear it actually did. That accounting lives in dc_common::BatteryCycleAccumulator, which has no ROS dependency and is tested on its own; the plugin subscribes, delegates and serialises.

sensor_msgs/BatteryState leaves most fields optional and signals "unmeasured" with NaN. A field the hardware doesn't fill is left out of the Record rather than written as null, so a pack that reports only a voltage still produces a valid Record. Until the input topic publishes at all, no Record is emitted: a gap in the data simply means the battery hasn't reported yet.

Battery health is reported the way the hardware reports it — power_supply_health when the pack sends one, and health_percentage (capacity against design_capacity) only when it sends both.

A robot with two packs runs one Measurement per pack, each with its own topic and topic_output. Like every other Measurement, it can be gated by Conditions and merged into a Group.

Session boundaries and the polling interval

A session boundary is queued when it happens and leaves on the next poll, one Record per poll, so it travels the same path as every other Record (Conditions, incident buffering, Group). It keeps the timestamp of the moment it happened, not of the poll that carried it out.

Parameters

ParameterDescriptionTypeDefault
topicTopic (sensor_msgs/BatteryState) to read the pack from. One Measurement per packstr"/battery_state" (Optional)
percentage_scaleFactor applied to the message's percentage. sensor_msgs/BatteryState specifies a 0-1 range; a driver that already publishes 0-100 is configured with 1.0double100.0 (Optional)

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Battery",
  "description": "Battery state of one pack: charge percentage, voltage and current on the polling interval, plus a Record at each charging session boundary",
  "properties": {
    "event": { "type": "string", "enum": ["sample", "charge_session_start", "charge_session_end"] },
    "power_supply_status": { "type": "string", "enum": ["unknown", "charging", "discharging", "not_charging", "full"] },
    "percentage": { "type": "number", "minimum": 0, "maximum": 100 },
    "voltage": { "type": "number" },
    "current": { "type": "number" },
    "charge": { "type": "number" },
    "capacity": { "type": "number" },
    "design_capacity": { "type": "number" },
    "health_percentage": { "type": "number", "minimum": 0 },
    "temperature": { "type": "number" },
    "present": { "type": "boolean" },
    "power_supply_health": { "type": "string" },
    "power_supply_technology": { "type": "string" },
    "location": { "type": "string" },
    "serial_number": { "type": "string" },
    "completed_cycles": { "type": "integer", "minimum": 0 },
    "session_id": { "type": "integer", "minimum": 1 },
    "duration_sec": { "type": "number", "minimum": 0 },
    "discharge_depth_percent": { "type": "number", "minimum": 0 },
    "start_percentage": { "type": "number", "minimum": 0, "maximum": 100 },
    "end_percentage": { "type": "number", "minimum": 0, "maximum": 100 },
    "charged_percent": { "type": "number" }
  },
  "required": ["event"],
  "type": "object"
}

The full file (plugins/measurements/json/battery.json) also requires power_supply_status on a sample; session_id and discharge_depth_percent on a session start; and session_id plus duration_sec on a session end — the fields the views depend on. Everything else is hardware dependent and therefore optional.

Configuration

...
battery:
  plugin: "dc_measurements/Battery"
  topic_output: "/dc/measurement/battery"
  polling_interval: 10000
  topic: "/battery_state"

Two packs:

...
battery_left:
  plugin: "dc_measurements/Battery"
  topic_output: "/dc/measurement/battery_left"
  topic: "/left/battery_state"
battery_right:
  plugin: "dc_measurements/Battery"
  topic_output: "/dc/measurement/battery_right"
  topic: "/right/battery_state"

Example output

One sample:

{
  "event": "sample",
  "percentage": 62.0,
  "voltage": 48.4,
  "current": -12.5,
  "power_supply_status": "discharging",
  "power_supply_health": "good",
  "capacity": 42.0,
  "design_capacity": 50.0,
  "health_percentage": 84.0,
  "present": true,
  "completed_cycles": 3,
  "serial_number": "PACK-A"
}

A charging session, start and end:

{
  "event": "charge_session_start",
  "session_id": 4,
  "percentage": 31.0,
  "discharge_depth_percent": 62.0
}
{
  "event": "charge_session_end",
  "session_id": 4,
  "duration_sec": 3600.0,
  "start_percentage": 31.0,
  "end_percentage": 97.0,
  "charged_percent": 66.0
}

Camera

Description

Save camera image files: raw, rotated and/or inspected. Images can be inspected using different detection modules (e.g. barcode/QR detection, in-process via ZXing-C++)

Parameters

ParameterDescriptionTypeDefault
cam_nameName to give to the camerastrN/A (mandatory)
cam_topicTopic from where camera data needs to be fetchedstrN/A (mandatory)
camera_info_topicTopic to read the camera intrinsics from, for pose estimationstrcamera_info next to cam_topic
code_sizePhysical side length of the detected codes, in meters. Mandatory when estimate_pose is truedouble0.0
detection_modulesDetection modules to uselist[str](barcode)N/A (optional)
draw_det_barcodesDraw barcode detection on imagesbooltrue
estimate_poseEstimate the pose of each detected code and add it to the Recordboolfalse
pose_frameFrame to transform the estimated pose into. Empty means the camera optical framestr""
rotation_angleRotate the image before inspecting it by this angleint (90, 180, 270)0
transform_timeoutHow long to wait for the pose_frame transform, in secondsdouble0.1
save_detections_imgWhether to save inspected image captured by the camera with detection shapesbooltrue
save_inspected_base64Whether to save inspected image captured by the camera with detection shapes as base64 stringboolfalse
save_inspected_pathPath to save the inspected camera image. Expands environment variables and datetime format are expandedstr"camera/inspected/%Y-%m-%dT%H:%M:%S"
save_raw_base64Whether to save raw image captured by the camera as base64 stringboolfalse
save_raw_imgWhether to save raw image captured by the cameraboolfalse
save_raw_pathPath to save the raw camera image. Expands environment variables and datetimestr"camera/raw/%Y-%m-%dT%H:%M:%S"
save_rotated_base64Whether to save rotated image captured by the camera as base64 stringboolfalse
save_rotated_imgWhether to save rotated image captured by the cameraboolfalse
save_rotated_pathPath to save the rotated camera image. Expands environment variables and datetime format are expandedstr"camera/rotated/%Y-%m-%dT%H:%M:%S"

Code pose estimation

With estimate_pose: true, every detected code carries a pose alongside its bounding box. The pose is solved from the code's four detected corners with cv::solvePnP (SOLVEPNP_IPPE_SQUARE, the solver for four coplanar corners of a square), which needs two things detection alone does not: the code's physical side length (code_size, in meters) and the camera intrinsics, read from camera_info_topic. It is off by default and costs nothing when off — no camera_info subscription is created and Records are unchanged.

Frame and convention

The pose is the code's pose, not the robot's — where the code is as seen from the robot. By default it is expressed in the camera optical frame (the frame_id of the camera_info message, following REP 103: X right, Y down, Z forward along the lens axis), so z is the depth of the code in front of the camera. The code's own frame is centered on the code and uses those same axes — X right, Y down, Z into its printed face — so a code seen square-on has the identity orientation, and roll/pitch/yaw read as how far off square-on it was. (This is the ArUco/OpenCV marker frame turned 180° about X: that convention puts Z out of the face towards the camera, which would make a square-on read a 180° roll.)

Set pose_frame to have the pose transformed into a robot frame (base_link, map, …) via TF before it is written. Every Record says which frame it is in: the emitted pose.frame_id is the frame actually used, so if the transform is unavailable within transform_timeout the pose is still reported — in the camera optical frame, with a warning logged, rather than dropped. pose.distance is the camera-to-code range in meters and is unaffected by pose_frame.

rotation_angle is handled: the corners are mapped back to raw-image coordinates before the solve, so the intrinsics still describe the image they were calibrated on.

camera:
  plugin: "dc_measurements/Camera"
  cam_topic: "/front_camera/image_raw"
  # camera_info_topic defaults to /front_camera/camera_info, next to cam_topic
  cam_name: my_camera_with_codes
  detection_modules: ["barcode"]
  estimate_pose: true
  code_size: 0.2       # meters, side length of the printed code
  pose_frame: "base_link"
  transform_timeout: 0.1
{
  "camera_name": "my_camera_with_codes",
  "inspected": {
    "barcode": [
      {
        "data": "0001", "type": "QRCode",
        "top": 210, "left": 295, "width": 84, "height": 84,
        "pose": {
          "frame_id": "base_link",
          "x": 1.482, "y": 0.037, "z": 0.611,
          "roll": 0.0, "pitch": 0.0, "yaw": 3.139,
          "distance": 1.483
        }
      }
    ]
  }
}

A pose is only as good as code_size and the calibration

The scale of the estimate comes entirely from code_size: a code declared 20 cm wide that is really 10 cm reports every distance twice as far as it is. Likewise the intrinsics are taken as published — if camera_info carries an uncalibrated or placeholder camera matrix, the pose is wrong without being flagged. Codes seen nearly edge-on or only a few pixels wide are also poorly conditioned; use pose.distance to filter those out downstream.

Two systematic biases are worth knowing about before treating a pose as a measurement rather than a hint:

  • Corner convention. A detector locates the code to within about one module, so range carries a bias of roughly one module width — a few percent for a low-version QR code. This is a bias, not noise: averaging repeated reads does not remove it.
  • Non-square codes. SOLVEPNP_IPPE_SQUARE assumes the four corners bound a square. A stretched or rectangular code is solved to a compromise scale. dc_simulation's own qrcode_* assets are exactly this case — a 290x365 texture over a 0.5 x 0.5 m face makes the printed code 0.362 m across and 0.292 m down — which is why the demo sets code_size to a mid-value of 0.325 and why the simulation check tolerates a metre of error rather than centimetres.

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Camera",
  "description": "Camera images with detected objects",
  "properties": {
    "camera_name": {
      "description": "Name of the camera",
      "type": "string"
    },
    "local_paths": {
      "description": "Paths of saved images",
      "type": "object",
      "items": {
        "$ref": "#/$defs/paths"
      }
    },
    "remote_paths": {
      "description": "Dictionary of paths where metadata and images will be remotely stored",
      "type": "object",
      "additionalProperties": {
        "type": "object",
        "items": {
          "$ref": "#/$defs/paths"
        }
      }
    },
    "inspected": {
      "description": "Inspected content of an image",
      "type": "object",
      "items": {
        "$ref": "#/$defs/inspected"
      }
    }
  },
  "$defs": {
    "paths": {
      "type": "object",
      "properties": {
        "raw": {
          "description": "Raw image",
          "type": "string"
        },
        "rotated": {
          "description": "Rotated image",
          "type": "string"
        },
        "inspected": {
          "description": "Inspected image",
          "type": "string"
        }
      }
    },
    "inspected": {
      "type": "object",
      "properties": {
        "barcode": {
          "description": "Barcode inspected data",
          "type": "array",
          "items": {
            "$ref": "#/$defs/barcode"
          }
        }
      }
    },
    "barcode": {
      "type": "object",
      "properties": {
        "data": {
          "description": "Barcode data",
          "type": "string"
        },
        "height": {
          "description": "Barcode height",
          "type": "integer"
        },
        "width": {
          "description": "Barcode width",
          "type": "integer"
        },
        "top": {
          "description": "Barcode top position",
          "type": "integer"
        },
        "left": {
          "description": "Barcode left position",
          "type": "integer"
        },
        "type": {
          "description": "Barcode type",
          "type": "string"
        },
        "pose": {
          "description": "Pose of the code, present only when estimate_pose is enabled",
          "$ref": "#/$defs/pose"
        }
      }
    },
    "pose": {
      "type": "object",
      "properties": {
        "frame_id": {
          "description": "Frame the pose is expressed in",
          "type": "string"
        },
        "x": {
          "description": "Code position along the frame's X axis, in meters",
          "type": "number"
        },
        "y": {
          "description": "Code position along the frame's Y axis, in meters",
          "type": "number"
        },
        "z": {
          "description": "Code position along the frame's Z axis, in meters",
          "type": "number"
        },
        "roll": {
          "description": "Code orientation about the frame's X axis, in radians",
          "type": "number"
        },
        "pitch": {
          "description": "Code orientation about the frame's Y axis, in radians",
          "type": "number"
        },
        "yaw": {
          "description": "Code orientation about the frame's Z axis, in radians",
          "type": "number"
        },
        "distance": {
          "description": "Straight-line distance from the camera to the code, in meters",
          "type": "number"
        }
      }
    }
  },
  "type": "object"
}

Configuration

The remote paths are also saved in the JSON under <measurement_name>._img_paths.(raw|rotated|inspected). If images want to be sent to a self-hosted S3-compatible store such as RustFS, add "rustfs" in remote_keys. This will add a remote path that can later be used in your API.

Note that this remote key is not included in the JSON schema, which only contains the local paths. If you want to enforce the schema with your custom remote key, you will need to write it and load it manually.

...
camera:
  plugin: "dc_measurements/Camera"
  group_key: "camera_with_codes"
  topic_output: "/dc/measurement/camera_with_codes"
  polling_interval: 10000
  init_collect: true
  node_name: "dc_measurement_camera"
  cam_topic: "/camera_with_codes"
  cam_name: my_camera_with_codes
  enable_validator: false
  draw_det_barcodes: true
  save_raw_img: true
  save_rotated_img: false
  save_detections_img: true
  save_raw_path: "camera_with_codes/raw/%Y-%m-%dT%H-%M-%S"
  save_rotated_path: "camera_with_codes/rotated/%Y-%m-%dT%H-%M-%S"
  save_inspected_path: "camera_with_codes/inspected/%Y-%m-%dT%H-%M-%S"
  rotation_angle: 0
  detection_modules: ["barcode"]
  remote_prefixes: [""]
  remote_keys: ["rustfs"] # Will create paths for RustFS, does not send the file

Destination (dc_bridge) configuration

Now that the path is set, it can be used to know where to send the image. The Destination name (rustfs) must match the remote_keys entry above — the Uploader matches a Record's remote_paths keys against receives: files Destination names (see Destinations):

dc_bridge:
  ros__parameters:
    destinations: ["rustfs", "pgsql"]
    rustfs:
      type: s3
      receives: files
      inputs: ["/dc/group/cameras"]
      endpoint: "http://127.0.0.1:9000"
      access_key_id: "XEYqG4ZcPY5jiq5i"
      secret_access_key: "ji011KCtI82ZeQS6UwsQAg8x9VR4lSaQ"
      force_path_style: true
      bucket: "mybucket"
    files:
      metadata_destination: "pgsql"  # a receives: records Destination for status rows

Example output

With estimate_pose: true (same sample as Code pose estimation above):

{
  "camera_name": "my_camera_with_codes",
  "inspected": {
    "barcode": [
      {
        "data": "0001", "type": "QRCode",
        "top": 210, "left": 295, "width": 84, "height": 84,
        "pose": {
          "frame_id": "base_link",
          "x": 1.482, "y": 0.037, "z": 0.611,
          "roll": 0.0, "pitch": 0.0, "yaw": 3.139,
          "distance": 1.483
        }
      }
    ]
  }
}

A plain capture with estimate_pose: false (no pose field, otherwise identical shape) has not been added yet.

Cmd_vel

Description

Collect command velocity sent to the robot by subscribing to cmd_vel topic.

Parameters

ParameterDescriptionTypeDefault
topicTopic to subscribe to to get the cmd_vel (geometry_msgs/Twist)str"/cmd_vel"

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Cmd_vel",
    "description": "Command velocity sent to the robot",
    "properties": {
        "computed": {
            "description": "Computed command velocity in meter/s",
            "type": "number"
        },
        "linear": {
            "description": "Linear velocity as a vector",
            "type": "object",
            "items": {
                "$ref": "#/$defs/vector3"
            }
        },
        "angular": {
            "description": "Angular velocity as a vector",
            "type": "object",
            "items": {
                "$ref": "#/$defs/vector3"
            }
        }
    },
    "$defs": {
        "vector3": {
            "type": "object",
            "properties": {
                "x": {
                    "description": "X speed",
                    "type": "number"
                },
                "y": {
                    "description": "Y speed",
                    "type": "number"
                },
                "z": {
                    "description": "Z speed",
                    "type": "number"
                }
            }
        }
    },
    "type": "object"
}

Configuration

...
cmd_vel:
  plugin: "dc_measurements/CmdVel"
  group_key: "cmd_vel"
  topic_output: "/dc/measurement/cmd_vel"

Example output

{
  "angular": {
    "x": 0.0,
    "y": 0.0,
    "z": 0.1
  },
  "computed": 0.3,
  "flattened": false,
  "linear": {
    "x": 0.3,
    "y": 0.0,
    "z": 0.0
  },
  "name": "cmd_vel",
  "nested": false,
  "run_id": "169"
}

CPU

Description

Collect cpu usage: average cpu, number of processes running and processes sorted by cpu usage.

Parameters

ParameterDescriptionTypeDefault
cpu_minFilters out processes using less than this cpu usage. -1 to disablefloat5.0
max_processesMax amount of processes to collect in the sorted field. -1 for allint5

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Cpu",
    "description": "CPU statistics",
    "properties": {
        "average": {
            "description": "Average CPU",
            "type": "number",
            "minimum": 0
        },
        "processes": {
            "description": "Number of processes running",
            "type": "integer",
            "minimum": 0
        },
        "sorted": {
            "description": "Processes sorted by CPU usage",
            "type": "array",
            "items": {
                "$ref": "#/$defs/process"
            }
        }
    },
    "$defs": {
        "process": {
            "type": "object",
            "description": "Process information",
            "properties": {
                "pid": {
                    "description": "Process ID of the process",
                    "type": "integer"
                },
                "user": {
                    "description": "User who started the process",
                    "type": "string"
                },
                "cmd": {
                    "description": "Command that launched the process",
                    "type": "string"
                },
                "cpu": {
                    "description": "Process' current utilization as a percentage of total CPU time",
                    "type": "number"
                },
                "ram": {
                    "description": "Memory in use by this process in kb",
                    "type": "integer"
                },
                "uptime": {
                    "description": "Age of the process in seconds",
                    "type": "integer"
                }
            }
        }
    },
    "type": "object"
}

Configuration

...
cpu:
  plugin: "dc_measurements/Cpu"
  topic_output: "/dc/measurement/cpu"
  max_processes: 10
  cpu_min: 10.0

Example output

{
  "average": 20.487500071525574,
  "flattened": false,
  "name": "cpu",
  "nested": false,
  "processes": 8,
  "run_id": "169",
  "sorted": []
}

sorted is empty here because no process crossed cpu_min (10%) in this poll window.

Diagnostics

Description

Subscribes to /diagnostics (diagnostic_msgs/DiagnosticArray) and converts matching DiagnosticStatus entries into a Record, so hardware/driver health reaches Destinations and dashboards like any other Measurement. Each DiagnosticStatus.values key/value pair is preserved as-is in the Record rather than being flattened into the message string.

/diagnostics is typically high-volume and mostly unchanging, so level_threshold and names are provided to shrink what gets collected. Pair this Measurement with the same_as_previous condition (if_none_conditions) to also skip republishing when nothing has changed since the previous collection.

Parameters

ParameterDescriptionTypeDefault
topicTopic to subscribe to for diagnosticsstr"/diagnostics" (Optional)
level_thresholdMinimum status level to collect: "OK", "WARN", "ERROR", or "STALE"str"OK" (Optional)
namesAllowlist of DiagnosticStatus.name values to collect; empty collects alllist[str][] (Optional)

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Diagnostics",
  "description": "Diagnostic statuses collected from /diagnostics",
  "properties": {
    "statuses": {
      "description": "Diagnostic statuses matching the configured level threshold and name allowlist",
      "type": "array",
      "items": {
        "title": "DiagnosticStatus",
        "description": "A single diagnostic_msgs/DiagnosticStatus entry",
        "properties": {
          "name": {
            "description": "Reporting component name",
            "type": "string"
          },
          "message": {
            "description": "Human-readable status summary",
            "type": "string"
          },
          "hardware_id": {
            "description": "Hardware identifier",
            "type": "string"
          },
          "level": {
            "description": "Status level: 0=OK, 1=WARN, 2=ERROR, 3=STALE",
            "type": "integer",
            "minimum": 0,
            "maximum": 3
          },
          "values": {
            "description": "Key/value pairs reported by the status, preserved as-is",
            "type": "object"
          }
        },
        "required": ["name", "message", "level", "values"],
        "type": "object"
      }
    }
  },
  "required": ["statuses"],
  "type": "object"
}

Configuration

...
diagnostics:
  plugin: "dc_measurements/Diagnostics"
  topic_output: "/dc/measurement/diagnostics"
  level_threshold: "WARN"
  names: ["motor_driver", "battery"]
  if_none_conditions: ["diagnostics_unchanged"]

diagnostics_unchanged:
  plugin: "dc_conditions/SameAsPrevious"
  keys: []
  exclude: []

Example output

{
  "flattened": false,
  "name": "diagnostics",
  "nested": false,
  "run_id": "169",
  "statuses": [
    {
      "hardware_id": "motor_driver_01",
      "level": 2,
      "message": "Overcurrent detected",
      "name": "motor_driver",
      "values": {
        "current_amps": "8.4"
      }
    }
  ]
}

Dummy

Description

The dummy measurement, generates dummy events. It is useful for testing, debugging, benchmarking and getting started with ROS 2 Data collection.

Parameters

ParameterDescriptionTypeDefault
recordDummy JSON record.str"{"message":"Hello from ROS 2 DC"}"

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Dummy",
    "description": "Dummy JSON",
    "properties": {
        "message": {
            "description": "Dummy message",
            "type": "string"
        }
    },
    "type": "object"
}

Configuration

...
dummy:
  plugin: "dc_measurements/Dummy"
  topic_output: "/dc/measurement/dummy"

Example output

{
  "flattened": false,
  "message": "Hello from ROS 2 DC",
  "name": "dummy",
  "nested": false,
  "run_id": "169"
}

Distance traveled

Description

Reports the straight-line distance moved between the robot's current TF pose (robot_base_frame in global_frame) and its pose at the previous poll — a per-poll delta, despite the field's name. Sum it downstream (e.g. SUM(distance_traveled) in a SQL view) to get a cumulative total since power-on.

distance_traveled is a per-poll delta

collect() computes sqrt((x - last_x)^2 + (y - last_y)^2) against the position recorded on the previous poll and overwrites last_x_/last_y_ with the current one — it never accumulates into a sum. A stationary robot reports 0.0 every poll; a robot that moved 3 m since the last poll reports 3.0 once, then 0.0 again once it stops. The Measurement's own name and its schema's "description": "Total distance traveled in meters" both suggest a running total, but that's misleading given what the code actually does. This is a known issue, still unfixed and unrenamed.

Parameters

ParameterDescriptionTypeDefault
global_frameGlobal framestr"map"
robot_base_frameRobot base framestr"base_link"
transform_timeoutTF Timeout to use for transformationfloat0.1

transform_timeout is not actually configurable

onConfigure() declares a transform_tolerance parameter (default 0.1) but then reads back transform_timeout — a name that was never declared — into the member this Measurement actually uses. rclcpp's get_parameter(name, out) silently no-ops on an undeclared name rather than throwing, so neither key you might set in YAML reaches the TF lookup: transform_tolerance is declared but never read, and transform_timeout is read but never declared. The member is left uninitialized (float transform_timeout_;, no default), so the TF-lookup timeout actually used at runtime is whatever that memory happened to contain, not the 0.1 shown above. Known, tracked in dc_measurements/plugins/measurements/distance_traveled.cpp, not yet fixed.

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Distance traveled",
    "description": "Total distance traveled in meters by the robot",
    "properties": {
        "distance_traveled": {
            "description": "Total distance traveled in meters",
            "type": "number"
        }
    },
    "type": "object"
}

Configuration

...
distance_traveled:
  plugin: "dc_measurements/DistanceTraveled"
  topic_output: "/dc/measurement/distance_traveled"
  global_frame: "map"
  robot_base_frame: "base_link"
  transform_timeout: 0.1

Example output

The poll during which the robot's TF pose moved 3 m since the previous poll:

{
  "distance_traveled": 3.0,
  "flattened": false,
  "name": "distance_traveled",
  "nested": false,
  "run_id": "169"
}

The next poll, stationary:

{
  "distance_traveled": 0.0,
  "flattened": false,
  "name": "distance_traveled",
  "nested": false,
  "run_id": "169"
}

Driving type

Description

Reports the robot's current operating mode (autonomous, manual, teleop, or unknown) as a single Record, so every other Measurement can be segmented by driving mode downstream. There is no standard ROS message for "current driving mode", so this Measurement is entirely configuration driven and supports two common shapes, chosen by which parameters are set (configuring both is a configuration error):

  • Dedicated mode topic: subscribes to mode_topic (std_msgs/String) and maps each raw value it carries to a mode through value_mapping_from/value_mapping_to. A raw value with no entry in the mapping is ignored (the previous mode is kept) rather than treated as unknown, since an unrecognized value is more likely an upstream hiccup than an actual mode change.
  • Velocity source inference: subscribes to velocity_topics (geometry_msgs/Twist, e.g. one topic per command source such as a Nav2 output and a joystick teleop node) and reports the mode of whichever configured source last published, mapped through the parallel velocity_modes list. A source that hasn't published within velocity_timeout_s is no longer considered active.

The emitted mode is always one of the four values above -- a documented, closed set -- so downstream grouping/dashboards never see an unbounded string. Before any mode has been observed (no Measurement configured, a dedicated mode topic that hasn't published yet, or every velocity source past its timeout) the Measurement reports "unknown" rather than skipping the Record: a Record is always published on every poll, so a gap in driving_type data means the plugin itself stopped, not "mode currently unknown".

Parameters

ParameterDescriptionTypeDefault
mode_topicTopic (std_msgs/String) carrying a raw mode value, mapped through value_mapping_from/value_mapping_to. Mutually exclusive with velocity_topicsstr"" (Optional)
value_mapping_fromRaw values received on mode_topic, aligned by index with value_mapping_tolist of str[] (Optional)
value_mapping_toMode each value_mapping_from entry maps to; must be one of autonomous, manual, teleop, unknownlist of str[] (Optional)
velocity_topicsVelocity command topics (geometry_msgs/Twist) to infer the mode from, aligned by index with velocity_modes. Mutually exclusive with mode_topiclist of str[] (Optional)
velocity_modesMode each velocity_topics entry reports while it's the most recently active sourcelist of str[] (Optional)
velocity_timeout_sSeconds since a velocity source's last message before it's no longer considered activedouble1.0 (Optional)

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "DrivingType",
  "description": "Current driving/operating mode of the robot, so every other metric can be segmented by mode",
  "properties": {
    "mode": {
      "description": "Driving mode: 'unknown' until a mode has been observed (dedicated mode topic) or a configured velocity source has published within 'velocity_timeout_s' (velocity-source inference)",
      "type": "string",
      "enum": ["autonomous", "manual", "teleop", "unknown"]
    }
  },
  "required": ["mode"],
  "type": "object"
}

Configuration

Dedicated mode topic:

...
driving_type:
  plugin: "dc_measurements/DrivingType"
  topic_output: "/dc/measurement/driving_type"
  mode_topic: "/driving_mode_raw"
  value_mapping_from: ["0", "1", "2"]
  value_mapping_to: ["manual", "autonomous", "teleop"]

Velocity source inference:

...
driving_type:
  plugin: "dc_measurements/DrivingType"
  topic_output: "/dc/measurement/driving_type"
  velocity_topics: ["/nav2/cmd_vel", "/teleop/cmd_vel"]
  velocity_modes: ["autonomous", "teleop"]
  velocity_timeout_s: 1.0

Example output

{
  "mode": "autonomous"
}

Fast DDS statistics

Fast DDS-specific

This Measurement links against eProsima's Fast-DDS-statistics-backend C++ library and reads data Fast DDS's own Statistics Module produces. It only means anything when the deployment runs Fast DDS as its RMW — Cyclone DDS and other RMW implementations have no equivalent library to read from (checked: cyclonedds-insight, eProsima's own recently-announced tool, is GUI-only with no headless export path). It builds conditionally: dc_measurements/CMakeLists.txt looks for fastdds_statistics_backend and skips building this plugin — with a clear message(STATUS ...), not a failed workspace build — when it isn't found. Configuring fastdds_stats: {plugin: "dc_measurements/FastddsStats"} without the library built fails pluginlib's load loudly (missing library), rather than silently doing nothing.

Description

Reads eProsima Fast DDS's own Statistics Module — latency, throughput and RTPS packet counts between the DomainParticipants, DataWriters and DataReaders it discovers on a DDS domain — through Fast-DDS-statistics-backend, and emits one sample Record per polling interval. It follows the same periodic-sample convention as Battery's sample event and Uptime: everything reported is scoped to the window since the previous poll, reset each time rather than accumulated since the Measurement started.

Unlike every other Measurement, fastdds_stats has no input topic: it starts a StatisticsBackend monitor on a DDS domain at onConfigure() and queries that domain's own statistics registry directly on each poll, rather than subscribing to anything. A robot's whole DDS graph (every node's participants, on whatever domain it runs) is visible to one Measurement instance, so normally one fastdds_stats Measurement per robot is enough.

Each sample reports:

  • Discovered entity counts (participant_count, datawriter_count, datareader_count)
  • Mean write-to-notification latency across every matched DataWriter/DataReader pair (latency_ns_mean, nanoseconds — Fast DDS's own unit for this statistic)
  • Mean data rate sent and received (publication_throughput_bytes_per_sec_mean, subscription_throughput_bytes_per_sec_mean, bytes/second)
  • RTPS packets sent and lost, summed across every participant (rtps_packets_sent, rtps_packets_lost)
  • Physical-layer data: each discovered participant's name/guid, and the names of the hosts, OS users and processes running them (hosts, users, process_names — not processes, which the CPU Measurement's total process count already owns in the shared dc table) — useful for spotting which machine or process is actually behind a noisy participant on a multi-process robot

A field tied to a DataKind (latency, throughput, packet counts) is absent when nothing reported data in the window — a domain with one lonely participant and no matched DataWriter/DataReader pair yet still produces a valid Record, just without a latency_ns_mean.

What init_monitor(domain_id) does

onConfigure() calls StatisticsBackend::init_monitor(domain_id) once, which is what makes Fast DDS's Statistics Module start reporting for that domain at all — nothing is collected on a domain no Measurement has called init_monitor() for. onCleanup() calls stop_monitor() to tear it back down on a lifecycle transition.

Prerequisites

  1. Fast DDS itself built with -DFASTDDS_STATISTICS=ON — the Statistics Module is compiled out by default, and most distro/apt Fast DDS builds do not enable it. ROS 2 Rolling pairs with Fast-DDS 3.x — rebuild the Fast-DDS set upstream's ros2.repos pins for this distro in a colcon workspace overlay with that cmake arg, then source the overlay before the rest of the workspace so it shadows the apt-installed Fast DDS.

  2. fastdds_statistics_backend built and installed against that same Fast-DDS. Pin a v2.x tag — that line is what targets Fast-DDS 3.0.0 (find_package(fastdds 3.0.0 REQUIRED)), which is what Rolling ships; the older v1.0.0/v1.1.0 releases target Fast-DDS >=2.13.0 and won't build against it. No rosdep/apt key exists for any distro, so rosdep install never pulls it in and dc_measurements/package.xml deliberately does not list it as a <depend>.

  3. The FASTDDS_STATISTICS environment variable, set on every process before it creates its first DomainParticipant — the library only emits the DataKinds named in it (semicolon- separated topic aliases), regardless of whether the plugin is built and running:

    export FASTDDS_STATISTICS="HISTORY_LATENCY_TOPIC;PUBLICATION_THROUGHPUT_TOPIC;SUBSCRIPTION_THROUGHPUT_TOPIC;RTPS_SENT_TOPIC;RTPS_LOST_TOPIC"
    

    Without it, participant_count/datawriter_count/datareader_count/participants/hosts/ users/process_names still populate (basic discovery data), but latency_ns_mean and every throughput/RTPS-packet field stay permanently absent — not intermittently, every single poll.

Without #1/#2, find_package(fastdds_statistics_backend) fails at CMake configure time and the plugin — and its test — are skipped from the build entirely.

Parameters

ParameterDescriptionTypeDefault
domain_idDDS domain to monitor (the same value ROS_DOMAIN_ID would use)int0 (Optional)

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "FastddsStats",
  "properties": {
    "event": { "type": "string", "enum": ["sample"] },
    "domain_id": { "type": "integer", "minimum": 0 },
    "participant_count": { "type": "integer", "minimum": 0 },
    "datawriter_count": { "type": "integer", "minimum": 0 },
    "datareader_count": { "type": "integer", "minimum": 0 },
    "latency_ns_mean": { "type": "number", "minimum": 0 },
    "publication_throughput_bytes_per_sec_mean": { "type": "number", "minimum": 0 },
    "subscription_throughput_bytes_per_sec_mean": { "type": "number", "minimum": 0 },
    "rtps_packets_sent": { "type": "integer", "minimum": 0 },
    "rtps_packets_lost": { "type": "integer", "minimum": 0 },
    "participants": { "type": "array" },
    "hosts": { "type": "array", "items": { "type": "string" } },
    "users": { "type": "array", "items": { "type": "string" } },
    "process_names": { "type": "array", "items": { "type": "string" } }
  },
  "required": ["event", "domain_id", "participant_count", "datawriter_count", "datareader_count"],
  "type": "object"
}

The full file (plugins/measurements/json/fastdds_stats.json) also spells out each participants[] entry's name/guid properties.

Configuration

...
fastdds_stats:
  plugin: "dc_measurements/FastddsStats"
  topic_output: "/dc/measurement/fastdds_stats"
  polling_interval: 5000
  domain_id: 0

Example output

Captured from a real run (all three prerequisites above met):

{
  "custom_keys": ["robot_name"],
  "datareader_count": 1,
  "datawriter_count": 11,
  "domain_id": 0,
  "event": "sample",
  "flattened": false,
  "hosts": ["d:14058711922191368192"],
  "name": "fastdds_stats",
  "nested": false,
  "participant_count": 3,
  "participants": [
    { "guid": "01.0f.4d.26.9c.1d.72.46.00.00.00.00|0.0.1.c1", "name": "/" },
    { "guid": "01.0f.4d.26.13.27.ae.cb.00.00.00.00|0.0.1.c1", "name": "/" },
    { "guid": "01.0f.4d.26.25.27.c4.fd.00.00.00.00|0.0.1.c1", "name": "/" }
  ],
  "process_names": ["7580", "10003", "10021"],
  "robot_name": "C3PO",
  "run_id": "172",
  "users": ["root"]
}

latency_ns_mean and every throughput/RTPS field are absent here: nothing exchanged data on a matched DataWriter/DataReader pair within this particular 5-second poll window, and per the Statistics Backend's own contract, absence is how "nothing to report" is signaled. A busier DDS graph (more topics, higher rate) makes them appear more often, though never guaranteed on every poll.


<footer id="open-on-gh">Found a bug? <a href="https://github.com/minipada/ros2_data_collection/edit/main/doc/src/dc/measurements/fastdds_stats.md">Edit this page on GitHub.</a></footer>

Fault

Description

Reports the transitions diagnostics's periodic snapshots leave a consumer to reconstruct: diagnostics writes the current DiagnosticStatus level on every poll, so recovering "when did this component break, and when was it fixed" means scanning for where a value changed in SQL -- exactly the failure mode transition Records exist to prevent. Fault subscribes to the same /diagnostics topic (diagnostic_msgs/DiagnosticArray) and emits one Record per component only when its level actually changes, feeding each watched component's stream of (level, timestamp) samples to its own dc_common::StateTransitionDetector, a header-only dc_common type generic over the state being tracked and owning no clock of its own.

Every Record carries the component, the level it left (from_level) and entered (to_level), how long the previous level had been held (previous_level_duration_s), and the status message as the reason. Levels are the DiagnosticStatus constants OK, WARN, ERROR and STALE -- reported by name, so a silent (STALE) component is never confused with a broken (ERROR) one.

Each transition is also classified as an event:

  • raise -- the component left OK. A new fault opens ("state": "open"), and fault_started_at records when.
  • change -- a transition between two non-OK levels (e.g. WARN to ERROR). The fault stays open; fault_started_at is unchanged from the raise that opened it.
  • clear -- the component returned to OK. The fault closes ("state": "closed") and the Record carries duration_s, the length of the whole fault, alongside the same fault_started_at the raise carried -- so the pair can be joined downstream for MTTR. MTBF is the gap between successive raise events for a component.

A fault still open when collection stops keeps its last Record's "state": "open" and no duration_s: no closing Record is invented, so it cannot be read as a zero-length outage. Every Record carries a seq that increments by one across every watched component, so a dropped Record is a detectable gap rather than a silently shortened outage. A component observed already faulted on its very first sample has no fault_started_at on that Record -- the detector treats the first sample as a baseline rather than a transition, since it never saw the fault actually start.

Parameters

ParameterDescriptionTypeDefault
topicTopic to subscribe to for diagnosticsstr"/diagnostics" (Optional)
namesWhich DiagnosticStatus.name values to watch; empty watches every component seenlist[str][] (Optional)

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Fault",
  "properties": {
    "seq": { "type": "integer", "minimum": 1 },
    "component": { "type": "string" },
    "event": { "type": "string", "enum": ["raise", "clear", "change"] },
    "from_level": { "type": "string", "enum": ["OK", "WARN", "ERROR", "STALE"] },
    "to_level": { "type": "string", "enum": ["OK", "WARN", "ERROR", "STALE"] },
    "previous_level_duration_s": { "type": "number", "minimum": 0 },
    "reason": { "type": "string" },
    "state": { "type": "string", "enum": ["open", "closed"] },
    "fault_started_at": { "type": "string" },
    "duration_s": { "type": "number", "minimum": 0 }
  },
  "required": ["seq", "component", "event", "from_level", "to_level", "previous_level_duration_s", "reason", "state"],
  "type": "object"
}

Configuration

...
fault:
  plugin: "dc_measurements/Fault"
  topic_output: "/dc/measurement/fault"
  group_key: "fault"
  topic: "/diagnostics"
  names: ["motor_driver", "battery"]

Example output

A raise:

{
  "seq": 1,
  "component": "motor_driver",
  "event": "raise",
  "from_level": "OK",
  "to_level": "ERROR",
  "previous_level_duration_s": 3612.4,
  "reason": "Motor fault",
  "state": "open",
  "fault_started_at": "2026-08-18T09:12:33.123456Z"
}

and its clear:

{
  "seq": 2,
  "component": "motor_driver",
  "event": "clear",
  "from_level": "ERROR",
  "to_level": "OK",
  "previous_level_duration_s": 214.9,
  "reason": "Motor nominal",
  "state": "closed",
  "fault_started_at": "2026-08-18T09:12:33.123456Z",
  "duration_s": 214.9
}

Intervention

Description

Reports human takeovers: how often somebody had to step in, how long the robot had been running itself beforehand, and how long the takeover lasted. It is a projection of driving-mode transitions -- it reads the same mode signal Driving type reads (the shared DrivingModeSource, so both Measurements are configured with the same parameters) and hands it to dc_common::StateTransitionDetector (#360), rather than running its own separate detection. The two therefore cannot disagree about when the robot was autonomous.

A takeover is a transition between autonomous and one of the two human-driven modes, manual or teleop -- this set is fixed and has no configuration knob, because dc_kpi_intervention_events (tools/infrastructure/sql/kpi_views.sql, #369) already matches these values literally; a custom mode name would silently never count downstream.

Two Records per takeover:

  • a start Record, when the mode leaves autonomous for manual or teleop. open is true.
  • an end Record, when the mode returns to autonomous. open is false.

Both carry from_mode, to_mode, and previous_duration: the dwell of from_mode, in seconds. On a start Record that is how long the robot had been autonomous before the takeover; on an end Record it is exactly how long the takeover itself lasted, because from_mode is the mode that just ended either way. sequence is the transition's own monotonically increasing number (from StateTransitionDetector), so a dropped Record shows up as a gap rather than silently corrupting a duration.

A takeover still open when collection stops simply never gets a matching end Record: nothing downstream can average an interval that was never closed as a zero, because there is no end Record to average. Nothing is published on a poll that saw no takeover boundary, so unlike driving_type this Measurement is silent most of the time. A transition that is not a boundary -- autonomy handing over to unknown because the mode signal went stale, or one human mode replacing another directly -- produces no Record.

Rates (interventions per autonomous hour, per kilometre travelled) are deliberately not computed here: they are views over these Records (dc_kpi_intervention_rate), so the denominator can change without touching a robot. Set group_key to merge an intervention with position in a Group and put the takeover on the site map.

Parameters

The mode-source parameters are the same ones Driving type takes, with the same meaning: mode_topic, value_mapping_from, value_mapping_to, velocity_topics, velocity_modes, velocity_timeout_s. Configuring both mode_topic and velocity_topics is a configuration error.

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Intervention",
  "description": "A human takeover, derived from driving-mode transitions: one Record when it starts, one when it ends",
  "properties": {
    "event": { "type": "string", "enum": ["start", "end"] },
    "from_mode": { "type": "string", "enum": ["autonomous", "manual", "teleop", "unknown"] },
    "to_mode": { "type": "string", "enum": ["autonomous", "manual", "teleop", "unknown"] },
    "previous_duration": { "type": "number", "minimum": 0 },
    "sequence": { "type": "integer", "minimum": 1 },
    "open": { "type": "boolean" }
  },
  "required": ["event", "from_mode", "to_mode", "previous_duration", "sequence", "open"],
  "type": "object"
}

Configuration

...
intervention:
  plugin: "dc_measurements/Intervention"
  topic_output: "/dc/measurement/intervention"
  group_key: "intervention"
  velocity_topics: ["/cmd_vel_smoothed", "/teleop/cmd_vel"]
  velocity_modes: ["autonomous", "teleop"]
  velocity_timeout_s: 1.0

Example output

Start:

{
  "event": "start",
  "from_mode": "autonomous",
  "to_mode": "teleop",
  "previous_duration": 412.5,
  "sequence": 7,
  "open": true
}

and end:

{
  "event": "end",
  "from_mode": "teleop",
  "to_mode": "autonomous",
  "previous_duration": 37.2,
  "sequence": 8,
  "open": false
}

Ip Camera

Description

Records video in small segments (in case of a cut) and store it locally. They are first stored in a temporary folder. Once the record (of e.g 10 seconds) is done, it is moved to another directory.

Compared to other plugins, the collect function only moves the files from the temporary location, it does not start the recording. It takes some time to establish connection, so we avoid doing this every time. Recording is started at initialization by an ffmpeg process and saved in HLS format.

Parameters

ParameterDescriptionTypeDefault
inputInput urlstrN/A (Mandatory)
videoEnable video recordingbooltrue
audioEnable audio recordingboolfalse
bitrate_videoVideo bitratestr([0-9]+[kmKM])"2M"
bitrate_audioAudio bitratestr([0-9]+[kmKM])"192k"
segmentRecords by small segment, managed by ffmpegbooltrue
segment_timeDuration of a segmentint (>0)10
ffmpeg_log_levelFfmpeg log levelstr (See doc)"info"
ffmpeg_bannerShow ffmpeg banner in consolebooltrue
save_pathPath used to save files with ffmpeg, UTC date is usedstr"ffmpeg_%Y-%m-%dT%H:%M:%S"

The global save_local_base_path default crashes this Measurement

onConfigure() expands %Y/%M/%D/%H in the directory it creates (dc_util::expand_time(storage_dir_)), but collect() later iterates storage_dir_ unexpanded, as a literal path — and the default global save_local_base_path ($HOME/ros2/data/%Y/%M/%D/%H, see Measurements) contains exactly those placeholders. The first collect() call throws an uncaught std::filesystem::filesystem_error ("cannot open directory: No such file or directory") and takes down the whole measurement_server process, along with every other Measurement it runs. This is a known issue, still unfixed. Set a save_local_base_path with no % placeholders (e.g. /var/lib/dc) to avoid it; save_path itself is unaffected, since only its parent directory is extracted into storage_dir_.

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Ip Camera",
    "description": "Local and remote path where the remote camera video is recorded",
    "properties": {
        "local_path": {
            "description": "Local video path",
            "type": "string"
        },
        "remote_path": {
            "description": "Remote video path",
            "type": "string"
        }
    },
    "type": "object"
}

Configuration

...
ip_camera:
  plugin: "dc_measurements/IpCamera"
  topic_output: "/dc/measurement/ip_camera"
  input: "rtsp://192.168.0.10:554/stream1"
  segment_time: 10
  save_path: "ip_camera/%Y-%m-%dT%H-%M-%S"

Example output

Captured from a real run, input pointed at a real-time MPEG-TS/TCP test stream (save_local_base_path overridden per the warning above):

{
  "data_src": "ip_camera",
  "flattened": false,
  "local_path": "/root/dc_capture_out/ip_camera/ffmpeg_2026-09-06T00:02:30.ts",
  "name": "ip_camera",
  "nested": false,
  "remote_path": "/ip_camera/ffmpeg_2026-09-06T00:02:30.ts",
  "run_id": "169",
  "timestamp": "ffmpeg_2026-09-06T00:02:30"
}

Manipulation

Description

Reports one MoveIt MoveGroup goal's lifecycle: a manipulation_start Record when the goal is accepted, a manipulation_end Record when it reaches a terminal state. This is a manipulation goal — it moves a robot arm for one planning group. MoveIt has its own outcome vocabulary here: one flat signed MoveItErrorCodes space, distinct from nav2's succeeded/failed/cancelled/aborted split used by the Mission Measurements.

The Measurement is a passive observer: it never sends the goal itself. It reads the action's status topic (<action_name>/_action/status, published for every goal the server knows about, regardless of which client sent it) to see a goal appear and reach a terminal state, then calls the action's get_result service -- a plain service any client may call given the goal's UUID, not only the one that sent it -- to read MoveGroup::Result's error_code and planning_time. group_name is this instance's own configuration rather than something read off the goal: MoveGroup's Goal (the only place a group name appears) is sent privately to the action server and never broadcast, so a passive observer structurally cannot see it. Run one Manipulation Measurement instance per planning group your robot moves.

outcome on the end Record is derived from error_code, not from the action's own terminal status: SUCCESS maps to succeeded, PREEMPTED maps to cancelled (the closest thing MoveIt has), and every other -- necessarily negative -- MoveItErrorCodes value maps to failed. sequence is a monotonically increasing counter across every Record this instance emits, so a dropped Record shows up as a gap rather than silently corrupting a downstream count.

A goal still executing when collection stops simply never gets a matching manipulation_end Record: nothing downstream can average an interval that was never closed as a zero, because there is no end Record to average.

Parameters

ParameterDescriptionDefault
action_nameThe MoveGroup action to watchmove_action
group_nameThe planning group this instance reports on the emitted Recordnone, required

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Manipulation",
  "description": "One MoveIt MoveGroup goal's lifecycle: a Record when it is accepted, one when it reaches a terminal state",
  "properties": {
    "event": {
      "description": "Whether this Record is the start or the end of one manipulation goal",
      "type": "string",
      "enum": ["manipulation_start", "manipulation_end"]
    },
    "goal_id": {
      "description": "The MoveGroup goal's own UUID, as a canonical hex string, correlating a start Record with its end",
      "type": "string"
    },
    "group_name": {
      "description": "The planning group this Measurement instance watches, as configured -- not read off the goal, which is never broadcast",
      "type": "string"
    },
    "sequence": {
      "description": "Monotonically increasing across every Record from this Measurement instance, so a dropped Record shows up as a gap",
      "type": "integer",
      "minimum": 1
    },
    "outcome": {
      "description": "Derived from MoveIt's own error_code: SUCCESS succeeds, PREEMPTED is the closest thing MoveIt has to cancelled, everything else fails",
      "type": "string",
      "enum": ["succeeded", "cancelled", "failed"]
    },
    "error_code": {
      "description": "MoveIt's own numeric MoveItErrorCodes, carried through verbatim",
      "type": "integer"
    },
    "planning_time": {
      "description": "Seconds MoveGroup spent planning, from MoveGroup::Result",
      "type": "number",
      "minimum": 0
    },
    "duration_sec": {
      "description": "How long the goal took from accepted to terminal",
      "type": "number",
      "minimum": 0
    }
  },
  "required": ["event", "goal_id", "group_name", "sequence"],
  "type": "object"
}

The full file (plugins/measurements/json/manipulation.json) also requires outcome, error_code, planning_time and duration_sec on a manipulation_end Record.

Configuration

...
manipulation:
  plugin: "dc_measurements/Manipulation"
  topic_output: "/dc/measurement/manipulation"
  group_key: "manipulation"
  action_name: "move_action"
  group_name: "arm"

Example output

Start:

{
  "event": "manipulation_start",
  "goal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "group_name": "arm",
  "sequence": 5
}

and end:

{
  "event": "manipulation_end",
  "goal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "group_name": "arm",
  "sequence": 6,
  "outcome": "succeeded",
  "error_code": 1,
  "planning_time": 0.842,
  "duration_sec": 3.15
}

Map

Description

Save map using nav2_map_server and collect the map of the local map saved. The measurement also includes metadata: width, height and x and y origin.

Parameters

ParameterDescriptionTypeDefault
quietDisable stdout for nav2 map saverbooltrue
save_base64Also save the PNG image as a base64 string, under base64.pngboolfalse
save_pathPath to save the map to. Environment variables and datetime format are expandedstr"map/%Y-%m-%dT%H:%M:%S"
save_map_timeoutTime to wait to save the mapfloat3.0
topicTopic to subscribe to to get the mapstr"/map"

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Map",
    "description": "Map saved metadata and paths",
    "properties": {
        "resolution": {
            "description": "Resolution of the map, meters/pixel",
            "type": "number",
            "minimum": 0
        },
        "local_paths": {
            "description": "Paths where metadata and image are stored",
            "type": "object",
            "items": {
                "$ref": "#/$defs/paths"
            }
        },
        "remote_paths": {
            "description": "Dictionary of paths where metadata and image will be remotely stored",
            "type": "object",
            "additionalProperties": {
                "type": "object",
                "items": {
                    "$ref": "#/$defs/paths"
                }
            }
        },
        "origin": {
            "description": "Robot origin position in meters",
            "type": "object",
            "items": {
                "$ref": "#/$defs/origin"
            }
        },
        "width": {
            "description": "Width of the PGM",
            "type": "integer",
            "minimum": 0
        },
        "height": {
            "description": "Height of the PGM",
            "type": "integer",
            "minimum": 0
        }
    },
    "$defs": {
        "origin": {
            "type": "object",
            "description": "The 2-D pose of the lower-left pixel in the map, as (x, y, yaw), with yaw as counterclockwise rotation (yaw=0 means no rotation). Many parts of the system currently ignore yaw.",
            "properties": {
                "x": {
                    "description": "X origin of the robot",
                    "type": "number"
                },
                "y": {
                    "description": "Y origin of the robot",
                    "type": "number"
                }
            }
        },
        "paths": {
            "type": "object",
            "properties": {
                "png": {
                    "description": "Path to the map PNG file containing the image",
                    "type": "string"
                },
                "yaml": {
                    "description": "Path to the map YAML file containing map metadata",
                    "type": "string"
                },
                "pgm": {
                    "description": "Path to the map PGM file containing the gray-scale image",
                    "type": "string"
                }
            }
        }
    },
    "type": "object"
}

Configuration

...
map:
  plugin: "dc_measurements/Map"
  topic_output: "/dc/measurement/map"
  topic: "/map"
  save_path: "map/%Y-%m-%dT%H:%M:%S"
  save_map_timeout: 0.2
  quiet: true
  remote_keys: ["s3"]

Example output

A 4x4 test grid, captured without remote_keys configured (so no remote_paths):

{
  "flattened": false,
  "height": 4,
  "local_paths": {
    "pgm": "/tmp/dc_capture/map_out/2026-09-05T23:46:20.pgm",
    "png": "/tmp/dc_capture/map_out/2026-09-05T23:46:20.png",
    "yaml": "/tmp/dc_capture/map_out/2026-09-05T23:46:20.yaml"
  },
  "name": "map",
  "nested": false,
  "origin": {
    "x": 0.0,
    "y": 0.0
  },
  "resolution": 0.05000000074505806,
  "run_id": "169",
  "width": 4
}

Memory

Description

Collect memory used in percentage.

Parameters

This Measurement has no parameters beyond the common Plugin parameters.

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Memory",
    "description": "Memory used",
    "properties": {
        "used": {
            "description": "Memory used in percent",
            "type": "number",
            "minimum": 0
        }
    },
    "type": "object"
}

Configuration

...
memory:
  plugin: "dc_measurements/Memory"
  topic_output: "/dc/measurement/memory"

Example output

{
  "flattened": false,
  "name": "memory",
  "nested": false,
  "run_id": "169",
  "used": 91.44149017333984
}

Mission (nav2 FollowWaypoints)

Description

Reports the outcome of a nav2 FollowWaypoints mission -- a patrol or waypoint-following run, including nav2's own per-waypoint failure reporting -- as one mission_start Record when a goal is accepted and one mission_end Record when it reaches a terminal state. It is the FollowWaypoints sibling of the NavigateToPose Mission Measurement (#387): same Record schema, same mission_id/sequence conventions, its own mission_type.

This Measurement is a passive watcher: it never sends a FollowWaypoints goal itself. Whatever already dispatches waypoint-following missions on the robot -- nav2_simple_commander, a WMS integration, a teleop panel -- keeps doing exactly that; this Measurement only observes. That is a deliberate match to every other Measurement's read-only relationship to the systems it reports on, and it is also why it does not use rclcpp_action::Client's typed goal-tracking API: that API only reports on goals the client itself sent. Instead it subscribes directly to the action's _action/status topic (action_msgs/msg/GoalStatusArray, the same message type for every action) to see a goal get accepted or reach a terminal state, and calls the action's _action/get_result service directly once it does, to fetch the Result -- both standard, public parts of the ROS 2 action wire protocol that any client may use, sender or not.

Known limitation: number_of_loops

nav2's FollowWaypoints goal carries a number_of_loops field (how many times to repeat the route), but the goal itself is never re-published anywhere a third party can observe it -- only the sender and the action server ever see it, and nothing in the ROS 2 action protocol (nor rclcpp_action::Client's public API) lets a client that did not send a goal read it back. A passive watcher therefore cannot report number_of_loops, and this Measurement's Records do not carry it. Sending the goal itself instead of watching it would make this Measurement responsible for driving navigation, contradicting DC's role as a telemetry pipeline (see CONTEXT.md) and directly competing with whatever else already commands the same action -- e.g. dc_demos/dc_demos/qrcodes_waypoint_follower.py, which already sends FollowWaypoints goals of its own. If a deployment needs number_of_loops on this Record, it has to come from the mission commander via a documented escape hatch (#305's territory), not from this adapter.

Parameters

ParameterTypeDefaultDescription
action_namestringfollow_waypointsThe nav2 FollowWaypoints action to watch

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "MissionNav2FollowWaypoints",
  "properties": {
    "event": { "type": "string", "enum": ["mission_start", "mission_end"] },
    "mission_id": { "type": "string" },
    "mission_type": { "type": "string", "const": "follow_waypoints" },
    "sequence": { "type": "integer", "minimum": 1 },
    "outcome": { "type": "string", "enum": ["succeeded", "failed", "cancelled", "aborted"] },
    "reason": { "type": "string" },
    "error_code": { "type": "integer", "minimum": 0 },
    "duration_sec": { "type": "number", "minimum": 0 },
    "missed_waypoints": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "index": { "type": "integer", "minimum": 0 },
          "error_code": { "type": "integer", "minimum": 0 }
        },
        "required": ["index", "error_code"]
      }
    }
  },
  "required": ["event", "mission_id", "mission_type", "sequence"],
  "type": "object"
}

outcome, duration_sec and missed_waypoints are required on mission_end only. reason and error_code are required on mission_end when outcome is failed or aborted: failed is a goal that reached nav2's GoalStatus.SUCCEEDED but whose FollowWaypoints::Result.error_code is still non-zero -- nav2's WaypointFollower can finish a route having missed a waypoint without the action itself aborting.

Configuration

...
mission:
  plugin: "dc_measurements/MissionNav2FollowWaypoints"
  topic_output: "/dc/measurement/mission"
  group_key: "mission"
  action_name: "follow_waypoints"

Example output

Start:

{
  "event": "mission_start",
  "mission_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "mission_type": "follow_waypoints",
  "sequence": 5
}

end, succeeded:

{
  "event": "mission_end",
  "mission_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "mission_type": "follow_waypoints",
  "sequence": 6,
  "outcome": "succeeded",
  "duration_sec": 184.2,
  "missed_waypoints": []
}

and end, failed on a missed waypoint:

{
  "event": "mission_end",
  "mission_id": "6c9f6a3e-2d1a-4e3a-9f7a-1b2c3d4e5f60",
  "mission_type": "follow_waypoints",
  "sequence": 8,
  "outcome": "failed",
  "reason": "task executor failed",
  "error_code": 601,
  "duration_sec": 92.6,
  "missed_waypoints": [
    { "index": 2, "error_code": 601 }
  ]
}

error_code is FollowWaypoints::Result.error_code verbatim -- in nav2_msgs, its only non-zero values are UNKNOWN (600) and TASK_EXECUTOR_FAILED (601); missed_waypoints[].error_code is nav2's per-waypoint error_code from the same table. nav2's WaypointStatus (new since Lyrical) also carries the waypoint's pose, a status enum and a free-text error_msg -- none part of this schema (the index key keeps the Jazzy-line Record shape downstream consumers already receive); reason/error_msg above are always the mission-level FollowWaypoints::Result.error_msg, not anything per-waypoint.

Mission Nav2 (NavigateToPose)

Description

The nav2 adapter of the Mission Measurement for nav2's NavigateToPose action -- a single-pose navigation goal, the base case the mission lifecycle contract (#305, recorded in ADR-0010) was agreed against. Emits a mission_start Record when a goal is accepted and a mission_end Record once it reaches a terminal state. NavigateThroughPoses and FollowWaypoints are its siblings (#388/#389): same Record schema, same mission_id/sequence conventions, each with its own mission_type.

This Measurement is a passive watcher: it never sends a NavigateToPose goal itself. Whatever already dispatches navigation missions on the robot -- nav2's bt_navigator, a fleet orchestrator, an operator command -- keeps doing exactly that; this Measurement only observes. That is a deliberate match to every other Measurement's read-only relationship to the systems it reports on, and it is also why it does not use rclcpp_action::Client's typed goal-tracking API: that API only reports on goals the client itself sent. Instead it subscribes directly to the action's _action/status (action_msgs/msg/GoalStatusArray, the same message type for every action) and _action/feedback topics to see a goal get accepted, pick up its recovery count, and reach a terminal state, and calls the action's _action/get_result service directly once it does, to fetch the Result -- all standard, public parts of the ROS 2 action wire protocol that any client may use, sender or not.

nav2's NavigateToPose action server processes one goal at a time, so this Measurement only ever tracks one mission at once. A second goal accepted before the first reaches a terminal state is logged and otherwise ignored -- it produces no Record of its own, and the mission already being tracked is unaffected.

outcome on the end Record is one of succeeded, failed, cancelled, aborted: CANCELED maps to cancelled, ABORTED to aborted (both carrying nav2's own error_msg/error_code as reason/error_code), and SUCCEEDED maps to succeeded unless NavigateToPose::Result.error_code is non-zero, in which case it is failed -- an application-level failure nav2 reported without aborting the goal status itself.

recoveries (from NavigateToPose::Feedback.number_of_recoveries, when feedback was seen before completion) is carried on the end Record when available.

A mission still running when collection stops simply never gets a matching mission_end Record: nothing downstream can average an interval that was never closed as a zero, because there is no mission_end Record to average. sequence is a monotonically increasing counter across every Record this Measurement instance emits (start and end alike, not per mission_id), so a dropped Record shows up as a gap rather than silently corrupting a duration or a mission-success-rate denominator.

Parameters

ParameterTypeDefaultDescription
action_namestringnavigate_to_poseThe nav2 NavigateToPose action to watch

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "MissionNav2",
  "properties": {
    "event": { "type": "string", "enum": ["mission_start", "mission_end"] },
    "mission_id": { "type": "string" },
    "mission_type": { "type": "string", "const": "navigate_to_pose" },
    "sequence": { "type": "integer", "minimum": 1 },
    "outcome": { "type": "string", "enum": ["succeeded", "failed", "cancelled", "aborted"] },
    "reason": { "type": "string" },
    "error_code": { "type": "integer", "minimum": 0 },
    "duration_sec": { "type": "number", "minimum": 0 },
    "recoveries": { "type": "integer", "minimum": 0 }
  },
  "required": ["event", "mission_id", "mission_type", "sequence"],
  "type": "object"
}

outcome and duration_sec are required on mission_end only. reason and error_code are required on mission_end when outcome is failed or aborted.

Configuration

...
mission:
  plugin: "dc_measurements/MissionNav2"
  topic_output: "/dc/measurement/mission"
  group_key: "mission"
  action_name: "navigate_to_pose"

Example output

Start:

{
  "event": "mission_start",
  "mission_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "mission_type": "navigate_to_pose",
  "sequence": 7
}

and end (succeeded):

{
  "event": "mission_end",
  "mission_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "mission_type": "navigate_to_pose",
  "sequence": 8,
  "outcome": "succeeded",
  "duration_sec": 96.4,
  "recoveries": 1
}

and end (aborted):

{
  "event": "mission_end",
  "mission_id": "6c9f6a3e-2d1a-4e3a-9f7a-1b2c3d4e5f60",
  "mission_type": "navigate_to_pose",
  "sequence": 10,
  "outcome": "aborted",
  "reason": "tf timeout",
  "error_code": 9102,
  "duration_sec": 12.1
}

Mission Nav2 (NavigateThroughPoses)

Description

The nav2 adapter of the Mission Measurement for nav2's NavigateThroughPoses action -- the action a deployment issues when a mission is a single job through several hard-constraint poses in one call, rather than a chain of separate NavigateToPose goals (that sibling adapter is mission_nav2, #387). Emits a mission_start Record when a goal is first observed and a mission_end Record once it reaches a terminal state, in the same Record schema #387 defines, with mission_type: "navigate_through_poses".

This Measurement is a passive observer of the action server's single active goal. It never sends a goal itself. Instead it subscribes to the action's own _action/status and _action/feedback topics and calls its _action/get_result service directly for whichever goal_id just reached a terminal status -- the same standard per-action topics/services every rclcpp_action::Server (nav2's bt_navigator included) exposes. Whatever in the deployment actually dispatches missions (a BT navigator, a fleet orchestrator, an operator command) keeps doing so exactly as before; this Measurement only watches.

Two Records per mission:

  • a mission_start Record, the first time a goal_id is observed on the action's status topic.
  • a mission_end Record, once that goal_id reaches a terminal status (SUCCEEDED, CANCELED, or ABORTED) and its result has been fetched.

outcome on the end Record is one of succeeded, failed, cancelled, aborted: CANCELED maps to cancelled, ABORTED to aborted (both carrying nav2's own error_msg/error_code as reason/error_code), and SUCCEEDED maps to succeeded unless NavigateThroughPoses::Result.error_code is non-zero, in which case it is failed -- an application-level failure nav2 reported without aborting the goal status itself.

recoveries (from NavigateThroughPoses::Feedback.number_of_recoveries, when feedback was seen before completion) is carried on the end Record when available.

No per-waypoint outcome is reported. The acceptance criteria this Measurement was built against ask for the final waypoint_statuses (which poses in the job succeeded versus failed) to be represented on mission_end, following an upstream nav2 NavigateThroughPoses.action result field of that name. That field exists on the nav2 this repository actually builds against (nav2_msgs from navigation2's main branch, pinned in ros2_data_collection.repos -- its NavigateThroughPoses::Result carries WaypointStatus[] waypoint_statuses) but this Measurement does not surface it yet: an earlier implementation reported it, and was trimmed when the repository tracked a distro branch whose NavigateThroughPoses::Result had no such field (fatal error: nav2_msgs/msg/waypoint_status.hpp: No such file or directory). Surfacing it again against the current pin is a follow-up, not something this Measurement does today.

A mission still running when collection stops simply never gets a matching mission_end Record: nothing downstream can average an interval that was never closed as a zero, because there is no mission_end Record to average. sequence is a monotonically increasing counter across every Record this Measurement instance emits (start and end alike, not per mission_id), so a dropped Record shows up as a gap rather than silently corrupting a duration or a mission-success-rate denominator.

Parameters

ParameterDefaultDescription
action_namenavigate_through_posesThe NavigateThroughPoses action to watch. Its _action/status, _action/feedback, and _action/get_result endpoints are subscribed/called directly.

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "MissionNav2ThroughPoses",
  "properties": {
    "event": { "type": "string", "enum": ["mission_start", "mission_end"] },
    "mission_id": { "type": "string" },
    "mission_type": { "type": "string" },
    "sequence": { "type": "integer", "minimum": 1 },
    "outcome": { "type": "string", "enum": ["succeeded", "failed", "cancelled", "aborted"] },
    "reason": { "type": "string" },
    "error_code": { "type": "integer", "minimum": 0 },
    "duration_sec": { "type": "number", "minimum": 0 },
    "recoveries": { "type": "integer", "minimum": 0 }
  },
  "required": ["event", "mission_id", "sequence"],
  "type": "object"
}

Configuration

...
mission_nav2_through_poses:
  plugin: "dc_measurements/MissionNav2ThroughPoses"
  topic_output: "/dc/measurement/mission_nav2_through_poses"
  group_key: "mission"
  action_name: "navigate_through_poses"

Example output

Start:

{
  "event": "mission_start",
  "mission_id": "3f2504e04f8911d39a0c0305e82c3301",
  "mission_type": "navigate_through_poses",
  "sequence": 7
}

and end (succeeded):

{
  "event": "mission_end",
  "mission_id": "3f2504e04f8911d39a0c0305e82c3301",
  "mission_type": "navigate_through_poses",
  "sequence": 8,
  "outcome": "succeeded",
  "duration_sec": 96.4,
  "recoveries": 1
}

and end (aborted):

{
  "event": "mission_end",
  "mission_id": "9f86d081884c7d659a2feaa0c55ad015",
  "mission_type": "navigate_through_poses",
  "sequence": 10,
  "outcome": "aborted",
  "reason": "tf timeout",
  "error_code": 9102,
  "duration_sec": 12.1
}

Mission (Open-RMF)

Description

The Open-RMF adapter of the Mission Measurement for Open-RMF's TaskState (the document rmf-web's API server maintains per task). Emits a mission_start Record when a task's status first leaves queued/standby into an active state, and a mission_end Record once it reaches a terminal status, in the same mission_start/mission_end Record schema #387 defines -- mission_id sourced from Open-RMF's own booking.id, mission_type from category (Open-RMF tasks have a real category/type, unlike nav2's bare goal).

Unlike the nav2 adapters (mission_nav2/mission_nav2_follow_waypoints/ mission_nav2_through_poses), Open-RMF exposes no ROS action or topic for task lifecycle. This Measurement instead consumes TaskState as a stream of JSON documents over a plain ws:// connection, using the reusable dc_common::WebSocketJsonClient. The websocket protocol rmf-web's API server actually speaks on its own endpoints (Socket.IO framing) is out of scope -- websocket_url must point at an endpoint that re-emits the task-state feed as one TaskState JSON object per plain text frame; bridging the real rmf-web wire protocol is a separate piece of infrastructure this Measurement assumes already exists, not something it does itself.

This Measurement is a passive observer. It never dispatches, cancels, or otherwise mutates any Open-RMF task -- it only watches the state stream.

Status mapping

Open-RMF's task model is richer than a flat start/end pair: TaskState.status is a continuously-updated 12-value enum (uninitialized, blocked, error, failed, queued, standby, underway, delayed, skipped, canceled, killed, completed). This Measurement still boils it down to two Records per mission:

  • a mission_start Record, the first time a booking.id is observed leaving queued/standby into an active state -- underway, delayed, or (once already active) blocked/error.
  • a mission_end Record, once that booking.id reaches a terminal status: completed, failed, canceled, killed, or skipped.

outcome on the end Record is one of succeeded, failed, cancelled, aborted: completed→succeeded, failed→failed, canceled→cancelled, killed→aborted.

Two cases the acceptance criteria asked to be resolved explicitly, not silently defaulted:

  • skipped is treated as task-terminal, and maps to outcome cancelled. It is defined in the same status enum task_state.json uses for the task's own top-level status field, the same enum that also carries in-progress phases, so a task can legitimately end its life with status: "skipped". Open-RMF's skipped means the task's work was bypassed rather than performed, which is closer to DC's cancelled (the work simply didn't complete) than to succeeded (the work was done) or a failure outcome. DC's outcome contract (#305/#387) has exactly four values; this Measurement maps into those four rather than adding a fifth skipped outcome.
  • blocked and error are treated as transient. Both describe a task Open-RMF is still actively trying to resolve or recover (a blocked path, a recoverable fault) -- they are absent from task_state.json's terminal set, so its task manager never settles on either as the end of the task's life. A task observed as blocked/error stays open; only a later terminal status closes it, and it may still resolve back to underway.

reason is populated on a best-effort basis, carried verbatim from whichever part of TaskState Open-RMF actually populated for that outcome -- dispatch.errors (or the top-level detail) for failed, cancellation.labels for cancelled, killed.labels for aborted. Unlike nav2's always-present error_msg, Open-RMF does not guarantee one of these for every outcome, so reason (and error_code, only ever populated for failed) may be absent even on a mission_end Record.

duration_sec prefers Open-RMF's own unix_millis_start_time/unix_millis_finish_time when the source provided both; it falls back to this Measurement's own locally observed start/end timestamps otherwise.

What this Measurement does not represent

  • No phase-level detail. TaskState.phases/active/completed/pending (which step of a multi-phase task is running) is not carried into the Record. Only the task's own top-level status drives mission_start/mission_end.
  • No interruptions. TaskState.interruptions (temporary holds placed on a task, distinct from cancellation) is not represented at all -- an interrupted-then-resumed task simply keeps running from this Measurement's point of view.
  • No dispatch/assignment detail. Which fleet or robot a task was assigned to (TaskState.assigned_to/dispatch.assignment) is not carried into the Record.

A task still active when the websocket connection drops or collection stops simply never gets a matching mission_end Record: nothing downstream can average an interval that was never closed, because there is no mission_end Record to average -- matching #387's open-interval handling. Symmetrically, a task whose first-ever observed sample is already active (started before this Measurement connected) or already terminal (finished before this Measurement connected) is not tracked at all: no mission_start Record (the active-transition boundary was never observed) and consequently no mission_end Record either.

Parameters

ParameterDefaultDescription
websocket_url(required)ws://host[:port][/path] of the endpoint streaming TaskState JSON, one object per text frame.
reconnect_initial_backoff_ms500Delay before the first reconnect attempt after a dropped connection.
reconnect_max_backoff_ms30000Reconnect delay never grows past this, however many attempts fail in a row.

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "MissionOpenRmf",
  "properties": {
    "event": { "type": "string", "enum": ["mission_start", "mission_end"] },
    "mission_id": { "type": "string" },
    "mission_type": { "type": "string" },
    "sequence": { "type": "integer", "minimum": 1 },
    "outcome": { "type": "string", "enum": ["succeeded", "failed", "cancelled", "aborted"] },
    "reason": { "type": "string" },
    "error_code": { "type": "integer", "minimum": 0 },
    "duration_sec": { "type": "number", "minimum": 0 }
  },
  "required": ["event", "mission_id", "sequence"],
  "type": "object"
}

Configuration

...
mission_open_rmf:
  plugin: "dc_measurements/MissionOpenRmf"
  topic_output: "/dc/measurement/mission_open_rmf"
  group_key: "mission"
  websocket_url: "ws://rmf-task-state-bridge:8080/task_states"

Example output

Start:

{
  "event": "mission_start",
  "mission_id": "delivery.dispenser_1.dispatch-14",
  "mission_type": "delivery",
  "sequence": 3
}

end (succeeded):

{
  "event": "mission_end",
  "mission_id": "delivery.dispenser_1.dispatch-14",
  "mission_type": "delivery",
  "sequence": 4,
  "outcome": "succeeded",
  "duration_sec": 214.7
}

end (cancelled, cancellation labels present):

{
  "event": "mission_end",
  "mission_id": "patrol.loop_a.dispatch-22",
  "mission_type": "patrol",
  "sequence": 9,
  "outcome": "cancelled",
  "reason": "operator; dashboard",
  "duration_sec": 42.1
}

end (failed, dispatch error present):

{
  "event": "mission_end",
  "mission_id": "delivery.dispenser_2.dispatch-31",
  "mission_type": "delivery",
  "sequence": 15,
  "outcome": "failed",
  "reason": "dispenser_unavailable: dispenser_1 did not respond",
  "error_code": 12,
  "duration_sec": 8.9
}

Network

Description

Collects ping value, whether or not the PC is online and interfaces available. ping() opens an unprivileged ICMP "ping" socket (socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)) — no root or CAP_NET_RAW needed, only a permissive net.ipv4.ping_group_range (the Linux default is permissive for the root group, which is what containers and most robot processes run as). This works unmodified in a plain rootless Podman/Docker container, with no --cap-add or --privileged needed.

Parameters

ParameterDescriptionTypeDefault
ping_addressIP address to test the ping tostr"8.8.8.8"
ping_timeoutTime in ms before ping times out. Offline if superior to itint200

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Network",
    "description": "Network accessibility and information",
    "properties": {
        "ping": {
            "description": "Time to ping the host in ms",
            "type": "integer",
            "minimum": -1
        },
        "online": {
            "description": "If the pc is online",
            "type": "boolean"
        },
        "interfaces": {
            "description": "List of network interfaces",
            "type": "array",
            "items": {
                "type": "string"
            }
        }
    },
    "type": "object"
}

Configuration

...
network:
  plugin: "dc_measurements/Network"
  topic_output: "/dc/measurement/network"
  ping_address: 192.168.0.1
  ping_timeout: 500

Example output

Captured from a plain rootless Podman container, no special flags, ping_address pointed at localhost:

{
  "flattened": false,
  "interfaces": ["lo", "tunl0", "enp0s31f6"],
  "name": "network",
  "nested": false,
  "online": true,
  "ping": 0,
  "run_id": "175"
}

The same container against a real external host (ping_address: 8.8.8.8):

{
  "flattened": false,
  "interfaces": ["lo", "tunl0", "enp0s31f6"],
  "name": "network",
  "nested": false,
  "online": true,
  "ping": 6,
  "run_id": "176"
}

And against an unreachable one (ping_address: 192.0.2.1, the timeout dropped to 300ms for a fast test):

{
  "flattened": false,
  "interfaces": ["lo", "tunl0", "enp0s31f6"],
  "name": "network",
  "nested": false,
  "online": false,
  "ping": -1,
  "run_id": "177"
}

OS

Description

Collects the Operating System information: cpus, operating system name and kernel information

Parameters

This Measurement has no parameters beyond the common Plugin parameters.

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "OS",
  "description": "OS, kernel and CPUs information",
  "properties": {
    "os": {
      "description": "Host distribution name",
      "type": "string"
    },
    "kernel": {
      "description": "Kernel version",
      "type": "string"
    },
    "cpu": {
      "description": "Number of CPUs",
      "type": "integer",
      "minimum": 0
    },
    "memory": {
      "description": "System memory",
      "type": "number",
      "minimum": 0
    }
  },
  "type": "object"
}

Warning

The Record's own field is cpus (plural) — os.cpp's collect() writes data_json["cpus"], not data_json["cpu"] as the schema above declares. The mismatch doesn't fail validation (an extra, unvalidated field isn't rejected without additionalProperties: false), but don't rely on cpu showing up in a query.

Configuration

...
os:
  plugin: "dc_measurements/OS"
  topic_output: "/dc/measurement/os"

Example output

{
  "cpus": 8,
  "flattened": false,
  "kernel": "6.12.101+deb13-amd64",
  "memory": 23.23,
  "name": "os",
  "nested": false,
  "os": "Ubuntu 24.04.4 LTS",
  "run_id": "169"
}

Permissions

Description

Collect UID, GID, if a file or directory exists and its permissions (in rwx or integer format).

Parameters

ParameterDescriptionTypeDefault
formatFormat to collect permissions instr(rwx or int)"int"
pathPath to the file or directory to collect data from, support environment variablesstrN/A (mandatory)

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Permissions",
    "description": "Permissions of a file/directory",
    "properties": {
        "uid": {
            "description": "File/directory User IDentifier",
            "type": "integer"
        },
        "gid": {
            "description": "File/directory Group IDentifier",
            "type": "integer"
        },
        "exists": {
            "description": "File/directory exists",
            "type": "boolean"
        },
        "permissions": {
            "description": "Permissions as rwx or integer",
            "type": "string"
        }
    },
    "type": "object"
}

Info

permissions.cpp's collect() also adds user and group (the owning username/group name from getpwuid/getgrgid) whenever the uid/gid resolves on the local system — neither is in the schema above. Both fields are simply left out of the Record when the file is owned by an id the container doesn't recognize (e.g. owned by the host rather than the container).

Configuration

...
permission_home_dc:
  plugin: "dc_measurements/Permissions"
  topic_output: "/dc/measurement/permissions_home_dc"
  path: "$HOME/dc"
  format: "rwx"

Example output

{
  "exists": true,
  "flattened": false,
  "gid": 0,
  "group": "root",
  "name": "permission_home_dc",
  "nested": false,
  "permissions": "rwx------",
  "run_id": "169",
  "uid": 0,
  "user": "root"
}

Position

Description

Collect x, y and yaw of the robot.

Parameters

ParameterDescriptionTypeDefault
global_frameGlobal framestr"map"
robot_base_frameRobot base framestr"base_link"
transform_timeoutTF Timeout to use for transformationfloat0.1

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Position",
    "description": "Position and orientation of the robot",
    "properties": {
        "x": {
            "description": "X position of the robot",
            "type": "number"
        },
        "y": {
            "description": "Y position of the robot",
            "type": "number"
        },
        "yaw": {
            "description": "Yaw angle of the robot",
            "type": "number"
        }
    },
    "type": "object"
}

Configuration

...
position:
  plugin: "dc_measurements/Position"
  topic_output: "/dc/measurement/position"

Example output

{
  "flattened": false,
  "name": "position",
  "nested": false,
  "run_id": "169",
  "x": 0.0,
  "y": 0.0,
  "yaw": 0.0
}

Random

Description

Emits a randomly generated value on every polling interval. Useful for exercising the pipeline and Destinations without any robot infrastructure — the simplest possible Measurement plugin, and a deterministic, infrastructure-free Record source for load and backpressure testing.

Parameters

ParameterDescriptionTypeDefault
typeType of the generated value: integer or doublestr"integer" (Optional)
minMinimum value (inclusive) of the generated rangedouble0.0 (Optional)
maxMaximum value of the generated range (inclusive for integer, effectively exclusive for double). Must be greater than mindouble100.0 (Optional)
seedSeed for the random number generator. A negative value seeds from a non-deterministic source; a non-negative value makes runs reproducibleint-1 (Optional)

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Random",
    "description": "Randomly generated value",
    "properties": {
        "value": {
            "description": "Randomly generated value within the configured range",
            "type": "number"
        }
    },
    "type": "object"
}

Configuration

...
random:
  plugin: "dc_measurements/Random"
  topic_output: "/dc/measurement/random"
  type: "double"
  min: 0.0
  max: 1.0
  seed: 42

Example output

{
  "flattened": false,
  "name": "random",
  "nested": false,
  "run_id": "169",
  "value": 0.8265497558024156
}

ROS2 control status

Description

Reports when a ros2_control controller or hardware component crosses into or out of the active lifecycle state -- the state in which it is actually commanding or reading hardware. Rather than polling list_controllers/list_hardware_components, Ros2ControlStatus subscribes to the controller manager's own ~/activity topic (controller_manager_msgs/ControllerManagerActivity), republished with transient-local QoS on every controller/hardware-component lifecycle change, and feeds each component's stream of (state, timestamp) samples to its own dc_common::StateTransitionDetector -- one detector per component, mirroring Fault's per-component detector map, so components activate and deactivate independently.

A Record is produced only for the boundary Intervention already reports in the same shape: a transition into active starts one ("event": "start"), and a transition out of active ends one ("event": "end"). A transition between two non-active states (e.g. unconfigured to inactive during startup) crosses no boundary and produces nothing.

Every Record carries the component type (controller or hardware_component), its name, the state it left (from_state) and entered (to_state), and how long the previous state had been held (previous_state_duration_s) -- on a start Record, how long the component was out of active before this; on an end Record, how long it had just been active. open is true on a start Record and false on an end Record, so a component still active when collection stops simply never gets a matching end Record and cannot be averaged as a zero. Every Record carries a seq that increments by one across every controller and hardware component, so a dropped Record is a detectable gap.

Parameters

ParameterDescriptionTypeDefault
topicTopic to subscribe to for controller manager activitystr"/controller_manager/activity" (Optional)

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Ros2ControlStatus",
  "properties": {
    "seq": { "type": "integer", "minimum": 1 },
    "component_type": { "type": "string", "enum": ["controller", "hardware_component"] },
    "component": { "type": "string" },
    "event": { "type": "string", "enum": ["start", "end"] },
    "from_state": { "type": "string", "enum": ["unconfigured", "inactive", "active", "finalized", "configuring", "cleaningup", "shuttingdown", "activating", "deactivating", "errorprocessing", "unknown"] },
    "to_state": { "type": "string", "enum": ["unconfigured", "inactive", "active", "finalized", "configuring", "cleaningup", "shuttingdown", "activating", "deactivating", "errorprocessing", "unknown"] },
    "previous_state_duration_s": { "type": "number", "minimum": 0 },
    "open": { "type": "boolean" }
  },
  "required": ["seq", "component_type", "component", "event", "from_state", "to_state", "previous_state_duration_s", "open"],
  "type": "object"
}

Configuration

...
ros2_control_status:
  plugin: "dc_measurements/Ros2ControlStatus"
  topic_output: "/dc/measurement/ros2_control_status"
  group_key: "ros2_control_status"
  topic: "/controller_manager/activity"

Example output

A start:

{
  "seq": 1,
  "component_type": "controller",
  "component": "diff_drive_controller",
  "event": "start",
  "from_state": "inactive",
  "to_state": "active",
  "previous_state_duration_s": 4.2,
  "open": true
}

and its end:

{
  "seq": 2,
  "component_type": "controller",
  "component": "diff_drive_controller",
  "event": "end",
  "from_state": "active",
  "to_state": "inactive",
  "previous_state_duration_s": 612.9,
  "open": false
}

Serial interface

Description

Reads line-delimited data off a configurable serial port (baud rate configurable) and parses each line into named fields, publishing them as a Record — for custom robot sensors/boards that talk over a UART/USB-serial link and never reach a ROS topic on their own.

The port is opened lazily, on the first poll after activation, and never on onConfigure() — an unplugged or not-yet-connected device does not fail activation. Disconnection is detected two ways: a hard read error (e.g. EIO on some USB-serial removal paths) closes the port immediately; a hangup with no error (the more common case on a raw-mode, non-blocking read, where read() returning 0 alone can't be told apart from "no data yet") is caught via poll()'s POLLHUP/POLLERR. Either way the Measurement logs a warning, closes the file descriptor, and keeps polling; the next poll after the device reappears reopens and resumes normally, with no operator action needed and no busy-looping in between (opening only happens once per polling_interval tick).

Only line-delimited framing (\n, with an optional trailing \r stripped) is implemented today; framing is still a configuration knob for future framing modes. Only the most recently completed line in a given poll is parsed — if several lines arrive within one polling_interval, earlier ones are dropped, the same lossy-between-polls behavior other subscription/poll-driven Measurements (e.g. cmd_vel, diagnostics) already have.

Two parsing modes are supported, both producing the same fields object shape:

  • delimiter: splits the line on delimiter and assigns tokens to fields in order.
  • regex: matches the line against regex and assigns capture groups to fields in order. std::regex/ECMAScript has no native named-capture-group syntax, so "named groups" here means pairing each positional capture group with a name from fields, in capture order.

If the token/capture count doesn't match the configured fields count, a warning is logged and whatever fields do line up are still published — a malformed line degrades rather than drops.

Parameters

ParameterDescriptionTypeDefault
portSerial device path (e.g. /dev/ttyUSB0)str"" (required)
baud_rateBaud rate: one of 1200/2400/4800/9600/19200/38400/57600/115200/230400int9600
framingLine framing mode; only "line" is implementedstr"line"
parsing_type"delimiter" or "regex"str"delimiter"
delimiterDelimiter string used when parsing_type: delimiterstr","
regexECMAScript regex (with capture groups) used when parsing_type: regexstr""
fieldsOrdered field names paired with delimiter tokens or regex capture groupslist[str][] (Optional)

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "SerialInterface",
  "description": "A line parsed off a configured serial port",
  "properties": {
    "raw": {
      "description": "The raw line read from the serial port, with any trailing CR/LF stripped",
      "type": "string"
    },
    "fields": {
      "description": "Named fields extracted from 'raw' per the configured parsing (delimiter split or regex capture groups)",
      "type": "object"
    }
  },
  "required": ["raw", "fields"],
  "type": "object"
}

Configuration

Delimiter split, e.g. a board emitting 23.5,60\n (temperature, humidity):

...
serial_sensor:
  plugin: "dc_measurements/SerialInterface"
  topic_output: "/dc/measurement/serial_sensor"
  port: "/dev/ttyUSB0"
  baud_rate: 9600
  parsing_type: "delimiter"
  delimiter: ","
  fields: ["temperature", "humidity"]

Regex capture groups, e.g. a board emitting T:23.5 H:60\n:

...
serial_sensor:
  plugin: "dc_measurements/SerialInterface"
  topic_output: "/dc/measurement/serial_sensor"
  port: "/dev/ttyUSB0"
  baud_rate: 9600
  parsing_type: "regex"
  regex: "^T:(\\d+\\.\\d+) H:(\\d+)$"
  fields: ["temperature", "humidity"]

Example output

Derived from the delimiter example above (23.5,60 split on , into fields: ["temperature", "humidity"]):

{
  "raw": "23.5,60",
  "fields": {
    "temperature": "23.5",
    "humidity": "60"
  }
}

Testing without hardware

The gtest suite (test/test_measurement_serial_interface.cpp) verifies this Measurement against a virtual serial pair created with socat, no hardware required:

socat -d -d pty,raw,echo=0,link=/tmp/dc_serial_dev pty,raw,echo=0,link=/tmp/dc_serial_peer

The Measurement is pointed at /tmp/dc_serial_dev; the test writes fixture lines to /tmp/dc_serial_peer. Killing and restarting socat against the same link= paths simulates an unplug/replug cycle and exercises the reconnect path.

slam_toolbox quality

Description

Localization quality from two of slam_toolbox's native ROS 2 topics: /pose (geometry_msgs/PoseWithCovarianceStamped), polled on the same interval as every other Measurement, for a sample Record carrying the pose and its covariance; and /slam_toolbox/loop_closure_event (slam_toolbox/LoopClosureEvent on slam_toolbox's development branch — see the note below on why this Measurement doesn't depend on that type directly) for a single-shot loop_closure Record per occurrence.

LoopClosureEvent carries nothing but its own timestamp — slam_toolbox doesn't say which nodes closed the loop or by how much, only that one happened. The Record reflects that: loop_closure has no fields beyond event. The KPI value is entirely in the timing of these Records against each other and against the samples: loop closures per hour is a rate over how often loop_closure Records land, and time since the last loop closure — how long the map has gone without a correction — is a localization-drift risk indicator on its own, the way a growing gap between battery samples reads as data loss rather than a battery at 0 %.

Every Record carries an event field naming which of the two it is:

eventWhenCarries
sampleEvery polling interval, once /pose has publishedx, y, yaw, and the diagonal covariance terms for each
loop_closureslam_toolbox reports one on /slam_toolbox/loop_closure_eventNothing else — the occurrence is the fact

The covariance terms are the diagonal of PoseWithCovariance's 6x6 matrix at the indices for x, y and yaw — the same terms AMCL/robot_localization dashboards already chart, and the ones that read as confidence on their own axis without needing the off-diagonal correlations.

Until /pose publishes at all, no sample Record is emitted: a gap means no localization data, not a robot at the origin. A loop closure is queued the moment it's reported and leaves on the next poll, one Record per poll, so it travels the same path as every other Record (Conditions, incident buffering, Group) — the same convention Battery's charging-session boundaries and Intervention's takeovers use.

Why loop_closure_topic is subscribed generically

slam_toolbox/LoopClosureEvent is declared on slam_toolbox's ros2 development branch but isn't part of any released binary yet — verified directly against the actual ros-jazzy-slam-toolbox package contents, which ship no msg/ interface headers for it at all, only the srv/ ones. A compile-time dependency on that message can't build against a real installation today, so this Measurement subscribes to loop_closure_topic with create_generic_subscription instead — the same runtime-discovery mechanism dc_bridge's raw mode uses. The content is never decoded (the Record only needs to know an occurrence happened, not what the message carried), and the topic's actual type is discovered from the ROS graph once slam_toolbox starts advertising it, so this Measurement keeps working whichever release adds the topic and whatever fields it ends up carrying — no dc_measurements rebuild required.

Parameters

ParameterDescriptionTypeDefault
pose_topicTopic (geometry_msgs/PoseWithCovarianceStamped) to read localization pose fromstr"/pose" (Optional)
loop_closure_topicTopic slam_toolbox reports loop closures on, subscribed generically (type discovered at runtime)str"/slam_toolbox/loop_closure_event" (Optional)

Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "SlamToolboxQuality",
  "description": "Localization quality from slam_toolbox: a pose sample with covariance on the polling interval, plus a Record on every loop closure",
  "properties": {
    "event": { "type": "string", "enum": ["sample", "loop_closure"] },
    "x": { "type": "number" },
    "y": { "type": "number" },
    "yaw": { "type": "number" },
    "covariance_x": { "type": "number", "minimum": 0 },
    "covariance_y": { "type": "number", "minimum": 0 },
    "covariance_yaw": { "type": "number", "minimum": 0 }
  },
  "required": ["event"],
  "type": "object"
}

The full file (plugins/measurements/json/slam_toolbox_quality.json) also requires x, y, yaw and all three covariance terms on a sample — loop_closure requires nothing beyond event.

Configuration

...
slam_quality:
  plugin: "dc_measurements/SlamToolboxQuality"
  topic_output: "/dc/measurement/slam_quality"
  polling_interval: 1000
  pose_topic: "/pose"
  loop_closure_topic: "/slam_toolbox/loop_closure_event"

Example output

One sample:

{
  "event": "sample",
  "x": 1.42,
  "y": -0.63,
  "yaw": 0.71,
  "covariance_x": 0.008,
  "covariance_y": 0.011,
  "covariance_yaw": 0.004
}

A loop closure:

{
  "event": "loop_closure"
}

KPIs

tools/infrastructure/sql/kpi_views.sql defines dc_kpi_loop_closure_rate() (loop closures per hour and seconds since the last one, over an arbitrary window) and dc_kpi_loop_closures_1h (the same count bucketed hourly for charting) over this Measurement's loop_closure Records, following the same convention as dc_kpi_intervention_rate(). The demo Grafana KPI dashboard charts loop closures per hour; the robot dashboard charts the covariance trend directly from sample Records.

Speed

Description

Collect robot speed using the Odom topic.

Parameters

ParameterDescriptionTypeDefault
odom_topicTopic to subscribe to to get the odometry (nav_msgs/Odometry)str"/odom"

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Speed",
    "description": "Computed, linear and angular speed of the robot",
    "properties": {
        "computed": {
            "description": "Computed speed in meter/s",
            "type": "number"
        },
        "linear": {
            "description": "Linear velocity as a vector",
            "type": "object",
            "items": {
                "$ref": "#/$defs/vector3"
            }
        },
        "angular": {
            "description": "Angular velocity as a vector",
            "type": "object",
            "items": {
                "$ref": "#/$defs/vector3"
            }
        }
    },
    "$defs": {
        "vector3": {
            "type": "object",
            "properties": {
                "x": {
                    "description": "X speed",
                    "type": "number"
                },
                "y": {
                    "description": "Y speed",
                    "type": "number"
                },
                "z": {
                    "description": "Z speed",
                    "type": "number"
                }
            }
        }
    },
    "type": "object"
}

Configuration

...
speed:
  plugin: "dc_measurements/Speed"
  topic_output: "/dc/measurement/speed"
  odom_topic: "/odom"

Example output

{
  "angular": {
    "x": 0.0,
    "y": 0.0,
    "z": 0.0
  },
  "computed": 0.5,
  "flattened": false,
  "linear": {
    "x": 0.5,
    "y": 0.0,
    "z": 0.0
  },
  "name": "speed",
  "nested": false,
  "run_id": "169"
}

Storage

Description

Collect storage information on a directory.

Parameters

ParameterDescriptionTypeDefault
pathAbsolute path to the directory to inspect, expect environment variablesstrN/A (Mandatory)

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Storage",
    "description": "Storage information of a directory",
    "properties": {
        "free_percent": {
            "description": "Free space on the filesystem, in percent",
            "type": "number",
            "minimum": 0
        },
        "free": {
            "description": "Free space on the filesystem, in bytes",
            "type": "integer",
            "minimum": 0
        },
        "capacity": {
            "description": "Total size of the filesystem, in bytes",
            "type": "integer",
            "minimum": 0
        }
    },
    "type": "object"
}

Configuration

...
storage_home:
  plugin: "dc_measurements/Storage"
  topic_output: "/dc/measurement/storage_home"
  path: "$HOME"

Example output

{
  "capacity": 460041527296,
  "flattened": false,
  "free": 74388291584,
  "free_percent": 11.073844909667969,
  "name": "storage_home",
  "nested": false,
  "run_id": "169"
}

String stamped

Description

Collect generic data from a topic publishing a StringStamped message and republish it. It allows to fetch data from rclc, rclpy and your custom ROS 2 nodes that don't have use a plugin.

Parameters

ParameterDescriptionTypeDefault
timer_basedIf true, collect data at interval and if false collect every record and ignores polling_intervalbooltrue
topicTopic to get data fromstrN/A (mandatory)

Schema

Given that the data is customized here, there is no default schema.

Configuration

...
my_data:
  plugin: "dc_measurements/StringStamped"
  topic_output: "/dc/measurement/my_data"
  topic: "/hello_world"
  timer_based: true
  enable_validator: false # no schema exists for custom, per-topic data -- see Schema above

Example output

{
  "flattened": false,
  "name": "string_stamped",
  "nested": false,
  "run_id": "169",
  "temperature": 21.5
}

TCP Health

Description

Collects status of a TCP server.

Parameters

ParameterDescriptionTypeDefault
nameAlias to give to the TCP ServerstrN/A (Mandatory)
hostName of the target host or IP address to checkstr"127.0.0.1" (Optional)
portTCP port where to perform the connection check.int(>0 <65536)80 (Optional)

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "TCP Health",
    "description": "Status of a TCP server",
    "properties": {
        "host": {
            "description": "Server hostname",
            "type": "string"
        },
        "port": {
            "description": "Port number",
            "type": "integer",
            "minimum": 1,
            "maximum": 65536
        },
        "server_name": {
            "description": "Server alias, from the 'name' parameter",
            "type": "string"
        },
        "active": {
            "description": "Whether the TCP connection check succeeded",
            "type": "boolean"
        }
    },
    "type": "object"
}

Warning

tcp_health.json (the schema file this block is copied from) is wrong twice: active — the boolean connection-health result collect() actually writes — isn't declared as a property at all, and server_name's own description says "Time the system has been up", copy-pasted from uptime.json. Corrected above; the schema file itself still has both bugs.

Configuration

...
tcp_health:
  plugin: "dc_measurements/TCPHealth"
  topic_output: "/dc/measurement/rustfs_health"
  group_key: "rustfs_health"
  host: "127.0.0.1"
  port: 9000
  name: "rustfs_api"

Example output

{
  "active": true,
  "flattened": false,
  "host": "127.0.0.1",
  "name": "tcp_health",
  "nested": false,
  "port": 9000,
  "run_id": "169",
  "server_name": "test_service"
}

Thermal

Description

Reports temperatures (CPU, GPU, board, ...) from the kernel's thermal sysfs interface (/sys/class/thermal/thermal_zone*/), so overheating trends reach dashboards before they become failures. Zones are auto-discovered by default; each zone's type string (e.g. x86_pkg_temp, cpu-thermal) is used as the Record key, not its numeric index, since zone numbering is platform-specific and differs between x86_64 and ARM targets. A missing or unreadable /sys/class/thermal (e.g. a container without the host's thermal sysfs mounted) degrades gracefully: activation still succeeds, and if no zone can currently be read the Measurement publishes nothing that cycle (the same "silent, retry next poll" contract other hardware-optional Measurements like Serial interface use) instead of failing or publishing an empty Record.

Parameters

ParameterDescriptionTypeDefault
base_pathDirectory to scan for thermal_zone* entriesstr"/sys/class/thermal" (Optional)
zonesExplicit list of zone directory names (e.g. ["thermal_zone0", "thermal_zone2"]) to read instead of auto-discovering every thermal_zone* entry under base_pathlist of str[] (Optional)

Schema

Zone type strings are the field names, so they can't be listed ahead of time — the schema constrains their shape instead: at least one entry (the Measurement publishes nothing rather than an empty Record), a non-empty type string as key, and a temperature in degrees Celsius above absolute zero as value.

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Thermal",
    "description": "One entry per thermal zone read this cycle, keyed by the zone's type string",
    "propertyNames": {
        "description": "The zone's type string, e.g. x86_pkg_temp or cpu-thermal",
        "minLength": 1
    },
    "additionalProperties": {
        "description": "Temperature of the zone in degrees Celsius",
        "type": "number",
        "minimum": -273.15
    },
    "minProperties": 1,
    "type": "object"
}

Configuration

...
thermal:
  plugin: "dc_measurements/Thermal"
  topic_output: "/dc/measurement/thermal"

Example output

On a host exposing a CPU package zone and a GPU zone:

{
  "x86_pkg_temp": 52.0,
  "gpu-thermal": 61.5
}

Uptime

Description

Time since when the robot PC has been on.

Parameters

This Measurement has no parameters beyond the common Plugin parameters.

Schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Uptime",
    "description": "Time the system has been up",
    "properties": {
        "time": {
            "description": "Time the system has been up",
            "type": "integer",
            "minimum": 0
        }
    },
    "type": "object"
}

Configuration

...
uptime:
  plugin: "dc_measurements/Uptime"
  topic_output: "/dc/measurement/uptime"

Example output

{
  "flattened": false,
  "name": "uptime",
  "nested": false,
  "run_id": "169",
  "time": 1783191
}

Overview

Description

A condition enables or disables one or multiple measurements to be published and thus collected. We could for example enable collecting camera images only when a robot is stopped.

Each condition is enabled or disabled through a pluginlib plugin. It has these configuration parameters.

Conditions only see forward in time

A Condition gates collection for as long as its predicate holds, giving you data from the moment it became true onward. For what happened before an event, use a Trigger instead — built from the same Condition plugins, it fires once on the false→true edge and releases a window a Measurement had already buffered.

Wiring a Condition to a Measurement

Configuring a Condition is a two-step wire-up, both under measurement_server:

  1. Declare it in condition_plugins and give it its own named block, exactly like a Measurement — plugin names the pluginlib class, and any other keys are that Condition's own parameters.
  2. Reference that name from a Measurement's if_all_conditions, if_any_conditions, if_none_conditions, or gate_condition (see Measurements). The Measurement is what names the Condition — nothing on the Condition side says which Measurement it gates, so one Condition can gate several Measurements at once.

The camera-only-when-stopped example above, taken from a real demo (dc_demos/params/qrcodes_stdout.yaml):

measurement_server:
  ros__parameters:
    condition_plugins: ["moving"]
    moving:
      plugin: "dc_conditions/Moving"
    right_camera:
      plugin: "dc_measurements/Camera"
      if_none_conditions: ["moving"] # collect only while NOT moving
      topic_output: "/dc/measurement/right_camera"
      cam_topic: "/right_intel_realsense_r200_depth/image_raw"

Available plugins:

NameDescription
CompareValue of a key compares to the configured operand (eq / ne / gt / ge / lt / le / match / exists)
Robot movingRobot is moving
Same as previousValue of the key is the same as the previous one

Compare

Description

Compare the value of a JSON key against a configured operand and activate while the comparison holds. One plugin covers the whole family: comparison picks the operator and the type you give value picks the operand type (the two axes thirteen former per-operator plugins each hard-coded).

Parameters

ParameterDescriptionTypeDefault
keyJSON key where value is located, separate nested dictionary with /strN/A (Mandatory)
comparisonOperator: eq, ne, gt, ge, lt, le, match or existsstrN/A (Mandatory)
valueOperand to compare the JSON value against; its type selects the operand type: bool, int, float, str, list[bool], list[int], list[float] or list[str]any of thoseN/A (Mandatory, except for match and exists)
regexRegex the JSON value must fully matchstrN/A (Mandatory for match)
order_mattersFor list operands: compare element order (true) or as unordered multisets (false)booltrue

The JSON comparison is type-strict: a Record field written as 5 is an integer and never matches a float operand (and 5.0 never matches an int one). gt/ge/lt/le need an int or float operand; ne inverts eq for every operand type.

Configuration

Only forward a TCP Health Record when the check actually failed — suppressing the steady stream of active: true polls and keeping only outage alerts:

measurement_server:
  ros__parameters:
    condition_plugins: ["endpoint_down"]
    endpoint_down:
      plugin: "dc_conditions/Compare"
      key: "active"
      value: false
      comparison: "eq"
    rustfs_health:
      plugin: "dc_measurements/TCPHealth"
      if_all_conditions: ["endpoint_down"]
      topic_output: "/dc/measurement/rustfs_health"
      host: "127.0.0.1"
      port: 9000
      name: "rustfs_api"

A dead-band on Distance traveled — filters out both "parked" noise and implausibly large jumps (dc_demos/params/tb3_simulation_pgsql_minio.yaml):

measurement_server:
  ros__parameters:
    condition_plugins: ["min_distance_traveled", "max_distance_traveled"]
    min_distance_traveled:
      plugin: "dc_conditions/Compare"
      key: "distance_traveled"
      value: 0.01
      comparison: "ge"
    max_distance_traveled:
      plugin: "dc_conditions/Compare"
      key: "distance_traveled"
      value: 2.0
      comparison: "le"
    distance_traveled:
      plugin: "dc_measurements/DistanceTraveled"
      if_all_conditions: ["min_distance_traveled", "max_distance_traveled"]
      topic_output: "/dc/measurement/distance_traveled"

Only publish Fast DDS statistics once its discovered hosts list no longer matches the expected single-host fingerprint — flags an unexpected extra participant joining the DDS graph:

measurement_server:
  ros__parameters:
    condition_plugins: ["known_hosts"]
    known_hosts:
      plugin: "dc_conditions/Compare"
      key: "hosts"
      value: ["d:14058711922191368192"]
      comparison: "eq"
      order_matters: false
    fastdds_stats:
      plugin: "dc_measurements/FastddsStats"
      if_none_conditions: ["known_hosts"]
      topic_output: "/dc/measurement/fastdds_stats"

Only collect a Battery Record while it is actively charging (match uses a full regex match, and exists tests only that key is present, taking no value):

measurement_server:
  ros__parameters:
    condition_plugins: ["actively_charging", "inspected_exists"]
    actively_charging:
      plugin: "dc_conditions/Compare"
      key: "power_supply_status"
      comparison: "match"
      regex: "charging|full"
    inspected_exists:
      plugin: "dc_conditions/Compare"
      key: "inspected"
      comparison: "exists"
    battery:
      plugin: "dc_measurements/Battery"
      if_all_conditions: ["actively_charging", "inspected_exists"]
      topic_output: "/dc/measurement/battery"

Migration from the former per-operator plugins

Former plugincomparisonvalue typeNotes
dc_conditions/BoolEqualeqbool
dc_conditions/DoubleEqualeqfloat
dc_conditions/IntegerEqualeqint
dc_conditions/DoubleInferiorle or ltfloatle was include_value: true, lt false
dc_conditions/IntegerInferiorle or ltintsame include_value mapping
dc_conditions/DoubleSuperiorge or gtfloatge was include_value: true, gt false
dc_conditions/IntegerSuperiorge or gtintsame include_value mapping
dc_conditions/ListBoolEqualeqlist[bool]order_matters unchanged
dc_conditions/ListDoubleEqualeqlist[float]order_matters unchanged
dc_conditions/ListIntegerEqualeqlist[int]order_matters unchanged
dc_conditions/ListStringEqualeqlist[str]order_matters unchanged
dc_conditions/StringMatchmatchnoneregex parameter unchanged
dc_conditions/Existexistsnonekey only

ne (value differs) and scalar str equality are new with Compare.

Moving

Description

Use a hysteresis on robot position to know whether the robot is moving.

Parameters

ParameterDescriptionTypeDefault
odom_topicTopic from where odom is used to know if the robot is movingstr"/odom"
speed_thresholdSpeed threshold used in the hysteresisfloat0.2
count_limitCounter to know if the robot is movingint8
count_hysteresisHysteresis counterint5

Configuration

Only record camera images while the robot is stationary (dc_demos/params/qrcodes_stdout.yaml):

measurement_server:
  ros__parameters:
    condition_plugins: ["moving"]
    moving:
      plugin: "dc_conditions/Moving"
    right_camera:
      plugin: "dc_measurements/Camera"
      if_none_conditions: ["moving"]
      topic_output: "/dc/measurement/right_camera"
      cam_topic: "/right_intel_realsense_r200_depth/image_raw"

Same as previous

Description

Compare JSON key value to the value passed in parameter and returns true if match.

Parameters

ParameterDescriptionTypeDefault
keysJSON keys where values are located, separate nested dictionary with /list[str]N/A (Mandatory)
excludeJSON keys to exclude in comparisonlist[str]N/A (Mandatory)

Configuration

Skip republishing Diagnostics when nothing has changed since the last collection — /diagnostics is typically high-volume and mostly unchanging:

measurement_server:
  ros__parameters:
    condition_plugins: ["diagnostics_unchanged"]
    diagnostics_unchanged:
      plugin: "dc_conditions/SameAsPrevious"
      keys: []
      exclude: []
    diagnostics:
      plugin: "dc_measurements/Diagnostics"
      if_none_conditions: ["diagnostics_unchanged"]
      topic_output: "/dc/measurement/diagnostics"
      level_threshold: "WARN"
      names: ["motor_driver", "battery"]

Triggers

Description

A Trigger fires a one-shot signal — a dc_interfaces/msg/FlushEvent — when a composition of Conditions goes from false to true. Measurements listening for that event release the window of recent data they have been holding back, so the seconds leading up to an event are collected too, alongside the seconds after it.

The motivating case is incident review: when an autonomous robot emergency-brakes, what matters is what happened before the brake. A Condition cannot express that — it gates collection going forward, and by the time it turns true the run-up is gone. A Trigger can, because the Measurement was already buffering.

Trigger vs Condition

Both are described with the same if_all/if_any/if_none Condition lists, and a Trigger is built out of Condition plugins — but they answer different questions.

ConditionTrigger
Question it answers"Should this Measurement be collecting right now?""Did something just happen?"
Shape of the signalA level: true for as long as the predicate holdsAn edge: fires once on the false→true transition
Effect on collectionGates live publishing while trueReleases a pre-collected buffer, once
Data it can give youEverything from the moment it turned trueEverything from the buffer_duration_sec before the event, plus post-roll
Where it is configuredPer Measurement (if_all_conditions, …)On the trigger_broadcast_node, once for the whole robot
How Measurements see itDirectly, as their own gateIndirectly, as a FlushEvent on a topic several Measurements can share
ScopeOne MeasurementEvery Measurement subscribed to its flush_topic

They compose: a Trigger is made of Conditions, and a buffering Measurement can still be gated by its own Conditions. Nothing about a Trigger changes what a Condition means.

Trigger is a specific word here

Before this feature existed, "trigger" was loose talk for "a Condition turning on", and the glossary told you to avoid the word. It now names a real, distinct plugin type. Say Condition for a gate that stays true, and Trigger only for the edge-firing plugin documented here.

The flush broadcast

The trigger_broadcast_node loads the Condition plugins named in condition_plugins (the same Condition plugins Measurements use) plus one Trigger plugin, polls the Trigger on a timer, and publishes a FlushEvent on a configurable topic (/dc/flush by default) each time it fires:

                    ┌──────────────────────┐
   /odom, … ───────▶│  Condition plugins   │
                    └──────────┬───────────┘
                               │ if_all / if_any / if_none
                    ┌──────────▼───────────┐
                    │  Trigger (EdgeTrigger)│  false → true?
                    └──────────┬───────────┘
                               │ FlushEvent { incident_id }
                    ┌──────────▼───────────┐
                    │      /dc/flush       │
                    └───┬──────────────┬───┘
                        │              │
              ┌─────────▼────┐  ┌──────▼───────┐
              │ Measurement A│  │ Measurement B│   release their buffered windows,
              │  (buffering) │  │  (buffering) │   both tagged with the same incident_id
              └──────────────┘  └──────────────┘

The node mints a fresh incident_id (a UUID) for every firing; nothing else generates one, so every subscriber of one event shares one ID. Several Measurements can point at the same flush_topic, which is the whole reason the signal is broadcast on a topic rather than wired per Measurement: adding a sensor stream to an existing incident-capture setup needs no change to the Trigger.

Only self-updating Conditions are meaningful here

A Measurement passes its own freshly-collected Record to each Condition it consults, so plugins like Compare read a field out of that Record. A Trigger has no Record of its own: it only makes sense to compose Conditions that maintain their own state from a subscription, such as Moving.

Running the broadcast node

trigger_broadcast_node is a lifecycle node, like the Measurement server, but it is not part of dc_bringup's launch file yet — run it alongside the rest of the stack and transition it yourself (or add it to your own launch file and lifecycle manager node_names):

ros2 run dc_triggers trigger_broadcast_node --ros-args --params-file <your_params.yaml>

A Measurement subscribes to a plain topic, so anything publishing a FlushEvent on flush_topic releases the buffer — including ros2 topic pub, which is how the tools/e2e/scripts/run_incident.sh scenario drives a flush.

FlushEvent

dc_interfaces/msg/FlushEvent is the entire contract between the Trigger side and the Measurement side:

FieldTypeDescription
incident_idstringUUID minted by the broadcast node, one per firing

A Measurement adopts the incident_id it receives; it never generates its own. An event that arrives while a Measurement is already flushing, in post-roll, or in cooldown is ignored.

incident_id

incident_id is a typed field of the dc_interfaces/msg/StringStamped Record envelope, beside group_key — not a key nested inside the measurement's own data. Every Record and File released by one flush cycle carries the same value, so "everything from this one event" is a single query rather than a timestamp range reconstructed by hand:

SELECT * FROM dc_records WHERE incident_id = '3f2b1c7e-…' ORDER BY date;
  • A postgres Destination writes it to its own incident_id column — the column must exist in the table beforehand; see Destinations.
  • A Record collected outside an incident carries an empty incident_id, leaving the column NULL.
  • A Group carries a member's envelope incident_id onto its own output envelope, so grouping does not bury it.
  • Every Destination receives it as a top-level key of the JSON the Bridge ships: the Bridge lifts the envelope field into the payload before handing a Record to its Destinations.

Available plugins

NameDescription
Edge triggerFires once on the false→true rising edge of an if_all/if_any/if_none Condition composition

Node parameters

Parameter nameDescriptionType(s)Default
condition_pluginsName of the condition plugins to loadlist[str][]

Plugin parameters

Every Trigger plugin loaded by this node is namespaced under trigger and shares these parameters:

Parameter nameDescriptionType(s)Default
trigger.pluginName of the trigger plugin to loadstrN/A (mandatory)
trigger.if_all_conditionsFire only once every named Condition is activelist[str][]
trigger.if_any_conditionsFire once any named Condition is activelist[str][]
trigger.if_none_conditionsFire only once no named Condition is activelist[str][]
trigger.topicTopic FlushEvent messages are published onstr"/dc/flush"
trigger.polling_intervalInterval in milliseconds at which the composed Condition state is checkedint100

Measurement parameters

The other half of the feature lives on each Measurement: what it buffers, and what it does once a FlushEvent releases it. All five are optional, and a Measurement that leaves buffer_duration_sec at 0 behaves exactly as it always has — this feature is entirely opt-in. They are documented in full, with the state machine and how Files are staged, in Measurements.

Parameter nameDescriptionType(s)Default
buffer_duration_secSeconds of history to hold instead of publishing live; 0 disables buffering entirelyfloat0
post_roll_duration_secSeconds to keep publishing live after the release finishes, still tagged with the same incident_id; 0 means pre-roll onlyfloat0
cooldown_secSeconds to ignore further FlushEvents once post-roll ends, before buffering re-arms itself; 0 re-arms immediatelyfloat0
max_flush_rate_hzCeiling in Records per second on how fast the released window is emitted; 0 releases it in one burstfloat0
flush_topicTopic the FlushEvent that releases this Measurement is received onstr"/dc/flush"

An armed Measurement moves through four states per incident — Buffering (accumulate, publish nothing) → Flushing (release the window, oldest first, rate-limited) → PostRoll (publish live, same incident_id) → Cooldown (ignore further events) → back to Buffering, with no manual re-arm.

Sizing max_flush_rate_hz

A Measurement's data publisher is KeepLast(1). A whole window published back-to-back in one callback is coalesced down to its last Record before any subscriber runs, so a burst release (max_flush_rate_hz: 0) only delivers the full window to a subscriber that keeps up. Set a rate comfortably above the collection rate being released — 20 Hz for a 2 Hz Measurement — and the window is paced out intact, which is also what keeps a backlog dump from competing with live collection right after an incident.

Example

An emergency-brake capture: the robot buffers the last 10 seconds of uptime data, and the moment it starts moving the buffer is released and collection continues live for 5 more seconds. Swap the moving Condition for whatever expresses your event.

trigger_broadcast_node's parameters:

trigger_broadcast_node:
  ros__parameters:
    condition_plugins: ["moving"]

    moving:
      plugin: "dc_conditions/Moving"
      odom_topic: "/odom"

    trigger:
      plugin: "dc_triggers/EdgeTrigger"
      if_all_conditions: ["moving"]
      topic: "/dc/flush"
      polling_interval: 100

The Measurements listening for it:

measurement_server:
  ros__parameters:
    measurement_plugins: ["uptime", "memory"]

    # Armed: publishes nothing until a FlushEvent arrives on /dc/flush, then releases the
    # last 10 seconds of collection tagged with that event's incident_id.
    uptime:
      plugin: "dc_measurements/Uptime"
      polling_interval: 500
      group_key: "uptime"
      buffer_duration_sec: 10.0
      post_roll_duration_sec: 5.0
      cooldown_sec: 30.0
      max_flush_rate_hz: 20.0
      flush_topic: "/dc/flush"

    # Not armed: collects live as usual, and its Records leave incident_id NULL.
    memory:
      plugin: "dc_measurements/Memory"
      polling_interval: 1000
      group_key: "memory"

A runnable end-to-end version of this — two Measurements, one armed, both landing in the same Postgres table, with the incident_id column asserted by query — is tools/e2e/scripts/run_incident.sh.

Edge trigger

Description

Composes the Condition plugins named in if_all_conditions/if_any_conditions/ if_none_conditions — the exact same rules Measurements already use — and fires exactly once on the false→true rising edge of the composed result. A sustained true fires only once; the Trigger stays quiet until the composed result falls back to false and rises again. No rule-evaluation logic of its own: composition is entirely delegated to the same ConditionSet evaluator dc_measurements::Measurement uses for live gating.

Parameters

See Plugin parameters — EdgeTrigger has no parameters of its own beyond the shared trigger.* ones every Trigger plugin reads.

Trigger vs Condition

EdgeTrigger fires once per false→true transition; a Condition stays true and keeps gating for as long as its predicate holds. See Trigger vs Condition.

Destinations

A Destination is an external system that receives Records or Files. In the DC 2.0 architecture (ADR-0003), the pluginlib destination-plugin layer is retired. The Bridge (dc_bridge) renders the external Vector Shipper's configuration from plain ROS parameters for a blessed set of receives: records Destination types — file, vector — and every other destination in Vector's sink catalog, including postgres, s3 and console, is available through the passthrough: raw Vector config snippets listed in the custom_config_files parameter, merged into the same rendered config (see Passthrough below).

type: s3 also stays blessed, but only for receives: files Destinations — object storage there is served entirely by the separate Uploader process reading these same ROS params (ADR-0005), never by a Vector sink, so there is no passthrough equivalent for it (see File uploads below). postgres/s3 (for receives: records)/console were blessed types through DC 2.0's early releases; their dedicated ROS-param templating was removed in #471/#472 once passthrough recipes for all three shipped (#470) — see Recipes below for the equivalent snippets.

Verified sink versions

SinkVersion verified
Vector (the Shipper itself)0.57.0 (vendored by vector_vendor)
PostgreSQL (postgres)13.23
RustFS (s3)v1.0.0-beta.11
Elasticsearch (passthrough)8.19.5
InfluxDB (passthrough)1.8.10

console and file write to local stdout/disk — no external version to track. Any other passthrough sink is whatever's current in Vector's own sink catalog for the pinned 0.57.0 release above.

What is public API here

Three things on this page are a stable contract you can build against, not implementation detail:

  1. The configuration shape — destinations: [...] plus one block per Destination with type, receives, inputs, and that type's own fields.
  2. The dc.<tag> routes — one Shipper route per Tag, named deterministically from the topic. Passthrough snippets consume these.
  3. The File status Record — the fields the Uploader writes under the dc.files Tag.

Everything else the renderer emits (component ids other than the reserved ones, the exact TOML layout, Vector's own defaults) may change.

Bridge node parameters

ParameterDescriptionTypeDefault
destinationsNames of the Destinations to enablelist[str]N/A (mandatory)
shipper.data_dirDirectory for the Shipper's persistent disk bufferstr"$HOME/.dc/buffer"
shipper.buffer_max_bytesDisk-buffer size; Vector rejects anything below ~256 MiBintVector's minimum
shipper.managedtrue: the Bridge locates, spawns and supervises the Shipper. false (unmanaged, #444): the Bridge only renders the config and connects — an orchestrator owns the Shipper's lifecyclebooltrue
shipper.config_pathWhere the rendered Shipper config is written (atomically: write then rename); shared with the Shipper container/pod in unmanaged modestra temp-file path
shipper.bind_hostThe fluent source's own listen address, embedded verbatim into the rendered config — distinct from vector_forward_host (below), which is where this Bridge process connects. Coincide in managed/native mode; diverge in split mode (#445/#447), where the Shipper binds 0.0.0.0 inside its own container while the Bridge connects by that container's DNS namestrvector_forward_host's value
uploader.data_dirDirectory for the Uploader's own durable intent queue and multipart-resume state (#441) — need not share a directory with the Shipper's disk bufferstrshipper.data_dir's value
custom_config_filesRaw Vector config snippets (TOML) to merge — the passthroughlist[str][]
vector_forward_hostHost this Bridge process's Forwarder and readiness prober connect to — not necessarily where the Shipper itself listens (see shipper.bind_host)str"127.0.0.1"
vector_forward_portPort this Bridge process's Forwarder and readiness prober connect toint24224
files.*Uploader settings; see File uploads——

Deployment modes: shipper.managed

shipper.managed picks who owns the Shipper's process lifecycle. The Bridge renders the same config and connects over the shipper ingest protocol the same way in both modes — only whether it also locates, spawns and supervises the Vector binary changes.

shipper.managed: true (default)shipper.managed: false
Shipper supervised bythe Bridge (fork/exec + PR_SET_PDEATHSIG)an external orchestrator (container runtime, Kubernetes, systemd, …)
Vector binarythe vendored one, located on AMENT_PREFIX_PATHnot located at all — the orchestrator runs its own (e.g. the upstream Vector image)
vector validate at startupyes, against the merged configno — nothing to validate against without a binary
shipper.config_pathusually left at its temp-file defaultset to a path on a volume shared with the Shipper container/pod
~/ready semanticsready once the supervised Shipper accepts connectionsidentical: a TCP probe against vector_forward_host:vector_forward_port, independent of who spawned it

Use shipper.managed: true (the default) for:

  • Single-robot / simulation / demos — one process tree on one machine, apt install or a workspace build, nothing else to run or orchestrate. This is what every demo under Demos and the dc_simulation warehouse simulation use.
  • Local development and testing — fewer moving parts: no container runtime, no volumes to wire up, ros2 launch dc_bringup dc_bringup.launch.py is the whole story.

Nothing about this mode changes with shipper.managed added — it is, and remains, the default, and an existing deployment that never sets the parameter is unaffected.

Use shipper.managed: false for:

  • Multi-container / orchestrator-managed deployments — Vector runs as its own container (the upstream image, not the vendored binary) under Compose, Podman Quadlet or Kubernetes, alongside the ROS/Bridge container on the same host. Set shipper.config_path to a path on a volume both containers mount, so the Bridge writes the config where the Shipper container reads it; the atomic write (below) is what makes that handoff safe even while the Shipper is already watching the file.
  • Per-component operations — separate logs (no ROS/Vector log interleaving), independent restarts, and orchestrator-native resource limits/credentials for the Shipper, without changing anything about how the Bridge renders or validates its config.

This parameter is the Bridge-side building block for the larger split-deployment and fleet topologies tracked in #440. dc_uploader as its own process (ADR-0014) and the blessed vector Destination type for robot→edge forwarding have both since shipped; running dc_uploader as its own container, with its own volumes and credentials, remains #447's work, not shipped by shipper.managed alone.

Atomic config write

The rendered config is always written atomically — a full write to <shipper.config_path>.tmp, then rename() over the real path — in both modes. A reader polling the path (an unmanaged Shipper container watching a shared volume; vector validate in managed mode) can therefore never observe a partial write, regardless of how large the config is or how the two containers' write/read timing lines up.

Configuration contract

Every Destination is declared in the destinations list of the dc_bridge node's parameters, with a parameter block named after it (see dc_bringup/params/dc_params.yaml for a complete commented example):

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"   # Vector's disk-buffer directory
      # buffer_max_bytes: 268435488  # optional; Vector's disk-buffer minimum
    destinations: ["records_log", "to_aggregator", "rustfs"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/uptime"]   # ROS topics feeding this Destination
      path: "/var/log/dc/records-%Y-%m-%d.log"   # Vector template syntax allowed
    to_aggregator:
      type: vector
      receives: records
      inputs: ["/dc/measurement/uptime"]
      host: "edge-aggregator.local"
      port: 6000
    rustfs:
      type: s3               # blessed only for `receives: files` — see below
      receives: files
      bucket: "dc-records"
      endpoint: "http://127.0.0.1:9000"    # omit for AWS S3
      region: "us-east-1"
      access_key_id: "rustfsadmin"         # omit both to use ambient AWS credentials
      secret_access_key: "$DC_S3_SECRET"
      force_path_style: true               # path-style addressing for self-hosted stores
      key_prefix: "robot1/"

The vector type forwards to another Shipper over Vector's own native inter-instance protocol (type = "vector" sink → type = "vector" source) — the standard way to chain a robot's local Shipper to an edge aggregator's Shipper in a fleet deployment. host and port name the downstream Shipper; there is no default for either, since there's no sensible address to assume for another Shipper.

The s3 type — used above with receives: files — works with any S3-compatible store. For self-hosting, RustFS (Apache 2.0, S3-compatible, a drop-in MinIO replacement) is the recommended choice — MinIO's community edition was archived upstream in 2026 and no longer receives maintenance — but existing MinIO or Ceph RGW deployments work identically: set endpoint, explicit credentials, and (typically) force_path_style: true. See File uploads below for the full receives: files contract; batch_timeout_secs above is a receives: records passthrough recipe field only (see Recipes), not something the Uploader's own S3 client uses.

Common parameters for every blessed type: type, receives (records | files, see the File uploads section below), inputs (ROS topic names — required for receives: records; optional for receives: files, see below), time_key (default date) and time_format (default epoch_nanos, receives: records only) controlling the normalized timestamp field written into each Record before routing.

time_format

The Bridge forwards each Record's timestamp at full nanosecond resolution, taken from the ROS message header. time_format decides how that is written into the Record:

ValueWritten asResolution kept
epoch_nanosinteger nanoseconds since the epoch (default)nanosecond — exact
iso86012026-08-09T11:36:28.123456789 stringnanosecond
doublefractional seconds as a float64~microsecond, rounds

double is lossy by construction, not by implementation: a float64 has ~15–16 significant digits and current epoch seconds already consume 10 of them. It remains available for consumers that want a fractional-seconds column, but it cannot represent the resolution the pipeline delivers.

Warning

The destination's own column type can truncate independently of time_format. A double precision column rounds an epoch_nanos value back off; PostgreSQL's native timestamptz is microseconds internally; Elasticsearch's date type is milliseconds (use date_nanos for the full value). Choose the column to match — bigint for epoch_nanos is exact, sortable and indexable.

incident_id

A Measurement configured for incident capture tags every Record it releases with the incident_id of the FlushEvent that released it. That field is typed on the StringStamped Record envelope (#506), and the Bridge lifts it into a top-level key of the payload JSON before handing a Record to its Destinations — no per-Destination configuration is needed to expose it. A postgres sink (via the passthrough recipe below) can therefore write it straight into its own incident_id column with no extra mapping — "everything from this one event" is a plain WHERE incident_id = '…' query — since Vector's postgres sink has no column options of its own: a top-level event key lands in the same-named column and everything else is dropped. The column has to exist in the table before the first incident, exactly like every other column (tools/e2e/sql/init.sql and tools/infrastructure/docker/config/postgresql/init.sql both carry it):

ALTER TABLE dc ADD COLUMN incident_id text;

Records collected outside an incident carry an empty incident_id, and an empty envelope field is never lifted, leaving the column NULL.

Note

Through #471, the blessed postgres Destination additionally coerced incident_id to a string (or null) before insertion, so a Measurement whose own payload happened to set a non-string incident_id couldn't fail the whole insert batch. That coercion was Bridge-side templating specific to the now-removed blessed postgres type and is not reproduced by the passthrough recipe — in the (narrow) case where a Measurement's payload sets a non-string incident_id, add your own remap transform ahead of the passthrough sink if this matters for your data.

Type-specific parameters:

TypereceivesRequiredOptional
filerecordspath (Vector template syntax allowed)
vectorrecordshost, port
s3filesbucketregion, endpoint, key_prefix, access_key_id + secret_access_key (together), force_path_style

postgres, s3 (for receives: records) and console are configured via the passthrough instead — see Recipes below.

Invalid parameters (unknown type, missing required field, half a credential pair, an out-of-range port…) are rejected with a clear error at Bridge startup — before Vector is ever started.

$VAR environment references are expanded by the Bridge in shipper.data_dir, custom_config_files, and the secret_access_key credential — and nowhere else. In particular file's path is handed to Vector verbatim, and Vector does not expand environment variables there: path: "$HOME/records.ndjson" silently writes to a literal $HOME directory beside the Bridge's working directory. Use an absolute path (Vector creates missing parent directories).

The dc.<tag> routing contract (public API)

The Bridge exposes one Shipper route per Tag. The Tag for a topic is its name with the leading / dropped and the remaining / turned into . (/dc/measurement/uptime → dc.measurement.uptime), and its route is named dc.<tag> — the leading dc. names the Bridge's route transform, the rest is the Tag verbatim (so /dc/measurement/uptime's Records are consumable as dc.dc.measurement.uptime). These names are stable public API: blessed sinks are wired to them internally, and custom snippets consume them the same way. A route exists for every topic listed in any configured Destination's inputs.

One route is not per-Tag: dc.dc.raw carries the whole dc.raw. Tag namespace when raw topic collection is enabled. Raw mode discovers topics — and so mints Tags — while the Shipper is already running, which no fixed set of exact-Tag branches can cover, so that branch matches on the Tag prefix instead. It is consumable from a snippet exactly like the others; each event still carries its own tag field.

Passthrough: custom_config_files

Any Vector sink type ships Records with zero DC code by consuming dc.<tag> routes from a raw Vector config snippet (TOML):

# ~/.dc/custom_sinks.toml — an un-blessed sink type (http), pure Vector config
[sinks.my_http]
type = "http"
inputs = ["dc.dc.measurement.uptime"]   # the public dc.<tag> route
uri = "http://127.0.0.1:8080/ingest"
encoding.codec = "json"
dc_bridge:
  ros__parameters:
    custom_config_files: ["$HOME/.dc/custom_sinks.toml"]

Snippets must not re-define component ids owned by the generated config (the dc_bridge_in source, the dc_bridge_normalize and dc transforms, or any configured Destination's name) or by another snippet. An invalid or colliding snippet is a loud Bridge startup error naming the offending file. The Bridge then folds every validated snippet into the same file it writes to shipper.config_path — one self-contained file regardless of deployment mode, since in unmanaged mode a snippet's own filesystem path is never wired into the separately-run Shipper container — and, in managed mode, runs vector validate over that merged file as a backstop before starting Vector. A snippet may define transforms as well as sinks — only defining a reserved id is rejected, consuming one is not.

A snippet cannot route a topic on its own. The Bridge derives both its ROS subscriptions and its dc.<tag> route branches from destinations, and never reads a snippet's inputs — so every topic a snippet consumes must also appear in some blessed Destination's inputs, and destinations must name at least one (a file Destination is the cheapest way to satisfy that). Two consequences follow from the passthrough being outside the rendered config: the snippet's sink gets Vector's default in-memory buffer, not the disk buffer dc_bridge gives every blessed sink, and it is the snippet's job to make re-delivery idempotent if the store cares — the Shipper is at-least-once (ADR-0002) either way.

The Elasticsearch tutorial is the worked example for all of this, end to end; the InfluxDB demo is the same mechanism against a simulated robot. MCAP recording (ADR-0009, issue #210) is the same passthrough consumed by a standalone process instead of a Vector-native sink — the shape to follow for any store Vector has no sink for at all.

Recipes: postgres, s3, console via passthrough

Per ADR-0003, postgres, s3 (for receives: records) and console are pure Vector-sink wrappers with no DC-specific logic layered on top — Vector's own vector validate already gives clear, field-level errors for these three sinks, so passthrough loses nothing on the validation front for this subset. This is now the only way to configure them: their dedicated ROS-param templating was removed in #471/#472. Each recipe below reproduces exactly what dc_bridge used to render for the equivalent formerly-blessed configuration — confirmed by running Vector 0.57.0 against a live PostgreSQL and RustFS instance, not just written by inspection.

As with any passthrough, at least one blessed Destination is still needed to create the dc.<tag> route the snippet consumes — file is the cheapest (see above). If you're migrating the console Destination itself, keep a file Destination (or another cheap blessed type) as the route anchor instead.

postgres — the former blessed form's host/port/user/password/database collapse into a single connection-string endpoint; table is unchanged:

# ~/.dc/postgres_sink.toml — passthrough equivalent of the blessed `pgsql` Destination
[sinks.pgsql]
type = "postgres"
inputs = ["dc.dc.measurement.uptime"]   # the public dc.<tag> route
endpoint = "postgres://dc:${DC_PG_PASSWORD}@127.0.0.1:5432/dc"  # user:password@host:port/database
table = "dc"

[sinks.pgsql.buffer]
type = "disk"
max_size = 268435488   # Vector's disk-buffer minimum; a passthrough sink gets none by default

If user or password contain characters reserved in a URI (:, @, /, %), percent-encode them yourself — the former blessed form did this automatically, but a passthrough endpoint is handed to Vector verbatim.

s3 — Vector's own sink id is aws_s3, not s3; credentials move under [sinks.<name>.auth] and batch_timeout_secs becomes [sinks.<name>.batch] timeout_secs:

# ~/.dc/s3_sink.toml — passthrough equivalent of the blessed `rustfs` Destination
[sinks.rustfs]
type = "aws_s3"                          # Vector's sink id — not "s3"
inputs = ["dc.dc.measurement.uptime"]
bucket = "dc-records"
endpoint = "http://127.0.0.1:9000"       # omit for AWS S3
region = "us-east-1"
key_prefix = "robot1/"
force_path_style = true                  # path-style addressing for self-hosted stores

[sinks.rustfs.auth]
access_key_id = "rustfsadmin"
secret_access_key = "${DC_S3_SECRET}"

[sinks.rustfs.batch]
timeout_secs = 60                        # object write interval; Vector default 300

[sinks.rustfs.buffer]
type = "disk"
max_size = 268435488

[sinks.rustfs.encoding]
codec = "json"

console — had no required fields either way, so this recipe mostly matters for consistency with the other two:

# ~/.dc/console_sink.toml — passthrough equivalent of the blessed `console` Destination
[sinks.debug_console]
type = "console"
inputs = ["dc.dc.measurement.uptime"]
target = "stdout"                        # or "stderr"

[sinks.debug_console.encoding]
codec = "json"

Warning

Unlike the blessed form's password/secret_access_key (expanded by dc_bridge itself before it ever writes a config file), the ${VAR} references above are Vector's own interpolation and are otherwise off in the vendored Vector 0.57.0 binary dc_bridge spawns — a snippet's ${VAR} is left as a literal string, silently sent as the password verbatim (or rejected outright, if it contains a reserved URI character like the braces here). Set VECTOR_DANGEROUSLY_ALLOW_ENV_VAR_INTERPOLATION=true in the environment that launches the Bridge process itself (the vendored Vector inherits it) to make Vector honor ${VAR} inside passthrough snippet content. Without it, put the literal secret in the file and rely on filesystem permissions instead.

File uploads: receives: files (the Uploader, ADR-0005, ADR-0014)

A Destination with receives: files (only type: s3 qualifies) is served by the Uploader — dc_uploader, a separate process with its own Shipper connection (ADR-0014) — not by a Vector sink: Records arriving on its inputs topics are durably enqueued by the Bridge, then scanned by dc_uploader for the local_paths/remote_paths File references Measurements embed (camera, map, …), and each referenced File is uploaded (multipart + resumable for large Files) to every receives: files Destination whose name appears as a key in the Record's remote_paths — so the Destination name in the params file must match the remote key the Measurement is configured with. The YAML config shape below is unchanged by the split: dc_bringup.launch.py reads these same files.*/uploader.* parameters from the dc_bridge params block and translates them into dc_uploader's own DC_UPLOADER_* environment variables when it starts that process.

{
  "name": "map",
  "local_paths":  { "yaml": "/tmp/map.yaml", "pgm": "/tmp/map.pgm" },
  "remote_paths": { "rustfs": { "yaml": "robot/map.yaml", "pgm": "robot/map.pgm" } }
}

Per File the Uploader: uploads (multipart and resumable for large Files — an interrupted transfer picks up from the last completed part after a reconnect or Bridge restart, using progress checkpointed under <shipper.data_dir>/uploader/), verifies the object landed (head + size comparison), extracts metadata (content type, size, and duration via ffprobe for video/* Files), and emits a status Record under the dc.files Tag, routed like any Record to the Destination named by files.metadata_destination. Status rows preserve the Humble files_metrics shape — group_name, robot_name/robot_id (when present in the Record), local_path, remote_path (s3://bucket/key), storage_type, uploaded, on_filesystem, deleted, content_type, size, duration, updated_at, plus thumbnail_path when thumbnails are enabled — but the log is append-only: deletion appends a deleted: true row instead of updating one, and PostgreSQL is only ever written through the Shipper's parameterized sink (no SQL strings anywhere in DC).

Two guarantees consumers can rely on:

  • Group completion markers: when one Record references several Files (map = pgm+yaml, camera batches), a kind: group_complete Record listing every File is emitted only after all of them are verified on all their Destinations — a consumer querying mid-upload sees per-File rows but no marker, and never has to guess whether a group fully arrived.
  • Deletion only after verified upload: with files.delete_when_sent: true, a local File is removed only once it is verified on every Destination that receives it. Retries are idempotent — already-verified objects are not re-uploaded and status rows are never duplicated.

Durability: the disk-backed intent queue

Neither a Bridge nor a dc_uploader restart ever forgets a pending upload — the intent queue is a shared on-disk directory, not in-process state either owns exclusively. Before a files-Destination's Record is ever handed to the Uploader, it is written to disk as one intent — a JSON file ({version: 1, tag, timestamp, payload}) at <shipper.data_dir>/queue/upload/<monotonic_ts>-<seq>.json, via tmp-write-then-rename (crash-atomic; no fsync, matching Humble Fluent Bit's own storage.sync normal). The intent leaves the queue only when its Record has been fully processed — every File verified everywhere, or reported missing — never on a timer, a cap, or a drop-oldest policy: an upload intent and its Files live and die together. On startup every intent left over from a previous run is replayed, oldest-first, alongside live traffic; because processing is idempotent (an already-verified object short-circuits, multipart resumes from its checkpointed sidecar), a replayed intent can never re-upload or duplicate a status row.

A permanently-failing intent cannot starve the rest of the backlog: each intent gets its own exponential backoff (5 s, doubling, capped at 2000 s — Humble Fluent Bit's own scheduler defaults) and the worker sweeps oldest-first, skipping whatever is still backing off rather than retrying the same head-of-line entry forever. The queue's depth is surfaced as a cheap observability hook in the ~/ready service's message text.

This is at-least-once, not exactly-once: a crash between a status Record being forwarded and its intent being acked can replay that status Record after restart (the Uploader's own dedup set is in-memory, so a fresh process may re-emit one it already sent). Consumers should key on the latest row per (local_path, storage_type), same as the Records path.

A Record referencing no Files is enqueued and acked in the same pass with no retries, so it never lingers in the queue's steady state. Note that base64-heavy Records (e.g. a map's save_base64: on) inline file content directly into the payload and so consume queue-directory bytes proportionally faster than a plain File reference.

Thumbnails: optional previews for image and video Files

A dashboard drawing a grid of inspection photos otherwise pulls every full-size camera JPEG — and every video file — just to render tiles. With files.thumbnails enabled, the Uploader derives a small JPEG preview from each image or video File and uploads it next to the original as <remote_path>.thumb.jpg, recording it in that File's status Record as thumbnail_path (same s3://bucket/key shape as remote_path; the field is simply absent when there is no preview).

Default is off, and enabling it cannot change what happens to your data:

dc_bridge:
  ros__parameters:
    files:
      thumbnails:
        enabled: true
        max_dimension: 320       # bound on the preview's longest side, in pixels
        ffmpeg_binary: "ffmpeg"  # override if it isn't on PATH

A preview is a derived artefact and is treated as strictly secondary to the File it comes from:

  • It is generated only after the original is uploaded and verified on that Destination, never before or in parallel.
  • Every failure — no ffmpeg on PATH, an undecodable File, a store that rejects the preview — is swallowed. It never fails, delays, retries, or blocks the primary upload; the File uploads exactly as it would with the feature off, and the only visible difference is a missing thumbnail_path plus a Bridge warning counting how many previews couldn't be generated.
  • Local previews are scratch files under <shipper.data_dir>/uploader/thumbs/, removed before the Record finishes processing. So a preview never joins the un-uploaded pool retention measures and can never outlive its original: an intent retention sheds never uploaded, so no preview was ever derived from it.
  • Remote previews are idempotent by key. A Bridge restart mid-upload replays the intent, finds the preview already on the store, and neither re-decodes nor re-uploads it.

max_dimension bounds the longest side; aspect ratio is preserved and a File already smaller than the bound is never upscaled. Images become a scaled still; videos become their first frame. Files that are neither (map YAML, logs) are skipped entirely rather than handed to a decoder.

The decode dependency: previews are generated by shelling out to the ffmpeg CLI, the same toolchain files.ffprobe_binary already uses for the duration column — one tool covering both stills and video first-frames, kept out of dc_uploader's link line, and crash-isolated in a child process. dc_uploader is built from the dc_bridge colcon package, and that package's package.xml declares ffmpeg as an exec_depend, so rosdep install provides it; if it is missing at runtime dc_uploader still uploads normally and simply produces no previews.

Retention: bounded local storage for un-uploaded Files

The intent queue above never abandons an intent by design — an intent and its Files live and die together, and deleting the only copy of data as a side effect of queue management is forbidden. That means a robot offline for weeks, or pointed at a store that's unreachable for weeks, accumulates un-uploaded Files on disk without bound, same as Humble did. A full disk takes down more than DC, so files.retention is the one, explicit, opt-in, audited mechanism allowed to abandon data under disk pressure — nothing else in the system deletes a File it hasn't verified was uploaded.

Default is off: with files.retention absent (or both limits at their defaults), behavior is unchanged — accumulation, matching Humble. Configuring it is a deliberate choice to shed data rather than run out of disk:

dc_bridge:
  ros__parameters:
    files:
      retention:
        max_bytes: 10737418240   # cap on the un-uploaded Files pool; 0/absent = unlimited (default)
        max_age_days: 30         # optional; whichever limit is hit first

Scope is exactly the pool of Files referenced by intents still pending in <shipper.data_dir>/queue/upload/ — the same disk-backed queue the durability section above describes. A File the Uploader has already verified everywhere but is still waiting to physically delete (files.delete_when_sent, e.g. after a filesystem error) is never retention's victim; that's delete_when_sent's own retry loop to resolve, not retention's.

When either limit is exceeded, dc_uploader sheds the oldest eligible intent — its File(s) and its queue entry, always together, never one without the other — and repeats until back under both limits. Each shed File gets its own audit row through the normal dc.files metadata path: deleted: true, uploaded: false is the queryable "shed without upload" signature, distinct from a normal delete_when_sent deletion (which is always uploaded: true, since that path only ever deletes a File already confirmed on every Destination). Emitting the audit row is best-effort — a Shipper outage doesn't block the shed itself — but every shed always logs a rate-limited warning, since data was just abandoned without ever leaving the robot.

A Destination named by metadata_destination may declare no inputs at all — being named there is itself what routes the dc.files Tag to it. That is the usual shape when the status log lives in its own table: one blessed file Destination (typically anchoring a passthrough postgres sink, Recipes above) with inputs for the Records, and a second one with no inputs for the File status log.

inputs is otherwise mandatory: a Destination that neither lists a topic nor receives dc.files would have nothing to deliver, and is rejected at Bridge startup.

Raw topic collection

Sometimes the point is not to measure three specific things well, but to capture everything on the ROS graph and put it somewhere — a bring-up session, a field incident, a robot whose interesting topics you don't know yet.

For that, dc_bridge has a generic-subscription mode. It subscribes to topics it was never compiled against, converts each message to JSON, and ships it to a Destination like any other Record. No Measurement plugin, no .msg header, no rebuild of DC when the robot's message packages change.

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["raw_log"]
    raw_log:
      type: file              # blessed types: file | vector — or postgres/s3/console via
                               # its passthrough recipe (destinations.md#recipes-postgres-s3-console-via-passthrough)
      receives: records
      path: "/tmp/dc/raw_records.ndjson"
      time_key: "date"
    raw:
      enabled: true
      destination: "raw_log"
ros2 launch dc_bringup dc_raw.launch.py

dc_raw.launch.py starts the Bridge alone — no measurement_server, no group_server, no lifecycle manager, no readiness gate (there are no collection nodes to gate). The default params file is dc_bringup/params/dc_raw_params.yaml; point the launch file at your own with dc_params_file:=…. The same raw: block also works inside a normal dc_bringup.launch.py params file, next to your Measurements.

How it works

DC uses exactly what ROS 2 offers for this — the same mechanism ros2 bag record -a and PlotJuggler use:

  1. get_topic_names_and_types() lists the graph. Topics are filtered by the include/exclude patterns below.
  2. rclcpp::GenericSubscription subscribes to a topic given only its name and type string, receiving raw serialized bytes.
  3. The type's two type support libraries are loaded at run time — rosidl_typesupport_cpp to deserialize, rosidl_typesupport_introspection_cpp to describe the fields — and the message is walked into JSON.
  4. The Record is Tagged dc.raw.<topic> and routed to raw.destination.

The only requirement is that the message package is on the Bridge's AMENT_PREFIX_PATH — the same requirement ros2 topic echo has. A type that can't be resolved is logged once and skipped; the rest of the graph is still collected.

Tags and routing

A raw Record's Tag is the topic name with the leading / dropped and the rest turned into dots, under the dc.raw. namespace:

TopicTagShipper route
/imudc.raw.imudc.dc.raw
/robot/battery_statedc.raw.robot.battery_statedc.dc.raw

Unlike a Measurement's Tag, the whole namespace shares one Vector route (dc.dc.raw), matched with starts_with rather than an exact comparison. That is what makes discovery possible at all: a rendered Shipper config is fixed when the Bridge starts, but raw mode mints new Tags whenever a topic appears, and those Records still have to reach a sink without restarting Vector. A passthrough sink can consume dc.dc.raw like any other route.

The Tag is carried on the event itself, so a single dump file or table stays topic-attributable — check the tag field.

What a Record looks like

The JSON mirrors the message structure, field for field: nested messages become nested objects, arrays and sequences become arrays, string stays a string. A dc_interfaces/msg/StringStamped on /demo/custom arrives as:

{
  "tag": "dc.raw.demo.custom",
  "date": "2026-08-12T09:51:33.114777089",
  "header": {"frame_id": "base_link", "stamp": {"sec": 1786528293, "nanosec": 114777089}},
  "data": "{\"value\": 2}",
  "group_key": "demo"
}

Messages that start with a std_msgs/msg/Header are timestamped from header.stamp — the moment the data was captured. Everything else is stamped with the Bridge's own clock on arrival.

Type mapping

ROS fieldJSON
boolboolean
int8…int64, uint8…uint64, byte, charnumber
float32, float64number
stringstring
wstringstring (transcoded UTF-16 → UTF-8)
a nested messageobject
any array or sequence (fixed, bounded, unbounded)array

Two things about numbers are worth knowing before you point a query at the result:

  • NaN and Infinity both become null. JSON has neither, so there is nowhere else for them to go — but it means +Inf, -Inf, NaN and "the producer sent nothing" are indistinguishable downstream. This is not a corner case: ROS messages routinely use NaN as a sentinel (sensor_msgs/msg/BatteryState.temperature is NaN when the battery has no temperature sensor), so expect nulls in fields whose message definition documents one.
  • float32 is widened to double, so a value written as 4.05 reads back as 4.050000190734863: the exact binary32 value. If the extra digits bother a dashboard, round at the query layer.

A uint8[] serializes as an array of plain numbers — DC does not compress raw payloads on the way out. That is one more reason the size and type limits below exist: the answer to a megabyte of image bytes is to not collect it, rather than to encode it more cleverly.

Choosing what to collect

    raw:
      include: ["^/"]                       # topic-name regexes: the allowlist
      exclude: ["^/rosout$", "^/parameter_events$", "^/dc/measurement/", "^/dc/group/"]
      exclude_types:                        # message-type regexes
        - "^sensor_msgs/msg/(Image|CompressedImage|PointCloud|PointCloud2|LaserScan)$"
        - "^tf2_msgs/msg/TFMessage$"
      rescan_interval_secs: 5.0

A topic is collected when it matches at least one include pattern, no exclude pattern, and its type matches no exclude_types pattern. All three are ECMAScript regexes matched with regex_search, so they are unanchored unless you anchor them: camera matches /front/camera/info, ^/front/ matches only that namespace.

The values above are the defaults, and they matter:

  • /rosout is excluded — it carries the Bridge's own log messages, including the warnings raw mode emits about volume.
  • /dc/measurement/* and /dc/group/* are excluded — those topics already reach their Destinations as Measurement and Group Records. Collecting them raw as well ships everything twice, under two different Tags.
  • High-rate sensor types are excluded, by type rather than by name (a camera topic is not reliably called anything in particular). One 640×480 sensor_msgs/msg/Image is 900 kB on the wire and roughly 3.7 MB once every pixel byte is a JSON number; at 30 Hz nothing downstream is sized for it. Override the list deliberately if you want them — DC will not stop you, but read the next section first.

To replace a list, write the replacement; note that an empty YAML list ([]) cannot be loaded by rclcpp (it has no inferable element type), so use a pattern that matches nothing, e.g. ["^$"], to disable a default.

rescan_interval_secs re-scans the graph so topics that appear after startup are picked up — a driver started later, a node that respawned. The cost is that a topic is collected only from ~one interval after it is first advertised: messages published before that are not captured. Set it to 0 to scan once at startup and never again.

Topics advertising more than one type are skipped and logged: one subscription carries one type, and picking arbitrarily would silently drop the other publisher's messages.

QoS is matched to the topic's current publishers, the same way ros2 bag record does it — best effort if any publisher is best effort, transient local if all of them are. Without that, a generic subscription silently receives nothing from a sensor driver.

Backpressure and volume

Collecting everything can outrun the Shipper trivially. Raw mode's contract is that it sheds at the source rather than buffering inside the Bridge — an unbounded in-process queue would just move a data problem into a memory problem. Four bounds, in the order a message meets them:

  1. qos_depth (default 10) — the subscription's own history. If the Bridge is momentarily busy, the middleware drops the oldest messages beyond this depth before DC ever sees them.
  2. max_message_size_bytes (default 1 MiB, 0 = unlimited) — a serialized message above this is dropped whole, before deserialization, so an unexpected point cloud costs nothing but a throttled warning. This is a per-message circuit breaker — see the warning below.
  3. max_rate_hz (default 10, per topic, 0 = unlimited) — at most one Record per 1/rate seconds per topic: a message passes once that long has elapsed since the last one that passed, so each Record is the newest message at the moment it is emitted and nothing is held back. A 200 Hz topic at the default becomes 10 Hz of Records. This is decimation: it flattens exactly the bursty traffic shape a token bucket would let through all at once.
  4. The Shipper refusing the Record — if Vector is unreachable or its socket is blocked past the Forwarder's write timeout, the raw Record is dropped and counted. Measurement Records are kept in the Forwarder's unacked window for resend; raw Records are not, because a firehose fills that window and pushes real Records out of it.

Everything past those bounds behaves like any other Record: Vector's disk buffer (shipper.buffer_max_bytes) absorbs a Destination outage, and delivery to the Shipper is acknowledged (see Destinations).

How much data is this?

These figures were measured on a simulated robot: tools/sim/scripts/measure_raw_volume.sh boots dc_simulation's warehouse world, drives the TurtleBot3-Waffle in a slow circle, points a Bridge in raw mode at its live topics, and reports what a file Destination stored, per Tag. Everything below is one run of it — re-run it after anything that changes what a Record costs. It's meant for manual local runs; its header explains the three configurations and why its rates are counted in simulated seconds.

Rates are that world's Waffle's: IMU 200 Hz, odometry and TF 30 Hz (the DiffDrive plugin), lidar and both RGBD cameras 5 Hz. /cmd_vel and /clock are left out — the first is the benchmark's own driving, the second exists only because the robot is simulated.

ConfigurationShipped
Defaults (10 Hz cap, sensor types excluded)24 kB/s — 86 MB/hour, 2.1 GB/day
…plus scan and tf re-enabled69 kB/s — 5.9 GB/day
…plus both 1280×720 cameras re-enabled≈ 9.5 TB/day, if anything could carry it

Per-Record cost, which is what to multiply by your own topics' rates:

Topic (message)Publishes atBytes per Record
/joint_states (JointState, 2 joints)1 000 Hz †355
/tf (TFMessage, 1 transform)30 Hz406
…/camera_info (CameraInfo)5 Hz581
/odom (Odometry)30 Hz669
/imu (Imu)200 Hz767
/scan (LaserScan, 360 ranges + intensities)5 Hz7 878
…/image_raw (Image, 1280×720 rgb8)5 Hz10 981 032

† the simulator's JointStatePublisher runs every physics step; a real driver is far slower. It makes no difference to the total, which is the point of the next paragraph.

The rate cap is what makes the first row affordable. A topic publishing faster than max_rate_hz contributes bytes_per_record × 10 per second no matter how fast it actually runs, so joint_states at 1 000 Hz and imu at 200 Hz cost 3.6 kB/s and 7.7 kB/s respectively. Only the topics below the cap — the 5 Hz sensors — bill at their real rate, which is why re-enabling one 5 Hz lidar (7.9 kB per Record, 40 kB/s, 3.5 GB/day) nearly triples the total on its own.

The last row is the whole argument for the default type exclusions: one 1280×720 frame costs as much as ~16 000 odometry Records. JSON does not merely double a byte array — 10 981 032 bytes for a 2 764 800-byte frame is four times the wire size, because most pixel values print as three digits and a comma.

Two cameras at 5 Hz is 110 MB/s of Records, and nothing in the pipeline carries that, which is why that row says "if anything could carry it": in the run it comes from, the Shipper refused 475 of the 894 Records offered to it (dropped … 475 shipper, 53 %). The collapse was not confined to the images either — imu arrived at 4 Hz instead of 200 and odom at 0.7 Hz instead of 30, because the Bridge spent the window serializing frames. Collecting a camera raw does not cost you a camera's worth of storage; it costs you the rest of your collection.

The size cap will not save you from a camera — and it should not be asked to. A 640×480 rgb8 frame is 921 600 bytes on the wire, under the default max_message_size_bytes of 1 MiB, so it passes the size gate and lands as megabytes of JSON. (This world's 1280×720 frames are three times that and the default cap does drop them whole — which is why the benchmark's camera profile has to lift it to measure anything at all. Relying on that is a collection policy that silently switches on the day someone fits a smaller sensor.) It is exclude_types that keeps images out, and removing that list removes the protection entirely.

The tempting fix — lower the size cap until frames stop fitting — is a bad trade, because the two limits fail differently:

  • max_rate_hz drops by time, uniformly. Every window is still represented, so you get an honest lower-resolution time series.
  • max_message_size_bytes drops by content. On any topic whose messages vary in size — a compressed image, a point cloud that grows with scene complexity, a diagnostics array, a string payload — it removes precisely the large ones and keeps the small ones. The result looks complete and is silently biased toward the least interesting data, with nothing in the stored Records to say what went missing.

So set the size cap high enough that it essentially never fires, and treat it as a circuit breaker against the pathological and unanticipated (the topic you did not know carried 100 MB). To not collect something, exclude it by type or topic: all-or-nothing, visible in the startup log, and no biased sample. The only case for a low size cap is when you genuinely want "this topic, except its outliers" — which is rarely what anyone means.

Every drop is counted, and the counters are on the Bridge's readiness service — the fastest way to answer "is raw mode shedding?":

$ ros2 service call /dc_bridge/ready std_srvs/srv/Trigger
response: success=True, message='vector is accepting connections | raw: 12 topic(s),
  3480 forwarded, dropped 51200 rate / 0 oversize / 0 shipper / 0 undecodable'

dropped rate climbing is normal and healthy (it is the limiter doing its job). dropped shipper climbing means the pipeline is genuinely behind: raise shipper.buffer_max_bytes, lower max_rate_hz, or narrow include.

When not to use it

Raw mode ships whatever a topic happens to contain. It has no Conditions, no data validation, no Groups, and no File uploads — a Measurement is still the right tool for data you know you want, in a shape you control, joined with other data. Raw mode is for the case where you don't know yet, or where "all of it" is the requirement.

It is also not a rosbag replacement: JSON in Postgres is not a replayable recording. If you want replay, point a passthrough sink at the dc.dc.raw route and let dc_mcap_writer record it, or run ros2 bag record alongside DC.

KPI views

DC collects Records; the numbers an operations team reports on are computed from those Records in SQL, on the database, never on the robot. The window is a query parameter, so changing a KPI definition is re-applying one file — not redeploying a fleet.

The set covers availability and uptime, utilisation, intervention rate, MTBF/MTTR and loop closure rate. It lives in tools/infrastructure/sql/kpi_views.sql and reads the dc table the PostgreSQL Destination writes into.

What it defines

ObjectKindReports
dc_kpi_uptime_samplesviewOne row per uptime Record: robot_name, sample_time, uptime_seconds, run_id, prev_sample_time
dc_kpi_availability(from, to [, max_gap])functionPer robot: samples, first_sample, last_sample, uptime_seconds, covered_seconds, window_seconds, availability
dc_kpi_availability_5mviewThe same metric in 5-minute buckets, for charting — filter on bucket_start
dc_kpi_driving_samplesviewOne row per driving_type Record: robot_name, sample_time, mode, prev_sample_time, and the speed last reported at or before it
dc_kpi_utilisation(from, to [, max_gap [, min_speed]])functionPer robot: samples, speed_samples, reported_seconds, autonomous_seconds, manual_seconds, teleop_seconds, unknown_seconds, productive_seconds, window_seconds, utilisation
dc_kpi_utilisation_5mviewUtilisation in 5-minute buckets
dc_kpi_intervention_eventsviewOne row per intervention Record, with is_start/is_end derived from the modes it names
dc_kpi_intervention_rate(from, to [, max_gap])functionPer robot: interventions, open_interventions, ended_interventions, autonomous_seconds, distance_km, per_autonomous_hour, per_km, mean_intervention_seconds, total_intervention_seconds
dc_kpi_interventions_1hviewInterventions in 1-hour buckets
dc_kpi_fault_eventsviewOne row per fault Record: component, from_level, to_level, previous_duration, sequence, reason, open
dc_kpi_reliability(from, to [, failure_levels])functionPer component: failures, repairs, open_faults, operating_seconds, downtime_seconds, mtbf_seconds, mttr_seconds
dc_kpi_faults_1hviewFailures, repairs and downtime in 1-hour buckets
dc_kpi_loop_closure_eventsviewOne row per slam_toolbox_quality loop_closure Record (#394)
dc_kpi_loop_closure_rate(from, to)functionPer robot: loop_closures, window_seconds, per_hour, last_loop_closure, seconds_since_last
dc_kpi_loop_closures_1hviewLoop closures in 1-hour buckets
dc_kpi_max_gap()functionThe grace period every definition above shares (30 s)
dc_kpi_min_speed()functionThe speed at or above which the robot counts as moving (0.05 m/s)
dc_kpi_failure_levels()functionThe diagnostic levels that count as broken (ERROR, STALE)
-- Every metric for the last 24 hours, per robot.
SELECT * FROM dc_kpi_availability(now() - INTERVAL '24 hours', now());
SELECT * FROM dc_kpi_utilisation(now() - INTERVAL '24 hours', now());
SELECT * FROM dc_kpi_intervention_rate(now() - INTERVAL '24 hours', now());
SELECT * FROM dc_kpi_reliability(now() - INTERVAL '24 hours', now());

-- Any of them as a chart series.
SELECT bucket_start, robot_name, availability FROM dc_kpi_availability_5m
WHERE bucket_start >= now() - INTERVAL '24 hours' ORDER BY 1;

Availability and uptime

Reads the uptime Measurement. Each uptime Record vouches for the time back to the previous one, capped at the grace period and clipped to the window. A robot polling uptime every 5 s therefore reports 100 % availability, and only a silence longer than the grace period costs anything: a 5-minute outage in a 15-minute window leaves 630 of 900 seconds covered, so 70 %.

Consequences worth knowing before you put the number on a report:

  • Availability is reported availability. It answers "was the robot up and shipping data", which is what an operations team can act on — not "was the robot doing useful work".
  • A window longer than DC has been running reads low, because the time before the first Record is genuinely unreported.
  • A robot whose Records stopped before the window starts drops out of the result entirely rather than reporting 0 %: nothing in the database distinguishes it from a robot that was never deployed. A fleet registry is what would fix that, and DC has none.

Utilisation

Reads the driving_type and speed Measurements. Time is credited exactly the way availability credits it — back to the previous driving_type Record, capped at the grace period, clipped to the window — and attributed to the mode of the Record that closes the interval. Of that reported time, the share spent moving under a known mode is productive:

utilisation = productive_seconds / reported_seconds

manual and teleop count as productive: a human driving the robot is still the robot being used. Only unknown — no command source has published within velocity_timeout_s, or no mode has ever been observed — does not.

What it deliberately does not claim:

  • Not "useful work". A robot standing still while it inspects something reads as idle, and a robot driving in circles reads as productive. Distinguishing the two needs the Mission Measurement, which does not exist yet (see below).
  • No speed Record in the window means utilisation is NULL. A deployment that collects driving_type but not speed has unreported movement rather than zero movement. speed_samples is in the output so a NULL can be told from an empty range.
  • A speed Record vouches for the grace period after it, no longer. Movement reported once and then never again buys only 30 seconds of productive time, however long the window actually runs.
  • The per-mode seconds add up to reported_seconds, so the ratio can always be checked against its parts.

Intervention rate

Reads the intervention Measurement (#362) for the numerator and driving_type plus distance_traveled for the denominators. An intervention starts when a Record leaves autonomous for manual or teleop, and ends when one returns to autonomous; the end Record's previous_duration is the takeover's own length.

per_autonomous_hour = interventions / (autonomous_seconds / 3600)
per_km              = interventions / distance_km

Two denominators rather than one because they fail differently: a robot that spends a shift parked has few autonomous hours and no kilometres, and a robot doing tight manoeuvring has plenty of hours and few kilometres. Reporting both makes the difference visible instead of picking a winner.

What it deliberately does not claim:

  • A denominator nothing reported gives a NULL rate. A robot with no driving_type Records has autonomous_seconds NULL and per_autonomous_hour NULL; the same holds for distance_traveled and per_km.
  • A takeover still running is counted but never timed. Only an end Record carries a duration, so an open interval is absent from mean_intervention_seconds and total_intervention_seconds rather than a zero in them. open_interventions counts the Records the Measurement flagged open.
  • A takeover that starts before the window or ends after it is counted in the window its Record falls in. The window selects events, not intervals.

MTBF and MTTR

Reads the fault Measurement (#365). A raise is a Record entering a failure level from a healthy one; a clear is one leaving a failure level for a healthy one. Both carry previous_duration — how long the level just left was held — which is what the two averages are averages of:

mtbf_seconds = mean healthy time preceding a raise
mttr_seconds = mean time in a failure level preceding a clear

Failure levels default to ERROR and STALE and are a query parameter: dc_kpi_reliability(from, to, ARRAY['ERROR']) treats a silent component as reportable but not broken. WARN is not a failure by default, so OK → WARN → ERROR is one failure, timed from the last healthy state.

What it deliberately does not claim:

  • A change inside the failure set is not a second failure. ERROR → STALE is neither a raise nor a clear: a component that goes quiet while already broken is still one fault.
  • A fault never cleared has no repair time. It counts in failures and open_faults, and is absent from mttr_seconds rather than averaged in as an instant repair.
  • Grouped by component, never rolled up per robot. "The robot is down" is a policy over components that nothing here knows, and averaging independent components together reports a number for a failure mode no component has.
  • The durations come from the Records, so they can reach outside the window. A failure after eight healthy hours reports eight hours of MTBF in a one-hour window; the window chooses which events count, not how long their intervals were.

Loop closure rate

Reads the slam_toolbox_quality Measurement (#394). slam_toolbox/LoopClosureEvent carries nothing but its own occurrence, so the KPI value is entirely in Record timing:

per_hour = loop_closures / (window_seconds / 3600)

alongside seconds_since_last — how long it's been since the most recent loop closure as of window_end, a localization-drift risk indicator distinct from the rate: a robot can have a healthy rate over a long window and still be mid-drift right now if its last correction was a while ago.

What it deliberately does not claim:

  • seconds_since_last looks past window_start. Clipped to the window it would read a robot with no loop closure yet in a short window as "just corrected" instead of "never corrected" — the other rate functions clip strictly to the window, this one deliberately doesn't, for this one column.
  • A robot with no loop closure Record at all, ever, is absent from the result — same as every other rate function here: nothing distinguishes "never ran slam_toolbox" from "ran it and never closed a loop" without a fleet registry.

Mission success rate is not here yet

The fourth starter metric — completed, failed, cancelled and aborted missions as a share of missions started — has no KPI view yet, but the design decision that used to block it (#305) is resolved: Mission (Nav2 NavigateToPose), Mission (Nav2 FollowWaypoints), Mission (Nav2 NavigateThroughPoses) and Mission (Open-RMF) are all implemented Measurements today, emitting outcome Records DC already collects. Writing the dc_kpi_mission_* view/function pair on top of that data — the same shape as every other metric on this page — is what's still open; the source data it would read from already exists.

What the data has to look like

The views read the dc table's columns directly, so the robot's configuration has to fill them:

  • every Measurement the views read runs with include_measurement_name: true — name is what tells one Measurement's Records apart from another's;
  • robot_name is set, through custom_keys_str — Records without it are grouped under unknown;
  • the postgres Destination writes to the dc table with time_key: "date", whose default format is epoch nanoseconds.
MetricMeasurements it needs
Availabilityuptime
Utilisationdriving_type, speed
Intervention rateintervention (#362), driving_type, distance_traveled
MTBF / MTTRfault (#365)
Loop closure rateslam_toolbox_quality (#394)

dc_demos/params/tb3_simulation_pgsql_minio.yaml is a working example of the uptime/driving_type/speed/distance_traveled set. intervention and fault (#362, #365) are implemented Measurements too, but no shipped demo params file configures either yet — their views return no rows until a deployment enables them. The columns they write into are already in tools/infrastructure/docker/config/postgresql/init.sql, since Vector's postgres sink maps a Record's keys onto existing columns and silently drops the rest.

In the demo

tools/infrastructure/docker/docker-compose.postgresql.yaml mounts the tables and the KPI definitions into PostgreSQL's init directory, so bringing PostgreSQL up applies both. Grafana's provisioning ships a ROS 2 Data Collection - KPI dashboard reading them. Start the demo, open http://localhost:3000 (admin/admin), and the availability and utilisation panels populate as Records arrive; nothing has to be imported by hand.

The demo runs driving_type off Nav2's /cmd_vel, which is the only command source the simulation has: the mode is autonomous while Nav2 publishes and unknown otherwise. A real deployment adds its teleop topic to velocity_topics — that is also what makes an intervention distinguishable from an idle robot.

Warning

PostgreSQL runs its init directory only on an empty data directory. A database container that already existed before this file did needs the views applied explicitly — that is what the script below is for.

Pointing the views at a real deployment

The definitions have nothing demo-specific in them. Apply them to any PostgreSQL that holds DC Records:

PGPASSWORD=... ./tools/infrastructure/scripts/apply_kpi_views.bash \
  --host=db.example.com \
  --port=5432 \
  --user=dc \
  --database=dc

The script uses psql when it is installed and a postgres container otherwise. Every object is CREATE OR REPLACE, so re-running it is how a changed definition ships — no robot is touched, and the next dashboard refresh picks up the new numbers.

Then, on the reporting side:

  • Grafana: point the PostgreSQL datasource at the deployment (tools/infrastructure/docker/config/grafana/provisioning/grafana-datasource.yml, uid dc_postgres) and the dashboard follows, since every panel queries by name. Grafana that isn't provisioned from this repo can import tools/infrastructure/docker/config/grafana/dashboards/kpi.json instead.

  • A read-only dashboard user needs no more than:

    GRANT SELECT ON dc_kpi_uptime_samples, dc_kpi_availability_5m, dc_kpi_driving_samples,
                    dc_kpi_utilisation_5m, dc_kpi_intervention_events, dc_kpi_interventions_1h,
                    dc_kpi_fault_events, dc_kpi_faults_1h, dc_kpi_loop_closure_events,
                    dc_kpi_loop_closures_1h TO grafana;
    GRANT EXECUTE ON FUNCTION dc_kpi_availability(timestamptz, timestamptz, interval),
                              dc_kpi_utilisation(timestamptz, timestamptz, interval, double precision),
                              dc_kpi_intervention_rate(timestamptz, timestamptz, interval),
                              dc_kpi_reliability(timestamptz, timestamptz, text[]),
                              dc_kpi_loop_closure_rate(timestamptz, timestamptz) TO grafana;
    
  • Records stored elsewhere — another schema, another table name — only affect the four sample/event views: they are the only objects that name the dc table, and everything else is defined on top of them.

Testing a change

The fixture test seeds PostgreSQL with a known Record sequence and asserts what the definitions report over it, including the outage, open-interval and missing-denominator cases that are awkward to reproduce by hand:

./tools/infrastructure/scripts/test_kpi_views.sh

It brings up a throwaway PostgreSQL with Podman, applies these same SQL files into a schema of its own, and runs tools/infrastructure/test. The intervention and fault cases seed the Records their Measurements will emit, so a definition change breaks a test here rather than a dashboard later. Add a case for every definition you add.

Configuration examples

Through minimal code examples, you will learn how to collect and send data with DC.

It will progressively present all features.

Info

By here, you must have built the workspace following the setup guide.

Copy the configuration and save it as a yaml file, and then run:

ros2 launch dc_bringup dc_bringup.launch.py params_file:="my_file.yaml"

Examples that use a Group also need the Group node:

ros2 launch dc_bringup dc_bringup.launch.py params_file:="my_file.yaml" group_node:=True

Tip

Every example follows the same shape: a dc_bridge block declaring Destinations and the topics each one inputs, and a measurement_server block declaring Measurements and the topic each one publishes on. Routing is the overlap between the two lists — nothing on the Measurement side names a Destination.

```admonish info title="Why file + a passthrough snippet, not a blessed console Destination" Every "to the console" example below prints through a passthrough console sink loaded via custom_config_files, not a blessed console Destination — per ADR-0003, console (along with postgres and s3) moved from the blessed ROS-param form to a passthrough recipe (#471), being a pure Vector-sink wrapper with no DC-specific logic. destinations still names a file Destination in each example: dc_bridge derives its ROS subscriptions and dc.<tag> routes from destinations alone, never from a passthrough snippet's inputs, so a cheap file anchor is what actually creates the route the snippet consumes. See Destinations: Recipes for the recipe this reuses throughout, and Passthrough for the underlying mechanism.

Save this once as ~/.dc/console_sink.toml, and update its inputs to match whichever example you're running (each example below says what to set it to):

[sinks.debug_console]
type = "console"
inputs = ["dc.dc.measurement.uptime"]   # <- change this to match the example
target = "stdout"

[sinks.debug_console.encoding]
codec = "json"

## Running the examples
### Example 1: Uptime to the console every second

`console_sink.toml`'s `inputs`: `["dc.dc.measurement.uptime"]` (the default above).

```yaml
dc_bridge:                                    # Bridge (Shipper) node configuration
  ros__parameters:
    destinations: ["records_log"]             # List of Destination names to enable
    records_log:                              # Destination name, you choose
      type: file                              # Blessed Destination type -- the passthrough's anchor
      receives: records
      inputs: ["/dc/measurement/uptime"]      # Same as topic_output in the uptime measurement in measurement_server
      path: "/tmp/dc/example1_records.ndjson"
    custom_config_files: ["$HOME/.dc/console_sink.toml"]

measurement_server:                           # Measurement node configuration
  ros__parameters:
    measurement_plugins: ["uptime"]           # List of measurement plugins names to enable
    uptime:                                   # Plugin name, you choose
      plugin: "dc_measurements/Uptime"        # Plugin class name, fixed
      topic_output: "/dc/measurement/uptime"  # Topic where data will be published

Example 2: Uptime to the console with ISO 8601 timestamps

console_sink.toml's inputs: ["dc.dc.measurement.uptime"] (unchanged from Example 1).

dc_bridge:
  ros__parameters:
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/uptime"]
      path: "/tmp/dc/example2_records.ndjson"
      time_key: "date"                       # Field the normalized timestamp is written to
      time_format: "iso8601"                 # "epoch_nanos" (default) | "iso8601" | "double"
    custom_config_files: ["$HOME/.dc/console_sink.toml"]

measurement_server:
  ros__parameters:
    measurement_plugins: ["uptime"]
    uptime:
      plugin: "dc_measurements/Uptime"
      topic_output: "/dc/measurement/uptime"

Example 3: Uptime to the console only at start and 3 times

console_sink.toml's inputs: ["dc.dc.measurement.uptime"] (unchanged from Example 1).

dc_bridge:
  ros__parameters:
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/uptime"]
      path: "/tmp/dc/example3_records.ndjson"
    custom_config_files: ["$HOME/.dc/console_sink.toml"]

measurement_server:
  ros__parameters:
    measurement_plugins: ["uptime"]
    uptime:
      plugin: "dc_measurements/Uptime"
      topic_output: "/dc/measurement/uptime"
      init_max_measurements: 3               # Maximum records to collect

Example 4: CPU and Memory to the console every 5 seconds forever

console_sink.toml's inputs: ["dc.dc.measurement.cpu", "dc.dc.measurement.memory"].

dc_bridge:
  ros__parameters:
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/cpu", "/dc/measurement/memory"]
      path: "/tmp/dc/example4_records.ndjson"
    custom_config_files: ["$HOME/.dc/console_sink.toml"]

measurement_server:
  ros__parameters:
    measurement_plugins: ["memory", "cpu"]
    memory:
      plugin: "dc_measurements/Memory"
      topic_output: "/dc/measurement/memory"
      polling_interval: 5000                  # Interval to which data is collected in milliseconds
    cpu:
      plugin: "dc_measurements/Cpu"
      topic_output: "/dc/measurement/cpu"
      polling_interval: 5000                  # Interval to which data is collected in milliseconds

Example 5: CPU and Memory as a group to the console every 5 seconds forever

console_sink.toml's inputs: ["dc.dc.group.cpu_memory"].

dc_bridge:
  ros__parameters:
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/group/cpu_memory"]        # Group to create
      path: "/tmp/dc/example5_records.ndjson"
    custom_config_files: ["$HOME/.dc/console_sink.toml"]

group_server:                                 # Group server configuration
  ros__parameters:
    groups: ["cpu_memory"]
    cpu_memory:
      inputs: ["/dc/measurement/cpu", "/dc/measurement/memory"] # Topics which are subscribed
      output: "/dc/group/cpu_memory"          # Topic where result will be published
      sync_delay: 5.0                         # How long to queue up messages before passing them through.
      group_key: "cpu_memory"

measurement_server:
  ros__parameters:
    measurement_plugins: ["memory", "cpu"]
    memory:
      plugin: "dc_measurements/Memory"
      topic_output: "/dc/measurement/memory"
      polling_interval: 5000
    cpu:
      plugin: "dc_measurements/Cpu"
      topic_output: "/dc/measurement/cpu"
      polling_interval: 5000

Example 6: Custom ROS message to the console every 2 seconds forever

console_sink.toml's inputs: ["dc.dc.measurement.my_string_stamped"].

dc_bridge:
  ros__parameters:
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/my_string_stamped"]
      path: "/tmp/dc/example6_records.ndjson"
    custom_config_files: ["$HOME/.dc/console_sink.toml"]

measurement_server:
  ros__parameters:
    measurement_plugins: ["my_string_stamped"]
    my_string_stamped:
      plugin: "dc_measurements/StringStamped"           # Plugin that allow to publish from your nodes
      topic_output: "/dc/measurement/my_string_stamped" # Topic where the Record is republished
      topic: "/hello-world"                             # Input topic where you are publishing
      polling_interval: 2000
      enable_validator: false                           # By default, StringStamped message does not have a JSON schema since it uses custom input data

You will then need in another terminal to publish data on the input topic (/hello-world)

ros2 topic pub -r 1 /hello-world dc_interfaces/msg/StringStamped  "{data: '{\"hello\":\"world\"}'}"

Example 7: Custom ROS message to the console every time it is published

console_sink.toml's inputs: ["dc.dc.measurement.my_string_stamped"] (unchanged from Example 6).

dc_bridge:
  ros__parameters:
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/my_string_stamped"]
      path: "/tmp/dc/example7_records.ndjson"
    custom_config_files: ["$HOME/.dc/console_sink.toml"]

measurement_server:
  ros__parameters:
    measurement_plugins: ["my_string_stamped"]
    my_string_stamped:
      plugin: "dc_measurements/StringStamped"
      topic_output: "/dc/measurement/my_string_stamped"
      topic: "/hello-world"
      enable_validator: false
      timer_based: false                                 # Get all data published on the input topic. Ignores polling_interval

Example 8: Uptime to PostgreSQL, and to the console at the same time

A Record is delivered to every Vector sink that consumes its route — listing the same dc.<tag> route in two sinks' inputs is how you fan out. Both PostgreSQL and console are reached through the passthrough here (per ADR-0003, neither is a blessed Destination any more — see Destinations: Recipes); a single file anchor creates the one route both passthrough sinks consume.

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"            # Where the Shipper keeps its disk buffer
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/uptime"]
      path: "/tmp/dc/example8_records.ndjson"
    custom_config_files: ["$HOME/.dc/example8_sink.toml"]

measurement_server:
  ros__parameters:
    measurement_plugins: ["uptime"]
    uptime:
      plugin: "dc_measurements/Uptime"
      topic_output: "/dc/measurement/uptime"
# ~/.dc/example8_sink.toml
[sinks.pgsql]
type = "postgres"
inputs = ["dc.dc.measurement.uptime"]
endpoint = "postgres://dc:$DC_PG_PASSWORD@127.0.0.1:5432/dc"  # user:password@host:port/database
table = "dc"

[sinks.pgsql.buffer]
type = "disk"
max_size = 268435488

[sinks.debug_console]
type = "console"
inputs = ["dc.dc.measurement.uptime"]
target = "stdout"

[sinks.debug_console.encoding]
codec = "json"

Warning

Vector's postgres sink maps a Record's top-level JSON keys onto existing columns; it does not create tables or columns. Create the table before starting DC. Unlike the blessed form's password, the $DC_PG_PASSWORD above is Vector's own ${VAR} interpolation, off by default in the vendored Vector binary dc_bridge spawns — see the warning in Destinations: Recipes.

Example 9: Camera images to object storage, with their metadata in PostgreSQL

Files (images, maps, videos) never travel through the Shipper. A receives: files Destination is served by dc_uploader, a separate process with its own Shipper connection (ADR-0014), and the per-File status Records it produces go to whichever Destination files.metadata_destination names. dc_uploader's durable upload intent queue and multipart-resume state live under uploader.data_dir, separate from the Shipper's own disk buffer under shipper.data_dir — set both, as below, so it's obvious on disk (and later in volume mounts) which files belong to which owner. If uploader.data_dir is omitted it defaults to shipper.data_dir, so existing configs that only set the latter keep working unchanged.

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/shipper"
    uploader:
      data_dir: "$HOME/.dc/uploader"
    destinations: ["records_log", "rustfs"]
    records_log:                              # anchor for the Records, and for the File status log
      type: file
      receives: records
      inputs: ["/dc/measurement/camera"]
      path: "/tmp/dc/example9_records.ndjson"
    rustfs:                                   # the File bytes
      type: s3
      receives: files
      inputs: ["/dc/measurement/camera"]
      bucket: "dc-files"
      endpoint: "http://127.0.0.1:9000"       # omit for AWS S3
      region: "us-east-1"
      access_key_id: "rustfsadmin"
      secret_access_key: "$DC_S3_SECRET"
      force_path_style: true                  # path-style addressing for self-hosted stores
    files:
      delete_when_sent: true                  # delete locally once verified remotely
      metadata_destination: "records_log"     # must name a `receives: records` Destination -- a passthrough sink id isn't eligible
    custom_config_files: ["$HOME/.dc/example9_sink.toml"]

measurement_server:
  ros__parameters:
    measurement_plugins: ["camera"]
    camera:
      plugin: "dc_measurements/Camera"
      topic_output: "/dc/measurement/camera"
      cam_topic: "/camera/image_raw"
      cam_name: "camera"
      save_detections_img: true
      save_inspected_path: "camera/inspected/%Y-%m-%dT%H-%M-%S"
      detection_modules: ["barcode"]
      remote_keys: ["rustfs"]                 # must equal the receives: files Destination name
      remote_prefixes: [""]
# ~/.dc/example9_sink.toml -- consumes both routes records_log creates: the camera
# measurement's own topic, and the dc.files Tag it gains from being named as
# files.metadata_destination.
[sinks.pgsql]
type = "postgres"
inputs = ["dc.dc.measurement.camera", "dc.dc.files"]
endpoint = "postgres://dc:$DC_PG_PASSWORD@127.0.0.1:5432/dc"
table = "dc"

[sinks.pgsql.buffer]
type = "disk"
max_size = 268435488

rustfs stays a blessed Destination: receives: files is served entirely by dc_uploader reading these same ROS params, never by a Vector sink, so there is no passthrough equivalent for it to migrate to (see Destinations: Recipes).

Example 10: A Destination DC does not bless, via the passthrough

Any sink in Vector's catalog is reachable by handing raw Shipper configuration through, consuming the public dc.<tag> route for the topic you want.

dc_bridge:
  ros__parameters:
    shipper:
      data_dir: "$HOME/.dc/buffer"
    destinations: ["records_log"]
    records_log:
      type: file
      receives: records
      inputs: ["/dc/measurement/uptime"]
      path: "/tmp/dc/example10_records.ndjson"
    custom_config_files: ["$HOME/.dc/http_sink.toml"]

measurement_server:
  ros__parameters:
    measurement_plugins: ["uptime"]
    uptime:
      plugin: "dc_measurements/Uptime"
      topic_output: "/dc/measurement/uptime"
# $HOME/.dc/http_sink.toml — raw Vector configuration, merged as-is
[sinks.my_api]
type = "http"
inputs = ["dc.dc.measurement.uptime"]   # /dc/measurement/uptime's public route
uri = "http://127.0.0.1:8080/ingest"
encoding.codec = "json"

Now that you know how it works, you can set up your own Measurements and Destinations — see Measurements and Destinations for every parameter.

Infrastructure setup

DC delivers Records and Files to systems you run yourself. These pages cover bringing up the ones the demos and examples assume:

PageUsed as
AdminerA database GUI for browsing the PostgreSQL Records and File status log
PostgreSQLA postgres Destination, and the File status log
RustFSAn s3 Destination, for Records and for File uploads
GrafanaDashboards over the PostgreSQL Records and the KPI views
ElasticsearchA passthrough Destination (elasticsearch), + Kibana
InfluxDBA passthrough Destination (influxdb_logs)
IP cameraAn RTSP source for the IP camera Measurement

The compose files live in tools/infrastructure/docker/. None of this is required to run DC — the console and file Destinations need nothing external.

Adminer

Description

Adminer (formerly phpMinAdmin) is a full-featured database management tool written in PHP. Conversely to phpMyAdmin, it consist of a single file ready to deploy to the target server. Adminer is available for MySQL, MariaDB, PostgreSQL, SQLite, MS SQL, Oracle, Elasticsearch, MongoDB and others via plugin

Start in a container

Execute:

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=adminer \
  --install-type=docker

Start natively

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=adminer \
  --install-type=native

Credentials

TypeUserPasswordDatabasePort
PostgreSQL (Native)dcpassworddc80
PostgreSQL (Container)dcpassworddc8080

How to use

Natively, by accessing http://localhost:80/adminer, and in a container, by accessing http://localhost:8080, you will be able to see this page:

Adminer

Elasticsearch

Description

Elasticsearch is a distributed, JSON-native search and analytics engine. It stores Records as documents — no schema to declare up front, no columns to pre-create — which makes it a natural fit for the passthrough Destination: dc_bridge has no type: elasticsearch, and none is needed, because Vector ships an elasticsearch sink that speaks the bulk API directly.

Kibana is Elasticsearch's own query and dashboard UI, and is what the Elasticsearch tutorial uses to look at what landed.

Start in a container

Execute:

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=elasticsearch \
  --install-type=docker

or, driving the compose file directly with Podman (the engine the rest of this repo's container tooling uses):

podman compose -f tools/infrastructure/docker/docker-compose.elasticsearch.yaml up -d

This starts a single-node Elasticsearch plus a Kibana pointed at it. Elasticsearch has a healthcheck and Kibana waits for it, so the stack is usable once up -d returns.

Check it is alive:

curl -s http://localhost:9200/_cluster/health
{"cluster_name":"docker-cluster","status":"green","timed_out":false,"number_of_nodes":1,"number_of_data_nodes":1,...}

Info

There is no native (non-container) install path for Elasticsearch in this script — it needs a JVM and a vm.max_map_count of at least 262144 on the host, which is more system-level setup than the script does for any other tool. Use the compose path above.

Configuration and credentials

ServiceURLCredentials
Elasticsearchhttp://localhost:9200none
Kibanahttp://localhost:5601none

The compose file sets xpack.security.enabled=false, so there are no credentials to manage and the tutorial's Vector sink needs no auth block.

Warning

Security disabled means anyone who can reach port 9200 can read and delete every index, and Records travel in cleartext. This is a demo setting. For anything else, re-enable xpack.security, and give the passthrough sink an auth block (or an API key) — see the commented example at the bottom of dc_demos/config/elasticsearch_sink.toml.

Two other settings are demo-shaped and worth changing before this stack is anything but local:

  • discovery.type=single-node — one node, so no replicas are ever allocated. Indices report yellow, not green, because their replica shard has nowhere to go; that is expected here and not a symptom.
  • ES_JAVA_OPTS=-Xms512m -Xmx512m — a small fixed heap so this fits next to a simulator on a laptop. Elasticsearch's own sizing guidance applies for a real deployment.

Kibana is only there to look at the data. On a constrained machine, comment its service out — the tutorial's verification step queries the _search API with curl and needs only Elasticsearch.

Grafana

Description

Grafana is a multi-platform open source analytics and interactive visualization web application. It provides charts, graphs, and alerts for the web when connected to supported data sources.

Start in a container

Execute:

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=grafana \
  --install-type=docker

Start natively

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=grafana \
  --install-type=native

What comes up with it

Datasource and dashboards are provisioned from tools/infrastructure/docker/config/grafana/ — no manual import. The PostgreSQL datasource (uid dc_postgres) points at the PostgreSQL container, and four dashboards ship: Home, Robot, KPI (availability and uptime, backed by the KPI views), and Fast DDS statistics.

Credentials

UserPasswordPort
adminadmin3000

How to use

Open http://localhost:3000 and log in; the provisioned dashboards are under Dashboards:

Grafana

InfluxDB

Description

InfluxDB is a time-series database purpose-built for metrics and events. DC has no blessed influxdb Destination — Vector's influxdb_logs sink is reached through the passthrough Destination, the same mechanism the InfluxDB demo uses.

Start in a container

Execute:

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=influxdb \
  --install-type=docker

Start natively

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=influxdb \
  --install-type=native

Credentials

UserPasswordDatabasePort
adminadmindc8086

Ip cameras

To start the ip camera example, to save video feeds from rtsp cameras, you can optionally create a virtual video feed if you don't have an ip camera, for testing purpose:

Start RTSP Server

A rtsp server. I use mediamtx available here. Download from the release page and start it:

./mediamtx

If the port is already used, download the configuration and edit the hlsAddress parameter. You would then need to start it this way:

./mediamtx conf.yml

Create a virtual camera

This is optional, and only useful if you want to not use a camera

You will first need to install some packages:

sudo apt-get install v4l-utils v4l2loopback-dkms

And start a virtual camera device:

sudo modprobe v4l2loopback

Now check its path:

v4l2-ctl --list-devices

Dummy video device (0x0000) (platform:v4l2loopback-000):
	/dev/video2

In my case, it is /dev/video2

To send your camera feed, run:

ffmpeg \
  -f v4l2 \
  -video_size 1920x1080 \
  -i /dev/video2 \
  -f rtsp \
  -rtsp_transport tcp rtsp://127.0.0.1:8554/mystream

Send video stream

To send an image of a clock ticking, create a blank image and call it bg-white.png (with gimp for example) and run:

ffmpeg \
  -re -loop 1 \
  -i bg-white.png \
  -vf drawtext="fontfile=monofonto.ttf: fontsize=96: box=1: boxcolor=black@0.75: boxborderw=5: fontcolor=white: x=(w-text_w)/2: y=((h-text_h)/2)+((h-text_h)/4): text='%{gmtime\:%H\\\\\:%M\\\\\:%S}'" \
  -r 25 \
  -vcodec libx264 \
  -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:8554/mystream

Now you can use the ip_camera plugin with the url from the command: rtsp://127.0.0.1:8554/mystream

RustFS

Requirements

  1. A container engine installed (Podman by default)
  2. Its compose plugin installed

Description

RustFS is an S3-compatible object storage server, written in Rust and licensed Apache 2.0. It is the s3 blessed Destination's recommended backing store — MinIO's community edition was archived upstream in 2026 and no longer receives maintenance, so DC 2.0's own examples and E2E harness (tools/e2e/) both target RustFS instead; existing MinIO or Ceph RGW deployments work identically since dc_bridge's s3 Destination just talks plain S3 API. See Destinations for the full Destination contract.

Start in a container

Execute:

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=rustfs \
  --install-type=docker

This starts a single RustFS container plus a one-shot container that bootstraps the dc-files bucket the demo params files upload into (RustFS does not auto-create buckets on startup).

Info

There is no native (non-container) install path for RustFS in this script yet — unlike MinIO, it has no apt/systemd packaging convention to mirror. Use the container path above.

How to use

RustFS listens on port 9000 and speaks the S3 API directly — point any S3-compatible client (mc, aws s3, or dc_bridge's own s3 Destination) at http://localhost:9000.

Credentials

UserPasswordPort
rustfsadminrustfsadmin9000

These are RustFS's built-in default credentials (no environment variables are set in docker-compose.rustfs.yaml) — set your own for anything beyond a local demo.

PostgreSQL

Description

PostgreSQL is a powerful, open source object-relational database system that uses and extends the SQL language combined with many features that safely store and scale the most complicated data workloads. The origins of PostgreSQL date back to 1986 as part of the POSTGRES project at the University of California at Berkeley and has more than 35 years of active development on the core platform.

Start in a container

Execute:

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=postgresql \
  --install-type=docker

Start natively

Execute:

./tools/infrastructure/scripts/install_infrastructure.bash \
  --tool=postgresql \
  --install-type=native

Credentials

UserPasswordDatabasePort
dcpassworddc5432

What comes up with it

The compose file applies two SQL files at first start: tools/infrastructure/docker/config/postgresql/init.sql creates the dc and dc_files tables the postgres Destination writes into, and tools/infrastructure/sql/kpi_views.sql adds the KPI views the Grafana dashboards read.

Migration

This page tracks in-progress moves between configuration shapes DC itself is making — distinct from Configuration examples, which teaches the current shape from scratch.

Blessed postgres/s3/console → passthrough (completed)

ADR-0003 originally blessed postgres, s3, file, console and vector with a ROS-param form rendered into Vector config by dc_bridge. An audit of that blessed set found postgres, s3 (for receives: records) and console carried no DC-specific logic — they were pure Vector-sink wrappers, and Vector's own vector validate already gives clear, field-level errors for them. Every in-repo demo, deploy param file and doc using them was moved to passthrough first (#471), then dc_bridge's blessed code path for these three types was removed (#472) — as of that change, postgres, s3 (for receives: records) and console are only configurable via passthrough; the blessed ROS-param form for them no longer exists.

file and vector are unaffected and remain blessed — file is a plain local-disk Vector sink with no DC-specific logic either, but is kept as the cheap "anchor" Destination every passthrough setup needs (dc_bridge derives its ROS subscriptions and dc.<tag> routes from destinations alone, never from a passthrough snippet's inputs); vector is reserved for the split-deployment/fleet work in #440. s3 also stays blessed for receives: files (File uploads, ADR-0005) — that path is served entirely by the Uploader's own S3 client, never by a Vector sink, so there is no passthrough equivalent for it to migrate to.

Working passthrough recipes for postgres, s3 (records) and console — reproducing exactly what dc_bridge used to render for the blessed form — are in Destinations: Recipes. Point custom_config_files at one of them, alongside a file (or vector) anchor Destination; nothing else about your Measurements or routing changes.

kind: NetworkPolicy validation

The reference fleet architecture (epic #440) claims a robot tier has no internet route, sites cannot reach each other, and connections only ever flow outward (robot -> edge -> hub). Routing alone does not prove that — NetworkPolicy is a Kubernetes object, and enforcing it requires a CNI that implements it. This page proves it with a real kind cluster (kubeadm, the same tool production clusters use) running Calico, a real policy-enforcing CNI — unlike the k3d dev loop's default Flannel, which silently accepts NetworkPolicy objects without enforcing them.

Info

Every command below is exactly what CI's verify-kind-networkpolicy job (.github/workflows/ci.yaml) runs, one step each — no wrapper script standing between this page and CI to fall out of sync with either. Run them in order to reproduce locally, or read on for what each does and why.

Prerequisites

  • Podman (pulling or building dc-ros, loading it into the cluster)
  • Docker (kind's node runtime — see Docker dependency below)
  • kind (v0.33.0) and kubectl (v1.31.4) on PATH — CI installs these via helm/kind-action's install_only: true mode; see tools/kind/README.md for why (kind create cluster --wait, which that action otherwise always runs, can't succeed before Calico is installed)

1. Get the dc-ros image

kubernetes/robot-a.yaml commits a real default: ghcr.io/minipada/ros2_data_collection/dc-ros:rolling — the same floating ref build-dc-ros-image pushes on every merge to rolling, and the same one deploy/robot/kubernetes/robot-pod.yaml runs. (Not :latest — this repo doesn't push that tag; :rolling is the one a real deployment actually pins to.) The simplest reproduction just pulls it:

podman pull ghcr.io/minipada/ros2_data_collection/dc-ros:rolling
export DC_ROS_IMAGE=ghcr.io/minipada/ros2_data_collection/dc-ros:rolling

CI instead pulls the PR's just-built :<sha> image (build-dc-ros-image's own output) — testing what this run actually built, same as verify-robot-manifests/ verify-runtime-images. To reproduce that case locally instead — testing a change before it's pushed — build it yourself and point DC_ROS_IMAGE at the local tag:

TARGET=runtime IMAGE_TAG=dc-runtime:local ./tools/e2e/scripts/build.sh
podman build -t dc-ros:local -f containers/dc-ros/Containerfile --build-arg BASE_IMAGE=dc-runtime:local containers/dc-ros
export DC_ROS_IMAGE=dc-ros:local

Either way, step 4 substitutes DC_ROS_IMAGE into the manifest's default only when it differs from :rolling — one workflow, whichever image you're pointing at.

2. Bring up the cluster and its CNI

kind create cluster --name dc-kind --config tools/kind/kind-config.yaml
kubectl --context kind-dc-kind apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.2/manifests/calico.yaml
kubectl --context kind-dc-kind -n kube-system rollout status daemonset/calico-node --timeout=180s
kubectl --context kind-dc-kind -n kube-system rollout status deployment/calico-kube-controllers --timeout=180s
kubectl --context kind-dc-kind wait --for=condition=Ready nodes --all --timeout=180s

tools/kind/kind-config.yaml sets disableDefaultCNI: true — kind's own default (kindnet) doesn't enforce NetworkPolicy either, so a node reports NotReady until Calico is applied.

3. Load dc-ros into the cluster — no registry

podman save -o /tmp/dc-ros.tar "$DC_ROS_IMAGE"
kind load image-archive /tmp/dc-ros.tar --name dc-kind

Not kind load docker-image: that reads from the Docker image store, which a Podman-built image never populates (CLAUDE.md "Containers: Podman, not Docker"). The tar round trip is also what keeps this registry-free — the robot Pod below needs no ghcr.io pull secret.

4. Apply the topology

# robot-a.yaml commits ghcr.io/.../dc-ros:rolling as dc-ros's real default — patched in
# place only if step 1 pointed DC_ROS_IMAGE somewhere else, before the kustomize build
# below reads the file.
sed -i "s|ghcr.io/minipada/ros2_data_collection/dc-ros:rolling|$DC_ROS_IMAGE|" \
  tools/kind/kubernetes/robot-a.yaml

# Namespaces, networkpolicies, the hub, edge and robot tiers, the probe Pods, and all
# three ConfigMaps (generated from tools/kind/params/* and tools/e2e/sql/init.sql) — in
# one apply. kustomize ships in kubectl; see tools/kustomization.yaml for what's
# deliberately left out (the outage-inducing NetworkPolicy variant) and why the file
# lives at the tools/ root rather than inside tools/kind/ itself.
kubectl --context kind-dc-kind apply -k tools/

kubectl --context kind-dc-kind rollout status -n dc-hub deployment/hub-postgres --timeout=180s
kubectl --context kind-dc-kind rollout status -n dc-edge-a deployment/edge-vector --timeout=180s
kubectl --context kind-dc-kind wait -n dc-robot-a --for=condition=Ready pod/robot-a-probe --timeout=60s
kubectl --context kind-dc-kind wait -n dc-edge-a --for=condition=Ready pod/edge-a-probe --timeout=60s
kubectl --context kind-dc-kind wait -n dc-edge-b --for=condition=Ready pod/edge-b-probe --timeout=60s

timeout 90 bash -c \
  "until kubectl --context kind-dc-kind logs -n dc-robot-a dc-robot -c dc-ros 2>&1 | grep -q 'dc_bridge reports ready'; do sleep 2; done"

Four namespaces: dc-robot-a and dc-edge-a (site A, the real topology under test), dc-edge-b (a stand-in second site — nothing runs there but a probe Pod, just enough to have a second site to deny), and dc-hub. See tools/kind/README.md's topology diagram and each manifest's own header for what runs where and why.

5. Prove the claims

tools/kind/scripts/verify_network_policy.sh

Six kubectl exec ... nc -z connection attempts from dedicated probe Pods (same podSelector: {} policy every real workload in that namespace is bound by):

AttemptExpected
robot-a -> public internetdenied
robot-a -> dc-edge-bdenied
dc-edge-b -> robot-a's edge (dc-edge-a)denied
dc-edge-b -> hubdenied
robot-a -> dc-edge-apermitted
dc-edge-a -> hubpermitted

Then the real check: wait for dc-ros's own Records to reach the hub's Postgres through the edge aggregator, and check the row count.

6. Induced outage: the backlog buffers through it

sleep 15  # steady state
COUNT_BEFORE="$(kubectl --context kind-dc-kind exec -n dc-hub deploy/hub-postgres -- psql -U dc -d dc -tAc 'SELECT count(*) FROM dc_records' | tr -d '[:space:]')"
WINDOW_START_TS="$(date +%s)"

kubectl --context kind-dc-kind apply -f tools/kind/kubernetes/networkpolicy-robot-outage.yaml  # cuts robot -> edge
sleep 30  # outage
kubectl --context kind-dc-kind apply -k tools/  # restore: same command that brought the topology up
sleep 15  # drain

COUNT_AFTER="$(kubectl --context kind-dc-kind exec -n dc-hub deploy/hub-postgres -- psql -U dc -d dc -tAc 'SELECT count(*) FROM dc_records' | tr -d '[:space:]')"
WINDOW_ELAPSED=$(( $(date +%s) - WINDOW_START_TS ))
DELTA=$(( COUNT_AFTER - COUNT_BEFORE ))
echo "records: $COUNT_BEFORE before, $COUNT_AFTER after (+$DELTA over ${WINDOW_ELAPSED}s)"

networkpolicy-robot-outage.yaml replaces dc-robot-a's NetworkPolicy object (same name, same namespace) with a version that drops the egress-to-edge rule and keeps only DNS — a real policy-enforced site-link outage rather than a stopped container. dc-ros and its local Vector Shipper keep running and buffering to disk the whole time (ADR-0002); once the policy is restored, the buffered backlog flushes and the hub's row count catches back up. CI additionally checks DELTA against WINDOW_ELAPSED seconds at the uptime Measurement's 1Hz rate (tools/kind/params/robot-a-params.yaml), with a 70% lower bound — a real loss would show up there as a permanent shortfall rather than a brief dip.

7. Tear down

kind delete cluster --name dc-kind

Docker dependency

kind runs each cluster node as a Docker container, and Calico's kind guide — which this harness follows — is documented and tested against Docker. Podman has experimental kind support (KIND_EXPERIMENTAL_PROVIDER=podman), but this harness doesn't depend on it: it is throwaway CI test infrastructure, not something DC ships, so it uses real Docker rather than an experimental path nothing else in this repo relies on. Building and shipping DC itself stays on Podman, unchanged (CLAUDE.md "Containers: Podman, not Docker") — Podman is the only tool that ever touches a DC image here; Docker's only job is running the kind nodes. GitHub-hosted ubuntu-latest runners ship Docker preinstalled, so CI needs no extra setup step for it.

CLI tools

Known packaging gap: typer isn't declared

dc_cli/package.xml doesn't declare a dependency on typer (the CLI framework this tool is built on) — a real python3-typer rosdep key exists and resolves to a real Ubuntu apt package, but nothing in package.xml tells rosdep to install it. A plain rosdep install + colcon build leaves ros2 run dc_cli list_plugins failing with ModuleNotFoundError: No module named 'typer'. pip install --break-system-packages typer (or add it to a uv-managed virtualenv) is the workaround until the package declares it — or sudo apt install python3-typer directly.

List plugins

You can list available plugins by running the CLI tool:

ros2 run dc_cli list_plugins --help


 Usage: list_plugins [OPTIONS] COMMAND [ARGS]...

╭─ Options ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --install-completion          Install completion for the current shell.                                                   │
│ --show-completion             Show completion for the current shell, to copy it or customize the installation.            │
│ --help                        Show this message and exit.                                                                 │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ by-package       List plugins of a pluginlib file with their descriptions, by package name and filename.                  │
│ by-path          List plugins of a pluginlib file with their descriptions, by their path.                                 │
│ conditions       List condition plugins with their descriptions.                                                          │
│ measurements     List measurement plugins with their descriptions.                                                        │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Info

There is no destinations command. Destinations are not pluginlib plugins in DC 2.0 (ADR-0003): the blessed types are listed in Destinations, and everything else is reached through the passthrough.

Architecture Decision Records

An ADR records a significant architecture decision, the reasoning behind it, and what it rules out. The list below is generated from docs/adr/ — edit an ADR there, not here.

External shipper process replaces embedded Fluent Bit

The Humble-era design embedded a forked Fluent Bit 2.1.3 as an in-process library, which forced us to maintain fluent_bit_vendor (source build of a patched fork), a custom C input plugin (in_ros2), and cgo-built Go output plugins — the dominant cause of install pain. For the Jazzy rewrite, the shipper runs as an external process, fed by a thin Bridge over the shipper ingest protocol. Install becomes "drop one prebuilt binary"; the fork, the vendor package, and the Go toolchain are deleted.

Considered Options

  • Keep embedding (rejected: the fork/build burden is inherent to embedding, not fixable)
  • Native C++/Rust destination implementations with our own buffering (rejected: re-implements buffering/backpressure/retry that shippers already do well, and caps destination breadth)

Consequences

  • One extra process at runtime alongside the Bridge; negligible hop for JSON-sized Records.
  • The Forward-protocol boundary makes the shipper swappable (Vector and Fluent Bit both consume it).

Amendment (#440/#444): supervision is no longer always "localhost, by DC's bringup"

This ADR originally read "the shipper runs as an external process on localhost, supervised by DC's bringup" without qualification. That is still the default (managed mode), but it is no longer the only supported shape: in unmanaged mode (#444) the Bridge renders the Shipper's config and connects over the shipper ingest protocol exactly as before, but does not locate a binary, spawn it, or supervise it — an orchestrator does, and the Shipper may run in its own container, possibly on a different host from the Bridge. See ADR-0015 for why the decomposition stops at the Shipper (and the Uploader) rather than extending further, and ADR-0014 for the Uploader's own extraction out of the Bridge process. Nothing about the shipper ingest protocol itself changes between the two modes; only who starts and watches the Shipper does.

Vector is the blessed default shipper

Both upstream Fluent Bit and Vector can sit behind the Bridge's Forward-protocol boundary. We bless Vector: its postgres and aws_s3 (MinIO-compatible) sinks are native and maintained where Fluent Bit's pgsql output stayed experimental and MinIO required our own Go plugin; VRL replaces our four chained Lua/rewrite-tag filter hacks with one typed transform; it adds end-to-end acknowledgements on top of disk buffering; and it ships as a single static binary (x86_64/arm64). Docs, demos, and generated config target Vector only — Fluent Bit remains a drop-in option for memory-starved targets (~5MB vs ~100MB RSS) but is not documented as a first-class path.

Consequences

  • A vector_vendor ament package vendors the official static binary, pinned to an exact version by checksum (arch-detected); a vector_path parameter allows using a system-installed Vector instead. Apt-repo install and Docker remain documented alternatives.

Amendment: checked-in binary instead of a build-time download (#424)

vector_vendor originally fetched its pinned binary over the network at build time via file(DOWNLOAD ...), which violates the ROS buildfarm's no-network-access policy for binarydeb jobs and defeats #423's network-isolated build check. The "vector-slim" feature-flagged build this ADR originally proposed CI would publish was never actually built — no such CI job exists in this repo — so it is not available to vendor either.

Fixed by checking the official vectordotdev/vector release tarballs (x86_64 and aarch64, same bytes file(DOWNLOAD ...) used to fetch, same pinned SHA256 checksums) directly into vector_vendor/prebuilt/; CMakeLists.txt now extracts and installs from the local file, performing no network I/O. The tarball, not the extracted binary, is what's committed: the extracted vector binary is 120–142MB (stripped/unstripped) depending on target, over GitHub's 100MB hard per-file limit, while the release tarball's upstream gzip compression brings each architecture's file to ~50-54MB — under the limit.

Not tracked via Git LFS, per #421's original implementation decision ("no release-time injection step, no git-lfs: committed like any other tracked file"). LFS was evaluated and briefly adopted mid-review: the specific concern that motivated #421's original call — that LFS content wouldn't survive bloom's release-tarball export step — turned out to be unfounded. bloom's export_upstream delegates to vcstools.GitClient.checkout() (a plain git clone + git checkout <tag>, which does run LFS's smudge filter), and the export machinery itself (vcstools.git_archive_all.GitArchiver) reads files off the working-tree filesystem rather than through git archive's blob-store plumbing (the actual, narrower reason plain git archive breaks LFS) — confirmed empirically against a throwaway LFS repo and the real vcstools/bloom code, not just read.

LFS was reverted anyway for a more basic reason: GitHub's free LFS tier is 1GB storage and 1GB bandwidth per month, and every future Vector version bump adds both tarballs (~106MB) as new, non-deduplicated LFS objects — roughly 9 version bumps before storage alone exhausts the free tier, independent of and sooner than the plain-blob approach's own cost (git history growing by the same ~106MB per bump, but against no comparable quota). A plain committed blob has no such ceiling; the tradeoff is that every future Vector bump grows this repo's ordinary git history by ~106MB, permanently, since git does not deduplicate binary blobs across versions. Revisit if/when that accumulation becomes the more pressing cost — the LFS path is proven to work, should it be needed.

Amendment: split into its own repo, pulled in via .repos

The ~106MB-per-bump cost the previous amendment accepted as vector_vendor's tradeoff doesn't have to be ros2_data_collection's cost to carry — nothing about the rest of this repo's history needs to grow every time Vector ships a release. vector_vendor now lives at github.com/Minipada/vector_vendor (default branch jazzy, matching this repo's own active line), containing exactly what used to sit at ros2_data_collection/vector_vendor/: the same CMakeLists.txt (checked-in tarball, checksum-pinned, no network at build time — the previous amendment's decision is unchanged, just relocated) and the same two prebuilt tarballs. package.xml's <version> there tracks the vendored Vector version directly (0.57.0 at the time of the split), not an independent counter.

Pulled into this workspace via ros2_data_collection.repos (vcstool) rather than a COPY/git submodule: vcs import pins to a tag (v<VECTOR_VERSION>), not a branch, so a given ros2_data_collection commit always resolves the same vector_vendor content — a floating branch would make the build's vector_vendor content silently drift out from under an unrelated ros2_data_collection change. tools/e2e/Containerfile's toolchain-base stage runs the import (network required, same as its rosdep install right below it); every downstream stage's actual colcon build builds against the already-fetched result, unaffected by where the source physically came from. Bumping Vector now touches two repos: land the new binaries + tag in vector_vendor, then bump the pinned version: in ros2_data_collection.repos to match.

This is a development-workspace convenience only (vcs import/colcon build from source); it does not by itself make vector_vendor installable via apt — that requires its own independent bloom-release into rosdistro, not yet done, at which point ros2_data_collection packages that need it would instead declare a normal <depend>vector_vendor</depend> and let rosdep resolve the released .deb, same as any other ROS package dependency. Until then, .repos is how CI and local dev get a buildable workspace.

Amendment: back to a live, checksum-pinned download (#435)

The no-network-access assumption the first amendment (#424) rested on — that ROS buildfarm binarydeb jobs can't reach the network — is false. ros_buildfarm's own job-generation source (ros_buildfarm/templates/release/deb/binarypkg_job.xml.em, the "Run Dockerfile - build binarydeb" section) invokes docker run --net=host for the container that runs the actual build step, and this isn't theoretical: zmqpp_vendor — which fetches its own dependency live at colcon build time via ament_cmake_vendor_package's ament_vendor() — has a real, currently succeeding Jenkins job on build.ros2.org (Jbin_uN64__zmqpp_vendor__ubuntu_noble_amd64__binary, build #12, SUCCESS). aws_sdk_vendor reached the identical conclusion independently for its own build (docs/adr/0012) after resting on the same false premise.

With real network available at build time, the live-download design this repo used before #424 is simply simpler than the checked-in-tarball design that replaced it, and it sidesteps the checked-in design's actual cost entirely: nothing binary is committed anywhere, so there is nothing for git to fail to deduplicate as Vector ships new versions. vector_vendor/CMakeLists.txt reverts to file(DOWNLOAD ...), fetching the official per-architecture release tarball at build time and verifying it against the same pinned SHA256 checksums the checked-in design used — the vector_path override for air-gapped/distro-packaged builds is unchanged. The checked-in prebuilt/*.tar.gz tarballs are removed from Minipada/vector_vendor.

This does not reopen the "own repo" amendment above: vector_vendor stays split out, independent of whether its own content is large enough to justify the split on git-bloat grounds by itself — see docs/adr/0012's identical reasoning for aws_sdk_vendor, a thin recipe with no bloat of its own that stays split anyway so every vendor package this workspace pulls in via .repos follows the same layout and bump/release workflow. Vector's vendored version is unchanged by this amendment (still 0.57.0), so Minipada/vector_vendor's existing v0.57.0 tag was moved onto the new commit rather than minted fresh — package.xml's <version> continues to track VECTOR_VERSION exactly, and there is no independent counter to bump for a fetch-mechanism-only change.

tools/e2e/Containerfile's vendor-network-check stage (#423) already built vector_vendor alongside aws_sdk_vendor under --network=none before this amendment — it was a no-op inclusion back then, since the checked-in binary's colcon build needed no network and so never failed. It stopped being a no-op here: vector_vendor's build started failing under --network=none for the same reason aws_sdk_vendor's always has, which is the change this amendment's own end-to-end verification confirmed. The stage itself (and the CI job wired to it) was later deleted — see docs/adr/0013 — once a permanently-failing check on both packages stopped being distinguishable from no check at all.

If a future ROS buildfarm policy change does restrict binarydeb network access (the false premise here becoming true later), this decision reverses again to the checked-in-tarball design; nothing about that design is lost — it is documented in the amendment above and in Minipada/vector_vendor's own git history, not deleted.

Why the forward protocol is the Bridge→Shipper wire format

The Bridge must hand Records to the Shipper across a process boundary such that the sender knows what arrived — durable ingest is impossible without receipt acknowledgement. Requirements: a listener the Shipper already ships, sender-visible acks, and a minimal Rust client. Vector's source menu, filtered:

  • socket, stdin — one-way byte streams; no response direction exists, so the Shipper cannot confirm receipt. A Shipper restart silently loses in-flight Records.
  • file — durable, but Vector neither deletes consumed files nor exposes its checkpoint, so the Bridge would rotate blind (delete too early = loss, too late = unbounded disk); fixing that means building an acknowledged queue anyway.
  • websocket — Vector has a websocket sink, not a source.
  • http — ack-capable (response deferred until the event is buffered) but costs an HTTP client plus batching logic; equal capability at a higher price.
  • vector (native gRPC) — ack-capable; drags tonic/prost/protobuf codegen into the Bridge for no functional gain.
  • fluent (Fluentd's open "Forward" spec) — ack-capable (chunk/ack options are in the spec), and the client side is ~170 lines of msgpack over TCP. Chosen: the cheapest ack-capable listener.

The forward format is not Fluent Bit. It is an open wire specification from the CNCF Fluentd project; no Fluentd/Fluent Bit software runs, links, or vendors into DC 2.0 (the embedded Fluent Bit is demolished separately, ADR-0001). DC docs call this boundary the shipper ingest protocol; the name "fluent" appears only in the generated Vector config (type = "fluent") and interop documentation. A side effect, not a goal: any forward-speaking receiver (including stock upstream Fluent Bit, ~5MB RSS vs Vector's ~100MB) can be swapped in behind the boundary without Bridge changes.

Amendment: confirmed delivery closes the acks half of the promise (#266)

The "sender-visible acks" requirement above was satisfied by choosing an ack-capable protocol, but until #266 the Bridge's Forwarder never actually used the chunk/ack option — every frame was sent bare. Spiked directly against the pinned Vector 0.57.0 binary before writing any C++: a hand-rolled msgpack client sending [tag, entries, {"chunk": id}] got back {"ack": id} even with the sink completely unreachable (connection refused) and only a disk buffer engaged — confirming acks fire on durable-buffer-write, not on final delivery to the Destination, which is the guarantee actually wanted here. Also found: Vector 0.57 deprecates enabling acknowledgements on the source itself in favor of the global [acknowledgements] enabled = true form (identical ack behavior, no deprecation warning) — the renderer uses the global form.

A second on-disk queue for this window (mirroring the Uploader's intent queue, #265) was considered and rejected in review: Vector's own disk buffer (shipper.buffer_max_bytes) already covers sink/Destination outages, and the bridge_ready_gate (ADR-0006) already covers "Vector isn't listening yet" at startup. What was missing was purely the in-flight window — a Record already handed to a send() call whose ack never arrives (Vector respawning, a TCP hiccup) — which an in-memory (not disk-backed) bounded window closes cheaply. The double failure of "Bridge crashes while this in-memory window is non-empty" is accepted as out of scope, same as this ADR's original ready-gate/disk-buffer split assumed no double failures either.

Destination config: blessed set via ROS params, everything else via passthrough

The pluginlib destination-plugin layer is retired. DC generates shipper sink config from plain ROS parameters for a blessed set only — PostgreSQL, S3-compatible object storage, file, console — via templating in the Bridge. Every other destination (Kinesis, InfluxDB, Slack, Kafka, …) is configured by passing raw Vector sink config through a custom_sinks parameter, which exposes the shipper's entire catalog at zero DC code and keeps the "one tool handles all destinations" promise.

Consequences

  • Every Destination — whether it receives Records or Files — is declared in the single destinations list, distinguished by a receives: records|files property; only File policy (delete-after-verified-upload, metadata destination) lives in a separate block. This keeps the config aligned with the glossary: a Destination is a Destination.
  • The passthrough routing contract is public API: the Bridge exposes one Shipper route per Tag under the stable name dc.<tag>, and custom sink snippets consume those names. This name is chosen deliberately here so implementation doesn't improvise it.

Considered Options

  • Keep per-destination pluginlib plugins (rejected: permanent per-sink maintenance; the layer was only ever plumbing around Fluent Bit's C API)
  • Raw Vector config only (rejected: loses ROS-native UX for the 90% path)

The Bridge is written in Rust (rclrs) as a contained pilot; the rest stays C++

We want first-party Rust in the project, but a full core rewrite was rejected: DC's value is its dynamically-loaded plugin ecosystem, and Rust has no stable ABI for dynamic plugins, while rclrs still lacks lifecycle nodes, message_filters, and pluginlib equivalents — a rewrite would be a 6–12 month redesign that discards 30+ tested C++ plugins. Instead, the new Bridge node is Rust: new code, no pluginlib dependency, minimal ROS surface (subscribe, socket forward, config render, child-process supervision), squarely within rclrs's capability. Measurements, conditions, groups, and lifecycle management remain C++.

Consequences

  • cargo/rustup joins the source-build toolchain (while Go and the C fork leave — net fewer toolchains than Humble).
  • If the pilot proves out and rclrs matures, Rust can expand component-by-component from a working system; if not, the loss is one small package.

File uploads live in the Bridge, not the shipper

Log shippers move event streams, not arbitrary local files as named objects — which is why the Humble design needed custom Go plugins (out_minio, out_files_metrics). In the Jazzy design the Bridge hosts an uploader module (Rust, object_store crate: S3/MinIO/GCS/Azure) that uploads Files, verifies they landed, extracts metadata (content-type, size, video duration), and emits the resulting metadata Record through Vector to PostgreSQL like any other Record. Delete-after-confirmed-upload and the upload-status table are preserved as features; local deletion stays robot-side.

Consequences

  • PostgreSQL writes happen only via Vector's parameterized sink — this retires the string-formatted SQL in the Go plugin and gives file metadata the same buffering/retry guarantees as all Records.
  • Consumers never guess completeness: multi-File groups (map = pgm+yaml, camera batches) get an explicit group completion marker written by the uploader only after every File in the group is verified — the "manifest as completion checkpoint" pattern, borrowed from dataset-platform upload design.
  • Large Files upload multipart and resume after interruption rather than restarting, since robot networks are flaky and videos/maps are large; object_store provides this natively.

The Bridge is a plain node, outside the lifecycle manager

Every other DC node is a nav2-style lifecycle node orchestrated by dc_lifecycle_manager (bond heartbeats, autostart). The Bridge deliberately is not: rclrs has no lifecycle-node or bond support, and the Bridge has no meaningful deactivated state — it is infrastructure, like the shipper itself. Startup determinism comes from launch ordering instead: Vector → Bridge → readiness gate → lifecycle manager activates collection nodes. Supervision is launch respawn (Bridge) and the Bridge supervising the Vector child process.

Consequences

  • Lifecycle introspection does not cover the Bridge; monitoring it means "process up + ready service answers".
  • If rclrs gains lifecycle support, the Bridge can adopt it without changing this boundary.

The Bridge returns to C++; the Rust pilot (ADR-0004) is reverted

Supersedes: ADR-0004 (Rust Bridge pilot).

ADR-0004 made the new dc_bridge node Rust (rclrs) as a deliberately contained pilot — its own words: "We want first-party Rust in the project." The bet was explicitly reversible: "if not, the loss is one small package." We are exercising that exit clause. The Bridge is now plain C++ (ament_cmake, rclcpp); everything else about the DC 2.0 architecture (external Vector shipper, shipper ingest protocol boundary, blessed Destinations + passthrough, the Uploader's verify-then-delete semantics) is unchanged.

Why

The pilot's stated goal was to prove that first-party Rust could live in DC at low risk. On ROS 2 Jazzy, today, it does not clear that bar — the cost is in the toolchain, not the Bridge's own logic:

  • ros2_rust has no stable Jazzy release. Building dc_bridge required vcs-importing the ROS message repos and the rosidl_rust code generator pinned to a specific git commit (Jazzy ships no rosidl_generator_rs, and pre-built apt message packages carry no Rust bindings). The generator and rosidl_runtime_rs are developed in lockstep and had already drifted out of sync once, forcing a [patch.crates-io].
  • colcon-ros-cargo link-flag generation is incomplete. A full-workspace build (as opposed to the narrower --packages-up-to dc_bridge every pilot PR verified with) made sibling interface packages "discoverable" via the ament index, at which point rclrs's build script emitted -l<pkg>__rosidl_typesupport_c flags for packages nothing supplied a matching -L for — surfacing as opaque "unable to find library" link failures that took real debugging to root-cause and only "fixed" by COLCON_IGNORE-ing packages back out of the index.
  • Every CI/dev environment paid for it. The image needed rustup, colcon-cargo/ colcon-ros-cargo, libclang, the source-built message repos, and a stack of COLCON_IGNORE/--skip-keys workarounds — none of which are about what the Bridge does.

Against that, none of the Bridge's actual responsibilities need Rust. Talking to Vector is the shipper ingest protocol (msgpack over a TCP socket); process supervision is fork/exec/waitpid + PR_SET_PDEATHSIG; config rendering is string/TOML generation; the File uploads (ADR-0005) are S3 multipart, which the AWS SDK for C++ provides directly. The Humble line did S3 uploads with a single minio-go call and no resumability at all, so nothing here is beyond C++.

The decisive factor is developer experience, of which the CI simplification is only the visible symptom: as C++, dc_bridge is an ordinary ament_cmake package. A contributor clones the repo and runs rosdep install && colcon build — the same as every other dc_* package. That plug-and-play property is exactly what the pilot was meant to test for Rust and, on Jazzy today, could not deliver.

Decision

  • dc_bridge is ament_cmake C++ (rclcpp). The ROS-independent core (Forwarder, Supervisor, Readiness, TopicConfig, ConfigRenderer) is a plain library, unit-tested with gtest and buildable/testable without a ROS install — preserving the dc_bridge_core "pure logic, no ROS needed to test it" property the Rust design had.
  • Dependencies are all rosdep-resolvable: rclcpp, dc_interfaces, std_msgs, std_srvs, nlohmann-json-dev, plus tomlplusplus/msgpack-cxx (header-only C++ libraries) via a small repo-local rosdep source (rosdep/dc.yaml) since upstream rosdistro has no key for them.
  • The File Uploader (ADR-0005) uses the AWS SDK for C++ against S3-compatible object storage, verified working against RustFS (PutObject + multipart) before adoption.

Consequences

  • Rust, rustup, colcon-cargo/colcon-ros-cargo, libclang, the source-built message repos (ros2_data_collection_jazzy.repos), and every COLCON_IGNORE/--skip-keys Rust workaround leave the tree. The toolchain is smaller than the Rust pilot's and than Humble's (Go and the Fluent Bit C fork were already gone per ADR-0001).
  • Signal handling is simpler: rclcpp handles SIGINT/SIGTERM and returns from spin(), and on_shutdown stops the supervised Vector — the Rust node needed an explicit ctrlc handler to avoid orphaning Vector because rclrs::Context::ok() never returned false.
  • First-party Rust in DC is deferred, not foreclosed. If ros2_rust matures to a stable distro release with reliable colcon integration, the experiment can be revisited from a working C++ baseline — the same component-by-component path ADR-0004 imagined, just in the other direction for now.

dc_util owns parameter declaration; nav2_util stays a dependency

Before this decision, parameter declaration in dc_measurements/dc_group was three patterns at once: most Measurement/Condition plugins called nav2_util::declare_parameter_if_not_declared directly, duplicating the same declare-then-get_parameter-then-fatal-on-failure boilerplate in each onConfigure(); measurement_server.cpp mixed that with a handful of raw declare_parameter calls for its own node-level parameters (which throw on redeclaration instead of being idempotent); and a dc_util::get_*_type_param() helper family existed but was only used in ~20 of the roughly 90 plugin-parameter call sites, wrapping nav2_util::declare_parameter_if_not_declared for the rest without most callers going through it.

Decision

  • nav2_util stays a dependency. MeasurementServer already inherits nav2_util::LifecycleNode for the bond/lifecycle machinery dc_lifecycle_manager orchestrates — dropping nav2_util isn't on the table regardless of how parameter declaration is handled, so re-implementing declare_parameter_if_not_declared's idempotent-declare logic inside dc_util would only add a second implementation of the same thing for no dependency-removal benefit.
  • dc_util::get_*_type_param() / get_*_param() become the single sanctioned way to declare a parameter in dc_measurements/dc_group C++ code. Plugin authors call these, never declare_parameter or nav2_util::declare_parameter_if_not_declared directly (documented in doc/src/dc/contributing.md). Internally these helpers still call nav2_util::declare_parameter_if_not_declared — dc_util/include/dc_util/node_utils.hpp is now the only file in dc_measurements/dc_group allowed to reference nav2_util for parameter declaration — so the dependency is kept, but callers no longer see it or hand-roll its error handling.
  • The helper family was extended to cover every parameter type actually declared across the plugins (double, and mandatory vector<bool>/vector<int64_t>/vector<double> were missing) plus a node-level (unprefixed) variant for MeasurementServer's own parameters, so the raw declare_parameter calls in its constructor could move to the same idempotent pattern as everything else.
  • dc_group (Python/rclpy) keeps its existing plain self.declare_parameter(...) calls. There is no Python equivalent of nav2_util::declare_parameter_if_not_declared in this codebase, and none is needed: every GroupServer parameter is declared exactly once, in init_parameters(), never re-entered — the idempotent-declare problem the C++ helpers solve (multiple plugins/onConfigure() calls potentially racing to declare a shared namespace) doesn't exist on the group-server side. Introducing a declare-if-not-declared wrapper there would be solving a problem this file doesn't have.

Consequences

  • A new plugin parameter is one dc_util::get_*_type_param() call, not a declare-if-not-declared pair plus a manual fatal-on-missing check.
  • Two pre-existing bugs surfaced while converting call sites — bool_equal.cpp's value_ field is double despite the parameter being declared PARAMETER_BOOL, and distance_traveled.cpp declares transform_tolerance but reads back the different, undeclared name transform_timeout — were deliberately left as direct nav2_util::declare_parameter_if_not_declared/get_parameter calls rather than folded into the new helpers, so as not to silently change behavior while unifying the declaration pattern. Both are noted inline and are follow-up work, not part of this change.

MCAP recording is a passthrough consumer, not a blessed Destination

Issue #210 asks for Records to be recordable as .mcap files so they can be replayed with ROS 2 bag tooling (ros2 bag info, Foxglove). Vector — the DC 2.0 Shipper (ADR-0002) — has no MCAP sink, so this cannot be a config-only addition the way postgres/s3/ file/console were; ADR-0003 gives two shapes and asks that the choice be recorded before implementing.

Decision: passthrough (dc_mcap_writer), not a Bridge-side blessed Destination.

dc_mcap_writer is a small standalone process (ament_python package, no ROS dependencies of its own) that consumes Records the same way every other non-blessed sink does — over the public dc.<tag> routes (ADR-0003), via a custom_config_files passthrough snippet using Vector's socket sink (TCP, newline-delimited JSON) pointed at it. It registers one JSON-schema Channel per Tag (the pattern in the linked foxglove/mcap jsonschema/writer.cpp example) and rotates to a new .mcap file by size or elapsed time, whichever comes first.

Why not the Bridge-side option

Local-first recording that survives a Shipper outage — the one stated justification in #210 for putting MCAP writing inside the Bridge — is not a requirement here: DC's Shipper is already at-least-once and disk-buffered per blessed sink (ADR-0002), so a robot losing connectivity does not lose Records either way, it just delays them. Adding MCAP writing to the Bridge would put a second delivery path (own file handles, own rotation policy, own failure modes) next to the Vector handoff, permanently, for a capability every other non-blessed destination gets by being outside the Bridge entirely (#246). That asymmetry is exactly what ADR-0003 exists to avoid.

Consequences

  • No dc_bridge code changes: destinations still only names blessed sinks (console/file/… ) to route the topics MCAP recording needs onto their dc.<tag> routes, same as the Elasticsearch and InfluxDB passthrough demos.
  • dc_mcap_writer gets Vector's default in-memory sink buffer, not the Bridge's disk buffer — consistent with every other passthrough sink (see the "Passthrough: custom_config_files" section of doc/src/dc/destinations.md), not a special case for MCAP.
  • Rotation and retention are dc_mcap_writer's own concern, not dc_bridge's — there is no dependency on the Files retention policy (#267), which governs the Bridge's Uploader intent queue, an unrelated code path.
  • If local-first recording that survives a Shipper outage later becomes a real requirement, that is grounds to revisit this decision, not to extend dc_mcap_writer to fake it (e.g. its own persistent queue) — the honest answer at that point is a Bridge-side writer per the rejected option below.

Considered Options

  • Bridge-side blessed Destination (dc_bridge writes MCAP itself, bypassing Vector) — rejected: no stated requirement for local-first durability past what the Shipper already gives every blessed sink, and it would duplicate a delivery path inside the Bridge indefinitely for one sink type.

The Mission Measurement's lifecycle contract: nav2 adapter scope and Record shape

ROS has no standard interface for mission or task lifecycle. Issue #305 asked two questions before any Mission Measurement could be written: how far a built-in nav2 adapter should go inferring "mission" from action goal status, and what an escape-hatch message for stacks with their own mission executive should look like. This ADR records that decision so #387 (the nav2 adapter) and later adapters (Open-RMF, a mission executive) can implement against it without re-litigating scope.

Decision: the nav2 adapter reports exactly one NavigateToPose goal's lifecycle, and every adapter — nav2 or otherwise — emits the same StringStamped/JSON Record shape used by every other Measurement, not a new typed dc_interfaces message.

The nav2 adapter derives mission_start/mission_end purely from one NavigateToPose goal: acceptance, terminal status, and result. It deliberately does not infer anything above that:

  • A "mission" that spans more than one nav2 goal (a multi-waypoint run coordinated by an external mission executive) is out of scope — nav2 has no concept of it, so DC will not guess at grouping goals into one mission.
  • A mission sourced from a stack other than nav2 (Open-RMF, VDA5050, a custom mission executive) is out of scope for this adapter. mission_id and mission_type exist on the Record specifically so a future adapter can supply its own values without a schema change or reopening this decision.
  • NavigateThroughPoses and FollowWaypoints are separate nav2 action interfaces with their own goal/feedback/result shapes; they get their own adapters, not a generalized one, tracked as issues blocked by #387.

The escape-hatch: no new message, reuse the existing Record contract

Every Measurement in this codebase (battery, intervention, fault) publishes dc_interfaces::msg::StringStamped carrying a JSON payload validated against a dc_measurements JSON Schema — there is no precedent for a typed dc_interfaces message per Measurement. The Mission Measurement does not introduce the first exception: a stack with its own mission executive is a new Measurement plugin (or a future generic "external mission" plugin) publishing the same Record shape below with its own mission_id/mission_type, not a new dc_interfaces message type.

Record shape

  • event: "mission_start" or "mission_end", required — matching battery's charge_session_start/_end convention (a named episode kind), not intervention/fault's generic start/end.
  • mission_id: string, required on both events. The nav2 adapter synthesises it from the goal UUID (unique_identifier_msgs/UUID, stringified). Typed as a string, not an incrementing integer like battery's session_id, so a future adapter can supply an externally issued id (a WMS order number, an Open-RMF booking id) without a type change.
  • mission_type: optional string. The nav2 adapter sets it to the nav2 action name ("navigate_to_pose", "navigate_through_poses", "follow_waypoints" for sibling adapters). The schema accepts any string so a non-nav2 source can supply a richer value later without a schema change.
  • sequence: integer, monotonic, incremented per emitted Record from one Measurement instance (not per mission_id) — gap detection, mirroring fault's global counter.
  • outcome: required on mission_end only. Enum: succeeded, failed, cancelled, aborted. For the nav2 adapter: GoalStatus.ABORTED → aborted, GoalStatus.CANCELED → cancelled, and failed is driven by a documented application-level condition (non-zero NavigateToPose::Result.error_code on an otherwise-succeeded goal status) rather than being collapsed into aborted.
  • reason: string, required on mission_end when outcome is failed or aborted. Free text — for the nav2 adapter, verbatim from NavigateToPose::Result.error_msg. No DC-invented coded/structured reason table.
  • error_code: integer, required alongside reason. For the nav2 adapter, verbatim from NavigateToPose::Result.error_code.
  • duration_sec: number, required on mission_end only.
  • recoveries: integer, optional on mission_end — for the nav2 adapter, from NavigateToPose::Feedback.number_of_recoveries at goal completion.
  • No update_id/order-update field: a VDA5050-specific mid-mission-amendment concept with no evidence DC needs it; adding it later is a non-breaking schema change.

A mission still running at shutdown

There is no explicit "open" field on the Record. A mission still running when the process stops simply never gets a mission_end Record — nothing downstream can average an unclosed interval as zero-duration, because there is nothing to average. Deriving an "open"/still-running signal at query time (the way dc_kpi_intervention_rate derives open_interventions) is the concern of the mission-success-rate view deferred from #363, not this Record schema.

Consequences

  • #387 (the nav2 NavigateToPose adapter) and its siblings for NavigateThroughPoses and FollowWaypoints implement against this Record shape and scope boundary without needing further design discussion.
  • A future non-nav2 adapter (Open-RMF is the leading candidate; VDA5050 explicitly is not, per #305) targets the same mission_start/mission_end contract, supplying its own mission_id/mission_type, rather than requiring a new message type or a schema redesign.
  • dc_measurements/plugins/measurements/json/mission_nav2.json (and any sibling adapter's schema) validates against this shape; downstream views (#363's deferred mission-success-rate panel) can rely on outcome and sequence having the same meaning across every adapter that targets this contract.

Considered Options

  • A new typed dc_interfaces message for mission lifecycle events — rejected: no existing Measurement publishes a typed message for structured data: introducing one here would be the first exception to a load-bearing convention, for no benefit over the JSON Record + schema every other Measurement already uses.
  • An adapter that groups nav2 goals into multi-goal missions itself (e.g. by proximity in time) — rejected: nav2 has no concept of a multi-goal mission, so any grouping heuristic DC invented would be a guess it could not honestly stand behind; the mission_id/mission_type escape hatch defers that grouping to a system that actually has the information.

aws_sdk_vendor fetches aws-sdk-cpp live from GitHub; the flattened-source design is rejected

aws_sdk_vendor builds aws-sdk-cpp (core + s3 only) by cloning github.com/aws/aws-sdk-cpp.git directly at colcon build time, via ament_cmake_vendor_package's ament_vendor() macro (VCS_TYPE git) — the same idiom zmqpp_vendor/tinyxml_vendor/yaml_cpp_vendor already use successfully on the real ROS buildfarm. No aws-sdk-cpp source is vendored anywhere: aws_sdk_vendor itself lives in its own repo, github.com/Minipada/aws_sdk_vendor (mirroring vector_vendor's split, ADR-0002's amendment), but that repo now holds only this package's CMakeLists.txt and package.xml — a thin recipe, not a vendored source tree.

Why

An earlier design (#425, worked out in PR #433) kept aws_sdk_vendor in its own repo but flattened aws-sdk-cpp's pinned source into it, plain-committed and pruned to exactly what -DBUILD_ONLY=s3 compiles, so no network fetch happened at colcon build time at all. That design rested on one assumption: that ROS buildfarm binarydeb jobs run with no network access, the same constraint that motivated vector_vendor's checked-in-binary amendment (see ADR-0002).

That assumption is false for the actual compilation step. ros_buildfarm's own job-generation source (ros_buildfarm/templates/release/deb/binarypkg_job.xml.em, the "Run Dockerfile - build binarydeb" section) invokes docker run --net=host for the container that runs the build. And this isn't theoretical: zmqpp_vendor — which does exactly this live-git-clone-at-build-time pattern via ament_vendor() — has a real, currently succeeding Jenkins job on build.ros2.org (Jbin_uN64__zmqpp_vendor__ubuntu_noble_amd64__binary, build #12, SUCCESS). vector_vendor's problem was never generic buildfarm network isolation; it was Vector shipping as a large pinned binary rather than buildable source, which the buildfarm has no story for regardless of network access, and which ament_vendor() doesn't help with either.

Given real network access, a live fetch is simpler than a flattened source and needs no vendored tree, no separate REUSE audit of ~2600 upstream files, and no manual re-pruning on every AWS SDK version bump. crt/aws-crt-cpp's own submodule chain (13 submodules, one of which — s2n's CBMC formal-verification model — is unrelated to -DBUILD_ONLY=s3) can be let recurse in full via ament_vendor()'s vcs import --recursive: with real network available, the one extra unused clone is harmless, and vcs import has no mechanism to exclude a single nested submodule anyway.

aws_sdk_vendor staying in its own repo, rather than moving back into ros2_data_collection, is a separate call from the fetch-mechanism question above: this package's own content (CMakeLists.txt + package.xml) is small either way, so unlike vector_vendor's split — which exists specifically to keep ~106MB/bump of binary growth out of ros2_data_collection's git history — there's no bloat this split avoids. It stays split anyway so every vendor package this workspace pulls in via .repos follows the same layout and the same bump/release workflow.

Decision

  • github.com/Minipada/aws_sdk_vendor's CMakeLists.txt calls ament_vendor() with VCS_TYPE git, VCS_URL https://github.com/aws/aws-sdk-cpp.git, and VCS_VERSION pinned to the same tag ADR-0007's Phase 2 verified against RustFS. GLOBAL_HOOK is required: dc_bridge (the real downstream consumer) only ever calls find_package(AWSSDK), never find_package(aws_sdk_vendor) first, so the install prefix has to reach CMAKE_PREFIX_PATH via an environment hook applied unconditionally, not a CMake config-extra a consumer would only pick up by find_package()-ing this vendor package itself.
  • The flattened vendor/aws-sdk-cpp/ tree #425/PR #433 committed into github.com/Minipada/aws_sdk_vendor is removed from that repo; it now holds only the thin ament_vendor() recipe. ros2_data_collection.repos gains an aws_sdk_vendor entry (pinned to a tag, alongside vector_vendor's), the same vcs import path vector_vendor already uses.
  • tools/e2e/Containerfile's vendor-network-check stage kept proving the lack of network isolation is expected for aws_sdk_vendor for a while after this decision — under --network=none its colcon build was supposed to fail, same as it always has, since nothing about this decision changes what the buildfarm actually does for this package. The stage (and the CI job wired to it) was later deleted, see docs/adr/0013: a check that fails by design, permanently, for both vendor packages it covers stopped being distinguishable from no check at all.

Consequences

  • No aws-sdk-cpp source lives in ros2_data_collection, in Minipada/aws_sdk_vendor, or anywhere else — one less REUSE audit surface, and no manual pruning step on every AWS SDK version bump (just move AWS_SDK_VERSION).
  • aws_sdk_vendor's colcon build step needs network, same as it always has since ADR-0007; toolchain-base's vcs import (which fetches both vector_vendor and aws_sdk_vendor) stays isolated from tools/e2e/Containerfile's own build steps.
  • Bumping the AWS SDK version now touches two repos, same as bumping Vector already does: land the new AWS_SDK_VERSION/<version>/tag in Minipada/aws_sdk_vendor, then bump the pinned version: in ros2_data_collection.repos.
  • If a future ROS buildfarm policy change does restrict binarydeb network access (the false premise here becoming true later), this decision reverses again; nothing about the flattened-source design explored in PR #433 is lost — it is documented there and in this ADR's history, not deleted.

Remove the network-isolation-check CI job and vendor-network-check stage

tools/e2e/Containerfile's vendor-network-check stage and CI's network-isolation-check job (#423) are deleted, along with tools/e2e/scripts/verify_network_isolation.sh. The check they implemented — "does aws_sdk_vendor's and vector_vendor's colcon build succeed under --network=none" — no longer has anything to verify: both packages fetch their dependency live over the network at colcon build time by design (docs/adr/0002's reversal, #435; docs/adr/0012, #434), so the check now fails by construction, always, for both packages it covers.

Why

The check was built (#423) on the assumption that ROS buildfarm binarydeb jobs run with no network access. #434's research (ros_buildfarm's own docker run --net=host for the binarydeb build step, zmqpp_vendor's real succeeding buildfarm job using an equivalent live-fetch pattern) showed that assumption false. Once #434 and #435 reversed both vendor packages back to live network fetches, the check stopped testing anything achievable: it fails on every run, forever, for the same reason every time, and nothing about that reason is a bug it could ever stop reproducing — it's the intended design those two ADRs record.

The job had already been reduced to if: false before this decision, because a disabled job still reports a red ❌ check-run conclusion on every PR (independent of continue-on-error, which only affects the workflow's aggregate conclusion), with nothing actionable behind it. Keeping the job, the stage, and the script around in that disabled state is dead weight: code that never runs, whose only content is restating a decision already recorded in docs/adr/0002 and docs/adr/0012.

Two alternative rationales were considered for keeping some form of the check, both raised by #436 itself:

  • Build-determinism/reproducibility safeguard. A check earning that rationale would assert something about the build output's reproducibility — that the pinned checksum (vector_vendor) or pinned tag (aws_sdk_vendor) actually resolves to the same content run over run. --network=none asserts nothing of the kind; it only asserts "no network reached this step," which is now always false by design. Repurposing the stage into a real reproducibility check would mean writing a materially different one, not weakening the existing one, and no evidence surfaced during #434/#435 that reproducibility of the pinned fetches is a risk this repo has actually hit.
  • Protection against transient upstream (GitHub/crates/apt mirror) unavailability during a real release build. A genuine operational concern, but --network=none doesn't model it — cutting network entirely is the opposite of a flaky network, not a simulation of one. A release build failing because GitHub is briefly unreachable is already visible, attributable, and retryable the same way any other CI network flake is, with no dedicated isolation check required. Retry-on-flake, if wanted later, is a bloom-release/CI workflow concern (retries, backoff) — unrelated to build-time network isolation.

Neither rationale survives contact with what the existing check actually mechanically tests, so there is no "weakened" version of it worth keeping.

Decision

  • tools/e2e/Containerfile's vendor-network-check stage is deleted.
  • .github/workflows/ci.yaml's network-isolation-check job is deleted.
  • tools/e2e/scripts/verify_network_isolation.sh is deleted.
  • toolchain-base — the stage vendor-network-check used to branch from — is kept: it still earns its keep as the cache-layering split between the OS/apt-tools layer and the vendor-repo vcs import, independent of the now-removed stage that used to branch from it too.
  • docs/adr/0002 and docs/adr/0012's own descriptions of the check as it existed at the time each ADR was written are left as historical record; each links here where it implied the check would keep running going forward.

Consequences

  • No CI job or Containerfile stage references "network isolation" for these vendor packages any more — that they fetch live, by design, is fully covered by docs/adr/0002 and docs/adr/0012 alone.
  • If a future ROS buildfarm policy change restricts binarydeb network access again (the false premise both those ADRs found reversing back to true), reintroducing a real network-isolated build check is straightforward: RUN --network=none around a colcon build --packages-select aws_sdk_vendor vector_vendor is exactly what this ADR removes, and both ADRs it depends on document why it existed and how it worked.
  • #423 itself stays closed with its history unchanged — this ADR documents its follow-up removal, not a re-litigation of the original harness's value at the time it was built.

The Uploader runs as its own process

Narrows: ADR-0005 ("File uploads live in the Bridge, not the shipper"), which is still true at the pipeline level — uploads are a DC/Bridge concern, not something a log shipper does — but no longer at the process level: the Uploader was a module and a worker thread inside dc_bridge; it is now dc_uploader, a separate executable.

Why

Epic #440 (split deployment for fleets) needs the Shipper and the Uploader to run as independently-restartable units so an orchestrator can give each its own lifecycle, resource limits, and credentials. Of the two, the Uploader is the one #440 itself singles out: "I want the upload daemon to crash and restart freely, so that a failing upload never takes down data collection" (user story 18). Inside the Bridge process, that was never true — an unhandled Uploader-thread failure takes the whole Bridge process down with it, Record collection included, because both run in the same address space.

It is also the one piece of this split that does not need to wait for containers. The Bridge's receives: files subscription, the durable intent queue (#265), and the Uploader's upload/verify/delete logic (ADR-0005) were already aws-sdk-free at the library level (dc_bridge_core builds without AWS SDK); only the S3 ObjectStore implementation and the worker thread's wiring held the Uploader inside the Bridge's address space. Pulling that into its own OS process is a complete, independently useful step before #447's container work: unmanaged-shipper mode (#444) needed a second process to exist on the robot before split deployment made sense; this is that second process's first independent capability.

Decision

  • dc_uploader is a new executable in the dc_bridge colcon package (not a new ROS package — nothing about it needs ament_cmake's ROS-specific machinery beyond reusing the existing build). It links dc_bridge_core (the same ROS-free library dc_bridge itself links) plus the AWS SDK S3 ObjectStore implementation, and nothing from rclcpp/rclpy. dc_bridge no longer links the AWS SDK at all.
  • Configured entirely by DC_UPLOADER_* environment variables (dc_bridge/uploader/process_config.hpp), parsed by a pure function (load_uploader_process_config) that is unit-tested against an in-memory map — no ROS parameters, no getenv calls to test around. Queue/state/files directories, object-storage endpoint and credentials, the shipper ingest protocol target, and delete-when-sent/multipart/thumbnail/retention knobs all move here from the files.*/ uploader.* ROS parameters the Bridge used to read for the Uploader's sake. The Bridge keeps only files.metadata_destination (it still renders Vector's config and has to know where the Uploader's status Records route) and uploader.data_dir (both processes derive the same queue path from it independently, so a deployment does not need to write the same path out twice).
  • The Bridge keeps the Files subscription and intent-writing side. A Record on a receives: files Destination's topic is parsed, durably enqueued (ADR-0005/#265), and forgotten — dc_uploader is the only reader. This split exactly where ADR-0005's durable queue already drew its own internal seam; no new coupling was invented; the queue is the interface.
  • IntentQueue gains rescan(). The queue's on-disk format and single-writer crash-atomicity (#265) were already safe for two processes; what was not is that each process's in-memory scheduling state (oldest-first order, per-entry backoff) was populated once, at construction, from whatever was on disk then. A Bridge process's enqueue() only updates the Bridge's own in-memory view — a separate dc_uploader process holding its own IntentQueue instance over the same directory never otherwise learns a new intent exists. rescan() closes that gap: it picks up any *.json file on disk the instance doesn't already know about, without touching already-known entries' backoff state, and dc_uploader's poll loop calls it every cycle (the same ~500ms cadence the worker thread used to poll next_ready() on).
  • Still one process tree, one machine. dc_bringup.launch.py starts dc_uploader as a supervised ExecuteProcess (the same pattern dc_mcap_writer already uses, and for the same reason: ros2 run does not forward signals to its child), translating the dc_bridge: params block's single receives: files Destination and uploader.data_dir/vector_forward_host/vector_forward_port/files.* into that process's environment. A deployment's params file is unchanged; only the process boundary moved. Running dc_uploader as a separate container, with its own volumes and credentials, is #447's work, not this change's.

Consequences

  • Killing dc_uploader no longer touches Record collection at all — there is no shared address space left for an Uploader failure to take down. Killing it mid-upload loses nothing: the intent that was in flight is still on disk (only ack() removes it, and that never ran), so the next dc_uploader start replays it from IntentQueue's existing crash-replay guarantee, unchanged by this ADR.
  • The ROS container's own credential surface shrinks: object-storage keys live only in dc_uploader's environment, never in the Bridge's. This is epic #440's user story 5 ("each container receives only the credentials it needs") arriving one process early.
  • A deployment with no receives: files Destination configured starts no dc_uploader process at all — build_uploader_action returns no action when the params file names none, matching the Bridge's own "Uploader only exists when Files are configured" behaviour before this change.
  • Only one receives: files Destination per deployment is supported by the environment- variable surface and by dc_bringup.launch.py's translation of it (every params file in this repo already configures at most one). Multiple object-storage endpoints behind one dc_uploader process is out of scope here; it was equally possible and equally untested before this change.

Split deployment topology: the Shipper and the Uploader as separable processes

ADR-0001 put the Shipper on localhost, supervised by DC's bringup, because that was correct for one robot and did not need to be anything else at the time. Epic #440 asks DC to run as a fleet: robots with no internet access, forwarding through an edge Vector aggregator, needing per-component restarts, resource limits, and credentials that a single process tree cannot give. This ADR records where the decomposition line goes and why it stops there.

Decision

The robot decomposes into three processes — the ROS stack (with the Bridge), the Shipper, and the Uploader — and no further. Whether those three run as one process tree (ADR-0001's original native mode), three containers on one machine (#447), or spread across robot, edge, and hub tiers (#440's target architecture) is a deployment-time choice, not a code change: the Bridge's unmanaged-shipper mode (#444) and the Uploader's extraction into its own process (ADR-0014) are what let the same binaries run in every shape.

Why the decomposition stops at the Shipper and the Uploader, and does not extend to ROS nodes. DDS is the wrong protocol to run over anything but a robot's own local network — it assumes multicast discovery and low-latency links that neither an edge tunnel nor a security-conscious deployment can offer (epic #440's user stories 21/22: DDS stays on the robot, no inbound connection reaches it). The Shipper and the Uploader are the two components that already speak something else — the shipper ingest protocol and a plain object-storage API, respectively — so they are the only components that can cross a process, container, or host boundary without inventing a new transport for the purpose. Every ROS node, including the Bridge, stays together in one container on one machine per robot.

Rejected alternative: routing File bytes through the Shipper or a database

Considered and rejected: instead of the Uploader talking to object storage directly, put File bytes on the same path as Records — base64 them into a Record field, or write them into a database column. Rejected because:

  • Base64 inflates payload size by roughly a third, for images and videos that are already the largest artifacts DC moves.
  • Multi-megabyte Records force the Shipper's buffer, and any relational Destination's row storage (PostgreSQL TOAST), to handle a size class neither is designed for — at the cost of every other Record sharing that same path.
  • On a fleet robot's constrained uplink, the bytes would cross the network twice: once to the Destination, and once because neither the Shipper nor a database offers a way to skip re-sending a field that already made it through.

DC keeps the claim-check pattern instead (ADR-0005): the Record carries a reference, the Uploader moves the bytes out of band.

Consequences

  • Managed mode (one supervised process tree, ADR-0001's original design) and unmanaged mode (#444, this ADR) are both first-class; native install and simulation do not need containers to stay correct.
  • The Shipper's own buffer and the Bridge's upload intent queue are separate volumes with separate owners — a container boundary makes visible, as two mount points, what was already two independent pieces of on-disk state internally.
  • compose.split.yaml (#447) is the concrete rendering of this decision for one machine; Podman Quadlet and Kubernetes manifests for the fleet tiers describe the same topology differently, not a different decision.
  • ADR-0001 is amended alongside this ADR: "runs on localhost, supervised by DC's bringup" is no longer true of every deployment.

Helm chart for the robot tier, Kustomize overlays per site/robot

deploy/robot/ (#450) renders the robot tier's fixed topology three ways (Compose, Quadlet, Kubernetes) — one shape, identical on every run. tools/kind/ (#452) faced the same question for its CI harness and rejected Helm there: that harness deploys one fixed topology every run, nothing to parameterize, so a chart would template a variance that doesn't exist (tools/kind/README.md's "Why not Helm"). That reasoning does not carry over to deploy/robot/'s actual deployable manifests: a fleet's robots genuinely differ — edge aggregator address, robot identity, resource limits, image tags, credentials — and kubernetes/robot-pod.yaml has no way to express that difference except hand-editing a copy per site, the same problem tools/kind/kubernetes/robot-a.yaml already shows in miniature (a second, hand-forked copy of the base Pod for one test site).

Decision

Two tools, two different jobs, composed in one command:

  • Helm owns the values surface: whatever genuinely varies per deployment and is worth typing, defaulting, and validating as a named field — robot identity (robot.name), image repository/tag per container, per-container resource requests/limits, the edge aggregator's address (edge.vectorHost/vectorPort), and the Uploader's S3 credentials/endpoint. deploy/robot/helm/dc-robot/ is this chart. Its defaults reproduce kubernetes/robot-pod.yaml + params/robot_params.yaml field for field (verified: helm template with no overrides passes the same kubeconform check that file does) — the chart is a parameterized version of the existing reference, not a new design, and that file remains the runtime-free/k3d/kind reference it already was.
  • Kustomize owns what a chart's values don't, and shouldn't, cover: per-site placement and cluster-specific concerns that have nothing to do with the application's own configuration — which namespace a site's release lands in, which node it's scheduled to, any raw patch a site needs that doesn't rise to the level of a chart value. deploy/robot/helm/overlays/site-a/ is the reference overlay: it sets namespace: dc-robot-site-a, supplies values-site-a.yaml (robot identity + edge address — the chart's own surface), and patches in a nodeSelector pinning the Pod to that site's labeled edge-adjacent node — deliberately not a chart value, because which node a robot lands on is a per-cluster scheduling fact the chart has no business knowing about.

The two compose through kustomize's native Helm chart inflator (helmGlobals.chartHome + helmCharts:, --enable-helm), not a two-step helm template | kubectl apply -k - pipeline: one command (kubectl kustomize --enable-helm --load-restrictor LoadRestrictionsNone deploy/robot/helm/overlays/site-a) renders the chart and applies the overlay's namespace/patches to the result, matching the single-command shape deploy/robot/k3d/kustomization.yaml already established for piping kubectl kustomize into kubectl apply -f -. --load-restrictor LoadRestrictionsNone is required for the same reason it already is there: the overlay's base (the chart, via chartHome: ../..) lives outside the overlay's own directory tree.

A site with N robots installs N releases — one per robot, each with its own values-<robot>.yaml and, where needed, its own overlay directory — never one release templating N robots internally; robot.name (and, by convention, the release's namespace) is the per-robot identity, matching how tools/kind/kubernetes/robot-a.yaml already names one Pod per test site rather than templating a list.

Rejected alternatives

Kustomize alone, patching kubernetes/robot-pod.yaml per site (no Helm). This is what tools/kind/ already does, correctly, for its own one-fixed-topology problem. deploy/robot/'s real fleet has a values surface shared and validated the same way across every site — image tags bumped in one place per release, resource limits typed as actual Kubernetes resources stanzas, credentials with a documented shape — that raw per-site strategic-merge patches would reduplicate at every overlay instead of declaring once. Kustomize's own patches are the right tool for one-off, structural differences (a nodeSelector, an extra label); they are the wrong tool for a values contract every site fills in.

Helm alone, one values-<site>.yaml per site, no Kustomize. Considered, since Helm values could technically carry a nodeSelector or namespace override too. Rejected because it pushes every future site-specific concern into the chart's own values schema regardless of whether it belongs there, growing values.yaml into a dumping ground and coupling unrelated cluster-placement changes to chart version bumps. Kustomize overlays keep that class of change scoped to the site's own directory, reviewable independently of the chart.

Per-site values vendored as Helm subcharts or an umbrella chart. Rejected: it couples a site's own inventory (and, worse, tempts committing its credentials) into the chart's own repository structure. Overlay directories under helm/overlays/ keep site inventory as plain files a site's own GitOps tooling can manage independently of chart releases, with credentials passed via an uncommitted -f/--set file rather than living in the chart.

Consequences

  • Additive only: compose.yaml and quadlet/*.container are untouched, and kubernetes/robot-pod.yaml remains the runtime-free/k3d/kind reference — this ADR adds a parameterized rendering path for real Kubernetes deployments, not a replacement of any of the three.
  • deploy/robot/helm/dc-robot/templates/configmap-robot-params.yaml replaces robot-pod.yaml's hostPath mount for robot_params.yaml with a ConfigMap — the real-cluster mechanism that file's own header already called for ("a Pod-scoped Secret/ConfigMap object is the real-cluster mechanism ... kustomize or a site's own GitOps tool"). robot-pod.yaml itself keeps the hostPath, since that is what makes podman kube play/k3d/kind runnable with no cluster-side object to create first.
  • Values that hold real credentials (uploader.s3.accessKeyId/secretAccessKey) ship with the same literal placeholders params/robot_params.yaml/robot-pod.yaml already use ("changeme"), for the same reason: runnable as shipped, not a design invitation to commit real ones — a real deployment overrides them from an uncommitted values file, never a committed one.
  • tools/kind/README.md's "Why not Helm" stays correct for that harness: it is a different problem (one fixed topology, nothing to parameterize) from the one this ADR answers.

Future work and Roadmap

DC still being in early development, it requires:

  1. High test coverage
  2. More Measurement plugins
  3. More blessed Destination types, promoted from the passthrough as usage justifies it
  4. Guide for the Backend developer: I would like DC to be simple enough so a web developer can run it, get the data and work on its backend application
  5. Guide for the ROS developer: I would like DC to be simple enough so a ROS developer can collect data without the expertise of knowing how to manage databases
  6. More use cases showcased in demos

Participating

TLDR

EventWhat to do
Want to contributeOpen a PR
Found a bugFile a ticket on Github Issues
Found a vulnerabilityReport it privately
Feature requestDescribe what you want on Github Discussions
Want to start a discussionStart one on Github Discussions
Be aware of the ongoing developmentTake a look at the Github Project and what is being worked on

Contributing

Feature requests

Since I want DC to be community driven, go to Github discussions, start a discussion about a features you want to see and users will be able to vote for your it. Most requested features will have more attention than others.

Found a bug?

If you find a problem, first search if an issue already exists. If a related issue doesn't exist, you can open a new issue using the issue form.

Found a vulnerability?

Do not open a public issue, discussion or pull request. Report it through GitHub private vulnerability reporting instead; the security policy states the supported branches, the response targets and what is in scope.

General guidelines

You can contribute to the source code with Pull Requests, for example:

  • To fix a typo you found on the documentation.
  • To propose new documentation sections.
  • To fix an existing issue/bug.
    • Make sure to add tests.
  • To add a new feature.
    • Make sure to add tests.
    • Make sure to add documentation if it's relevant.

Branches and backports

Development happens on rolling, the repository's default branch: open every PR against rolling. One branch per ROS 2 distro keeps the release lines:

BranchRole
rollingdevelopment tip — all new work lands here first
jazzyROS 2 Jazzy line, receives rolling backports until its EOL (05/2029)
lyricalROS 2 Lyrical line, receives rolling backports until its EOL (05/2031)
humblelegacy line (DC 1.x, embedded Fluent Bit), maintained on its own branch, no backports

To have a merged change land on a distro branch too, label the PR backport:jazzy and/or backport:lyrical before merging. Small mechanical fixes port cleanly; restructuring changes usually don't — keep those rolling-only. When the PR merges, the Backport workflow cherry-picks the PR's commits onto each labeled branch and opens a PR there, linking back to the original. A cherry-pick that conflicts still opens a PR, with the conflict markers left in the files and the conflicting files listed in the PR body: resolve them there.

Backport PRs created by the workflow don't start CI on their own — GitHub doesn't run workflows for PRs created with the workflow's own token. Close and reopen one to trigger its checks.

Preview what a merge would backport, creating nothing:

./tools/ci/backport.sh <pr-number> --dry-run
# or the same plan through the workflow:
gh workflow run backport.yml -f pr_number=<pr-number>

Setup environment

ROS

Follow the steps to build your workspace and install dependencies in the setup section

Then install the git hook. .pre-commit-config.yaml is run by prek, a single-binary reimplementation of pre-commit:

uv tool install prek   # or: curl -LsSf https://prek.j178.dev/install.sh | sh
prek install

You are now ready to write some code, commit and follow the standards with the git hook. To run every hook over the whole tree the way CI does:

prek run --all-files --skip build-doc   # drop the --skip to check the docs build too

License headers

The repo follows REUSE: every file declares its copyright and license, and the reuse hook fails if one doesn't. Sources say so in a header:

// SPDX-FileCopyrightText: 2022-2026 David Bensoussan
// SPDX-License-Identifier: MPL-2.0

reuse annotate writes it for you, in whatever comment syntax the file uses:

uvx reuse annotate --copyright "2022-2026 David Bensoussan" --license MPL-2.0 path/to/new_file.cpp
uvx reuse lint          # what the hook runs

Files that can't carry a comment — images, meshes, fonts — are covered by path in REUSE.toml, which is also where third-party assets declare their own upstream license. Add a new license there and run uvx reuse download --all to fetch its text into LICENSES/.

Docs

The doc toolchain (mdbook plus its preprocessors — mdbook-admonish, mdbook-mermaid, mdbook-open-on-gh — and mdbook-linkcheck, all pinned) runs inside a Podman image built from containers/doc/Containerfile; mdbook's preprocessor ABI isn't stable across minor versions, so an unpinned cargo install mdbook can silently pull an incompatible set. The same two scripts CI, the build-doc pre-commit hook, and a local editing loop all use:

./tools/ci/pre-commit/build_doc.sh    # one-shot build -> doc/book/html
./tools/ci/pre-commit/serve_doc.sh    # live-reloading dev server at http://127.0.0.1:3000

Open the doc folder of the repository and edit the Markdown files you need — serve_doc.sh reloads the browser on every save. Editing an ADR under docs/adr/ needs one re-run of tools/ci/pre-commit/generate_adr_pages.py first, since that's what mirrors it into doc/src/dc/adr/.

Declaring plugin parameters

Measurement and Condition plugins (dc_measurements/plugins/{measurements,conditions}/) declare their own parameters in onConfigure(). Always do this through dc_util::get_*_type_param() (dc_util/include/dc_util/node_utils.hpp) — never call declare_parameter or nav2_util::declare_parameter_if_not_declared directly. One call both declares and reads the value, and exits with a clear RCLCPP_FATAL if it can't be retrieved, instead of a hand-rolled declare/get/try-catch block per parameter:

// Mandatory (no default; fatal if not overridden):
cam_name_ = dc_util::get_str_type_param(node, measurement_name_, "cam_name");

// Optional, with a default:
polling_interval_ = dc_util::get_int_type_param(node, measurement_name_, "polling_interval", 1000);

plugin_name/measurement_name_/condition_name_ is the namespace prefix — the helper declares and reads "<plugin_name>.<param_name>". Available types: str, str_array, bool, bool_array (mandatory only), int, int_array (mandatory only), double, double_array (mandatory only). measurement_server.cpp/group_server.py-level parameters that have no plugin namespace use the equivalent dc_util::get_str_param() / get_str_array_param() (no plugin_name argument).

This single-source-of-truth convention is deliberate: see ADR-0008 for why nav2_util stays a dependency and dc_util wraps it rather than replacing it.

dc_group (Python) has no plugins and no equivalent wrapper — group_server.py declares each of its parameters exactly once via plain self.declare_parameter(...), which is sufficient there (see the ADR).

Tests

TODO...

FAQ

I can't find a Measurement I need

Measurements will keep being added, but the current focus is on getting feedback, fixing bugs, documentation and reaching a minimum test coverage.

Create a feature request in Github Discussions...or better, write your plugin and open a Pull Request.

I can't find the Destination I need

You do not need one to exist. The blessed Destination types are the ones DC configures natively from ROS parameters; everything else in Vector's catalog works today through the passthrough — see the question below.

How can I send data to a Destination that isn't blessed?

The Bridge (dc_bridge) renders its Shipper's (Vector) config from plain ROS parameters for a blessed set of receives: records Destination types only (local file, another Vector Shipper — see Destinations; S3-compatible storage is also blessed, but only for receives: files File uploads). Every other sink in Vector's catalog — including PostgreSQL, S3-compatible storage for Records, console, and third-party sinks like Kafka, Kinesis, InfluxDB, webhooks, … — is reachable through the passthrough: list a raw Vector config snippet (TOML) in the custom_config_files parameter, and consume the public dc.<tag> route it needs. No DC code, plugin, or extra language required — only Vector configuration.

Can a passthrough snippet be generated or written in a language other than TOML?

The snippet the Bridge merges in must be Vector's own TOML configuration syntax — DC does not transform it. If you would rather generate that TOML from another language or tool, nothing stops you from doing so as a build or deploy step; the Bridge only reads the resulting file.

My group data is not published on the group topic

This may happen for different reasons:

  1. The group node is not started, be sure it is (ros2 node list), you will need to enable it in your launch file or using the group_node:=true when launching the bringup
  2. Data is not being published on all topics it subscribes to (use ros2 topic echo on each to ensure that). A Group waits for every one of its inputs, so one silent Measurement keeps the whole Group silent. Set the group's sync_timeout and on_sync_timeout: "emit_partial" to publish a partial Record with whatever did arrive instead — see Groups → Incomplete sets. message_filters has no timeout of its own (see this discussion), so DC runs a deadline timer alongside the synchroniser to do this.

About and Contact

About

DC is maintained by:

GitHub avatar of Minipada

David Bensoussan

@Minipada

Contact

Pick the channel that fits:

d.bensoussan@proton.me for private inquiries, configuration support and private feature development.

GitHub Issues for bugs and open-source feature requests.

GitHub Discussions for questions and usage help.