The tracing crate lets you observe your Rust program’s execution in production, but it’s not just about sprinkling info! macros everywhere.

Let’s see it in action. Imagine a web service. We’ll set up tracing to capture requests, their durations, and any errors that occur.

use tracing::{subscriber::DefaultGuard, Level, instrument, error};
use tracing_subscriber::{FmtSubscriber, Registry};
use tracing_subscriber::fmt::format;
use std::time::Duration;
use tokio::time::sleep;

#[instrument]
async fn handle_request(request_id: u32) -> Result<(), String> {
    error!("Processing request {}", request_id); // Simulate an error
    sleep(Duration::from_millis(50)).await;
    Err(format!("Failed to process request {}", request_id))
}

#[tokio::main]
async fn main() -> DefaultGuard {
    let subscriber = FmtSubscriber::builder()
        .with_max_level(Level::INFO)
        .with_span_events(format::Close) // Capture span close events
        .finish();

    tracing::subscriber::set_global_default(subscriber)
        .expect("setting default subscriber failed");

    let _guard = tracing::subscriber::set_default(subscriber);

    for i in 1..=3 {
        handle_request(i).await;
    }

    _guard // Keep the subscriber alive
}

This code sets up a basic subscriber that prints INFO level logs and above to stderr. The #[instrument] attribute automatically creates a span for handle_request, recording its entry, exit, and any errors. with_span_events(format::Close) is crucial; it tells the subscriber to log when a span finishes, giving us duration information.

The problem tracing solves is the "black box" nature of production systems. You can’t step through code, but you need to understand what’s happening. tracing provides structured, contextualized events that act like a live, introspective debugger. It’s built around two core concepts: spans and events.

Spans represent a period of work. They have a name, can carry arbitrary key-value data (fields), and can be nested. When a span starts, it’s "entered"; when it finishes, it’s "exited." The #[instrument] macro is a convenient way to create spans that automatically wrap function calls, capturing the function name and its arguments.

Events are instantaneous occurrences within a span. Think of them as logging statements like info!, warn!, or error!. They are always associated with the currently active span.

Here’s how you’d configure a more sophisticated setup for production, capturing request IDs, durations, and error details, and sending them to a structured logging backend like JSON.

use tracing::{subscriber::DefaultGuard, Level, instrument, error, span, Field};
use tracing_subscriber::{
    fmt,
    layer::SubscriberExt,
    registry::Registry,
    EnvFilter,
    json_layer::JsonLayer
};
use std::time::Duration;
use tokio::time::sleep;
use serde_json::{json, Value};

#[derive(Debug)]
struct RequestInfo {
    id: u32,
    method: String,
    path: String,
}

impl tracing::Value for RequestInfo {
    fn record(&self, field: &Field, visitor: &mut dyn tracing::field::Visit) {
        match field.name() {
            "request.id" => visitor.record_u64(field, self.id as u64),
            "request.method" => visitor.record_str(field, &self.method),
            "request.path" => visitor.record_str(field, &self.path),
            _ => {}
        }
    }
}

#[instrument(fields(request = %RequestInfo { id: request_id, method: "GET".to_string(), path: "/users".to_string() }))]
async fn handle_request(request_id: u32) -> Result<(), String> {
    error!("Starting request processing"); // Error level event
    let inner_span = span!(Level::INFO, "processing_data", data_size = 1024);
    let _enter = inner_span.enter();

    sleep(Duration::from_millis(100)).await;

    if request_id % 2 == 0 {
        error!("Simulated processing error for request {}", request_id);
        return Err(format!("Internal error processing request {}", request_id));
    }

    Ok(())
}

#[tokio::main]
async fn main() -> DefaultGuard {
    let env_filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info")); // Default to info if RUST_LOG not set

    let subscriber = Registry::default()
        .with(env_filter)
        .with(JsonLayer::default()); // Output logs in JSON format

    tracing::subscriber::set_global_default(subscriber)
        .expect("setting default subscriber failed");

    let _guard = tracing::subscriber::set_default(subscriber);

    for i in 1..=5 {
        match handle_request(i).await {
            Ok(_) => tracing::info!("Request {} completed successfully", i),
            Err(e) => tracing::error!("Request {} failed: {}", i, e),
        }
    }

    _guard
}

In this enhanced example, we’re using EnvFilter to control log levels via the RUST_LOG environment variable (e.g., RUST_LOG=debug). The JsonLayer serializes each log event into a JSON object, making it easy for log aggregation systems (like Elasticsearch, Splunk, etc.) to parse and query. We also introduced a custom RequestInfo struct that implements tracing::Value to record structured request details directly into the span’s fields. The span! macro creates a nested span for specific work units within the request handler.

The core mental model is a tree of spans, with events happening at specific points within those spans. When you set with_span_events(format::Close) (or use a layer like JsonLayer that implicitly captures span duration), you get the duration of each span, which is invaluable for performance analysis. You can see which requests took the longest, and which internal operations within those requests were the bottlenecks.

The single most surprising thing about tracing is how easily it scales from simple println!-style debugging to a robust, production-grade observability solution without fundamentally changing the API you use. The #[instrument] macro and the span!/event! macros are the same whether you’re running a local cargo run or a distributed system. The complexity comes entirely from the subscriber configuration, which you can swap out entirely to pipe your traces to different backends.

The next step in mastering tracing is understanding how to sample your traces in high-throughput systems to avoid overwhelming your logging infrastructure.

Want structured learning?

Take the full Rust course →