Skip to content

Structured tracing for a Rust service

Async services quickly make plain text logging hard to follow. Tokio tasks can interleave work on the same runtime threads, so “line 12 happened after line 11” is not enough context. The tracing ecosystem models diagnostic events and spans instead of treating every message as an isolated string.

EXAMPLE: CI-CHECKEDCOMPILED · examples/axum-backend/src/main.rs#recipe-structured-tracing
fn init_tracing() {
    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .json()
        .init();
}

The checked setup does two jobs:

  • EnvFilter reads runtime filtering directives, normally from RUST_LOG
  • the formatter emits newline-delimited JSON records

If RUST_LOG is missing or invalid, the example falls back to info.

Examples of useful filters include:

RUST_LOG=debug
RUST_LOG=my_service=debug
RUST_LOG=my_service=debug,hyper=warn

Filtering is operational configuration. You should not need a rebuild just to temporarily increase detail for one module.

The JSON formatter is useful when a log collector parses structured records. It does not by itself export distributed traces, create metrics, or send anything to an observability backend.

Keep the layers separate:

  • tracing records events and spans in the application
  • a subscriber decides how those records are processed
  • the JSON formatter writes structured log lines
  • an OpenTelemetry layer, if you add one later, is a separate export decision

That separation makes it possible to keep local development readable while production ships structured records somewhere else.

Instrument values, not preformatted sentences

Section titled “Instrument values, not preformatted sentences”

Prefer fields that a collector can query.

Conceptually:

tracing::info!(user_id, latency_ms, "request completed");

is more useful than constructing one large string that contains the same values. The event still has a human message, but the interesting data remain fields.

For request tracing, add fields such as route, method, status, request ID, and latency at the middleware boundary. Avoid recording secrets, authorization headers, session tokens, or raw request bodies by default.

A span represents a period of execution with associated fields. Events emitted inside that span can carry the surrounding context.

That is especially useful in async code, where a logical request may move between runtime threads. It is one of the main reasons tracing is a better fit for Tokio applications than reasoning from thread-oriented log lines.

Install the subscriber before starting the listener or spawning long-lived workers. A global subscriber is process-level infrastructure, and trying to initialize it more than once can fail or panic depending on the API used.

Libraries should emit tracing events. The final application should decide which subscriber and output format to install.

Do not add an observability stack just because a tutorial had one.

Add another layer when you have a concrete need:

  • OpenTelemetry export when traces must cross service boundaries
  • metrics when rates, saturation, and latency distributions matter
  • log shipping when operators need centralized retention and search
  • request IDs when one request must be followed across components

The Axum REST API blueprint uses this minimal subscriber so the example has a real production-facing logging seam without pretending to be a complete observability platform.