Rolling Log Rotation in Rust with tracing-appender
Set up daily rolling logs, size caps, and non-blocking async writers in Rust using tracing-appender and tracing-subscriber for production daemons.
Rolling Log Rotation in Rust with tracing-appender
A long-running Rust daemon that writes every event to a single app.log file is a disk-space incident waiting to happen. After a few weeks the file balloons past 10 GB, grep takes minutes, and the host alerts on inode pressure. Rolling log rotation fixes this: each day (or hour, or minute) the writer closes the current file and opens a fresh one, optionally pruning old ones.
The tracing ecosystem ships an answer in tracing-appender, a small crate that pairs a RollingFileAppender with a non-blocking async writer. It's the same crate the tokio team uses internally, and it composes cleanly with tracing-subscriber so you don't fight your logging stack.
The minimal setup
Add the dependencies first.
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
Now wire a daily rolling file in main.
use tracing_appender::rolling;
use tracing_subscriber::{fmt, EnvFilter};
fn main() {
let file_appender = rolling::daily("./logs", "app.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
fmt()
.with_writer(non_blocking)
.with_env_filter(EnvFilter::from_default_env())
.with_ansi(false)
.init();
tracing::info!("daemon started");
// ... rest of the program
}
Two things matter here. First, rolling::daily("./logs", "app.log") writes to ./logs/app.log.2026-05-11 and rotates at UTC midnight, opening ./logs/app.log.2026-05-12 for the next day's events. Second, non_blocking returns a WorkerGuard that you must keep alive for the whole program. If you drop it, the background flush thread exits and you silently lose buffered events on shutdown. The _guard binding looks like dead code; it isn't.
Rotation cadence options
tracing-appender exposes four cadences via the rolling module:
let minutely = rolling::minutely("./logs", "trace.log");
let hourly = rolling::hourly("./logs", "trace.log");
let daily = rolling::daily("./logs", "app.log");
let never = rolling::never("./logs", "boot.log");
Pick the cadence based on log volume rather than habit. A web service emitting 50 lines per second produces roughly 4.3 million lines per day; at ~150 bytes per line that's 600 MB uncompressed. Daily rotation is fine. A noisy debug build pushing 5000 lines per second hits 60 GB per day, and hourly rotation lets you compress and ship each finished file before it dominates the disk.
rolling::never is useful for boot-time setup logs that should land in a single file regardless of when you restart.
Why non-blocking matters
The naive alternative \u2014 wiring RollingFileAppender directly as the subscriber writer \u2014 works, but every tracing::info! call now acquires a Mutex<File> and syncs to disk on the calling thread. For an HTTP handler hot loop this turns a 200 \u00b5s request into a 2 ms request the first time the page cache misses.
tracing_appender::non_blocking solves this with a bounded MPSC channel and a dedicated flush thread:
- Calling threads serialize a formatted line into a
Vec<u8>and push it onto the channel. No file I/O on the hot path. - The flush thread drains the channel and writes batches to the appender.
- If the channel fills (default capacity 128k messages), the calling thread either drops the event or blocks \u2014 configurable via
NonBlockingBuilder.
For production daemons I prefer to drop on overflow rather than block, because a stuck disk should not stall request handling:
use tracing_appender::non_blocking::NonBlockingBuilder;
let (writer, _guard) = NonBlockingBuilder::default()
.lossy(true)
.buffered_lines_limit(8192)
.finish(file_appender);
The trade-off is honest. lossy(true) means under sustained disk-write backpressure you lose events; lossy(false) means you preserve every event at the cost of blocking the producer. Choose lossy for request-path latency guarantees, non-lossy for audit trails where dropping is unacceptable.
Stacking stdout and file writers
In development you usually want logs on stdout too. tracing-subscriber composes layers, so you stack a file layer over a stdout layer:
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
let file_appender = rolling::daily("./logs", "app.log");
let (file_writer, _file_guard) = tracing_appender::non_blocking(file_appender);
let (stdout_writer, _stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(fmt::layer().with_writer(stdout_writer).with_ansi(true))
.with(fmt::layer().with_writer(file_writer).with_ansi(false).json())
.init();
The file layer emits JSON for downstream tools like jq or Vector; the stdout layer keeps the human-readable color output developers expect. Each writer gets its own guard; bundle them in a tuple and stash them in a OnceCell so they live for the program's lifetime.
Size caps \u2014 what tracing-appender doesn't do
tracing-appender rotates on time, not size. There's no built-in max_size_mb = 100 knob, which surprises people coming from log4j or Python's RotatingFileHandler. The maintainers have intentionally kept the crate small; the v0.2 docs describe only time-based rotation.
You have three pragmatic options:
-
Use
logrotateexternally. On a Linux host, drop a/etc/logrotate.d/myappconfig withsize 100M,rotate 7,compress, andcopytruncate. The kernel handles rotation; your Rust process keeps writing to the same path. This is the recommended pattern for systemd-managed daemons. -
Switch to
file-rotate. Thefile-rotatecrate adds size-based rotation with compression. Wrap it in aWriterand pass totracing-subscriber::fmt::layer().with_writer(...). You give up the daily-suffix naming convention but gain size caps. -
Use
rolling-file. Therolling-filecrate adds size + count limits on top of an interface similar totracing-appender. About 3\u00d7 the configuration surface, but you stay in thetracingecosystem.
For a typical single-binary daemon, logrotate + tracing-appender::daily is the lowest-config option that handles both time and size pressures.
Cleaning up old files
tracing-appender never deletes old rotated files. If your daemon runs for 90 days you'll have 90 app.log.2026-MM-DD files in ./logs/. Two ways to clean up:
# cron entry \u2014 keep last 30 days
0 3 * * * find /var/log/myapp -name "app.log.*" -mtime +30 -delete
Or inside the Rust program, spawn a cleanup task at startup that runs once a day:
use tokio::time::{interval, Duration};
use std::path::Path;
async fn cleanup_old_logs(log_dir: &Path, keep_days: u64) {
let mut tick = interval(Duration::from_secs(86_400));
loop {
tick.tick().await;
if let Ok(entries) = std::fs::read_dir(log_dir) {
let cutoff = std::time::SystemTime::now()
- Duration::from_secs(keep_days * 86_400);
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified < cutoff {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
}
}
}
External logrotate wins on operational simplicity; in-process cleanup wins when the binary ships to environments where you don't control the cron daemon.
Graceful shutdown \u2014 the dropped-guard bug
The most common production bug with tracing-appender is losing the final 100ms of logs on shutdown. It happens when:
fn main() {
let (writer, _guard) = tracing_appender::non_blocking(file_appender);
init_subscriber(writer);
run_server(); // panics or exits
} // _guard dropped here, but the channel may still have pending events
When _guard drops, it signals the flush thread to exit and waits for the channel to drain \u2014 but only if the runtime gives it time. With tokio::main you get clean teardown for free. With a manual std::process::exit, you bypass destructors and lose events.
Two defenses:
- Never call
std::process::exitfrom a daemon; return frommainor usepanicso destructors run. - Move the guard into a top-level
OnceCelland use a graceful-shutdown signal that returns frommainrather than aborting.
The 200 ms or so of latency between "event logged" and "event hits disk" is the cost of non-blocking I/O. Plan for it.
References: