Skip to content
rusthacks.dev
Web

Axum Structured Logging and Request IDs With Tracing

Add structured logs and a request id to every Axum request with tower-http TraceLayer, SetRequestIdLayer and tracing spans that carry the id into your code.

2 min read
Summarize in AI
Flat illustration for the post “Axum Structured Logging and Request IDs With Tracing”, composed around tooling.

When something fails at 2 a.m., the first question is which request it was. A request id on every log line answers it, and tower-http plus tracing make that nearly free. Everything compiles on Rust 1.90 with edition 2024, axum 0.8, tower-http 0.6 (features trace, request-id), tracing 0.1 and tracing-subscriber 0.3.

The layer stack

rust
use axum::{http::Request, routing::get, Router};
use tower::ServiceBuilder;
use tower_http::{
    request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
    trace::TraceLayer,
};
use tracing::info;

async fn hello() -> &'static str {
    info!("handling hello");
    "hello"
}

fn app() -> Router {
    let x_request_id = axum::http::HeaderName::from_static("x-request-id");

    Router::new().route("/", get(hello)).layer(
        ServiceBuilder::new()
            .layer(SetRequestIdLayer::new(x_request_id.clone(), MakeRequestUuid))
            .layer(
                TraceLayer::new_for_http().make_span_with(|req: &Request<_>| {
                    let id = req
                        .headers()
                        .get("x-request-id")
                        .and_then(|v| v.to_str().ok())
                        .unwrap_or("-");
                    tracing::info_span!(
                        "request",
                        method = %req.method(),
                        path = %req.uri().path(),
                        request_id = %id,
                    )
                }),
            )
            .layer(PropagateRequestIdLayer::new(x_request_id))
            .into_inner(),
    )
}

Order matters. SetRequestIdLayer is first so the id exists before TraceLayer builds the span. PropagateRequestIdLayer copies the id onto the response. Layer ordering rules are in Tower layers.

The subscriber

rust
use tracing_subscriber::{fmt, EnvFilter};

fn init_logging() {
    tracing_subscriber::fmt()
        .json()
        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,tower_http=debug".into()))
        .init();
}

A request now logs lines such as:

Text
{"timestamp":"...","level":"INFO","fields":{"message":"handling hello"},"target":"demo","span":{"method":"GET","path":"/","request_id":"c3c8e5d2-...","name":"request"}}

The span object carries the id, so searching a log platform for that id returns every line for one request. Use RUST_LOG to adjust levels at run time; the filter syntax is in tracing basics.

Putting the id on outbound calls

If your service calls others, forward the header so their logs join up:

rust
async fn call_downstream(client: &reqwest::Client, request_id: &str) -> reqwest::Result<String> {
    client
        .get("http://inventory.internal/items")
        .header("x-request-id", request_id)
        .send()
        .await?
        .text()
        .await
}

Take the id from the incoming request with an extractor or from the span. Do not generate a new one downstream.

What to log

  • One line per request at info with status and duration, which TraceLayer emits on response.
  • Errors at error with the full chain, once, at the boundary that handles them (error handling).
  • No secrets, tokens or full bodies. Redaction techniques are in logging without leaking secrets.

Health checks and metrics endpoints tend to flood logs; exclude them with a filter or a separate router without TraceLayer. The level guidance is in log levels.

Also inTooling
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.