When silence is your enemy
You build a CLI tool. It works perfectly on your machine. You send the binary to a friend, and they report it crashes immediately. No error message. No stack trace. Just a silent exit. You're staring at a black box, guessing what went wrong.
Logging turns that black box into a window. It gives your program a voice so it can report what it's doing, what it's thinking, and where it's failing. In Rust, logging isn't a single built-in feature. The ecosystem offers a choice between two main approaches: the log crate paired with env_logger, and the tracing crate. Both solve the same problem, but they do it with different philosophies and capabilities.
The facade pattern: interface vs implementation
Rust handles logging differently than Python or JavaScript. There is no global console.log. Instead, Rust uses a facade pattern. The log crate defines the interface: macros like info!, debug!, and error!. It does not print anything. It delegates to a backend that you choose.
This separation is deliberate. It allows your code to be neutral about how logs are handled. A library can use log macros without forcing users to adopt a specific logging implementation. The application developer picks the backend. They might choose env_logger for simple text output, slog for structured JSON, or a custom backend that writes to a database. The library code stays the same.
tracing takes this further. It also separates interface from implementation, but it adds richer metadata and context tracking. tracing is designed for complex applications, especially those using async runtimes like Tokio. It supports spans, which track periods of time and execution context, rather than just discrete events.
The facade keeps your code portable. Swap the backend without touching the macros.
Quick start: env_logger
For simple CLI tools, env_logger is the standard choice. It pairs with the log crate to provide easy text logging controlled by environment variables.
Add both dependencies to your Cargo.toml. You need log for the macros and env_logger for the backend.
[dependencies]
log = "0.4"
env_logger = "0.11"
In your code, initialize the logger before using any macros. The env_logger::init() function sets up the backend and reads configuration from the RUST_LOG environment variable.
use log::{info, debug, error};
fn main() {
// env_logger reads RUST_LOG and configures the backend.
// This must happen before any log macros are called.
env_logger::init();
info!("Application started");
debug!("Loading configuration");
error!("Failed to connect to database");
}
Run the binary. You'll see output for info and error, but not debug. The default log level is info. env_logger filters out anything below that threshold.
The log macros check the current level at runtime. If the level is too low, the macro does nothing. This avoids the cost of formatting strings for messages that won't be printed.
Don't hardcode log levels. Let the environment decide.
Controlling verbosity with RUST_LOG
The RUST_LOG environment variable controls what gets printed. It supports a flexible syntax that lets you tune verbosity per module or per level.
Set RUST_LOG=debug to see everything. Set RUST_LOG=info to hide debug messages. You can also target specific modules. If your app has a module named network, set RUST_LOG=my_app::network=debug to see debug logs only from that module while keeping the rest at info level.
# Show all logs
RUST_LOG=debug ./my-cli
# Show info and above, but debug for the network module
RUST_LOG=info,my_app::network=debug ./my-cli
This granularity is essential for debugging. You can leave debug logging in your code without cluttering normal output. When something breaks, you flip the switch and get the details you need.
Convention aside: always use RUST_LOG in your development workflow. Add it to your shell profile or use cargo run --env RUST_LOG=debug to make debugging frictionless.
Test your logs in a clean environment. RUST_LOG inheritance from your shell can hide bugs.
Beyond flat logs: tracing and spans
The log crate gives you a flat list of messages. Each message stands alone. This works for simple scripts, but it falls apart in complex applications. When a request fails, you need to know which request it was, what function was running, and what state existed at that moment. Flat logs force you to manually thread context through every function call.
tracing solves this with spans. A span represents a period of time in the execution of your program. You enter a span, do some work, and exit. Events inside the span inherit the span's context. The output shows a hierarchy, making it easy to see how events relate to each other.
Think of log as a diary where you write entries one after another. tracing is like a security camera system with multiple rooms. Each room is a span. When an event happens, the log tells you exactly which room it occurred in and what other events were happening at the same time.
Add tracing and tracing-subscriber to your dependencies. The subscriber formats and routes the events.
[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"
Initialize the subscriber in main. The fmt subscriber prints human-readable logs to stdout.
use tracing::{info, debug, error, info_span};
fn main() {
// The fmt subscriber formats events and writes them to stdout.
// with_max_level sets the default filter.
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
info!("Application started");
}
Spans are created with macros like info_span!. You enter the span using the enter() method or a block guard. Events inside the span automatically include the span's metadata.
fn process_request(id: u32) {
// Create a span that tracks this request.
// The span includes the request ID as metadata.
let span = info_span!("processing_request", request_id = id);
let _guard = span.enter();
info!("Fetching data");
debug!("Parsing payload");
error!("Validation failed");
}
The output shows the span context alongside each event. You can see that all three events belong to the same request.
Spans turn a flat list of logs into a map of execution. Use them to navigate complexity.
Realistic example: tracing with context
Here's a more realistic example showing how spans and events work together. This simulates a CLI tool that processes files. Each file gets its own span. Errors inside the span are immediately associated with the file being processed.
use tracing::{info, debug, warn, error, info_span};
use std::fs;
fn main() {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.init();
let files = vec!["data1.txt", "data2.txt", "data3.txt"];
for file in files {
// Each file gets a span.
// The span tracks the filename.
let span = info_span!("processing_file", filename = file);
let _guard = span.enter();
info!("Starting processing");
match fs::read_to_string(file) {
Ok(contents) => {
debug!("Read {} bytes", contents.len());
info!("Processing complete");
}
Err(e) => {
// The error event inherits the span context.
// We know exactly which file failed.
error!("Failed to read file: {}", e);
}
}
}
}
When data2.txt fails, the log output shows the error inside the processing_file span for data2.txt. You don't need to grep through logs to find the culprit. The context is baked into the output.
Convention aside: tracing is the default for async Rust. Tokio, Axum, and most async libraries integrate with tracing. If you're building an async application, tracing is the natural choice. The log crate doesn't track context across async boundaries.
Spans are your best friend for debugging async code. Use them.
Pitfalls and silent failures
Logging in Rust has a few gotchas that trip up beginners.
The most common issue is forgetting to initialize the backend. If you use log macros without calling env_logger::init(), nothing happens. The macros check for a backend, find none, and silently drop the message. There's no compiler error. There's no runtime panic. Just silence. This can waste hours of debugging time.
If you see no logs, check initialization. The macros are silent ghosts without a subscriber.
Another pitfall is calling env_logger::init() multiple times. The function panics if a logger is already set. This happens when a library calls init() and the application also calls init(). Libraries should never force a logger. Use env_logger::try_init() instead. It returns a Result and does nothing if a logger is already configured.
// Safe for libraries. Ignores errors if a logger exists.
let _ = env_logger::try_init();
If you mix log and tracing in the same project, you might run into macro name collisions. Both crates export info!, debug!, etc. If you import both, the compiler complains about ambiguity. Use fully qualified names or stick to one ecosystem.
// E0252: the name `info` is defined multiple times
use log::info;
use tracing::info; // Conflict
Resolve this by choosing one. If you're using tracing, you can bridge log calls to tracing using the tracing-log crate. This allows libraries that use log to work seamlessly with your tracing setup.
Add tracing-log = "0.2" to your dependencies and call tracing_log::LogTracer::init() before initializing the subscriber. This redirects all log events to the tracing backend.
Use try_init in libraries. Panicking on init is rude.
Choosing your logging strategy
Both approaches are solid. The right choice depends on your application's needs.
Use log with env_logger for simple CLIs that just need text output and don't use async. The setup is minimal, and the environment variable control is straightforward.
Use tracing when you need structured logs, spans, or are building a library that others might use with different backends. The metadata and context tracking pay off quickly as complexity grows.
Use tracing with tracing-subscriber when you want flexible formatting, JSON output, or filtering by target. The subscriber system is highly extensible.
Use tracing for async applications. The log crate doesn't track context across async boundaries. tracing integrates with Tokio and other runtimes to preserve spans across await points.
Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. The same applies to logging. The more context you track, the easier it is to debug. Invest in spans early.