How to Log Errors in Rust (tracing, log crates)

Add tracing and tracing-subscriber to Cargo.toml, initialize the subscriber in main, and use macros like info! and error! to log messages.

Your tool crashes, and you have no idea why

Your CLI tool crashes on a user's machine. You get a screenshot of a panic message that says "index out of bounds." That tells you nothing about why the index was out of bounds. Was the config file missing? Did the network request return garbage? Did the user pass a flag that broke the parser? Without logging, you're guessing. You need a way to record what the program was doing right before it died, or why it made a bad decision.

println! isn't enough. It dumps text to stdout, mixes with your actual output, has no timestamps, and offers no way to filter noise. The Rust ecosystem standard is the tracing crate. It separates instrumentation from output, supports structured data, and tracks context across async tasks. It's the tool you reach for when println! stops working.

Logging vs. printing

Logging is recording events with metadata. Printing is just dumping text. tracing treats logs as events with levels, targets, and structured fields. A subscriber decides what to do with those events. You can print to the console, write to a file, or send to a remote server without changing your code.

tracing also supports spans. A span represents a logical operation, like handling a request or parsing a file. Events inside a span inherit the span's context. This turns flat logs into a timeline. You can see exactly which operation failed and what state it was in. The older log crate doesn't support spans. It's just text with levels. tracing is the modern replacement.

Minimal setup

Add tracing and tracing-subscriber to your dependencies. tracing provides the macros. tracing-subscriber provides the default subscriber that prints to stdout.

[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"

Initialize the subscriber in main before emitting any events. The subscriber sets up a global dispatcher. Macros send events to the dispatcher, which forwards them to the subscriber.

use tracing::{info, error, Level};

/// Entry point that initializes logging and emits sample events.
fn main() {
    // Initialize the subscriber once at the start of the program.
    // This sets up the global dispatcher that captures events.
    tracing_subscriber::fmt()
        .with_max_level(Level::INFO)
        .init();

    // Emit an event. The subscriber formats and prints it.
    info!("Application started");
    error!("Something went wrong");
}

Run this and you'll see timestamped output with levels. The info line appears. The error line appears. If you add debug!("This won't show"), it disappears because the max level is INFO.

Convention aside: The community relies on the RUST_LOG environment variable to control filtering at runtime. You don't hardcode levels in production. You let the operator decide. Set RUST_LOG=debug to see everything. Set RUST_LOG=error to see only errors. This is how you debug without recompiling.

How the pipeline works

When you call info!, the macro expands to a call to the global dispatcher. The dispatcher asks the subscriber: "Do you care about INFO level events?" If yes, the subscriber formats the message, adds a timestamp, and writes it to stdout. If the level is below the threshold, the call is a no-op. This keeps logging cheap when it's disabled.

The subscriber is pluggable. tracing-subscriber provides fmt for console output. You can swap it for json to emit structured JSON. You can add layers to write to a file or send to a metrics backend. Your code stays the same. You only change the subscriber configuration.

Spans add context. When you enter a span, the dispatcher pushes it onto a stack. Events emitted inside the span include the span's fields. When you exit, the stack pops. This lets you correlate events to operations. In async code, spans travel across task boundaries. You can trace a request from the HTTP handler down to the database query.

Spans turn flat logs into a timeline. Use them to trace the path of a request.

Realistic example with spans and structured fields

Structured fields let you capture data alongside the message. Subscribers can extract these fields for metrics or filtering. This is why tracing beats log. log is just text. tracing is data.

use tracing::{info, error, span, Level};

/// Fetches data from a file, tracking progress with a span.
fn fetch_data(url: &str) -> Result<String, Box<dyn std::error::Error>> {
    // Create a span to track this logical operation.
    // All events inside this block inherit the span's context.
    let _guard = span!(Level::INFO, "fetch_data", url = url).entered();

    info!("Starting request");

    // Simulate a failure by reading a non-existent file.
    let data = std::fs::read_to_string(url)?;

    // Log structured data. The subscriber can extract 'length'.
    info!(length = data.len(), "Data received");
    Ok(data)
}

/// Entry point that demonstrates span usage and error logging.
fn main() {
    tracing_subscriber::fmt()
        .with_max_level(Level::DEBUG)
        .init();

    if let Err(e) = fetch_data("nonexistent.txt") {
        // Log the error with the cause chain.
        // The %e formatter calls Display on the error.
        error!(error = %e, "Failed to fetch data");
    }
}

The output shows the span context. You see fetch_data and the url field attached to events. The length field appears in the structured log. If you use a JSON subscriber, you get {"length": 42, "message": "Data received"}.

Convention aside: Use %e for errors when you want the user-facing message. Use ?e when you need the debug representation with the full cause chain. %e calls Display. ?e calls Debug. Most error types implement both. Pick the one that matches your audience.

Pitfalls and compiler errors

If you forget to call init(), tracing silently drops all events. You won't get a compiler error. You'll just get silence. This is the most common "bug" when starting. Always initialize early. If you see no logs, check your subscriber initialization before blaming the macros.

If you try to log a type without Display or Debug, the compiler rejects you with E0277 (trait bound not satisfied).

struct MyData {
    value: i32,
}

fn main() {
    let data = MyData { value: 42 };
    // This fails. MyData doesn't implement Display.
    info!("{}", data);
}

The compiler tells you the trait bound isn't met. Implement Display for your type, or use {:?} if it implements Debug. You can also log fields directly: info!(value = data.value, "Data"). This avoids formatting entirely.

Silence is the enemy. If you see no logs, check your subscriber initialization before blaming the macros.

Bridging legacy dependencies

Many crates still use the log crate. tracing doesn't automatically see log events. You need to bridge them. Add tracing-log and call LogTracer::init() before initializing the subscriber. This routes log events into tracing.

use tracing_subscriber;

/// Entry point that bridges log crate events into tracing.
fn main() {
    // Bridge log crate events into tracing.
    // This must happen before initializing the subscriber.
    tracing_log::LogTracer::init().expect("Failed to set global logger");

    tracing_subscriber::fmt().init();
}

Convention aside: Order matters. LogTracer::init() must run before fmt().init(). If you initialize the subscriber first, the bridge won't catch events from crates that log during their own initialization.

Bridge the gap. If your dependencies use log, wire up tracing-log or you're flying blind.

When to use what

Use tracing when you need structured logs, spans for async context, or multiple outputs. Use tracing for any library code; it's the ecosystem standard and lets downstream crates configure logging. Use log only when maintaining legacy code that depends on it; tracing can bridge to log via tracing-log, so you rarely need log directly anymore. Use println! or eprintln! for quick scripts or when you need to guarantee output to a specific stream without any framework overhead. Reach for eprintln! when writing to stderr for a CLI tool that doesn't need structured analysis.

Where to go next