Step 3 of 7
Debuggers and logging
Print debugging is quick, but each new question means editing and rebuilding. Two other tools cover what it can't: a debugger for exploring a running program, and logging for knowing what a program did when you weren't watching.
Debuggers
A debugger runs your program under its control. You set a breakpoint on a line, the program stops there, and you can inspect every variable, see the call stack, and step forward one line at a time. Rust ships wrappers around the two common debuggers, rust-gdb (Linux) and rust-lldb (macOS), that teach them to display Rust types like Vec and String nicely. Here is a session on the dividing-by-zero program from the previous step:
$ cargo build
$ rust-gdb -q target/debug/scores
(gdb) break src/main.rs:3
Breakpoint 1 at 0x1547e: file src/main.rs, line 3.
(gdb) run
Breakpoint 1, scores::average (scores=&[u32](size=2) = {70, 80}) at src/main.rs:3
3 total / scores.len() as u32
(gdb) print total
$1 = 150
(gdb) continue
75
Breakpoint 1, scores::average (scores=&[u32](size=0)) at src/main.rs:3
(gdb) backtrace
#0 scores::average (scores=&[u32](size=0)) at src/main.rs:3
#1 0x00005555555693c5 in scores::main () at src/main.rs:9
The second stop shows size=0: the empty round, caught before it crashes. Useful commands: break file:line, run, next (run the next line), step (go into a function call), print expr, backtrace, continue.
Most people use a debugger through their editor. In VS Code, install the rust-analyzer and CodeLLDB extensions: a Debug button appears above fn main and above each test, you click in the margin to set breakpoints, and variables show up in a side panel. RustRover and other IDEs work the same way. Debug in a normal (debug) build; in --release builds the optimizer removes many variables.
Logging
Logging is permanent, structured printing: each message has a level (error, warn, info, debug, trace, from most to least important), and you choose at run time which levels to show, without recompiling. Servers and CLIs at work log instead of using println!.
Here's the idea in plain Rust: an ordered level enum and a minimum level that filters messages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Level {
Debug,
Info,
Warn,
Error,
}
struct Logger {
min: Level,
}
impl Logger {
fn log(&self, level: Level, msg: &str) {
if level >= self.min {
println!("[{level:?}] {msg}");
}
}
}
fn main() {
let logger = Logger { min: Level::Info };
logger.log(Level::Debug, "cache lookup for order 42");
logger.log(Level::Info, "order 42 accepted");
logger.log(Level::Warn, "stock for item 7 is low");
logger.log(Level::Error, "payment service unreachable");
}
[Info] order 42 accepted
[Warn] stock for item 7 is low
[Error] payment service unreachable
Deriving PartialOrd and Ord on an enum orders the variants as they're declared, so level >= self.min compares importance. (This demo prints to stdout; real loggers write to stderr or a file.)
In real projects you use crates. The log crate gives you the macros, and a separate logger crate such as env_logger decides where they go:
[dependencies]
log = "0.4"
env_logger = "0.11"
use log::{debug, info, warn};
fn main() {
env_logger::init(); // reads the RUST_LOG environment variable
info!("server starting on port {}", 8080);
debug!("config loaded from {}", "shop.toml");
warn!("disk is {}% full", 91);
}
$ RUST_LOG=info cargo run
[2026-10-11T09:14:03Z INFO shop] server starting on port 8080
[2026-10-11T09:14:03Z WARN shop] disk is 91% full
Larger services, especially async ones, use tracing, which adds spans (a named region of work, like "handling request 17") and structured fields that log tools can search:
use tracing::{info, instrument};
#[instrument] // log lines inside get the order_id and items fields
fn ship(order_id: u64, items: usize) {
info!(items, "packing order");
}
fn main() {
tracing_subscriber::fmt::init();
ship(17, 3);
}
Common mistakes
- Debugging a release build: variables show as
<optimized out>. Use the default dev profile. - Logging at the wrong level:
infofor things an operator cares about,debugfor developer detail,erroronly for real failures. If everything iserror, nothing is. - Logging secrets (passwords, tokens, personal data). Logs get copied to many places.
Your task
Write the two functions a logger needs. parse_level reads a level name like RUST_LOG does: case-insensitive, surrounding spaces ignored, warning accepted as another name for warn, anything else is None. enabled(min, level) says whether a message at level is shown when the minimum is min.
Challenge 2
Write format_record(level, target, msg), which formats a log line like env_logger does: the level name in capitals, padded to 5 characters, then the target (the module or component), in square brackets, followed by the message:
[WARN orders] stock low
[ERROR db] timeout after 30s
Challenge 3
Write a log filter. The first line of stdin is the minimum level (trace, debug, info, warn or error). Every following line is a record like WARN disk almost full: a level, a space, the message.
Print the records at or above the minimum, unchanged, then a summary N shown, M hidden. Records whose level isn't a known name are counted as hidden and reported on stderr. Example: with minimum warn and the records INFO started, WARN disk almost full, ERROR disk full, print:
WARN disk almost full
ERROR disk full
2 shown, 1 hidden
Previous: Reading panics and backtraces Next: Documenting code with rustdoc