Chapter 20
Logging, Config & CLI Arguments
Replace println! with structured logging, load configuration from multiple sources, and give the server a proper command-line interface.
Structured Logging with tracing
println! works for debugging but fails in production: no timestamps, no log levels, no structured data, and it can't be filtered or routed to files. The tracing crate is the Rust ecosystem standard for structured, leveled logging.
cargo add tracing
cargo add tracing-subscriber --features "fmt,env-filter"tracing provides the macros (info!, error!, etc.). tracing-subscriber provides the output formatting and filtering. They're separate because you might want different outputs - terminal, JSON, a file, or a remote service.
Setup
use tracing::{info, warn, error, debug, trace};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
// Initialize the subscriber - do this once, at startup
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"))
)
.with_target(false) // hide module paths in output
.with_thread_ids(false) // hide thread IDs
.init();
info!("Server starting");
// 2026-04-10T12:00:00.000Z INFO Server starting
let addr = "127.0.0.1:8080";
info!(addr = addr, "Listening");
// 2026-04-10T12:00:00.001Z INFO Listening addr="127.0.0.1:8080"
}Log Levels
Five levels, from most to least severe:
error!("Failed to bind to port {}", port);
// Something is broken. The server can't continue normally.
warn!("Slow response: {} took {:?}", path, elapsed);
// Something is wrong but recoverable. Worth investigating.
info!("Listening on http://{}", addr);
// Normal operation milestones. The default level in production.
debug!("Parsed request: {:?}", request);
// Detailed info for debugging. Off by default in production.
trace!("Read {} bytes from socket", n);
// Very verbose. Only for deep debugging.Structured Fields
Unlike println!, tracing records key-value pairs as structured data - they can be queried, filtered, and formatted:
// Structured fields - machine-parseable
info!(
method = %request.method,
path = %request.path,
status = response.status,
elapsed_ms = elapsed.as_millis() as u64,
"Request handled"
);
// 2026-04-10T12:00:00.050Z INFO Request handled method=GET path=/api/todos status=200 elapsed_ms=3
// The % sigil uses Display formatting
// The ? sigil uses Debug formatting
debug!(headers = ?request.headers, "Request details");Spans - Tracing Across Async Tasks
Spans group related log events. In an async server, a span tracks one request through all its stages, even as the task jumps between threads:
use tracing::{info_span, Instrument};
async fn handle_connection(stream: TcpStream, router: Arc<Router>) {
let peer = stream.peer_addr().ok();
// Create a span for this entire connection
let span = info_span!("request", peer = ?peer);
async {
// Everything inside here is tagged with the span
info!("Connection accepted");
let response = process_request(&mut stream, &router).await;
match &response {
Ok(resp) => info!(status = resp.status, "Response sent"),
Err(e) => warn!(error = %e, "Request failed"),
}
}
.instrument(span) // attach the span to this async block
.await;
}
// Output:
// INFO request{peer=127.0.0.1:54321}: Connection accepted
// INFO request{peer=127.0.0.1:54321}: Response sent status=200.instrument(span) is how you attach a span to an async future. The span context follows the task even when it's moved between threads by the Tokio scheduler. This makes it possible to correlate all log lines from one request.Configuration from Files and Environment Variables
A production server needs configurable settings: port, bind address, static file root, database URL, and more. The standard pattern is to load from multiple sources with increasing priority:
- Hardcoded defaults
- Configuration file (TOML, JSON, YAML)
- Environment variables
- Command-line arguments (highest priority)
Let's start with a simple approach using Serde and TOML:
cargo add toml
# serde is already a dependency from Chapter 14use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
pub struct Config {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_workers")]
pub workers: usize,
#[serde(default = "default_static_root")]
pub static_root: PathBuf,
#[serde(default)]
pub log_level: LogLevel,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Trace,
Debug,
#[default]
Info,
Warn,
Error,
}
fn default_host() -> String { "127.0.0.1".to_string() }
fn default_port() -> u16 { 8080 }
fn default_workers() -> usize { 4 }
fn default_static_root() -> PathBuf { PathBuf::from("public") }
impl Config {
/// Load config from a TOML file, falling back to defaults.
pub fn from_file(path: &str) -> Self {
match std::fs::read_to_string(path) {
Ok(contents) => {
toml::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Warning: invalid config file: {}", e);
Config::default()
})
}
Err(_) => Config::default(),
}
}
/// Override fields from environment variables.
pub fn with_env(mut self) -> Self {
if let Ok(host) = std::env::var("HOST") {
self.host = host;
}
if let Ok(port) = std::env::var("PORT") {
if let Ok(p) = port.parse() {
self.port = p;
}
}
if let Ok(root) = std::env::var("STATIC_ROOT") {
self.static_root = PathBuf::from(root);
}
if let Ok(level) = std::env::var("LOG_LEVEL") {
self.log_level = match level.to_lowercase().as_str() {
"trace" => LogLevel::Trace,
"debug" => LogLevel::Debug,
"info" => LogLevel::Info,
"warn" => LogLevel::Warn,
"error" => LogLevel::Error,
_ => self.log_level,
};
}
self
}
pub fn addr(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
impl Default for Config {
fn default() -> Self {
Config {
host: default_host(),
port: default_port(),
workers: default_workers(),
static_root: default_static_root(),
log_level: LogLevel::default(),
}
}
}# Server configuration
host = "0.0.0.0"
port = 3000
workers = 8
static_root = "/var/www/html"
log_level = "debug"#[serde(default = "function")] attribute calls the named function when a field is missing from the config file. This means partial config files work - you only override what you need.Parsing Command-Line Arguments with clap
clap is Rust's most popular CLI argument parser. With the derive feature, you define your CLI as a struct and clap generates the parser, help text, and validation:
cargo add clap --features deriveuse clap::Parser;
use std::path::PathBuf;
/// A webserver built from scratch in Rust.
#[derive(Parser, Debug)]
#[command(name = "webserver", version, about)]
pub struct Cli {
/// Host address to bind to
#[arg(short = 'H', long, default_value = "127.0.0.1")]
pub host: String,
/// Port to listen on
#[arg(short, long, default_value_t = 8080)]
pub port: u16,
/// Number of worker threads
#[arg(short, long, default_value_t = 4)]
pub workers: usize,
/// Directory to serve static files from
#[arg(short, long, default_value = "public")]
pub static_root: PathBuf,
/// Log level (trace, debug, info, warn, error)
#[arg(short, long, default_value = "info")]
pub log_level: String,
/// Path to config file
#[arg(short, long, default_value = "config.toml")]
pub config: PathBuf,
}Clap auto-generates help and version output:
$ webserver --help
A webserver built from scratch in Rust.
Usage: webserver [OPTIONS]
Options:
-H, --host <HOST> Host address to bind to [default: 127.0.0.1]
-p, --port <PORT> Port to listen on [default: 8080]
-w, --workers <WORKERS> Number of worker threads [default: 4]
-s, --static-root <STATIC_ROOT> Directory to serve static files from [default: public]
-l, --log-level <LOG_LEVEL> Log level [default: info]
-c, --config <CONFIG> Path to config file [default: config.toml]
-h, --help Print help
-V, --version Print versionConfigurable Server
Now wire everything together - config file, environment variables, and CLI arguments, with proper logging:
mod cli;
mod config;
mod error;
mod handler;
mod request;
mod response;
mod router;
use std::sync::Arc;
use clap::Parser;
use tokio::net::TcpListener;
use tracing::{info, error};
use tracing_subscriber::EnvFilter;
use cli::Cli;
use config::Config;
#[tokio::main]
async fn main() {
// 1. Parse CLI arguments
let cli = Cli::parse();
// 2. Load config: file → env overrides → CLI overrides
let mut config = Config::from_file(cli.config.to_str().unwrap_or("config.toml"))
.with_env();
// CLI arguments take highest priority
config.host = cli.host;
config.port = cli.port;
config.workers = cli.workers;
config.static_root = cli.static_root;
// 3. Initialize logging
let log_filter = format!(
"webserver={},tower=warn",
cli.log_level
);
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&log_filter))
)
.with_target(false)
.init();
// 4. Build the application
let state = Arc::new(handler::build_router());
info!(
host = %config.host,
port = config.port,
workers = config.workers,
static_root = %config.static_root.display(),
"Server configured"
);
// 5. Bind and serve
let addr = config.addr();
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(addr = %addr, error = %e, "Failed to bind");
std::process::exit(1);
}
};
info!(addr = %addr, "Listening");
loop {
match listener.accept().await {
Ok((stream, peer)) => {
let state = Arc::clone(&state);
tokio::spawn(async move {
tracing::debug!(peer = %peer, "Connection accepted");
// handle_connection(stream, state).await;
});
}
Err(e) => error!(error = %e, "Accept failed"),
}
}
}Usage examples:
# Defaults - localhost:8080, info logging
cargo run
# Custom port and debug logging
cargo run -- --port 3000 --log-level debug
# From environment variables
PORT=9090 LOG_LEVEL=trace cargo run
# Override the RUST_LOG env var directly (tracing-subscriber respects it)
RUST_LOG=debug cargo run
# With a config file
cargo run -- --config production.toml
# All together
PORT=3000 cargo run -- --host 0.0.0.0 --workers 8 --log-level infoLog Levels and Filtering
The EnvFilter from tracing-subscriber supports powerful per-module filtering via the RUST_LOG environment variable:
# Set the global level
RUST_LOG=debug cargo run
# Different levels for different modules
RUST_LOG="webserver=debug,tokio=warn,hyper=info" cargo run
# Show everything from your server, silence dependencies
RUST_LOG="webserver=trace" cargo run
# Only errors
RUST_LOG=error cargo run
# Specific module at trace level
RUST_LOG="webserver::request=trace,webserver=info" cargo runThe syntax is target=level with comma separation. The target is usually the module path. This lets you turn on verbose logging for just the request parser while keeping everything else quiet.
JSON Output for Production
Human-readable logs are great for development. Production systems typically want JSON for log aggregation (ELK, Datadog, etc.):
// Development - pretty, human-readable
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new("info"))
.init();
// 2026-04-10T12:00:00.000Z INFO Request handled method=GET path=/ status=200
// Production - JSON for log aggregation
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new("info"))
.json()
.init();
// {"timestamp":"2026-04-10T12:00:00.000Z","level":"INFO","message":"Request handled","method":"GET","path":"/","status":200}--log-format json for production, --log-format pretty (or default) for development. Keep the same structured fields in both - they just render differently.Replacing println! Throughout the Server
With tracing in place, here's a before/after for the most common log points in our server:
| Before | After |
|---|---|
| println!("Listening on ...") | info!(addr = %addr, "Listening") |
| println!("{} {}", method, path) | info!(method = %m, path = %p, "Request") |
| eprintln!("Error: {}", e) | error!(error = %e, "Request failed") |
| println!("→ {}", status) | info!(status, elapsed_ms, "Response") |
| println!("Read bytes", n) | trace!(bytes = n, "Socket read") |
Exercise
- Replace every
println!andeprintln!in your server with the appropriate tracing macro. Run withRUST_LOG=traceand observe the output. - Add a
--jsonflag to the CLI. When set, output logs in JSON format. Verify the output is valid JSON withcargo run -- --json 2>&1 | jq. - Create a
config.tomlwith non-default values. Verify the priority chain: the config file sets port 3000, thePORTenv var sets 4000, and--port 5000wins. - Add a span to
handle_connectionthat includes a request ID (incrementing counter or random UUID). Verify that all log lines from one request share the same ID. - Add a
--dry-runflag that loads config, logs all settings atinfolevel, and exits without actually starting the server. Useful for validating config files.
What's Next
The server is now properly instrumented: structured logging with levels and spans, configuration from files and environment variables, and a polished CLI with --help and version output. These are the operational basics every production server needs.
In the next chapter, we build a middleware system - composable layers for logging, authentication, CORS, and other cross-cutting concerns that wrap every request.