How to Use tracing-subscriber for Log Output

Initialize the logger by calling `rustc_log::init_logger` with a configuration derived from an environment variable like `LOG`. This sets up `tracing-subscriber` to handle log output based on the filter rules you define.

When your application needs to speak

You write a Rust service. It works perfectly on your machine. You deploy it to production, and it silently drops a connection. You add println! statements everywhere, rebuild, redeploy. The logs become a wall of unstructured text. You strip them out, lose visibility, and repeat the cycle. This happens because println! is a blunt instrument. It prints directly to stdout, ignores log levels, blocks the calling thread, and gives you zero control over formatting. Rust gives you a better path: the tracing ecosystem. The tracing crate emits events. tracing-subscriber catches them, filters them, and formats them for output.

What tracing-subscriber actually does

Rust's tracing crate uses a publish-subscribe architecture. Your code publishes events. A subscriber listens to them. Without a subscriber, your events vanish into the void. The subscriber attaches to the current thread or process, intercepts every event, checks it against your rules, and either drops it or writes it to a console, file, or external service.

Think of tracing as a network of sensors and tracing-subscriber as the monitoring dashboard. The sensors fire events constantly. The dashboard decides which ones to display, how to format them, and where to send them. The subscriber is not a single monolithic block. It is built from layers. Each layer handles one responsibility: filtering, formatting, routing to a file, or shipping to a metrics backend. You stack layers to build a pipeline. The pipeline runs on a global dispatcher. The dispatcher routes events to every registered layer in order.

This architecture keeps your code fast. Filtering happens before formatting. If an event fails the filter, the subscriber drops it immediately. No string allocation occurs. No I/O happens. The CPU moves on.

The minimal setup

You do not need a complex configuration to get started. A few lines attach a default subscriber to your process. The standard approach reads an environment variable to control log levels at runtime.

use tracing::info;
use tracing_subscriber::{EnvFilter, fmt};

/// Initializes the global tracing subscriber with environment-based filtering.
fn setup_logging() {
    // Read RUST_LOG from the environment, or fall back to "info" if unset.
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info"));

    // Attach the fmt layer with the filter to the global dispatcher.
    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .init();
}

fn main() {
    setup_logging();
    info!("service started");
}

Run this with RUST_LOG=debug cargo run to see debug-level output. Run it with RUST_LOG=error cargo run to suppress everything except errors. The EnvFilter parses the variable using a simple syntax. info sets the global default. my_crate=debug overrides it for a specific module. my_crate::module=trace drills down further. The parser handles wildcards, level overrides, and target matching without requiring a restart.

Convention aside: the standard environment variable is RUST_LOG. You will sometimes see LOG in older tooling or compiler-specific crates like rustc_log. Stick to RUST_LOG for application code. The ecosystem expects it, and third-party crates will automatically respect it.

How the pipeline works

When init() runs, it installs a fmt::Subscriber as the global dispatcher. The dispatcher lives in thread-local storage for performance. Every call to info!, debug!, or error! pushes an event to that dispatcher. The dispatcher forwards the event to the fmt layer. The fmt layer runs the EnvFilter first. The filter checks the event's target module and level against your rules. If the event passes, the layer allocates a buffer, writes the timestamp, level, target, and message, and flushes it to stdout. If the event fails, the layer returns immediately.

This design means logging carries almost zero cost when disabled. The macro expands to a function call. The function checks the filter. If the filter rejects it, the function returns before any formatting logic runs. You get production-grade performance without sacrificing developer ergonomics.

The fmt layer also handles color. It detects whether stdout is connected to a terminal. If it is, it applies ANSI escape codes for levels and module names. If you pipe the output to a file or a log aggregator, it strips the codes automatically. You do not need to write conditional formatting logic.

Real-world configuration

Production systems rarely use the default setup. You need structured output for log aggregators, separate sinks for different modules, and graceful fallbacks when environment variables are malformed. A realistic initialization function handles these requirements without panicking on startup.

use tracing::Level;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};

/// Configures logging based on the current deployment environment.
fn setup_logging() {
    // Convention: check APP_ENV or similar to toggle dev/prod behavior.
    let is_dev = std::env::var("APP_ENV")
        .unwrap_or_else(|_| "dev".into()) == "dev";

    // Parse RUST_LOG, falling back to a safe default if the variable is invalid.
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info"));

    if is_dev {
        // Pretty printing adds colors and indentation for terminal readability.
        fmt::Subscriber::builder()
            .with_env_filter(filter)
            .pretty()
            .finish()
            .try_init()
            .expect("failed to set up development logging");
    } else {
        // JSON output is machine-readable and works with Datadog, ELK, or Splunk.
        fmt::Subscriber::builder()
            .with_env_filter(filter)
            .json()
            .finish()
            .try_init()
            .expect("failed to set up production logging");
    }
}

fn main() {
    setup_logging();
    // Application logic follows.
}

The prelude::* import brings extension traits into scope. These traits add methods like .pretty() and .json() to the builder. Without the prelude, you must call the full path fmt::format().pretty(), which clutters the code. The community convention is to import the prelude at the top of your logging module.

Notice try_init() instead of init(). The init() method panics if a subscriber is already registered. try_init() returns a Result. It fails gracefully if another part of your dependency tree already set up logging. This prevents startup crashes in larger projects.

Common traps and compiler feedback

Logging in Rust behaves differently from println!. The compiler will not save you from silent drops. If you forget to call init() or try_init(), your events disappear. No warning appears. No error code. You simply get an empty terminal. This is by design. tracing assumes you control the subscriber lifecycle.

Double initialization is the next frequent issue. If you call init() twice, the second call panics. The panic message points to tracing::subscriber::set_global_default. Switch to try_init() and handle the Err variant. Most teams log a warning and continue, or they panic during development and swallow the error in production.

Environment variable parsing fails silently if you use unwrap(). A typo in RUST_LOG=debg causes EnvFilter::try_from_default_env() to return an error. If you unwrap it, your binary crashes before it starts. Use unwrap_or_else with a sensible fallback. The fallback keeps your service alive while you fix the deployment configuration.

Mixing tracing with the older log crate causes trait bound errors. The log crate uses a different dispatcher. If you call log::info! without a bridge, the message vanishes. The compiler may reject your code with E0277 (trait bound not satisfied) when you try to combine subscriber layers that expect different event types. Add tracing-log to your dependencies and call tracing_log::LogTracer::init() before setting up your subscriber. The bridge routes log events into the tracing pipeline.

Performance traps appear when you log large structures. info!("state = {:?}", huge_struct) forces a full debug format at call time. The formatting happens before the filter checks the level. If you run with RUST_LOG=error, you still pay the CPU cost of formatting the struct, only to have the subscriber drop it. Use info!(state = %huge_struct) for cheap string conversion, or wrap expensive formatting in a closure that the subscriber evaluates lazily. The tracing macros support lazy evaluation through the fields! API.

Trust the filter. Put it at the top of the pipeline. Never format before you filter.

Choosing your logging strategy

Use tracing_subscriber::fmt::init() when you need a quick default setup for a binary or integration test. Use EnvFilter with try_from_default_env() when you need runtime control over log levels without recompiling. Use the json() formatter when shipping to a log aggregator like Datadog or ELK. Use tracing-log when migrating a codebase that still depends on the older log crate. Reach for custom Layer implementations when you need to route specific events to separate files or metrics systems.

Where to go next