rust.
rust6 min read

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:

  1. Use logrotate externally. On a Linux host, drop a /etc/logrotate.d/myapp config with size 100M, rotate 7, compress, and copytruncate. The kernel handles rotation; your Rust process keeps writing to the same path. This is the recommended pattern for systemd-managed daemons.

  2. Switch to file-rotate. The file-rotate crate adds size-based rotation with compression. Wrap it in a Writer and pass to tracing-subscriber::fmt::layer().with_writer(...). You give up the daily-suffix naming convention but gain size caps.

  3. Use rolling-file. The rolling-file crate adds size + count limits on top of an interface similar to tracing-appender. About 3\u00d7 the configuration surface, but you stay in the tracing ecosystem.

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:

  1. Never call std::process::exit from a daemon; return from main or use panic so destructors run.
  2. Move the guard into a top-level OnceCell and use a graceful-shutdown signal that returns from main rather 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: