Rust

Structured Logging with tracing

Replace println debugging with structured, leveled observability using the tracing ecosystem

Structured Logging with tracing

println! debugging works until it doesn't. Once your Rust application grows beyond a single binary — async services, multi-crate workspaces, production deployments — you need structured, leveled, filterable logging. The tracing ecosystem is the Rust community's answer.

Why tracing over log?

The log crate gives you leveled messages. tracing gives you spans — structured regions of execution with typed fields. This matters for:

  • Async code: spans follow futures across .await points
  • Request correlation: attach a request ID once, see it in every log line
  • Performance: filter by level before formatting (zero-cost when disabled)
  • Structured output: emit JSON for log aggregators, human-readable for terminals

Setup

Add the core dependencies:

Cargo.toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

Initialize the subscriber early in main:

src/main.rs
use tracing_subscriber::EnvFilter;

fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(
            EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| EnvFilter::new("info")),
        )
        .init();

    tracing::info!("application started");
}

Now control verbosity at runtime:

Terminal
# Default: info level
cargo run

# Debug for your crate, info for everything else
RUST_LOG=my_crate=debug cargo run

# Trace everything (noisy but complete)
RUST_LOG=trace cargo run

Leveled Events

Use the macros directly — they mirror log levels:

use tracing::{trace, debug, info, warn, error};

fn process_signal(frequency_hz: f64, power_dbm: f64) {
    trace!(frequency_hz, power_dbm, "processing signal");

    if power_dbm < -100.0 {
        warn!(power_dbm, "signal below noise floor");
    }

    info!(frequency_hz, power_dbm, "signal processed");
}

Note the structured fieldsfrequency_hz and power_dbm are key-value pairs, not interpolated into a string. Log aggregators can filter and query on them directly.

Spans

Spans represent a unit of work with a beginning and end:

use tracing::{info_span, instrument};

fn analyze_chain(blocks: &[Block]) -> ChainResult {
    let span = info_span!("analyze_chain", block_count = blocks.len());
    let _guard = span.enter();

    // Everything logged here is "inside" the span
    for (i, block) in blocks.iter().enumerate() {
        tracing::debug!(index = i, name = %block.name, "processing block");
    }

    // _guard dropped here, span closes
    ChainResult::default()
}

The #instrument Macro

For functions, #[instrument] auto-creates a span with the function name and arguments:

use tracing::instrument;

#[instrument(skip(db), fields(user_id))]
async fn load_design(db: &Database, user_id: i64) -> Result<Design> {
    tracing::Span::current().record("user_id", user_id);
    let design = db.get_design(user_id).await?;
    tracing::info!(design_id = design.id, "design loaded");
    Ok(design)
}

Key options:

  • skip(field) — don't log sensitive or large arguments
  • fields(name) — declare fields to record later
  • level = "debug" — override the span's level
  • err — automatically log errors from Result returns

Axum Integration

For web services, add request tracing with tower:

Cargo.toml
[dependencies]
tower-http = { version = "0.6", features = ["trace"] }
src/main.rs
use axum::Router;
use tower_http::trace::TraceLayer;

let app = Router::new()
    .route("/api/designs", get(list_designs))
    .layer(TraceLayer::new_for_http());

Every request now gets a span with method, URI, status code, and latency — no manual instrumentation needed.

Output Formats

Human-readable (development)

tracing_subscriber::fmt()
    .with_target(false)      // hide module paths
    .with_level(true)
    .compact()               // single-line format
    .init();

Output:

2026-03-01T12:00:00Z  INFO signal processed frequency_hz=2.4e9 power_dbm=-45.0

JSON (production)

tracing_subscriber::fmt()
    .json()
    .init();

Output:

{"timestamp":"2026-03-01T12:00:00Z","level":"INFO","message":"signal processed","frequency_hz":2.4e9,"power_dbm":-45.0}

Testing with tracing

Capture logs in tests to verify behavior:

use tracing_subscriber::fmt::MakeWriter;

#[test]
fn test_warns_on_low_signal() {
    // tracing-test crate provides an assertion layer
    let (subscriber, handle) = tracing_test::subscriber::mock()
        .event(tracing_test::event::expect()
            .at_level(tracing::Level::WARN))
        .done()
        .run_with_handle();

    tracing::subscriber::with_default(subscriber, || {
        process_signal(2.4e9, -110.0);
    });

    handle.assert_finished();
}

Or more simply with the tracing-test crate:

use tracing_test::traced_test;

#[traced_test]
#[test]
fn test_low_signal_warning() {
    process_signal(2.4e9, -110.0);
    assert!(logs_contain("signal below noise floor"));
}

Common Patterns

Error context with spans

#[instrument(err)]
fn parse_touchstone(path: &Path) -> Result<Network> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| {
            tracing::error!(path = %path.display(), "failed to read file");
            e
        })?;
    // parse...
    Ok(network)
}

Conditional compilation

Keep tracing out of library crates that need to stay minimal:

Cargo.toml
[features]
tracing = ["dep:tracing"]

[dependencies]
tracing = { version = "0.1", optional = true }
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn cascade_noise_figure(stages: &[(f64, f64)]) -> f64 {
    // ...
}

Performance

tracing is designed for production use:

  • Disabled levels are zero-cost (checked at compile time with max_level_* features)
  • Fields are recorded lazily — no formatting until a subscriber processes them
  • Spans use thread-local storage, not heap allocation

For extreme performance sensitivity:

Cargo.toml
[dependencies]
tracing = { version = "0.1", features = ["max_level_info", "release_max_level_warn"] }

This compiles out trace! and debug! in release builds entirely.

Summary

Featureprintln!logtracing
Levels
Structured fields
Spans
Async-aware
Runtime filtering
Zero-cost disable

Start with tracing from day one. The API is barely more complex than println!, but the observability payoff compounds as your project grows.