Rust Web Server with Axum 0.8: Logging with tracing, RUST_LOG and TraceLayer
November 3, 2026
Episode 8 of the Rust Backend series, where we build the backend of a blog step by step. After episode 7 the server reads its settings from .env, but when a request fails we still see nothing: the server prints one line at start and then stays silent. Today we add logging with the tracing crates: levels and fields, a filter we set with RUST_LOG, TraceLayer from tower-http to log every request, a warn! event in our errors, and #[instrument] for spans. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 7 (axum 0.8.9, tokio, serde, serde_json, thiserror, dotenvy). Three new crates today: tracing, tracing-subscriber and tower-http.
1. Why tracing
println! just prints text. tracing gives every message a level (ERROR, WARN, INFO, DEBUG, TRACE), a time and fields, and we choose at run time how much we want to see.
2. The crates
cargo add tracing tracing-subscriber --features tracing-subscriber/env-filtertracing writes the log events. tracing-subscriber prints them, and its env-filter feature reads the RUST_LOG variable.
3. Set up the subscriber
At the top of main, right after we read the config (so .env is loaded first), set up the subscriber. The filter comes from RUST_LOG; if it is not set we use info. Add the use line too:
use tracing_subscriber::EnvFilter;
// in main(), after `let cfg = config::config();`
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();4. println! becomes info!
tracing::info!(%addr, "listening");The address is a field. The % prints it with Display. Run it:
cargo run2026-10-30T00:17:43.739581Z INFO blog_api: listening addr=0.0.0.0:8080Now there is a time, the level INFO, the module and our field.
5. A request shows nothing
In a second terminal:
curl -i localhost:8080/posts/9The server pane stays unchanged: nobody logs requests yet.
6. TraceLayer
tower-http has a TraceLayer. A layer wraps every request, so it can log before and after the handler.
cargo add tower-http --features traceuse tower_http::trace::TraceLayer;
// in main(), at the end of the router
.with_state(db)
.layer(TraceLayer::new_for_http());Run again and send the same request: still nothing. No error, just silence.
7. Why: levels
TraceLayer logs at the level DEBUG, and our filter shows info and above. The events are there, but they are filtered out.
8. RUST_LOG in .env
Set the filter in .env: blog_api at debug, and tower_http at debug. Everything else stays at info.
HOST=0.0.0.0
PORT=8080
RUST_LOG=blog_api=debug,tower_http=debug9. Every request is logged
Run again and send the request:
2026-10-30T00:19:52.360071Z DEBUG request{method=GET uri=/posts/9 version=HTTP/1.1}: tower_http::trace::on_request: started processing request
2026-10-30T00:19:52.360230Z DEBUG request{method=GET uri=/posts/9 version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=404
2026-10-30T00:19:52.360293Z DEBUG request{method=GET uri=/posts/9 version=HTTP/1.1}: tower_http::trace::on_eos: end of stream stream_duration=0 msEach request gets a line when it starts, one when it finishes (with the status and the time it took) and one at the end of the body. Everything runs inside a request{...} span with the method, the path and the HTTP version.
10. Log our errors
In src/error.rs, add one line at the start of into_response:
tracing::warn!(error = %self, "request failed");Run it and ask for post 9 again:
2026-10-30T00:19:52.360154Z WARN request{method=GET uri=/posts/9 version=HTTP/1.1}: blog_api::error: request failed error=post 9 not foundThe WARN line sits inside the span of that request, between its start and its end.
11. instrument
The instrument attribute on a handler opens a span with its arguments. We add it to create_post and skip the arguments that cannot be printed (NewPost has no Debug), and add a debug! event:
#[tracing::instrument(skip(db, payload))]
pub async fn create_post(...) -> ... {
...
tracing::debug!(id = post.id, "created");
Ok((StatusCode::CREATED, Json(post)))
}Create a post:
curl -i localhost:8080/posts -H 'content-type: application/json' -d '{"title":"Hello","body":"First post"}'2026-10-30T00:20:32.105295Z DEBUG request{method=POST uri=/posts version=HTTP/1.1}:create_post: blog_api::handlers: created id=1Our event now sits inside the create_post span, inside the request span.
12. On a real server
Run with RUST_LOG=info and you see only the important lines. A variable set in the shell wins over .env.
The final main.rs
mod config;
mod error;
mod handlers;
mod models;
use axum::{routing::get, Router};
use models::Db;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
let cfg = config::config();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let addr = format!("{}:{}", cfg.host, cfg.port);
let db = Db::default();
let app = Router::new()
.route("/posts", get(handlers::list_posts).post(handlers::create_post))
.route(
"/posts/{id}",
get(handlers::get_post).put(handlers::update_post).delete(handlers::delete_post),
)
.with_state(db)
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
tracing::info!(%addr, "listening");
axum::serve(listener, app).await.unwrap();
}The final error.rs
use axum::extract::rejection::JsonRejection;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("post {0} not found")]
NotFound(u32),
#[error("title must not be empty")]
EmptyTitle,
#[error(transparent)]
BadJson(#[from] JsonRejection),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
tracing::warn!(error = %self, "request failed");
let status = match &self {
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::EmptyTitle => StatusCode::BAD_REQUEST,
ApiError::BadJson(rejection) => rejection.status(),
};
let message = match &self {
ApiError::BadJson(rejection) => rejection.body_text(),
other => other.to_string(),
};
(status, Json(json!({ "error": message }))).into_response()
}
}The final handlers.rs
use axum::extract::{rejection::JsonRejection, Path, State};
use axum::{http::StatusCode, Json};
use crate::error::ApiError;
use crate::models::{Db, NewPost, Post};
fn check(new: &NewPost) -> Result<(), ApiError> {
if new.title.trim().is_empty() {
return Err(ApiError::EmptyTitle);
}
Ok(())
}
#[tracing::instrument(skip(db, payload))]
pub async fn create_post(
State(db): State<Db>,
payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<(StatusCode, Json<Post>), ApiError> {
let Json(new) = payload?;
check(&new)?;
let mut store = db.lock().unwrap();
store.next_id += 1;
let post = Post { id: store.next_id, title: new.title, body: new.body };
store.posts.insert(post.id, post.clone());
tracing::debug!(id = post.id, "created");
Ok((StatusCode::CREATED, Json(post)))
}
pub async fn get_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Post>, ApiError> {
let store = db.lock().unwrap();
let post = store.posts.get(&id).ok_or(ApiError::NotFound(id))?;
Ok(Json(post.clone()))
}
pub async fn list_posts(State(db): State<Db>) -> Json<Vec<Post>> {
let store = db.lock().unwrap();
Json(store.posts.values().cloned().collect())
}
pub async fn update_post(
State(db): State<Db>,
Path(id): Path<u32>,
payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<Json<Post>, ApiError> {
let Json(new) = payload?;
check(&new)?;
let mut store = db.lock().unwrap();
let post = store.posts.get_mut(&id).ok_or(ApiError::NotFound(id))?;
post.title = new.title;
post.body = new.body;
Ok(Json(post.clone()))
}
pub async fn delete_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<StatusCode, ApiError> {
let mut store = db.lock().unwrap();
store.posts.remove(&id).ok_or(ApiError::NotFound(id))?;
Ok(StatusCode::NO_CONTENT)
}Recap
tracingevents have a level and fields;tracing-subscriberprints themEnvFilterreadsRUST_LOG, so we choose at run time how much to seeTraceLayerlogs every request atDEBUG: settower_http=debugto see itwarn!ininto_responselogs every error we send#[instrument]opens a span for a function;skipleaves out arguments that cannot be printed
Next episode: a real database. We connect to SQLite with SQLx, so our posts survive a restart. Watch the series on YouTube.