When using tracing_subscriber, how do fmt::Layer and EnvFilter compose for structured logging?

fmt::Layer handles formatting and outputting log events while EnvFilter controls which spans and events pass through based on environment variables like RUST_LOG. These layers compose through the Layer trait, forming a pipeline where each layer can inspect, modify, or filter events. EnvFilter acts as a gate that decides which events reach fmt::Layer, while fmt::Layer formats and writes the events that pass through. Together they provide configurable structured logging: EnvFilter reduces noise by filtering events, and fmt::Layer controls the output format including JSON support for structured logging in production.

Basic Subscriber with fmt Layer

use tracing_subscriber;
 
fn main() {
    // Simple subscriber with fmt layer
    tracing_subscriber::fmt()
        .init();
    
    // Equivalent to:
    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer())
        .init();
    
    tracing::info!("Hello from tracing");
}

fmt::Layer provides human-readable output to stdout by default.

Adding EnvFilter to Control Verbosity

use tracing_subscriber;
 
fn main() {
    // Combine fmt layer with EnvFilter
    tracing_subscriber::fmt()
        .with_env_filter("debug")  // Show debug and above
        .init();
    
    tracing::debug!("Debug message");
    tracing::info!("Info message");
    tracing::warn!("Warn message");
}
 
// Run with RUST_LOG environment variable:
// RUST_LOG=my_app=debug cargo run
// RUST_LOG=info cargo run
// RUST_LOG=debug,my_module=trace cargo run

EnvFilter reads RUST_LOG environment variable to configure filtering.

Layer Composition Pattern

use tracing_subscriber::{registry, fmt, EnvFilter};
 
fn main() {
    // Explicit layer composition
    let fmt_layer = fmt::layer()
        .with_target(false)
        .with_thread_ids(true);
    
    let env_filter = EnvFilter::from_default_env()
        .add_directive("my_app=debug".parse().unwrap());
    
    registry()
        .with(env_filter)
        .with(fmt_layer)
        .init();
    
    tracing::info!("Layers composed");
}

Layers are added with .with() and execute in order: filter then format.

EnvFilter Configuration Options

use tracing_subscriber::EnvFilter;
 
fn main() {
    // From environment variable (RUST_LOG)
    let filter1 = EnvFilter::from_default_env();
    
    // From specific environment variable
    let filter2 = EnvFilter::try_from_env("MY_LOG_VAR")
        .unwrap_or_else(|_| EnvFilter::new("info"));
    
    // Hard-coded default with env override
    let filter3 = EnvFilter::try_from_default_env()
        .or_else(|_| EnvFilter::new("warn").ok())
        .unwrap();
    
    // Multiple directives
    let filter4 = EnvFilter::new("info")
        .add_directive("my_app=debug".parse().unwrap())
        .add_directive("hyper=warn".parse().unwrap());
}

EnvFilter supports defaults, environment variables, and programmatic configuration.

Directive Syntax

use tracing_subscriber::EnvFilter;
 
fn main() {
    // Directive syntax: target[span{field}=value]
    
    // Level only
    let filter = EnvFilter::new("debug");
    
    // Module target
    let filter = EnvFilter::new("my_app::module=debug");
    
    // Wildcard modules
    let filter = EnvFilter::new("my_app::http::*=info");
    
    // Span filtering
    let filter = EnvFilter::new("my_app[request{path=\"/api\"}]=trace");
    
    // Multiple directives
    let filter = EnvFilter::new("debug,hyper=warn,tokio=off");
    
    // Level meanings:
    // trace > debug > info > warn > error
    // "my_app=debug" shows debug, info, warn, error
    // "my_app=off" disables all for that target
}

Directives control which events pass through at each level.

fmt Layer Configuration

use tracing_subscriber::fmt;
 
fn main() {
    tracing_subscriber::fmt()
        // Output destination
        .with_writer(std::io::stderr)
        
        // Include/exclude fields
        .with_target(true)
        .with_thread_ids(true)
        .with_thread_names(false)
        .with_line_number(true)
        .with_file(true)
        
        // Format options
        .with_level(true)
        .with_ansi(true)  // Colors
        
        // Compact format
        .compact()
        
        .init();
    
    tracing::info!("Formatted output");
}

fmt::Layer has many formatting options for human-readable output.

JSON Format for Structured Logging

use tracing_subscriber::fmt;
 
fn main() {
    tracing_subscriber::fmt()
        .json()  // Output as JSON
        .with_current_span(true)
        .with_span_list(true)
        .with_target(true)
        .with_level(true)
        .init();
    
    tracing::info!(
        user_id = 42,
        action = "login",
        "User logged in"
    );
}

.json() outputs structured logs suitable for log aggregation systems.

Combining Filter and Formatter

use tracing_subscriber::{fmt, EnvFilter};
 
fn main() {
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info"));
    
    let fmt_layer = fmt::layer()
        .with_target(true)
        .with_thread_ids(false)
        .compact();
    
    tracing_subscriber::registry()
        .with(filter)
        .with(fmt_layer)
        .init();
    
    // Only info and above passes to fmt layer
    tracing::debug!("Hidden by filter");  // Not shown
    tracing::info!("Visible");             // Shown
}

Filter executes before formatter, reducing output.

Per-Layer Filtering

use tracing_subscriber::{fmt, EnvFilter, Layer};
 
fn main() {
    // Each layer can have its own filter
    let stdout_layer = fmt::layer()
        .with_filter(EnvFilter::new("info"));
    
    let stderr_layer = fmt::layer()
        .with_writer(std::io::stderr)
        .with_filter(EnvFilter::new("error"));
    
    tracing_subscriber::registry()
        .with(stdout_layer)
        .with(stderr_layer)
        .init();
    
    // Info goes to stdout, errors to both stdout and stderr
    tracing::info!("Info message");     // stdout only
    tracing::error!("Error message");   // stdout and stderr
}

Each layer can filter independently.

Structured Fields in Logging

use tracing_subscriber;
 
fn main() {
    tracing_subscriber::fmt()
        .json()
        .with_env_filter("debug")
        .init();
    
    // Structured fields become JSON keys
    tracing::info!(
        user_id = %12345,
        email = "user@example.com",
        logged_in = true,
        "User authenticated"
    );
    
    // Spans add context
    let span = tracing::info_span!("http_request", method = "GET", path = "/api/users");
    let _guard = span.enter();
    
    tracing::info!("Processing request");
}

Structured fields integrate with JSON output for log aggregation.

Span Fields and EnvFilter

use tracing_subscriber::EnvFilter;
 
fn main() {
    // Filter based on span fields
    let filter = EnvFilter::new("debug")
        .add_directive("my_app[request{path=\"/api/*\"}]=trace".parse().unwrap());
    
    tracing_subscriber::registry()
        .with(filter)
        .with(tracing_subscriber::fmt::layer())
        .init();
    
    let span = tracing::info_span!("request", path = "/api/users");
    let _guard = span.enter();
    
    tracing::trace!("This shows because path matches filter");
}

EnvFilter can filter based on span field values.

Layer Order Matters

use tracing_subscriber::{registry, fmt, EnvFilter, Layer};
 
fn main() {
    // Order: filter -> formatter
    // Events flow through layers in reverse order of .with() calls
    
    // This works correctly:
    registry()
        .with(EnvFilter::new("info"))  // Applied second (after fmt)
        .with(fmt::layer())             // Applied first
        .init();
    
    // Layer execution order:
    // 1. fmt::layer receives event
    // 2. EnvFilter decides if it should be written
    
    // For per-layer filtering, use .with_filter() on individual layers
}

Layers are composed in reverse order of .with() calls.

Production Configuration Pattern

use tracing_subscriber::{fmt, EnvFilter};
 
fn init_logging() {
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| {
            // Production default: warn, but info for our app
            EnvFilter::new("warn")
                .add_directive("my_app=info".parse().unwrap())
        });
    
    let fmt_layer = fmt::layer()
        .json()
        .with_target(true)
        .with_thread_names(true)
        .with_line_number(true);
    
    tracing_subscriber::registry()
        .with(filter)
        .with(fmt_layer)
        .init();
}
 
fn main() {
    init_logging();
    
    tracing::info!("Application started");
}

Use environment variable overrides with sensible defaults for production.

Development vs Production Configurations

use tracing_subscriber::{fmt, EnvFilter};
 
#[cfg(debug_assertions)]
fn init_logging() {
    // Development: human-readable, verbose
    tracing_subscriber::fmt()
        .with_env_filter("debug")
        .with_target(true)
        .with_file(true)
        .with_line_number(true)
        .pretty()  // Multi-line formatted output
        .init();
}
 
#[cfg(not(debug_assertions))]
fn init_logging() {
    // Production: JSON, filtered
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::new("info")
            .add_directive("my_app=info".parse().unwrap()))
        .json()
        .with_target(true)
        .with_current_span(true)
        .init();
}
 
fn main() {
    init_logging();
    tracing::info!("Configured appropriately");
}

Different configurations for development and production environments.

Testing with Captured Logs

use tracing_subscriber::{fmt, EnvFilter, layer::SubscriberExt};
use tracing::collect::with_default;
 
#[test]
fn test_with_logging() {
    // Use a test-specific subscriber
    let subscriber = tracing_subscriber::registry()
        .with(EnvFilter::new("debug"))
        .with(fmt::layer().with_test_writer());  // Captures for test output
    
    with_default(subscriber, || {
        tracing::info!("Test log");
    });
}

with_test_writer() captures logs for test output.

Multiple Output Destinations

use std::{fs::File, sync::Arc};
use tracing_subscriber::{fmt, EnvFilter};
 
fn main() {
    let file = File::create("app.log").unwrap();
    
    // Console output for info and above
    let console = fmt::layer()
        .with_filter(EnvFilter::new("info"));
    
    // File output for debug and above
    let file_layer = fmt::layer()
        .with_writer(Arc::new(file))
        .with_filter(EnvFilter::new("debug"));
    
    tracing_subscriber::registry()
        .with(console)
        .with(file_layer)
        .init();
    
    tracing::debug!("File only");
    tracing::info!("Both file and console");
    tracing::error!("Both file and console");
}

Multiple layers can write to different destinations with different filters.

Event Flow Through Layers

use tracing_subscriber::{registry, fmt, EnvFilter, Layer};
use tracing::{Event, Metadata};
 
fn main() {
    // Custom layer to demonstrate event flow
    struct DebugLayer;
    
    impl<S: tracing::Subscriber> Layer<S> for DebugLayer {
        fn on_event(&self, event: &Event, _ctx: tracing_subscriber::layer::Context<'_, S>) {
            println!("Event seen: {}", event.metadata().name());
        }
    }
    
    registry()
        .with(DebugLayer)           // 2. Custom layer
        .with(EnvFilter::new("info")) // 3. Filter
        .with(fmt::layer())            // 4. Format
        .init();
    
    // Flow: fmt::layer -> EnvFilter -> DebugLayer
    tracing::debug!("Filtered out");  // Never reaches DebugLayer
    tracing::info!("Passes through");  // Reaches all layers
}

Understanding layer order helps debug logging issues.

Dynamic Log Level Changes

use tracing_subscriber::EnvFilter;
use std::sync::Arc;
 
fn main() {
    let filter = EnvFilter::new("info");
    let filter_handle = filter.clone();  // For dynamic changes
    
    tracing_subscriber::registry()
        .with(filter)
        .with(tracing_subscriber::fmt::layer())
        .init();
    
    // Can't directly modify EnvFilter after init
    // For dynamic changes, use ReloadFilter or similar approaches
    
    tracing::info!("Static level");
}

For dynamic log level changes, use ReloadFilter or similar.

Comparison Table

Feature fmt::Layer EnvFilter
Purpose Format and output Filter events
Position Output layer Filter layer
Configuration Format options, writer Level directives, env vars
JSON support Yes (.json()) N/A (filtering only)
Common use Format logs for display Control verbosity

Synthesis

fmt::Layer and EnvFilter compose to provide structured, configurable logging:

fmt::Layer handles how log events are formatted and where they're written. It supports human-readable formats (.compact(), .pretty()) and machine-readable JSON (.json()). Configuration controls which metadata fields appear: target, thread ID, line numbers, etc.

EnvFilter controls which events pass through to subsequent layers. It reads RUST_LOG (or a custom env var) for directive-based filtering: my_app=debug enables debug level for a module, trace enables everything, off disables all output.

Composition works through the layer system: events flow through layers in reverse order of .with() calls. Typically, EnvFilter gates events and fmt::Layer formats those that pass. This separation allows multiple output layers with independent filters—e.g., warnings to console, debug to file.

Key insight: The layer architecture separates concerns cleanly. EnvFilter answers "what should be logged?" while fmt::Layer answers "how should it look?" This enables powerful combinations like JSON output for production (parseable by log aggregators) with human-readable output for development, all controlled by environment variables for runtime configuration without code changes.