Logging & Tracing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against tracing 0.1.44, tracing-subscriber 0.3.23 and tower-http 0.7.1 with axum 0.8.9 on Rust
1.99 (edition 2024), checked on 2026-10-06. Rust services log with the tracing crate rather than log: besides
events (log lines) it records spans, periods of time with fields that every event inside inherits. A request span
with the method, route and request id makes every log line of that request attributable, without passing ids around.
Exporting the same spans to a distributed tracing backend is the next page,
OpenTelemetry & Distributed Tracing.
Where it runs in the request
Logging is not one step of the request lifecycle (tokio, hyper, tower layers, router, extractors, handler, response) but a frame around most of it:
-
The subscriber is process-wide and installed in
mainbefore the runtime accepts connections, so tokio, hyper and every library log through it. -
SetRequestIdLayerandTraceLayerare tower layers. Applied withRouter::layer, as below, they run after the router has matched (soMatchedPathis available for the span name) and before the extractors. The request span is entered before the first extractor and stays current until the response body has been sent, so extractor rejections, handler events and database spans all become its children. -
#[instrument]spans are opened inside the handler and the services it calls;on_responseandon_failurerun on the way back out, after the handler returned. Unmatched requests (404) and anything rejected by layers outsideTraceLayernever get the request span — wrap the whole router (ServiceBuilderaroundapp) if they must be traced, at the cost ofMatchedPath.
Spans, events and levels
-
Events —
trace!,debug!,info!,warn!,error!— record something that happened, with structured fields:info!(isbn = %isbn, copies, "book lent").%usesDisplay,?usesDebug, a bare name aValue. -
Spans —
info_span!("lend", member_id)or#[instrument]— cover a unit of work. Entering a span (or instrumenting a future with it) makes it the parent of every event and span created inside, across.awaitpoints. -
Levels filter by importance:
ERRORneeds attention,WARNis unexpected but handled,INFOmarks business-level milestones,DEBUG/TRACEare for developers. Each crate logs under its module path as target (sqlx::query,tower_http::trace,axum::rejection), which filters select.
The subscriber
A subscriber decides what happens to spans and events. The modern idiom — Azam (Advanced Rust, chapter 2) uses it
too — composes layers on a Registry: an EnvFilter for levels, a formatting layer for output, and later an
OpenTelemetry layer. Wrapping the filter in reload::Layer lets an admin endpoint change the level without a restart:
use std::time::Duration;
use axum::{
Router,
body::Body,
extract::{MatchedPath, State},
http::{Request, Response, StatusCode, header::HeaderName},
routing::{get, put},
};
use tower::ServiceBuilder;
use tower_http::{
classify::ServerErrorsFailureClass,
request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
trace::TraceLayer,
};
use tracing::{Span, info, info_span, instrument, warn};
use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt, reload, util::SubscriberInitExt};
type FilterHandle = reload::Handle<EnvFilter, Registry>;
fn init_tracing(json: bool) -> FilterHandle {
// RUST_LOG=bookshelf=debug,tower_http=info,sqlx=warn ... default below
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,tower_http=debug,axum::rejection=trace"));
let (filter, handle) = reload::Layer::new(filter);
let registry = tracing_subscriber::registry().with(filter);
if json {
// one JSON object per line, with the current span and its parents
registry.with(tracing_subscriber::fmt::layer().json().with_current_span(true).with_span_list(true)).init();
} else {
registry.with(tracing_subscriber::fmt::layer().compact()).init();
}
handle
}
#[instrument(skip_all, fields(isbn = %isbn, outcome))]
async fn lend(axum::extract::Path(isbn): axum::extract::Path<String>) -> StatusCode {
info!(member_id = 42, "lending book");
Span::current().record("outcome", "lent");
StatusCode::NO_CONTENT
}
/// PUT /admin/log-level with a body such as "debug,sqlx=info"
async fn set_level(State(handle): State<FilterHandle>, directives: String) -> Result<String, (StatusCode, String)> {
let filter = EnvFilter::try_new(directives.trim()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
handle.reload(filter).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
warn!(directives = %directives.trim(), "log level changed");
Ok("ok\n".into())
}
#[tokio::main]
async fn main() {
let handle = init_tracing(std::env::var("LOG_JSON").is_ok());
let request_id = HeaderName::from_static("x-request-id");
let trace = TraceLayer::new_for_http()
// one span per request, named after the route template (never the raw path: cardinality)
.make_span_with(|req: &Request<Body>| {
let route = req.extensions().get::<MatchedPath>().map_or("unmatched", MatchedPath::as_str);
let id = req.headers().get("x-request-id").and_then(|v| v.to_str().ok()).unwrap_or("-");
info_span!("http_request", method = %req.method(), route, request_id = %id, status = tracing::field::Empty)
})
.on_response(|res: &Response<Body>, latency: Duration, span: &Span| {
span.record("status", res.status().as_u16());
info!(latency_ms = latency.as_millis(), "response");
})
.on_failure(|class: ServerErrorsFailureClass, latency: Duration, _span: &Span| {
tracing::error!(%class, latency_ms = latency.as_millis(), "request failed");
});
let app = Router::new()
.route("/api/v1/books/{isbn}/loans", put(lend))
.route("/admin/log-level", put(set_level))
.route("/health", get(|| async { "ok" }))
.layer(
ServiceBuilder::new()
.layer(SetRequestIdLayer::new(request_id.clone(), MakeRequestUuid))
.layer(trace)
.layer(PropagateRequestIdLayer::new(request_id)),
)
.with_state(handle);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
$ cargo run &
$ curl -s -X PUT localhost:3000/api/v1/books/9780441013593/loans
... DEBUG http_request: tower_http::trace::on_request: started processing request method=PUT route="/api/v1/books/{isbn}/loans" request_id=15897168-...
... INFO http_request:lend: bookshelf_web: lending book member_id=42 method=PUT route="/api/v1/books/{isbn}/loans" request_id=15897168-... isbn=9780441013593
... INFO http_request: bookshelf_web: response latency_ms=0 method=PUT route="/api/v1/books/{isbn}/loans" request_id=15897168-... status=204
$ curl -s -X PUT -d 'warn' localhost:3000/admin/log-level # from now on only WARN and above
ok
$ LOG_JSON=1 cargo run &
$ curl -s -X PUT localhost:3000/api/v1/books/9780441013593/loans
{"timestamp":"...","level":"INFO","fields":{"message":"lending book","member_id":42},"target":"bookshelf_web",
"span":{"isbn":"9780441013593","name":"lend"},"spans":[{"method":"PUT","request_id":"d0654f75-...",
"route":"/api/v1/books/{isbn}/loans","name":"http_request"},{"isbn":"9780441013593","name":"lend"}]}
-
EnvFilterdirectives (info,tower_http=debug,sqlx::query=warn) come fromRUST_LOG; the reload handle changes them at runtime — protect such an endpoint like any admin route. -
The subscriber must be installed before anything logs, at the top of
main; libraries never install one. -
JSON output is what log pipelines (Loki, Elasticsearch, CloudWatch) parse. Keep the pretty format for local development.
TraceLayer and request IDs
TraceLayer::new_for_http() records a span per request and events at request start, response and failure.
make_span_with controls the span: here it names the route template from MatchedPath (available because the
layer is applied with Router::layer, which runs after routing) and attaches the request id. on_response records the
status into the span, on_failure fires for 5xx responses (classified by ServerErrorsFailureClass).
SetRequestIdLayer generates an x-request-id (a UUID) unless the client or an upstream proxy supplied one, and
PropagateRequestIdLayer copies it to the response; the order in the ServiceBuilder puts the id in place before the
trace span is created. Clients report the id with bug reports, and support finds every log line of that request. See
the official request-id example and Applying Middleware &
Ordering for the full stack.
#[instrument]
#[tracing::instrument] wraps a function (sync or async) in a span named after it, recording its arguments as
fields. In handlers and services:
-
skip(state, pool, password)— never record large values or secrets;skip_alland thenfields(...)is the safest default; -
fields(isbn = %isbn, outcome)— add fields, including empty ones filled later withSpan::current().record(...); -
err— record the returnedErras an error event;ret— record the return value; -
level = "debug"— lower the span’s level for hot paths.
Correlating logs and traces
When OpenTelemetry is set up, every span has a trace_id. Adding it to log lines lets you jump from a log entry to the
whole distributed trace in Grafana or Jaeger. Gangappa & Rangaraj (Mastering Distributed Observability in Rust,
chapter 7) record trace_id and span_id as fields in the request span and configure the JSON layer to emit them; the
OpenTelemetry page shows how to obtain them from the current span context.
Sampling, cost and privacy
-
Volume:
DEBUGin production for one module is fine; for everything it is expensive. Prefer dynamic filters (above) to temporary redeploys, and sample high-volumeINFOevents (log one in N successful health checks, or none at all). -
Personal data: never log passwords, tokens, full request bodies, or personal data that your privacy policy does not allow you to keep; log ids instead of e-mail addresses.
SetSensitiveHeadersLayerhidesAuthorizationand cookies fromTraceLayer(see CSRF & Security Hardening). -
Anti-patterns (Gangappa & Rangaraj, chapter 7): string-formatting values into the message instead of fields (
info!("lent {isbn}")cannot be queried byisbn); logging the same error at every layer it passes through (log once, where it is handled — theAppErrorresponse mapping is a good place);println!in services (bypasses filters, levels and spans); unbounded field values such as raw paths or user agents in span names.
Redacting personal data, and spans across .await
The cheapest redaction is a type that cannot print its content. Wrapping e-mail addresses, tokens and similar values
in a Redacted newtype makes the safe behaviour the default: a derived Debug of the containing struct, ?member and
%member.email all print [redacted], and the code that needs the real value calls expose(), which is easy to find
in a review. The example also shows how to keep a span current across .await: attach it to the future with
Instrument::instrument instead of holding the guard returned by span.enter().
use std::fmt;
use tracing::{Instrument, info, info_span};
/// Wraps personal data so that neither `Debug` nor `Display` can print it into a log.
struct Redacted<T>(T);
impl<T> fmt::Debug for Redacted<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[redacted]")
}
}
impl<T> fmt::Display for Redacted<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[redacted]")
}
}
impl<T> Redacted<T> {
/// The one, greppable, place where the real value is read.
fn expose(&self) -> &T {
&self.0
}
}
#[derive(Debug)]
struct Member {
id: i64,
email: Redacted<String>,
}
async fn send_reminder(member: &Member) {
let _to: &str = member.email.expose(); // handed to the mailer, never to a log macro
tokio::task::yield_now().await; // an await point: the span below must still be current after it
info!(member_id = member.id, email = %member.email, "reminder queued");
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().with_target(false).without_time().init();
let member = Member { id: 42, email: Redacted("ursula@bookshelf.example".into()) };
info!(?member, "member loaded");
// attach the span to the future; a `span.enter()` guard held across `.await` would leak into other tasks
send_reminder(&member).instrument(info_span!("reminder_job", job = "loan-due")).await;
}
$ NO_COLOR=1 cargo run
INFO member loaded member=Member { id: 42, email: [redacted] }
INFO reminder_job{job="loan-due"}: reminder queued member_id=42 email=[redacted]
Source: tracing-subscriber: fmt
The secrecy crate offers the same idea for secrets (SecretString, with zeroing on drop) and is a good fit for
configuration values such as database passwords.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
Nothing is logged at all |
No subscriber was installed, or it was installed after the first events (they are dropped). Call |
|
The filter was built with |
Events from |
tracing-subscriber’s |
Span fields appear on the wrong request, or a span never closes |
A |
Every request span is named |
|
JSON lines lack the request id or route |
The JSON formatter omits span fields unless |
A field recorded later never shows up |
|
Where the books are outdated
Yoon (Beginning Axum, chapter 6) initialises logging with tracing_subscriber::fmt::init(), which is fine for a
start; production services need the layered registry above for JSON output, runtime filters and OpenTelemetry.
Gangappa & Rangaraj use tower-http 0.6, whose TraceLayer API is unchanged in 0.7 (0.7 parents the default
callbacks' events to the request span explicitly and includes gRPC error messages).