In any real-world application, logging is crucial for diagnosing problems and understanding application behavior. Rust offers several powerful crates for logging (log
) and structured tracing (tracing
). In this post, we’ll build a simple Axum-based REST API and show how to use tracing
to automatically include context—like HTTP method and path—in our logs.
We’ll also delve into the variety of log levels, how to set different log levels per crate, and why structured logs are so beneficial for observability.
Table of Contents
Open Table of Contents
What Are Logging and Tracing?
- Logging is the traditional practice of printing messages (like “User 123 not found” or “Connection error”) at various places in your code. In Rust,
log
is the standard facade for logging, and you typically emit messages via macros likelog::info!
orlog::error!
. - Tracing goes one step further by allowing you to create “spans” of time that carry contextual data. For instance, if you have an HTTP request, you can capture details such as the method (
POST
), path (/messages
), and a unique request ID in a span. Any logging done within that span automatically attaches these details, making it easy to see which request triggered which log messages.
When you add asynchronous concurrency into the mix, tracing
becomes even more valuable because it can track these spans across async await boundaries, ensuring logs stay tied to the correct request or operation.
Log Levels
Rust’s log
crate (and by extension tracing
) supports the following log levels, from most verbose to most urgent:
- Trace – For extremely detailed logs, often describing almost every step in your code.
- Debug – For information that’s more relevant for debugging but might be too verbose for production.
- Info – For general operational messages, e.g., “Server started”, “User logged in”.
- Warn – For non-fatal issues that might need attention, e.g., “Couldn’t parse config, using defaults”.
- Error – For serious issues that might lead to failures, e.g., “Database connection lost”.
When you configure your logger, you decide the minimum level you want to see. Anything above that level also appears. For example, if you set RUST_LOG=warn
, you’ll see only warn
and error
logs.
Why Combine Axum, tower-http
, and tracing
?
- Axum: A lightweight, ergonomic framework for building async web servers using the Tokio runtime.
- tower-http: Provides middleware (including
TraceLayer
) that can automatically create atracing
span for each HTTP request, capturing metadata like the request path or method. - tracing +
log
: By bridginglog
macros withtracing
, you can keep using the familiarlog::info!
orlog::warn!
macros.tracing
will attach the active span’s data (such as path, method, request ID) to every log statement—no manual passing required!
Project Setup
Start by creating a new Rust project:
cargo new axum-tracing-example
cd axum-tracing-example
In your Cargo.toml
, include:
[package]
name = "axum-tracing-example"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.1"
log = "0.4.24"
serde = { version = "1.0.216", features = ["derive"] }
tokio = { version = "1.43.0", features = ["full"] }
tower = "0.5.2"
tower-http = {version = "0.6.2", features = ["trace"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features=["env-filter"] }
Walkthrough of main.rs
Below is our entire src/main.rs
. We’ll examine it piece by piece:
use axum::response::IntoResponse;
use axum::{routing::get, Json, Router};
use log::info;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use tokio;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
// A simple data type we'll send and receive as JSON.
#[derive(Debug, Serialize, Deserialize)]
struct Message {
content: String,
}
// Handler for GET /messages
async fn list_messages() -> impl IntoResponse {
info!("Handling list_messages request");
Json(vec!["Hello from the server!".to_string()])
}
// Handler for POST /messages
async fn create_message(Json(message): Json<Message>) -> impl IntoResponse {
info!("Handling create_message request");
Json(format!("New message: {}", message.content))
}
#[tokio::main]
async fn main() {
// 1. Initialize tracing + log bridging
tracing_subscriber::fmt()
// This allows you to use, e.g., `RUST_LOG=info` or `RUST_LOG=debug`
// when running the app to set log levels.
.with_env_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("axum_tracing_example=error,tower_http=warn"))
.unwrap(),
)
.init();
// 2. Build our router
let app = Router::new()
// Define routes: GET /messages and POST /messages
.route("/messages", get(list_messages).post(create_message))
// 3. Add a TraceLayer to automatically create and enter spans
.layer(TraceLayer::new_for_http());
// 4. Run our Axum server
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
info!("Starting server on {}", addr);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Key Points
- We use
tracing_subscriber::fmt()
to create a subscriber that formats log output. .with_env_filter("axum_tracing_example=trace,tower_http=trace")
sets the default filtering rules for log levels (more on that below).TraceLayer::new_for_http()
automatically creates a tracing span for every HTTP request, capturing metadata like the path, method, and request headers.- We write logs via
info!()
. These logs now inherit the activeSpan
context.
How with_env_filter
Works
with_env_filter
lets you specify which crates log at which levels. By default, we use axum_tracing_example=trace,tower_http=trace
. This means:
axum_tracing_example=trace
: All logs in our local crate (named “axum_tracing_example” inCargo.toml
) will show up at level trace or higher (which includes debug, info, warn, and error).tower_http=trace
: Thetower_http
crate logs at the trace level, which is the most verbose.
If you wanted to see fewer logs from tower_http
, you could do:
.with_env_filter("axum_tracing_example=error,tower_http=warn")
That means anything from our code logs at trace
, but tower_http
logs only its warn
and error
messages (no debug or info or trace from it).
Setting It via Environment Variable
You can also override the filter via the RUST_LOG
environment variable without changing code. For example:
RUST_LOG=axum_tracing_example=debug,tower_http=warn cargo run
Now, at runtime:
- Our crate logs only at
debug
level or above. tower_http
logs only atwarn
level or above.- If we wanted to see all logs from all crates, we’d do
RUST_LOG=trace cargo run
.
This flexible filtering is a major advantage because you can enable or disable logging in third-party crates without changing your code—just adjust the environment variable!
Trying It Out
-
Run the server:
# Start the server with the default filter in code: cargo run
Or specify your own levels:
RUST_LOG=axum_tracing_example=info,tower_http=debug cargo run
-
Send requests:
In another terminal:
curl -X GET http://127.0.0.1:3000/messages curl -X POST -H "Content-Type: application/json" \ -d '{"content":"Hello Axum"}' \ http://127.0.0.1:3000/messages
-
Check the logs:
You’ll see lines like:
INFO request{method=GET uri=/messages ...}: axum_tracing_example: Handling list_messages request INFO request{method=POST uri=/messages ...}: axum_tracing_example: Handling create_message request
Notice that each log includes request{method=... uri=...}
, courtesy of TraceLayer
. It wraps each request in a “span” that captures the request metadata. When you call info!
, trace
automatically appends that active span information to your log message.
Conclusion
By combining Axum with tower-http’s TraceLayer
and the tracing ecosystem, you get:
- Structured logs that automatically include request context.
- Flexible log filters to tweak verbosity at runtime.
- Minimal code overhead, letting the
log
macros you know and love emit structured tracing events under the hood.
The code for this tutorial is available on GitHub: https://github.com/irbull/axum_tracing_example.