rust 83 lines · 2 tabs

Axum Latency Logging Middleware With Request IDs and Tracing Spans

Shared by codesnips Jul 2026
2 tabs
use std::time::Instant;

use axum::{
    extract::Request,
    http::HeaderValue,
    middleware::Next,
    response::Response,
};
use tracing::field;
use uuid::Uuid;

pub async fn track_latency(req: Request, next: Next) -> Response {
    let method = req.method().clone();
    let path = req.uri().path().to_owned();
    let request_id = Uuid::new_v4();

    let span = tracing::info_span!(
        "http_request",
        %method,
        %path,
        request_id = %request_id,
        status = field::Empty,
        latency_ms = field::Empty,
    );
    let _enter = span.enter();

    let started = Instant::now();
    let mut response = next.run(req).await;
    let latency_ms = started.elapsed().as_secs_f64() * 1000.0;

    let status = response.status();
    span.record("status", status.as_u16());
    span.record("latency_ms", latency_ms);

    if let Ok(value) = HeaderValue::from_str(&request_id.to_string()) {
        response.headers_mut().insert("x-request-id", value);
    }

    if status.is_server_error() {
        tracing::error!(status = %status, latency_ms, "request failed");
    } else {
        tracing::info!(status = %status, latency_ms, "request completed");
    }

    response
}
2 files · rust Explain with highlit

This snippet shows how request latency logging is wired into an axum service using a tower-style middleware function and structured tracing spans. The middleware in latency.rs is written with axum::middleware::from_fn, which adapts a plain async function into a layer that sits between the router and the handlers. The pattern is common in production HTTP services because it centralizes cross-cutting concerns — timing, request identifiers, and status logging — so individual handlers stay focused on business logic.

In latency.rs, track_latency receives the incoming Request and a Next, records Instant::now() before calling next.run(req).await, then computes elapsed() after the response comes back. Wrapping the downstream call this way is important: the timer must straddle the entire handler chain, including any inner middleware, so the elapsed value reflects real end-to-end latency rather than just handler execution. A per-request Uuid is generated and injected into the response headers via x-request-id, giving clients and log aggregators a correlation key. The tracing::info_span! creates a span carrying the method, path, and request id, and record fills in status and latency_ms once known, so a single structured log line captures the full picture. The % and field::Empty sigils are tracing conventions for display-formatting and deferred fields.

Because from_fn closures must be Clone and Send, the function signature avoids capturing non-Send state and instead derives everything from the request. Requests that panic or return early still flow through because next.run yields a Response in all normal cases; genuinely panicking handlers are a separate concern handled by a catch layer.

In main.rs, the middleware is attached with .layer(middleware::from_fn(track_latency)) after tracing_subscriber is initialized, since spans need a subscriber to be recorded. Layer ordering matters — outer layers wrap inner ones — so the latency layer is placed to encompass the routes it should measure. A pitfall worth noting is that adding this layer above a large ServiceBuilder stack changes what the timer includes; developers should place it deliberately depending on whether they want to measure gzip, auth, and other middleware. This approach is reached for whenever an operator needs cheap, always-on visibility into slow endpoints without pulling in a full APM agent.


Related snips

Share this code

Here's the card — post it anywhere.

Axum Latency Logging Middleware With Request IDs and Tracing Spans — share card
Link copied