In this document, we will introduce several tools that can be used for debugging distributed systems:
spdlog: Logging to terminal/files
OpenTelemetry (OTel) Toolset: Distributed tracing Toolset
OTel and gRPC-intercepter: generate the traces
Jaeger and Grafana: collect and visualize the traces
GDB: Single-process debugger
DDB: Distributed interactive debugger
To demo these tools, we integrated examples into lab0a's codebase. See this repo. The rest of this guide will refer to these examples.
One good way to learn is looking at this commit history, which shows the incremental changes for integrating debugging to lab0a's client and server. For this example and Raft labs, we provide wrappers around spdlog and OTel, so that you can use those tools easily. Lower-level implementations on-top-of spdlog and OTel libraries are very complex, and they will NOT be covered by this guide. In case you want to learn, you can check-out this directory, but this is not required.
After cloning the repo into your Ubuntu VM, please run the following commands to setup dependencies.
$ cd CSCI-546-debugging-tools-demo/
$ ./setup.sh
$ ./install_otel.sh # skip if already installed
$ ./install_grpc.sh # skip if already installed
$ ./build.sh
Then you can follow the below guide to reproduce the examples.
NOTE: in this demo, if you use Jaeger, it can throw the following error as it doesn't support logs. These errors are harmless, please ignore. Yet it's still recommended for OTel beginners to start with Jaeger, as it's much simpler than Grafana. For later Raft labs, the docker compose is configured to suppress such errors for you.
[Error] File: /tmp/opentelemetry-cpp/exporters/otlp/src/otlp_grpc_log_record_exporter.cc:173 [OTLP LOG GRPC Exporter] Export() failed: unknown service opentelemetry.proto.collector.logs.v1.LogsService
NOTE: After the request is served, it takes time for the traces to appear in the UI searches. The delay is usually within seconds, but it can be longer if your machine performance is low (e.g. an old/ultra-thin laptop).
NOTE: Avoid using the ports 12345 and 4317, which are used internally by Otel/Grafana suite.
spdlog is the de facto standard for logging in C++, and there are several factors making it better than printing logs directly:
spdlog adds log levels (debug/info/warn/error) so you can filter noise without removing code
timestamps for debugging timing issues
async writing so logging doesn't block your application
spdlog supports multiple outputs simultaneously—the same log writes to console and file(s)
In the demo, the client invokes the pre-initialized logger with different log levels here, where the debug log will be shown if you set SPDLOG_LEVEL=debug:
At the same time, the logs are written to the file sinks:
This also shows another benefit of spdlog,
when there are more than one process, the logs are separated to different files, rather than jamming the console.
OTel is the state-of-the-art distributed tracing system, which is widely used in modern cloud.
The key feature of OTel toolset is producing a timeline of each RPC request, like the timeline generated by AddWordCount below. In production, a request can involve dozens of distributed services, and with OTel integration, developers can see a single timeline aggregated from many machines/processes. Therefore, OTel is the de facto standard for debugging and performance analysis for cloud applications, especially micro-services.
In the above timeline, every horizontal bar is called a span, usually representing the lifespan of a function, an RPC, or just a segment of code. Notably, the longer green span is the parent span of the shorter green span, as the short one is a "subcomponent" of its parent.
More specifically, the span wc_server /Accumulator/AddWordCount is automatically generated by OTel's gRPC interceptor. For the demo and Raft labs, when compile with tracing enabled, the interceptor will automatically intercept all gRPC calls, recording the request and response contents as span attributes (shown below).
On contrary, the AddWordCountManualSpan is manually created (link to code). Manually created spans are usually used for tracing function calls inside a server, giving you a magnified view of the RPC handling process. Unlike intercepted spans, manual spans' attributes need to be manually set as well.
0. Install OTel
In both the demo repo and the Raft lab repo, Ubuntu VM can use the install_otel.sh directly.
1. Building your binaries with tracing enabled
$ cd ~/CSCI-546-debugging-tools-demo/ # or the Raft lab
$ mkdir build
$ cmake .. -DTRACING=ON
$ make -j$(nproc)
2. Launch the Jaeger/Grafana collector + web UI server
Both Jaeger and Grafana can visualize the tracing spans, but they are different:
Jaeger UI is very simple and intuitive. It's user-friendly for OTel beginners. However, it doesn't support logs attaching to spans (introduced later).
Grafana UI is much more complex, which contains Grafana Tempo for tracing, and Grafana Loki for logging. In the above example, you can see Grafana has a button "Logs for this span" to view logs attached each span.
Under the tools/ directory, we packaged docker-compose for both Jaeger and Grafana so that you can launch either of them in one line:
$ cd tools/jaeger-suite/
$ docker compose up -d
OR
$ cd tools/grafana-suite/
$ docker compose up -d
Note: you should NOT launch both at the same time. To remove the deployment, switch to corresponding directory, and do
$ docker compose down
# OR
$ docker compose stop # temporary stop, doesn't delete previous trace data
3. Port forwarding and accessing UI in browser
By default, Jaeger uses port 16686 and Grafana uses port 3000 to expose their web UI. If you are using a VM, the port is exposed only inside the VM, so you need to forward it to your host machine before connecting to it in your browser. You can do this by ssh port forwarding, OR a simpler way is the "Add Port" button in VS Code's "Ports" menu:
Then, you can use your browswer to connect to localhost:16686 or localhost:3000, and Hooray! You are ready to launch some word count requests/raft nodes and then start exploring the UI! Before you start, review the notes in "Demo Repo" section above.
When you have a huge volume of logs (which is very normal in distributed systems), reviewing logs become difficult. A powerful feature of this toolset is allowing you to click on a span in the timeline, and see the logs associated with it. So you can walk through the logs inside a visual timeline.
When you compile labs with cmake .. -DTRACING=on , the spdlog will writes to OTel sink, and then logs are fed into Grafana. If you use Jaeger, logs are dropped. Then, when you view a trace in Grafana Tempo, you can click on the "Logs for this span" button to view logs inside that span. Note that you need to manually create spans to associate with logs, because spdlog doesn't attach to gRPC intercepted spans automatically. If a log is not associated with a span, you can still view it in Grafana Loki directly.
For more details on how to glue logs with spans, see this code snippet.
One limitation of tracing is that, if the service stuck forever (e.g. deadlock), the spans are never generated, and no tracing will be produced to help you understand what's the issue. This limitation is fundamental for non-interactive debugging, thus this leads to the interactive debugging tools introduced below.
Since VSCode is the most widely used IDE, we'll use it for interactive debugging.
To get started:
Open the Debug panel by clicking the Run and Debug icon in the sidebar (or press Ctrl+Shift+D / Cmd+Shift+D).
Configure your debugging settings by either clicking the gear icon or directly opening .vscode/launch.json.
Note that debugging requires both a language extension and a compatible debugger. For C++ development:
GDB: Install the C/C++ extension (ms-vscode.cpptools), which includes GDB support
DDB: Install the DDB extension along with the DDB debugger for distributed debugging
Refer to the following sections for installation instructions.
How to search in Call Stack(when we are trying to find another thread based on threadId, similar to what we are doing in class) ?
To search within the call stack:
Open keyboard shortcuts settings and find the keybinding for list.find
Click on any thread or frame in the Call Stack panel to focus it
Press the keybinding to open the search box within the panel
You should see something like this.
Install GDB:
sudo apt install gdb
Install the C/C++ extension in VSCode:
Open Extensions (Ctrl+Shift+X)
Search for "C/C++" and install the one from Microsoft (ms-vscode.cpptools)
3. Example launch configuration (.vscode/launch.json):
{
"name": "(gdb) Attach wcServer",
"type": "cppdbg",
"request": "attach",
"program": "${workspaceFolder}/build/wcServer",
"processId": "${command:pickProcess}",
"MIMode": "gdb",
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
},
{
"description": "Set Disassembly Flavor to Intel",
"text": "-gdb-set disassembly-flavor intel",
"ignoreFailures": true
}
]
}
This configuration attaches GDB to a running wcServer. When launched, VSCode will prompt you to select the wcServer process to debug.
DDB is akin to GDB, but provides native support for distributed systems. It addresses several critical issues that make GDB hard to apply to distributed systems for the step-over debugging experience.
To name one example, GDB doesn't allow attaching to multiple processes simultaneously, making it really hard to debug distributed systems.
Why I need docker?
Docker is used to launch the EMQX broker, which handles service discovery. In a distributed system with multiple processes, using a broker to discover and attach to them is more convenient than managing connections manually. To simplify setup, we use Docker to run EMQX directly rather than installing it on your machine.
If you have docker but sitll cannot connect to the emqx broker, relate to this post CSCI 546 (15 unread) | Piazza QA
This step is important. Docker should already be installed. Verify by running docker ps— you should see an empty container list with no errors. If you get a permission error, run sudo usermod -aG docker $USER, then log out and back in for it to take effect. This step is important
Check the latest update here: https://piazza.com/class/mj7u1zurfkn8e/post/65.
There are several links that point to the official DDB website to guide you through the setup procedure and also learn how to use DDB:
Install DDB: https://usc-nsl.gitbook.io/ddb/getting-started/ddb-installation
Install DDB VSCode Extension: https://usc-nsl.gitbook.io/ddb/getting-started/ddb-vscode-extension
Distributed Debugging Infrastructure
Automatic attaching through service discovery
Boundary-crossing RPC backtracing
Time Synchronization
Faketime module to avoid time deviation
Breakpoint Management
Smart breakpoint handling system
Quality of Life
Frame filtering
Process grouping
And more...
Start the debugger from the VSCode Debug panel. (For details on each configuration option, see Configure VSCode | DDB)
Usually at the start of main entrypoint through ddb connecter (if you are using demo code or the course's lab don't worry, we've added it for you, just use the --ddb flag)
if (args.enable_ddb) {
auto cfg = DDB::Config::get_default("127.0.0.1")
.with_alias(proc_alias)
.with_logical_group(proc_alias);
cfg.wait_for_attach = args.wait_for_attach;
auto connector = DDB::DDBConnector(cfg);
connector.init();
}
ddb_runapp ./wcServer --bind 127.0.0.1:50051 --ddb
In the Call Stack panel, the server's processes will appear but won't be running yet—they're paused when attached to the debugger. You can either click the Continue button to let the server run, or add breakpoints first. (Adding breakpoints while the server is running works fine too.)
Try to click at the left side of a line of code, a popup will appear, prompting you to select which session(s) to add a breakpoint to. In this case, we only have one session, but if you launch multiple instances, you'll see multiple sessions listed.
After you click OK, a breakpoint should be added shortly. If "processing" displays for too long, try adding it again. Then click Continue to let the server run.
After you see the server is running, launch client
ddb_runapp ./wcClient --dest 127.0.0.1:50051 --text "Hello!" -ddb
After this step, remember to click Continue. Processes are interrupted when added to DDB.
The breakpoint should be triggered on the server side. DDB has a powerful feature: it can perform stack backtraces across process boundaries. The red rectangle on the left marks the boundary between two processes—below it is the client stack, and above it is the server stack. This allows you to inspect the entire call stack at once.
For example, here we can see the client's stack from the server side. We can also see the text being passed: "Hello!".
Record and Replay (R&R) is a powerful technique used to capture the execution of a system so that it can be faithfully reproduced at a later time. Because distributed environments are plagued by non-determinism—where factors like network delays, thread scheduling, and message ordering can change every time a program runs—simply re-running code rarely produces the same bug twice. The recording phase logs all external inputs and "non-deterministic events" (such as the exact order in which messages arrived at a node), while the replay phase uses this log to force the system through the exact same execution path. This makes R&R an indispensable tool for debugging "Heisenbugs," performing fault-tolerance recovery, and conducting rigorous offline analysis of complex, interconnected services.
Conventionally (not just limited to distributed systems), record and replay tools can be further divided into these categories:
Execution Replay
It records every CPU instruction, memory access, and hardware interrupt. Then it replays the execution instruction-by-instruction and also allows "reverse-debugging" (also called time-travel debugging), which enables developers to step back through previous instructions or code lines.
One example of this type of RR tool is Mozilla RR (https://rr-project.org/). However, Mozilla RR is primarily designed for single-machine applications and does not provide execution replay for all distributed processes.
Logic Replay
It records the results of non-deterministic function calls and external interactions—such as side effects, timers, or API responses—to ensure high-level application logic can be reconstructed. During recovery, the system reruns the actual code and "injects" the recorded values from a history log, allowing a process to crash on one node and resume execution on another with the same internal state.
One example of this type of RR tool is Temporal (https://temporal.io/) and Azure Duration Function. This durable execution approach is highly effective for long-running distributed workflows, though it requires that the application code itself remains deterministic and does not rely on local state that isn't captured by the framework. This type of tool was primarily designed for failure avoidance. Replay debugging is offered by Temporal but not in Azure Duration Function.
Message/Traffic Replay
It captures the network traffic and communication between services, such as gRPC calls or HTTP requests, at the application boundary. This technique, also known as traffic replay, allows developers to "play back" production-level interactions to a service in a controlled environment to simulate real-world load, perform regression testing, or conduct shadow deployments. One example of this type of RR tool is grpcreplay (https://github.com/vearne/grpcreplay). Unlike execution-level replay, this method focuses entirely on the service's inputs and outputs; it does not capture the internal CPU instructions or the specific thread interleaving that occurred during the original execution.
We will include the message replay tool (grpcr) in the following labs to allow you to record and replay raft communications for debugging purposes.
Once raft lab is released, you can find the installation script for grpcr in ./scripts/install_grpcreplay.sh. Run the script to install grpcr.
We provide a bundle of scripts for you to use grpcr on the raft lab in ./tools/grpcr.
For example, to record the gRPC requests for a multi-raft cluster, you can first start the multinode app. Before sending any command via the multinode app, you may run ./grpcreplay-record-multinode.sh (from ./tools/grpcr). After starting it, you can go back to the multinode app and enter "r" command to start the entire cluster. You will find all recorded traffic in "grpcreplay-capture" (in ./tools/grpcr).
Later, you can start multinode app again, and run ./grpcreplay-replay-multinode.sh to start replaying recorded gRPC requests to all raft nodes.