How do I log messages in Rust?

Walkthrough

The log crate provides a lightweight logging facade for Rust applications. It defines logging macros (error!, warn!, info!, debug!, trace!) but doesn't implement actual output—you choose a logger implementation like env_logger, simple_logger, or fern. This separation lets libraries log without forcing a specific logging backend on applications.

Key concepts:

  1. Log levelserror, warn, info, debug, trace (in descending priority)
  2. Macroserror!, warn!, info!, debug!, trace! for each level
  3. Logger facadelog crate provides the API, implementations provide the output
  4. env_logger — popular implementation controlled by RUST_LOG environment variable
  5. Structured data — format strings and structured logging support

The log crate is the standard logging interface used by most Rust libraries.

Code Example

# Cargo.toml
[dependencies]
log = "0.4"
env_logger = "0.10"
use log::{error, warn, info, debug, trace};
 
fn main() {
    // Initialize the logger
    env_logger::init();
    
    // Log at different levels
    error!("This is an error message");
    warn!("This is a warning message");
    info!("This is an info message");
    debug!("This is a debug message");
    trace!("This is a trace message");
    
    println!("Application finished");
}
 
// Run with: RUST_LOG=debug cargo run

Log Levels and Filtering

use log::{error, warn, info, debug, trace, Level, LevelFilter};
 
fn main() {
    // Configure logger programmatically
    env_logger::builder()
        .filter_level(LevelFilter::Debug)  // Minimum level to show
        .init();
    
    // Each level has a specific use case:
    
    // ERROR: Serious problems that require attention
    error!("Failed to connect to database");
    error!("Configuration file not found: {}", "config.toml");
    
    // WARN: Potentially harmful situations
    warn!("Connection pool is running low");
    warn!("Rate limit approaching: {}/{} requests", 95, 100);
    
    // INFO: Important runtime events
    info!("Server started on port {}", 8080);
    info!("User {} logged in", "alice");
    
    // DEBUG: Detailed diagnostic information
    debug!("Processing request: {:?}", "/api/users/42");
    debug!("Query execution time: {}ms", 15);
    
    // TRACE: Very detailed diagnostic information
    trace!("Entering function process_request");
    trace!("Variable x = {}, y = {}", 10, 20);
    
    // Check level at runtime
    if log::log_enabled!(Level::Debug) {
        let expensive_data = compute_debug_info();
        debug!("Debug info: {}", expensive_data);
    }
}
 
fn compute_debug_info() -> String {
    // Expensive computation only done if debug logging is enabled
    "expensive debug data".to_string()
}

Environment Variable Configuration

use log::{info, debug};
 
fn main() {
    // The env_logger reads RUST_LOG environment variable
    // Examples:
    // RUST_LOG=info           - Show info and above
    // RUST_LOG=debug          - Show debug and above
    // RUST_LOG=my_app=trace   - Show trace for my_app module only
    // RUST_LOG=my_app::db=debug,my_app::api=info
    // RUST_LOG=warn,my_app=debug
    
    env_logger::Builder::from_env(
        env_logger::Env::default().default_filter_or("info")
    ).init();
    
    info!("Application starting");
    debug!("Debug information");  // Only shown if RUST_LOG includes debug
}
 
// Run with:
// cargo run                                    # Uses default (info)
// RUST_LOG=debug cargo run                     # Shows debug and above
// RUST_LOG=my_app=trace cargo run              # Shows trace for my_app
// RUST_LOG=warn cargo run                      # Shows warn and above
// RUST_LOG=info,my_app::module=debug cargo run # Per-module filtering

Format Strings and Variables

use log::{info, warn, error, debug};
 
fn main() {
    env_logger::init();
    
    let username = "alice";
    let user_id = 42;
    let balance = 1234.56;
    let items = vec!["apple", "banana", "cherry"];
    
    // Basic formatting
    info!("User logged in");
    info!("User {} logged in", username);
    info!("User {} (id: {}) logged in", username, user_id);
    
    // Named arguments (clearer for multiple values)
    info!("User {user} has balance {balance}", user = username, balance = balance);
    info!("Processing user {id} with name {name}", id = user_id, name = username);
    
    // Debug formatting with {:?}
    debug!("Items: {:?}", items);
    debug!("User struct: {:?}", User { id: user_id, name: username });
    
    // Display formatting with {}
    info!("Balance: ${:.2}", balance);
    
    // Binary, octal, hex
    debug!("ID in hex: {:#x}", user_id);
    debug!("ID in binary: {:b}", user_id);
}
 
#[derive(Debug)]
struct User {
    id: u32,
    name: String,
}

Module-Level Filtering

// src/main.rs
use log::{info, debug};
 
mod network {
    use log::{info, debug, trace};
    
    pub fn connect(host: &str) {
        trace!("network::connect called with host={}", host);
        debug!("Establishing connection to {}", host);
        // ... connection logic
        info!("Connected to {}", host);
    }
    
    pub fn disconnect() {
        info!("Disconnected from server");
    }
}
 
mod database {
    use log::{info, debug, trace};
    
    pub fn query(sql: &str) {
        trace!("database::query called");
        debug!("Executing SQL: {}", sql);
        // ... query logic
        info!("Query returned 42 rows");
    }
}
 
fn main() {
    // Configure different log levels for different modules
    env_logger::Builder::new()
        // Default to info level
        .filter_level(log::LevelFilter::Info)
        // Show debug for network module
        .filter_module("network", log::LevelFilter::Debug)
        // Show trace for database module
        .filter_module("database", log::LevelFilter::Trace)
        .init();
    
    info!("Application starting");
    
    network::connect("example.com");
    database::query("SELECT * FROM users");
    network::disconnect();
    
    info!("Application finished");
}
 
/* Output with above configuration:
INFO  [main] Application starting
DEBUG [network] Establishing connection to example.com
INFO  [network] Connected to example.com
TRACE [database] database::query called
DEBUG [database] Executing SQL: SELECT * FROM users
INFO  [database] Query returned 42 rows
INFO  [network] Disconnected from server
INFO  [main] Application finished
*/

Custom Log Formatting

use log::{info, warn, error, LevelFilter};
use std::io::Write;
 
fn main() {
    // Custom formatter
    env_logger::builder()
        .format(|buf, record| {
            writeln!(
                buf,
                "[{} {} {}] {}",
                chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
                record.level(),
                record.target(),
                record.args()
            )
        })
        .filter_level(LevelFilter::Debug)
        .init();
    
    info!("Application started");
    warn!("Low memory warning");
    error!("Failed to open file");
}
 
// Add to Cargo.toml:
// chrono = "0.4"

Logging in Libraries

// src/lib.rs
// Libraries should use the log crate but NOT initialize a logger
// The application using the library will choose the logger implementation
 
use log::{debug, info, warn, error};
 
pub struct Cache<K, V> {
    data: std::collections::HashMap<K, V>,
    capacity: usize,
}
 
impl<K: std::hash::Hash + Eq + Clone + std::fmt::Debug, V: Clone> Cache<K, V> {
    pub fn new(capacity: usize) -> Self {
        debug!("Creating cache with capacity {}", capacity);
        Cache {
            data: std::collections::HashMap::new(),
            capacity,
        }
    }
    
    pub fn get(&self, key: &K) -> Option<&V> {
        let result = self.data.get(key);
        match &result {
            Some(_) => debug!("Cache hit for key {:?}", key),
            None => debug!("Cache miss for key {:?}", key),
        }
        result
    }
    
    pub fn insert(&mut self, key: K, value: V) {
        if self.data.len() >= self.capacity {
            warn!("Cache full, cannot insert {:?}", key);
            return;
        }
        debug!("Inserting key {:?}", key);
        self.data.insert(key, value);
    }
    
    pub fn clear(&mut self) {
        info!("Clearing cache (was {} items)", self.data.len());
        self.data.clear();
    }
}
 
// Application code would initialize the logger:
// fn main() {
//     env_logger::init();
//     let mut cache = Cache::new(10);
//     // ...
// }

File Logging with Fern

# Cargo.toml
[dependencies]
log = "0.4"
fern = "0.6"
chrono = "0.4"
use log::{info, warn, error, LevelFilter};
use fern::colors::{Color, ColoredLevelConfig};
 
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
    let colors = ColoredLevelConfig::new()
        .error(Color::Red)
        .warn(Color::Yellow)
        .info(Color::Green)
        .debug(Color::Blue)
        .trace(Color::BrightBlack);
    
    fern::Dispatch::new()
        // Output to stdout with colors
        .chain(
            fern::Dispatch::new()
                .format(move |out, message, record| {
                    out.finish(format_args!(
                        "[{} {} {}] {}",
                        chrono::Local::now().format("%H:%M:%S"),
                        colors.color(record.level()),
                        record.target(),
                        message
                    ))
                })
                .chain(std::io::stdout())
        )
        // Output to file
        .chain(
            fern::Dispatch::new()
                .format(|out, message, record| {
                    out.finish(format_args!(
                        "[{} {} {}] {}",
                        chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
                        record.level(),
                        record.target(),
                        message
                    ))
                })
                .chain(fern::log_file("app.log")?)
        )
        .level(LevelFilter::Debug)
        .apply()?;
    
    Ok(())
}
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    setup_logging()?;
    
    info!("Application starting");
    warn!("This is a warning");
    error!("This is an error");
    
    Ok(())
}

Conditional Compilation

use log::{info, debug, trace};
 
fn main() {
    env_logger::init();
    
    // log_enabled! macro checks if logging is enabled at compile time
    // Useful for avoiding expensive computations when logging is disabled
    
    // Always logged
    info!("Application starting");
    
    // Only compute if debug logging is enabled
    if log::log_enabled!(log::Level::Debug) {
        let stats = compute_expensive_statistics();
        debug!("Application stats: {:?}", stats);
    }
    
    // Only compute if trace logging is enabled
    if log::log_enabled!(log::Level::Trace) {
        let state = capture_full_state();
        trace!("Full application state: {:?}", state);
    }
}
 
fn compute_expensive_statistics() -> Vec<u64> {
    // Simulate expensive computation
    (0..1000).collect()
}
 
fn capture_full_state() -> String {
    // Simulate expensive state capture
    "Full state...".repeat(100)
}

Integration with Structs

use log::{info, debug, warn, error};
 
struct UserService {
    db_url: String,
    cache: std::collections::HashMap<u32, String>,
}
 
impl UserService {
    pub fn new(db_url: &str) -> Self {
        info!("Creating UserService with db_url={}", db_url);
        UserService {
            db_url: db_url.to_string(),
            cache: std::collections::HashMap::new(),
        }
    }
    
    pub fn get_user(&mut self, id: u32) -> Option<&String> {
        debug!("Getting user with id={}", id);
        
        // Check cache first
        if let Some(user) = self.cache.get(&id) {
            debug!("User {} found in cache", id);
            return Some(user);
        }
        
        // Simulate database lookup
        debug!("User {} not in cache, querying database", id);
        if id > 0 && id < 100 {
            let user = format!("User {}", id);
            self.cache.insert(id, user);
            info!("User {} loaded from database", id);
            self.cache.get(&id)
        } else {
            warn!("User {} not found", id);
            None
        }
    }
    
    pub fn delete_user(&mut self, id: u32) -> bool {
        debug!("Deleting user {}", id);
        if self.cache.remove(&id).is_some() {
            info!("User {} deleted from cache", id);
            true
        } else {
            warn!("User {} not in cache, cannot delete", id);
            false
        }
    }
}
 
struct App {
    service: UserService,
}
 
impl App {
    pub fn new() -> Self {
        info!("Initializing application");
        App {
            service: UserService::new("postgres://localhost/mydb"),
        }
    }
    
    pub fn run(&mut self) {
        info!("Application running");
        
        // Test user operations
        self.service.get_user(1);
        self.service.get_user(1);  // Cache hit
        self.service.get_user(999);  // Not found
        self.service.delete_user(1);
        self.service.delete_user(1);  // Already deleted
        
        info!("Application finished");
    }
}
 
fn main() {
    env_logger::builder()
        .filter_level(log::LevelFilter::Debug)
        .init();
    
    let mut app = App::new();
    app.run();
}

Error Handling with Logging

use log::{info, warn, error, debug};
use std::fs;
use std::io;
 
fn read_config(path: &str) -> io::Result<String> {
    info!("Reading configuration from {}", path);
    fs::read_to_string(path).map_err(|e| {
        error!("Failed to read config '{}': {}", path, e);
        e
    })
}
 
fn parse_config(content: &str) -> Result<Config, String> {
    debug!("Parsing configuration ({} bytes)", content.len());
    
    if content.is_empty() {
        warn!("Configuration is empty");
        return Err("Empty configuration".to_string());
    }
    
    // Simulate parsing
    Ok(Config {
        name: "default".to_string(),
        port: 8080,
    })
}
 
#[derive(Debug)]
struct Config {
    name: String,
    port: u16,
}
 
fn run() -> Result<(), Box<dyn std::error::Error>> {
    let content = read_config("config.txt")
        .map_err(|e| format!("Config error: {}", e))?;
    
    let config = parse_config(&content)
        .map_err(|e| format!("Parse error: {}", e))?;
    
    info!("Configuration loaded: name={}, port={}", config.name, config.port);
    Ok(())
}
 
fn main() {
    env_logger::builder()
        .filter_level(log::LevelFilter::Debug)
        .init();
    
    match run() {
        Ok(()) => info!("Application completed successfully"),
        Err(e) => error!("Application failed: {}", e),
    }
}

Summary

  • Use log crate for logging macros in libraries and applications
  • Use env_logger or another implementation for actual log output
  • Log levels (descending): error!, warn!, info!, debug!, trace!
  • Initialize logger in main() with env_logger::init()
  • Control output with RUST_LOG environment variable
  • RUST_LOG=debug shows debug and above
  • RUST_LOG=my_module=trace sets level for specific module
  • Use log_enabled!(Level::Debug) to avoid expensive computations
  • Format with {} for Display, {:?} for Debug
  • Named arguments: info!("User {id} logged in", id = 42)
  • Libraries should log but NOT initialize a logger
  • Applications choose and configure the logger implementation
  • Use .filter_module("module", level) for per-module filtering
  • Use fern for advanced features like file output and rotation
  • Log important events at INFO, diagnostic info at DEBUG, detailed traces at TRACE
  • Always log errors at ERROR level with context