Skip to main content
Basis uses the MCAP file format for recordings. MCAP is a self-describing, indexed container for timestamped messages. Each message is stored alongside its schema, so a recording can be read back without recompiling any code. The recorder.h header exposes three classes: RecorderInterface, Recorder, and AsyncRecorder. All three are in the basis namespace (via using aliases from basis::recorder).

RecorderInterface

RecorderInterface is the abstract base class. It defines the four operations every recorder must support:
class RecorderInterface {
public:
  virtual ~RecorderInterface() = default;

  // Open an output file named "<output_name>.mcap" in the configured directory.
  // Returns true on success.
  virtual bool Start(std::string_view output_name) = 0;

  // Flush and close the output file.
  virtual void Stop() = 0;

  // Register a topic's schema before writing any messages to it.
  // Must be called once per topic before the first WriteMessage call.
  virtual bool RegisterTopic(const std::string &topic,
                             const core::serialization::MessageTypeInfo &message_type_info,
                             const core::serialization::MessageSchema &basis_schema) = 0;

  // Write a serialized message payload for the given topic.
  // `now` is the monotonic timestamp to associate with the message.
  virtual bool WriteMessage(const std::string &topic,
                            OwningSpan payload,
                            const basis::core::MonotonicTime &now) = 0;
};
You can provide a custom implementation of RecorderInterface in tests — for example, to assert that a specific topic was or was not recorded, or to capture messages in memory instead of writing to disk.

Recorder — synchronous writes

Recorder is the basic implementation. It writes directly to an mcap::McapWriter on the calling thread.
class Recorder : public RecorderInterface {
public:
  // RECORD_ALL_TOPICS matches every topic (empty pattern list means no filtering)
  static const std::vector<std::pair<std::string, std::regex>> RECORD_ALL_TOPICS;

  Recorder(
    const std::filesystem::path &recording_dir = {},
    const std::vector<std::pair<std::string, std::regex>> &topic_patterns = RECORD_ALL_TOPICS
  );
};
Start(output_name) creates a file named <output_name>.mcap inside recording_dir. The MCAP profile is set to "basis". Stop() (also called by the destructor) closes and flushes the file. Split(new_name) closes the current file and opens a new one. Use this for rolling recordings.

Example: recording a single unit

#include <basis/recorder.h>
#include <basis/unit.h>

int main() {
  basis::core::logging::InitializeLoggingSystem();

  // Write to /data/recordings/, record all topics
  auto recorder = std::make_unique<basis::Recorder>("/data/recordings/");

  MyUnit unit;
  auto* coordinator = unit.WaitForCoordinatorConnection();

  // Pass the recorder when creating the transport manager.
  // All messages published by this unit will be captured.
  unit.CreateTransportManager(recorder.get());
  unit.Initialize();

  recorder->Start("my_unit_run");

  std::atomic<bool> stop = false;
  while (!stop) {
    unit.Update(&stop, basis::core::Duration::FromSecondsNanoseconds(1, 0));
  }

  recorder->Stop();
}

AsyncRecorder — background writes

AsyncRecorder wraps a Recorder and moves disk I/O to a dedicated background thread using an MPSC queue. WriteMessage() enqueues the payload and returns immediately; the worker thread drains the queue.
class AsyncRecorder : public RecorderInterface {
public:
  AsyncRecorder(
    const std::filesystem::path &recording_dir = {},
    const std::vector<std::pair<std::string, std::regex>> &topic_patterns = Recorder::RECORD_ALL_TOPICS,
    bool drain_queue_on_stop = true   // flush remaining messages before closing
  );
};
Start() opens the file via the inner Recorder and starts the worker thread. Stop() sets the stop flag and joins the worker thread, then closes the inner Recorder. If drain_queue_on_stop is true (the default), the worker flushes all enqueued messages before joining. RegisterTopic() is protected by a mutex because it is called from the publishing thread while the worker thread may be running.
Use AsyncRecorder on robots where minimizing publish latency matters. Use Recorder in tests where you need deterministic write ordering.

Choosing Recorder vs AsyncRecorder

RecorderAsyncRecorder
Write pathSynchronous, on publish threadAsynchronous, background thread
Publish latencyAdds disk I/O latencyMinimal — enqueue only
Message orderingAlways in-orderIn-order within the MPSC queue
Suitable forTests, low-frequency topicsProduction, high-frequency topics

Topic filtering with regex patterns

Both classes accept a topic_patterns argument: a vector of (string, regex) pairs. A topic is recorded only if at least one regex matches. The string member is the human-readable pattern source; the regex is the compiled form. To record all topics, pass Recorder::RECORD_ALL_TOPICS (the default) or an empty vector:
// Record everything
auto recorder = basis::Recorder("/data/");

// Record only topics under /camera/
std::vector<std::pair<std::string, std::regex>> patterns = {
  {"/camera/.*", std::regex("/camera/.*")}
};
auto recorder = basis::Recorder("/data/", patterns);

Attaching a recorder to a Unit

Pass a RecorderInterface* to Unit::CreateTransportManager(). The transport manager forwards the recorder to each publisher it creates; every serialized message destined for the network is also handed to the recorder.
basis::RecorderInterface* CreateTransportManager(
    basis::RecorderInterface* recorder = nullptr);
The recorder lifetime must exceed the Unit’s lifetime. The Unit does not take ownership.

RecordingSettings in launch files

You can configure recording declaratively in a launch YAML file. The RecordingSettings struct maps directly to a recording block:
struct RecordingSettings {
  bool async = true;                                     // use AsyncRecorder
  std::string name = "basis";                            // MCAP output file stem
  std::vector<std::pair<std::string, std::regex>> patterns;  // topic filter
  std::filesystem::path directory;                       // output directory
};
A LaunchDefinition carries an optional RecordingSettings:
struct LaunchDefinition {
  std::optional<RecordingSettings> recording_settings;
  std::unordered_map<std::string, ProcessDefinition> processes;
};
Example launch file with recording enabled:
launch.yaml
recording:
  async: true
  name: robot_run_2024
  directory: /data/recordings
  topics:
    - /camera/.*
    - /lidar/.*
    - /control/.*

groups:
  main:
    units:
      perception: {}
      control: {}
When async is true, an AsyncRecorder is created and shared across all units in the process. When false, a synchronous Recorder is used instead.

Next steps

Deterministic replay

Feed a recorded MCAP file back through a Unit’s handlers to reproduce a robot run exactly

Testing overview

How Basis’s execution model enables both unit testing and replay-based testing

Build docs developers (and LLMs) love