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.
Start with one subscriber
Section titled “Start with one subscriber”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:
EnvFilterreads runtime filtering directives, normally fromRUST_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=debugRUST_LOG=my_service=debugRUST_LOG=my_service=debug,hyper=warnFiltering is operational configuration. You should not need a rebuild just to temporarily increase detail for one module.
JSON logs are still logs
Section titled “JSON logs are still logs”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:
tracingrecords 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.
Use spans for context that lasts
Section titled “Use spans for context that lasts”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.
Initialization belongs at process startup
Section titled “Initialization belongs at process startup”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.
What to add next
Section titled “What to add next”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.