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 levels — error, warn, info, debug, trace (in descending priority)
  2. Macros — error!, warn!, info!, debug!, trace! for each level
  3. Logger facade — log 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