When println! stops working
You ship a Rust binary to a server. It runs. It crashes. The only output you get is a stack trace and a vague process exit code. You spend an afternoon sprinkling println! statements through your code to track variable states, only to realize you need to strip them all out before deployment. Or worse, you leave them in, and your production logs drown in noise while actual errors get buried.
Printing to standard output was fine for your first script. It fails the moment your application needs to separate debugging chatter from critical failures, or when you need to filter output without recompiling. You need a logging system that understands priority, context, and performance.
The emitter and subscriber model
Think of logging like a cockpit dashboard. You do not want engine temperature, fuel pressure, and navigation coordinates screaming at you all at once. You want the right information at the right time. During routine operation, you only care about major warnings. During troubleshooting, you want every sensor reading.
Rust handles this with a two-part architecture. The emitter is just a macro that announces an event at a specific priority level. The subscriber is the actual code that decides what to do with that message. It might print it to the terminal, write it to a file, send it to a cloud service, or drop it entirely. This separation keeps your core logic clean. The emitter does not care where the log goes. The subscriber does not care what your application does.
The modern Rust ecosystem has standardized on tracing for this job. It replaced the older log crate by adding structured data and execution spans, but for basic logging, it works exactly like a traditional logger. Under the hood, it uses a global registry. When you call a logging macro, it checks the registry for an active subscriber. If one exists, it forwards the message. If not, the call compiles down to a single branch instruction that does nothing.
Treat the subscriber as the brain of your logging system. Without it, your macros are just silent function calls.
Setting up the basics
Setting up logging takes two steps. You add the crates to your manifest, then initialize the subscriber before your application starts doing work.
[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"
The tracing crate provides the macros. The tracing-subscriber crate provides the default formatter that prints to standard output. You need both to see anything on the screen.
use tracing::{info, debug, warn, error, Level};
use tracing_subscriber;
fn main() {
// Initialize the subscriber before any logging happens.
// This registers the formatter with the global registry.
tracing_subscriber::fmt()
.with_max_level(Level::INFO)
.init();
// Logs below INFO level will be filtered out at runtime.
debug!("This message will not appear in the output");
info!("Application started successfully");
warn!("Configuration file is using default values");
error!("Failed to connect to database");
}
Run this with cargo run. You will see the INFO, WARN, and ERROR lines. The DEBUG line vanishes. The compiler did not delete it. The subscriber filtered it out at runtime.
Initialize the subscriber at the very top of main. If you spawn threads or start servers before calling .init(), those early threads will log into the void.
How filtering actually works
What happens when you call info!? The macro expands to a function call that captures the file name, line number, and thread ID. It then checks the global subscriber registry. If a subscriber is registered, it formats the message and passes it along. If you set .with_max_level(Level::INFO), the subscriber compares the message level against the threshold. DEBUG is lower than INFO, so it gets dropped.
This design enables zero-cost filtering. You can leave debug! calls scattered throughout your codebase. In production, you set the max level to WARN. The runtime check is a single integer comparison. The formatting and I/O overhead never happens. You get the safety of leaving debug code in place without paying a performance penalty.
The tracing-subscriber::fmt() builder also handles thread safety automatically. Each thread gets its own buffer. When the buffer fills or a newline is encountered, it flushes to standard output. You do not need to worry about interleaved output from concurrent tasks. The subscriber serializes the writes.
Convention aside: the Rust community prefers tracing over the older log crate for new projects. The log crate still works, but it lacks structured data and spans. If you are starting fresh, tracing is the standard. If you are maintaining a legacy codebase, you can bridge them with tracing-log.
Do not fight the filtering system. Set your level once at startup and let the subscriber handle the rest.
A realistic service function
Real applications rarely log from main. They log from functions that handle requests, process files, or manage connections. Here is how a typical service function looks with proper logging levels and runtime configuration.
use tracing::{info, debug, warn, error};
use std::fs;
/// Reads a configuration file and returns its contents.
/// Logs appropriate messages based on success or failure conditions.
fn load_config(path: &str) -> Result<String, std::io::Error> {
debug!("Attempting to read configuration from: {}", path);
match fs::read_to_string(path) {
Ok(contents) => {
if contents.is_empty() {
warn!("Configuration file exists but is empty");
} else {
info!("Loaded {} bytes from configuration", contents.len());
}
Ok(contents)
}
Err(e) => {
error!("Failed to load configuration: {}", e);
Err(e)
}
}
}
fn main() {
// Use EnvFilter to read the RUST_LOG environment variable.
// This allows runtime tuning without recompiling the binary.
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let result = load_config("config.toml");
match result {
Ok(_) => info!("Application ready"),
Err(_) => error!("Aborting due to missing configuration"),
}
}
Notice the progression. debug! tracks the input parameter. info! confirms successful completion. warn! flags an unusual but recoverable state. error! marks a failure that stops execution. This hierarchy lets you tune visibility without touching the code. Run the program with RUST_LOG=debug and you see every file path being read. Run it with RUST_LOG=error and only crashes surface. The EnvFilter builder reads this variable automatically.
Convention aside: always use RUST_LOG for level control in development. Hardcoding .with_max_level() works, but it forces a rebuild every time you want to change verbosity. The environment variable approach is the community standard for flexible debugging.
Keep your log messages consistent. Use present tense for actions, past tense for completed events, and never log sensitive data like passwords or tokens.
Common traps and compiler errors
The most common mistake is forgetting to call .init(). If you skip subscriber initialization, your logging macros compile perfectly. They run perfectly. They also produce absolutely no output. The macros check for a registered subscriber, find none, and silently return. You will spend twenty minutes wondering why your logs disappeared. Always initialize before the first log call.
Another trap is mixing println! with tracing. println! writes directly to standard output without timestamps, levels, or thread IDs. When both run in the same process, the output becomes a jumbled mess. Stick to one system. If you need to print a final status message to a terminal, use eprintln! for errors to keep them separate from the log stream.
You will also hit compiler errors if you misuse the macro syntax. The tracing macros expect format strings that match println! syntax. If you pass a type that does not implement std::fmt::Display or std::fmt::Debug, the compiler rejects it with E0277 (trait bound not satisfied). The macro expands to a formatting call under the hood, so the same rules apply. Use {} for Display types and {:#?} for pretty-printed Debug types.
Watch your macro arguments carefully. The format string must be a string literal. You cannot pass a variable as the first argument. The compiler needs the literal at compile time to generate the correct formatting code. If you try to interpolate a variable into the format string position, you will get a syntax error.
Never log inside a tight loop without checking the level first. Even filtered logs incur a macro expansion cost. If you are processing millions of items, batch your logging or use a sampling strategy.
Choosing the right logging tool
Use tracing when you are building a new application and want structured logging, execution spans, and ecosystem compatibility. Use log when you are maintaining a legacy codebase that depends on crates which only support the older facade. Use env_logger when you need a dead-simple, zero-dependency logger for a tiny CLI tool that never needs structured output. Reach for tracing-subscriber with EnvFilter when you want runtime level control without recompiling. Stick to println! only for final user-facing messages that should never be filtered out.
Pick the tool that matches your project's complexity. Over-engineering a logger for a five-line script wastes time. Under-engineering a logger for a distributed service wastes debugging hours.