Skip to content
portfolio/howtos/Learning Rust/

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

src/main.rs
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
Tip
.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:

  1. Hardcoded defaults
  2. Configuration file (TOML, JSON, YAML)
  3. Environment variables
  4. 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 14
src/config.rs
use 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(),
        }
    }
}
config.toml
# Server configuration
host = "0.0.0.0"
port = 3000
workers = 8
static_root = "/var/www/html"
log_level = "debug"
Note
Serde's #[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 derive
src/cli.rs
use 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 version

Configurable Server

Now wire everything together - config file, environment variables, and CLI arguments, with proper logging:

src/main.rs
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 info

Log 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 run

The 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}
Tip
Switch between formats based on an environment variable or CLI flag: --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:

BeforeAfter
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

  1. Replace every println! and eprintln! in your server with the appropriate tracing macro. Run with RUST_LOG=trace and observe the output.
  2. Add a --json flag to the CLI. When set, output logs in JSON format. Verify the output is valid JSON with cargo run -- --json 2>&1 | jq.
  3. Create a config.toml with non-default values. Verify the priority chain: the config file sets port 3000, the PORT env var sets 4000, and --port 5000 wins.
  4. Add a span to handle_connection that includes a request ID (incrementing counter or random UUID). Verify that all log lines from one request share the same ID.
  5. Add a --dry-run flag that loads config, logs all settings at info level, 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.