tracing (Rust): Spans, Subscribers & vs log Guide

Max WellsMax WellsFounder of Rustify

TL;DR: tracing is a structured logging framework built for async Rust. Unlike log (which only emits flat log lines), tracing has spans, units of work with a start and end, that nest across async .await points. This lets you track what a request is doing across tasks and threads. It's the standard observability library for Tokio-based applications.


What Is the Difference Between tracing and log?

log emits flat, unstructured text lines. tracing emits structured events inside nested spans, it understands async context, task IDs, and structured key-value fields.

// log; simple, no structure
log::info!("processing request for user {}", user_id);
 
// tracing; structured fields, spans, async-aware
tracing::info!(user_id, request_id, "processing request");

tracing is a superset: it implements the log crate's macros, so libraries using log automatically integrate with a tracing subscriber.


What Are Spans and Events?

A span represents a period of time (e.g., one HTTP request, one DB query). Events are instant log points within a span. Spans nest, a request span may contain a DB span.

use tracing::{info, instrument, span, Level};
 
// #[instrument] automatically creates a span for the function
#[instrument(fields(user_id))]
async fn handle_request(user_id: i64) -> Result<Response, Error> {
    info!("handling request"); // event inside the span
 
    let user = fetch_user(user_id).await?; // inner span created by fetch_user
    Ok(build_response(user))
}
 
#[instrument]
async fn fetch_user(id: i64) -> Result<User, Error> {
    info!("fetching from DB");
    // ... db query
}

Output (with tracing-subscriber):

INFO handle_request{user_id=42}: handling request
INFO handle_request{user_id=42}:fetch_user{id=42}: fetching from DB

How Do You Set Up tracing?

Add tracing and a subscriber (typically tracing-subscriber) to your Cargo.toml, then initialize in main().

[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
 
#[tokio::main]
async fn main() {
    // Initialize; reads RUST_LOG env var for level filtering
    tracing_subscriber::registry()
        .with(EnvFilter::from_default_env())
        .with(tracing_subscriber::fmt::layer())
        .init();
 
    tracing::info!("server starting");
    run_server().await;
}

Set the log level via environment variable:

RUST_LOG=debug cargo run      # all debug+ events
RUST_LOG=my_app=info,sqlx=warn cargo run  # per-crate levels

How Does tracing Work With Axum?

tower-http's TraceLayer adds automatic request/response spans to every Axum route, no manual instrumentation required for HTTP logging.

use axum::Router;
use tower_http::trace::TraceLayer;
 
let app = Router::new()
    .route("/users/:id", get(get_user))
    .layer(TraceLayer::new_for_http());

This automatically logs request method, path, status code, and duration for every request.


What Are Subscribers and Layers?

A subscriber collects the spans and events tracing emits. Layers are composable, you can stack JSON output, filtering, and OpenTelemetry export together.

use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
 
tracing_subscriber::registry()
    .with(EnvFilter::from_default_env())              // filter by level/target
    .with(tracing_subscriber::fmt::layer().json())    // JSON output
    // .with(tracing_opentelemetry::layer())          // OpenTelemetry export
    .init();

Popular subscriber backends:

  • tracing-subscriber, human-readable or JSON output to stdout
  • tracing-opentelemetry, export to Jaeger, Tempo, etc.
  • tracing-appender, non-blocking file logging

Frequently Asked Questions

Use tracing, it's backward compatible with log (libraries using log work seamlessly with a tracing subscriber). For libraries, use tracing events without setting up a subscriber, let the application decide how to collect them.

Yes, this is tracing's main advantage over log. Spans are async-aware: #[instrument] correctly tracks context across .await points even when tasks are suspended and resumed on different threads.

Minimal when events are filtered out. tracing uses a fast filtering mechanism, if the level is disabled, instrumented functions have near-zero overhead. Active spans have small per-event allocations.

Yes, via tracing-opentelemetry. Once spans are in OpenTelemetry format, they can be exported to any compatible backend (Jaeger, Tempo, Datadog, Honeycomb).


Sources


  • Tokio: The async runtime tracing is designed to work with
  • Axum: TraceLayer integrates tracing into HTTP middleware
  • Async/Await: Spans track context across async suspension points

Keep Reading

Ready to Land a $120k+ Rust Job in the US or Europe?