When logs eat your disk
Your Rust service has been running for three months. It's handling traffic fine until the host disk fills up completely. The culprit is app.log, now 40 gigabytes. You can't open it in a text editor. You can't grep it efficiently. The system is choking on its own history. You need logs that manage themselves, splitting into manageable chunks based on size or time, so your disk stays healthy and your debugging stays sane.
What log rotation actually does
Log rotation is the practice of managing log files so they don't grow without bound. Instead of one endless file, you get a series of files: app.log, app.log.1, app.log.2. The active file is always the smallest. When it hits a limit, it rotates. The "appender" is the part of the logging system responsible for writing to the file.
Think of a receipt printer. The paper doesn't go on forever. When the roll gets too long, you tear it off and start a new one. Or a diary where you start a new volume every year. Log rotation is the same idea. You write to a file until a condition is met, then you close that file, rename it with an index or timestamp, and open a fresh one. The old files stay around for reference, or get deleted after a while.
Rust's logging ecosystem splits this into two layers. The log crate is a facade. It provides macros like info! and error!, but it doesn't write anything itself. You must register a backend implementation that handles the actual I/O. rotating_file_appender is a crate that provides that backend, handling file creation, rotation logic, and index management so you don't have to write it yourself.
Minimal setup
Add the dependencies to your Cargo.toml. You need the log facade for the macros and rotating_file_appender for the file handling.
[dependencies]
rotating_file_appender = "2.2"
log = "0.4"
Initialize the appender in your binary. This example sets a size limit of 100 megabytes per file.
use log::{info, LevelFilter};
use rotating_file_appender::{Builder, ConfigBuilder, rotating_file_appender};
use std::io;
fn main() -> io::Result<()> {
// Configure rotation rules: 100MB max size per file.
let config = ConfigBuilder::default()
.filename("app.log")
.max_size(100_000_000)
.build();
// Build the appender. This creates the file handle and sets up rotation logic.
let appender = Builder::default()
.config(config)
.build()?;
// Wrap the appender in a logger that implements the `log::Log` trait.
let logger = rotating_file_appender::Logger::new(appender);
// Register the logger globally and set the max level to Info.
log::set_boxed_logger(Box::new(logger))
.map(|()| log::set_max_level(LevelFilter::Info))
.unwrap();
info!("Application started");
Ok(())
}
How the pieces fit together
The ConfigBuilder defines the rules. Here, max_size sets the byte limit. When the file grows past 100 megabytes, the appender closes the current file, shifts existing rotated files (renaming app.log.1 to app.log.2, etc.), and opens a new app.log. The Builder creates the actual file handle. The Logger struct implements the Log trait, which is the contract the log crate expects.
Calling log::set_boxed_logger registers this implementation globally. From that point on, every info!, warn!, or error! macro call routes through your rotating appender. The map chain sets the maximum log level. If the logger registration succeeds, set_max_level runs. If it fails, the unwrap panics, which is acceptable in a main function where logging is required for the app to function.
The rotation happens on the next write after the threshold is crossed. The crate handles the file system operations internally. Your code just calls info! and the appender decides whether to write or rotate.
Production-ready configuration
A minimal setup gets you rotating files, but production needs more safeguards. You need to limit the number of rotated files to prevent disk exhaustion. You also need to handle initialization errors gracefully.
use log::{info, LevelFilter};
use rotating_file_appender::{Builder, ConfigBuilder, rotating_file_appender};
use std::io;
fn main() -> io::Result<()> {
// Realistic config: 50MB max, keep only 5 rotated files.
// This prevents disk exhaustion if the app logs heavily.
let config = ConfigBuilder::default()
.filename("server.log")
.max_size(50_000_000)
.max_files(5) // Keep server.log.1 through server.log.5
.build();
let appender = Builder::default()
.config(config)
.build()?;
let logger = rotating_file_appender::Logger::new(appender);
// Initialize logger. Handle the case where logger is already set.
if log::set_boxed_logger(Box::new(logger)).is_ok() {
log::set_max_level(LevelFilter::Debug);
}
info!("Server listening on port 8080");
Ok(())
}
The max_files parameter is a safety net. It caps the number of rotated files kept on disk. When the limit is reached, the oldest file gets deleted. Without this, a high-traffic service could generate terabytes of logs and crash the host. The convention in Rust logging is to set the max level during initialization. If you're building a library, avoid setting the global logger; let the binary do that. Libraries should just use the macros.
Pitfalls and silent failures
Calling log::set_boxed_logger twice panics. The second call returns a SetLoggerError. If your app initializes logging in multiple places, you'll hit this. Check the return value.
If you call set_boxed_logger after the logger is already initialized, the function returns Err(SetLoggerError). Unwrapping this result crashes your app.
File operations are fallible. Builder::build() returns a Result. If the directory doesn't exist or permissions are wrong, you get an io::Error. Handle this result. Don't let the app start without a working logger, or you'll lose visibility into crashes.
The log crate is a facade. If you use info! without initializing a logger, the message is discarded. There is no compiler error. The code compiles and runs, but you see nothing. This is the "silent failure" trap. Always verify your logger is set up before relying on logs.
Treat the logger initialization as critical infrastructure. If it fails, fail fast.
The hard way
You could write rotation yourself. Open a file, track bytes, close, rename, open new. Sounds easy. It isn't. You have to handle concurrent writes if your app is multi-threaded. You have to ensure renames are atomic on the file system. You have to handle the case where the file is deleted while the app is running. You have to manage the index shifting. The rotating_file_appender crate encapsulates this complexity. Writing your own rotation logic is a recipe for subtle bugs and lost logs.
Don't reinvent rotation. The edge cases are endless.
Choosing your logging stack
Use rotating_file_appender when you are using the log facade and need a drop-in solution for size-based or time-based file rotation without managing the rotation logic yourself.
Use tracing-appender when your application uses the tracing crate and you want rotation that works seamlessly with tracing's layer system and structured data.
Use fern when you need a multi-destination logger that can split logs by level, send errors to a file while info goes to stdout, and handle rotation as part of a broader configuration.
Use env_logger for CLI tools and scripts where you only need logs to standard error and want configuration via environment variables.
Pick the tool that matches your logging facade. Mixing log and tracing backends causes more pain than it solves.