The debugging trap
You deploy a Rust web service. It works perfectly on your laptop. In production, it returns 500 errors on random requests. You add println! everywhere, rebuild, redeploy. The logs are a mess of interleaved output from concurrent requests. You can't tell which log line belongs to which user. You're debugging blind.
This is the moment you realize println! is a trap for concurrent systems. It gives you a flat stream of text with no context. When ten requests are processing at once, their output mixes together. You lose the ability to trace a single request through your application.
Rust solves this with tracing. It adds structure, hierarchy, and context to your logs. You get spans that track the lifecycle of a request, events that record specific moments, and fields that attach data to every log entry. The ecosystem has standardized on tracing for a reason. It scales from simple scripts to massive distributed systems without changing your code.
Ditch println!. Your future self will thank you when the first production bug hits.
Spans, events, and the wristband analogy
Logging in Rust splits into two concepts: events and spans. An event is a point-in-time message, like "User logged in" or "Database query took 50ms". A span represents a period of time with a start and an end, like the duration of handling a single HTTP request.
Think of a hospital emergency room. Logging is a nurse writing "Patient fell" on a clipboard. Anyone can write anything, and the notes pile up in a box. Tracing is the patient's wristband with a unique ID. Every test, every doctor visit, and every medication is tagged with that ID. Even if the patient moves between rooms and multiple doctors work on them simultaneously, you can filter the entire hospital's activity to see exactly what happened to that one patient.
tracing gives you that wristband. When a request enters your server, you start a span. Every log message inside that request automatically inherits the span's context. When the request finishes, the span closes. You can filter, search, and visualize your logs by span. This turns a flat log stream into a navigable map of your application's execution.
Spans nest inside each other. A request span contains a database query span, which contains a network call span. This hierarchy lets you see where time is spent and where errors originate.
Setting up the subscriber
tracing is split into two crates. tracing provides the API for creating spans and events. tracing-subscriber provides the backend that collects, filters, and formats those events. This separation is intentional. You can swap subscribers without changing your instrumentation code. You might use a console subscriber during development and a JSON exporter in production.
Add both crates to your Cargo.toml. The env-filter feature is essential. It lets you control log levels via environment variables without recompiling.
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio = { version = "1", features = ["full"] }
Initialize the subscriber in main before any logging happens. If you skip this step, tracing events are dropped silently. The library doesn't panic; it just discards data. This is a common pitfall for beginners.
use tracing::{info, instrument};
use tracing_subscriber;
/// Starts the application and initializes the tracing subscriber.
#[tokio::main]
async fn main() {
// Initialize the subscriber to capture and format events.
// The env-filter feature reads RUST_LOG to set levels dynamically.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into())
)
.init();
info!("Server starting on port 8080");
// Run your server logic here
}
The EnvFilter::try_from_default_env() call checks the RUST_LOG environment variable. If you run your app with RUST_LOG=debug, you get debug output. If you set RUST_LOG=error, you only see errors. This convention is universal across the Rust ecosystem. Every tool respects RUST_LOG.
Convention aside: tracing_subscriber::fmt() is the standard choice for most applications. It outputs human-readable logs to stdout. For production, you might switch to a JSON subscriber, but the instrumentation code stays identical.
Instrumenting functions with macros
Writing info! calls manually is tedious. You have to log entry, log arguments, log exit, and handle errors. tracing provides the #[instrument] attribute macro to automate this. It wraps your function in a span, logs the arguments, and closes the span when the function returns.
use tracing::{info, instrument, warn};
/// Handles an incoming user request and fetches data.
/// The instrument macro creates a span with the function name
/// and captures arguments as span fields.
#[instrument(skip(db_pool))]
async fn handle_user_request(user_id: u64, db_pool: &DbPool) {
// The span automatically captures user_id.
// skip(db_pool) prevents the compiler from complaining
// because DbPool doesn't implement Debug.
info!("Fetching user profile");
let user = db_pool.get_user(user_id).await;
if user.is_none() {
warn!("User not found in database");
}
}
The skip(db_pool) argument is critical. #[instrument] tries to log all arguments by default. It requires arguments to implement the Debug trait. Database pools, file handles, and many other types don't implement Debug. If you don't skip them, the compiler rejects the code.
The compiler rejects this with E0277 (trait bound not satisfied) because #[instrument] generates code that calls Debug::fmt on your arguments, and your type doesn't implement that trait. Use skip() for any argument you don't want to log or that can't be debug-printed.
Convention aside: The community convention is to skip() anything that is a connection, a pool, a file handle, or a large buffer. Log the IDs, not the resources. This keeps logs clean and avoids accidental data leaks.
Realistic async workflow
In a real web application, requests trigger chains of async operations. Spans nest naturally. When handle_request calls fetch_data, which calls db_query, you get a hierarchy of spans. Each span captures its own context.
use tracing::{info, instrument, warn};
/// Processes an HTTP request by fetching and validating data.
/// Spans nest automatically when instrumented functions call each other.
#[instrument(skip(pool))]
async fn handle_request(path: &str, pool: &DbPool) {
info!("Received request");
let data = fetch_data(path, pool).await;
if data.is_empty() {
warn!("No data found for path");
}
}
/// Fetches data from the database with a timeout.
/// The span captures the path and records the duration automatically.
#[instrument(skip(pool))]
async fn fetch_data(path: &str, pool: &DbPool) -> Vec<u8> {
info!("Querying database");
// Simulate async work
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
vec![]
}
When you run this, the output shows the hierarchy. The handle_request span opens, then fetch_data opens inside it. All events are tagged with both spans. You can see exactly which request triggered which database query.
Add #[instrument] to your public API handlers first. That's where the context matters most. You can always add instrumentation deeper in the stack later.
Structured fields and JSON
The info! macro supports structured fields. Instead of formatting strings, you pass key-value pairs. This lets subscribers export logs as JSON, which is essential for log aggregation tools like Elasticsearch or Datadog.
use tracing::{info, instrument};
#[instrument]
async fn process_order(order_id: u64, amount: f64) {
// Structured fields are preferred over format strings.
// Subscribers can extract these fields for indexing and search.
info!(
order_id = order_id,
amount = amount,
currency = "USD",
"Order processed successfully"
);
}
The syntax is key = value, "message". The message is optional. Fields can be any type that implements Value or Debug. This approach separates data from formatting. You can change the log format without changing the instrumentation code.
Convention aside: Use structured fields for all machine-readable data. Reserve format strings for human-readable messages that don't need to be parsed. This makes your logs useful for both humans and machines.
Pitfalls and compiler errors
Tracing is powerful, but there are traps.
Forgetting to initialize the subscriber is the most common mistake. If you call info! without a subscriber, nothing happens. No panic, no warning. The event is dropped. Always call tracing_subscriber::fmt().init() or equivalent in main.
The #[instrument] macro can break your build if you add arguments that don't implement Debug. The compiler error points to the macro expansion, which can be confusing. Check your arguments and add skip() as needed.
Performance is often a concern. tracing is designed to be zero-cost when disabled. The macros check the current log level before executing. If you set RUST_LOG=error, calls to info! and debug! return immediately without formatting strings or evaluating arguments. The overhead is negligible.
If #[instrument] breaks your build, check your arguments. The compiler is usually right about Debug.
Decision matrix
Use tracing when you need structured logs, hierarchical context, or async support. It is the modern standard for Rust web services and integrates with the entire async ecosystem.
Use the log crate when you are maintaining a legacy codebase that depends on it, or when you need maximum compatibility with older crates that haven't migrated to tracing. The log crate is flat and lacks spans.
Use println! only for quick scripts or one-off debugging where you don't care about formatting, levels, or concurrent output. It has no filtering and no structure.
Use env_logger when you want a simple, zero-config logging setup for a small CLI tool and don't need the richness of spans or structured data. It works with the log crate and is easy to set up.
Pick tracing for anything new. The ecosystem has moved on.