How to Use the tracing Crate in Rust

Add the tracing crate to Cargo.toml and initialize a subscriber to enable structured logging and diagnostics in Rust.

When print statements stop working

You build a command line tool that parses configuration files, validates inputs, and writes results to disk. Locally it runs fine. You deploy it to a shared server and it silently fails. You sprinkle println! everywhere, but the terminal floods with unstructured text. You cannot tell which function printed what, you cannot filter noise from actual errors, and you cannot trace the lifecycle of a single request.

Rust gives you a better path. The tracing crate replaces flat logging with a hierarchical, structured diagnostic system. It tracks where your code is, how long operations take, and what state exists at each step. You get filterable output, zero overhead when disabled, and a clean separation between emitting diagnostics and handling them.

The architecture behind the macros

tracing splits its responsibilities into two crates. The tracing crate defines the API: the macros you call to emit events and spans. The tracing-subscriber crate handles the output: formatting, filtering, and routing diagnostics to stdout, files, or external services.

This split exists because Rust favors explicit composition. The core crate stays small and fast. It only knows how to create diagnostic records. The subscriber crate knows how to process them. You can swap subscribers without touching your application code. You can stack multiple subscribers to send logs to the terminal while simultaneously shipping metrics to a monitoring service.

The system revolves around two primitives. Events represent point-in-time occurrences. They are equivalent to traditional log messages but carry structured fields. Spans represent periods of time. They track the duration of a function call, a database query, or a network request. Spans nest inside each other, forming a tree that mirrors your program's execution flow.

Think of a hospital monitoring system. Events are individual vital sign readings: heart rate, blood pressure, temperature. Spans are the patient's stay in a specific department: triage, surgery, recovery. The subscriber is the nurse station that decides which readings to print, which departments to watch closely, and which data to archive.

The minimal setup

You need two dependencies. The core crate provides the macros. The subscriber crate provides the default formatter and initialization logic.

[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"

The core crate alone does nothing visible. It compiles to lightweight checks that verify whether a subscriber is active. Without a subscriber, your diagnostic calls become empty function calls that the compiler optimizes away.

use tracing::{info, Level};
use tracing_subscriber;

fn main() {
    // Initialize the default subscriber with human-readable formatting.
    // This sets a global default that all subsequent tracing calls will use.
    tracing_subscriber::fmt()
        .with_max_level(Level::INFO)
        .init();
    
    // Emit a point-in-time diagnostic event at the INFO level.
    // The macro expands to a function call that checks the active subscriber.
    info!("Application started");
}

The fmt() function returns a builder for the default subscriber. It formats output as colored terminal text by default. The with_max_level() call caps verbosity. Anything below INFO gets discarded before it reaches the formatting stage. The init() method installs the subscriber globally and returns a guard that cleans it up when dropped.

Do not skip the subscriber initialization. The macros will compile, but your output will vanish into the void. Trust the separation. Emit diagnostics with tracing. Handle them with tracing-subscriber.

How the pieces connect at runtime

When you call info!("Application started"), the macro expands to a structured call. It captures the file, line, and module path. It checks the current logging level. If the level is enabled, it constructs an event record and dispatches it to the active subscriber.

The subscriber receives the record. It runs it through its filter chain. If the filter passes, the subscriber formats the record and writes it to the configured output. The default fmt subscriber uses std::io::stdout by default. It appends timestamps, thread names, and module paths to each line.

This design keeps overhead low. The level check happens before any string formatting or allocation. If you set the max level to ERROR, the info! macro expands to a single boolean check that the optimizer removes entirely. You pay zero cost for disabled diagnostics.

The subscriber system uses a layer model. Each layer handles a specific concern. One layer might format text. Another might convert events to JSON. Another might forward records to a remote server. Layers stack on top of each other. Records flow through the stack from bottom to top. Each layer can modify, filter, or drop the record before passing it along.

This architecture solves a common problem in other languages. Traditional logging frameworks tie formatting, filtering, and output together. Changing one requires replacing the whole system. tracing lets you compose behavior. You keep your diagnostic calls unchanged while swapping the backend.

Convention aside: the Rust community treats tracing-subscriber as the standard formatter. You will rarely see custom subscribers in production code unless you are building a framework. Stick to fmt() for terminals and json() for machine parsing.

A realistic configuration

Real applications need more than a single level filter. You want to tune verbosity per module, track request lifecycles, and control output without recompiling. The EnvFilter system handles this.

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

fn main() {
    // Read filtering rules from the RUST_LOG environment variable.
    // Falls back to INFO if the variable is unset.
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info"));
    
    // Build a subscriber with the dynamic filter and human-readable formatting.
    // The with_target() option includes the module path in each line.
    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_target(true)
        .init();
    
    // Create a span to track the duration of a logical operation.
    // Spans must be entered before emitting events inside them.
    let app_span = span!(Level::INFO, "application_lifecycle");
    let _guard = app_span.enter();
    
    info!("Configuration loaded");
    
    // Nested spans track sub-operations within the parent context.
    let db_span = span!(Level::DEBUG, "database_connection");
    let _db_guard = db_span.enter();
    
    info!("Connected to primary database");
}

The RUST_LOG environment variable controls verbosity at runtime. You can set it to debug to see everything, error to see only failures, or my_crate=debug,other_crate=warn to tune specific modules. The filter parses this string and applies the rules before any formatting happens.

Spans require explicit entry. The enter() method returns a guard that pushes the span onto the current thread's span stack. When the guard drops, the span exits. This explicit lifecycle prevents accidental context leakage. You always know which span is active.

Convention aside: name your spans with nouns or noun phrases. Use request_handling, database_query, or cache_lookup. Name your events with verbs or past tense phrases. Use connection_established, request_failed, or cache_miss. This distinction keeps your trace trees readable.

Do not nest spans deeper than necessary. Each span adds overhead to the stack management. Keep spans at logical boundaries: function calls, network requests, or file operations. Flatten the rest into events.

Common pitfalls and compiler friction

The most common mistake is forgetting to initialize a subscriber. The code compiles cleanly. The macros expand to empty checks. Your terminal stays silent. The compiler cannot warn you because the subscriber is a runtime concept. You must call init() or set_global() before emitting diagnostics.

Another friction point involves mixing tracing with the older log crate. Many dependencies still use log. Your tracing subscriber will ignore log records by default. You need the tracing-log crate to bridge the gap. Add it to your dependencies and call tracing_log::LogTracer::init() before your subscriber. The bridge converts log records into tracing events automatically.

Feature flags control compile-time behavior. The tracing crate enables level checks by default. If you disable them with default-features = false, the macros skip the level check entirely. This saves a few cycles but removes runtime filtering. Most applications keep the default features enabled. The overhead is negligible compared to I/O.

Compiler errors appear when you misuse the API. If you forget to import the subscriber builder, you get E0433 (unresolved import). If you try to use a span without entering it, events inside that scope will lack context. The compiler will not stop you, but your output will be flat and confusing. If you pass a non-string type to an event macro without implementing Display or Debug, you get E0277 (trait bound not satisfied). The macros require formattable types.

The tracing macros capture arguments by reference. They do not clone or move your data. If you pass a temporary value, the compiler rejects it with E0716 (temporary value dropped while borrowed). Pass owned values or stable references. The subscriber copies only what it needs for formatting.

Treat your span hierarchy as a performance map. If your traces show a span taking longer than expected, you have found your bottleneck. Do not ignore the structure. It tells you exactly where time is spent.

When to reach for tracing

Use tracing when you need hierarchical context and duration tracking for complex applications. Use log when you are maintaining a legacy codebase or writing a tiny script that only needs flat messages. Use tracing-subscriber with fmt() for human-readable terminal output during development. Use tracing-subscriber with json() when you are shipping logs to Elasticsearch, Loki, or a cloud logging service. Use EnvFilter when you want runtime control over verbosity without recompiling or restarting. Use tracing-opentelemetry when you need to export spans to distributed tracing backends like Jaeger or Zipkin. Use tracing-log when your dependency tree relies on the older log crate and you want unified output.

Keep your diagnostic calls close to the action. Emit events where state changes. Open spans where work begins. Close them where work ends. The trace tree becomes a reliable map of your program's behavior.

Where to go next