Building an HTTP Service with Axum 0.7, From Zero to Tracing
TL;DR — Axum 0.7’s extractor pattern is the right shape for production HTTP. Wire
tower-http::TraceLayer, anIntoResponseerror type, structured logging viatracing-subscriber, and OpenTelemetry export. That’s most of the setup you need.
Following last week’s async patterns, today’s a concrete walkthrough. We’ll go from a blank cargo new to a production-shaped HTTP service with extractors, typed errors, middleware, and distributed tracing. Most of what you’ll re-discover in your first month on Axum is in here.
The skeleton
# Cargo.toml — March 2024
[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.36", features = ["full"] }
axum = { version = "0.7", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "compression-gzip", "cors", "timeout"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = "0.22"
opentelemetry = "0.22"
opentelemetry-otlp = "0.15"
opentelemetry_sdk = { version = "0.22", features = ["rt-tokio"] }
anyhow = "1"
thiserror = "1"
uuid = { version = "1.7", features = ["v4", "serde"] }
Extractors do most of the work
Axum’s killer feature is the extractor pattern. Handler arguments are typed; the framework runs the extraction. You don’t have to remember middleware order or threadlocal context tricks.
use axum::{
extract::{Path, Query, State, Json},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone)]
struct AppState {
pool: sqlx::PgPool,
}
#[derive(Deserialize)]
struct CreateOrder {
customer_id: Uuid,
total_cents: i64,
}
#[derive(Serialize)]
struct Order {
id: Uuid,
customer_id: Uuid,
total_cents: i64,
}
async fn create_order(
State(state): State<AppState>,
Json(input): Json<CreateOrder>,
) -> Result<Json<Order>, AppError> {
let id = Uuid::new_v4();
sqlx::query!(
"INSERT INTO orders (id, customer_id, total_cents) VALUES ($1, $2, $3)",
id, input.customer_id, input.total_cents
).execute(&state.pool).await?;
Ok(Json(Order {
id, customer_id: input.customer_id, total_cents: input.total_cents,
}))
}
async fn get_order(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Order>, AppError> {
let row = sqlx::query_as!(Order,
"SELECT id, customer_id, total_cents FROM orders WHERE id = $1", id
).fetch_one(&state.pool).await?;
Ok(Json(row))
}
The handler signature documents itself. State pulls from the router’s state, Path parses URL params, Json deserializes the body. Each extractor returns its own error if something goes wrong; Axum composes them.
Error handling that doesn’t fight you
The right pattern: one error type per crate, implement IntoResponse so handlers can return Result<T, AppError>.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("validation failed: {0}")]
Validation(String),
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Sqlx(sqlx::Error::RowNotFound) => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::Sqlx(e) => {
tracing::error!(error = ?e, "database error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
}
AppError::Other(e) => {
tracing::error!(error = ?e, "unexpected error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
}
};
(status, Json(serde_json::json!({"error": message}))).into_response()
}
}
The #[from] attributes on Sqlx and Other mean ? works inside handlers for both error types. Adding new categories is one match arm.
I keep this in error.rs and use crate::error::AppError; everywhere. A single point of truth for “what does an API error look like to clients.”
Middleware via tower layers
tower-http ships the most useful middleware out of the box:
use std::time::Duration;
use tower_http::{
compression::CompressionLayer,
cors::CorsLayer,
timeout::TimeoutLayer,
trace::TraceLayer,
};
fn build_app(state: AppState) -> Router {
Router::new()
.route("/orders", post(create_order))
.route("/orders/:id", get(get_order))
.route("/healthz", get(|| async { "ok" }))
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
}
Layer order is bottom-up — the last .layer() runs first on the request. For most services, TraceLayer outermost (so it captures everything including timeout failures) is what you want.
Custom middleware uses the same pattern:
use axum::{middleware::Next, extract::Request};
async fn auth(req: Request, next: Next) -> Result<axum::response::Response, AppError> {
let token = req.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Validation("missing token".into()))?;
verify_token(token).await?;
Ok(next.run(req).await)
}
// In router:
// .layer(axum::middleware::from_fn(auth))
For per-route auth, attach the layer on a sub-router rather than the whole Router.
Tracing wired right
tracing plus tracing-subscriber plus tracing-opentelemetry is the trio. Wire once, reap forever.
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
fn init_tracing() -> anyhow::Result<()> {
let otlp_exporter = opentelemetry_otlp::new_exporter().tonic();
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(otlp_exporter)
.with_trace_config(opentelemetry_sdk::trace::Config::default().with_resource(
opentelemetry_sdk::Resource::new(vec![
opentelemetry::KeyValue::new("service.name", "orders-api"),
]),
))
.install_batch(opentelemetry_sdk::runtime::Tokio)?;
let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer().json())
.with(otel_layer)
.init();
Ok(())
}
Now TraceLayer creates a span per HTTP request. The span is exported via OTLP. Logs inside handlers (tracing::info!, tracing::error!) attach to the span automatically.
To add context inside a handler:
#[tracing::instrument(skip(state))]
async fn get_order(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Order>, AppError> {
tracing::info!(order_id = %id, "fetching order");
// ...
}
#[instrument] creates a child span around the function. skip(state) keeps the AppState (which doesn’t implement Debug meaningfully) out of the span attributes.
Putting it together
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing()?;
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let state = AppState { pool };
let app = build_app(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
tracing::info!("listening on {}", listener.local_addr()?);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
opentelemetry::global::shutdown_tracer_provider();
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c().await.ok();
}
with_graceful_shutdown lets in-flight requests finish before the server exits. shutdown_tracer_provider flushes pending spans to OTLP before the process dies.
That’s the production skeleton.
Common Pitfalls
#[axum::debug_handler]once and never again. Use it during development to get readable error messages on handler signature problems. Remove for release builds.- Returning
Result<impl IntoResponse, AppError>vs concrete types. The concrete version compiles faster and gives better error messages. UseResult<Json<T>, AppError>rather thanResult<impl IntoResponse, AppError>where you can. - Mixing
tracingandlogad-hoc. Usetracingexclusively. Most ecosystem crates emitlogevents thattracing-logbridges; configure it once. - Forgetting
with_graceful_shutdown. Without it, the server exits immediately on SIGTERM and drops in-flight requests. #[instrument]on every handler. It’s verbose and the spans add overhead.TraceLayeralready creates a span per HTTP request.#[instrument]on functions called from the handler is more useful.- Holding a
PgConnectionacross many awaits. Use&PgPooland let sqlx manage connections per query.
Wrapping Up
Axum 0.7 plus the tower-http middleware and the tracing + OpenTelemetry stack is what a 2024 production Rust HTTP service looks like. The setup takes an afternoon. The patterns above cover what you’d otherwise learn by stubbing your toe over several weeks.
Next post leaves the server world entirely — embedded Rust with Embassy, async on bare metal. The Axum docs are the canonical reference for the bits I glossed over.