logrotate

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 7, 2026 License: MIT Imports: 14 Imported by: 0

README

logrotate

Doc Go Release Test Report Card Stars License

A modern rotating-file writer for Go logs, with no third-party dependencies.

logrotate.Writer is an io.WriteCloser that sits at the bottom of any logging stack — log, slog, zap, zerolog, anything that writes to an io.Writer — and takes care of everything that happens to the file afterwards: size- and clock-based rotation, backup naming, gzip compression, and retention by count, age and total disk usage.

Features

  • Validated configuration. New returns an error for a bad option — an unwritable path, an invalid schedule, a broken timestamp layout — instead of degrading silently at the first write. Options are immutable afterwards, so there is no configuration race by design.
  • Byte-precision size limit (WithMaxSize(64*logrotate.MB)) with readable size constants. A record larger than the cap is still written in full and isolated into its own backup; log data is never rejected.
  • Wall-clock rotation without a timer goroutine. WithRotateEvery(d) rotates on boundaries anchored at midnight (hourly on the hour, daily at midnight); WithRotateAt("03:00") rotates at fixed times of day. Boundaries are evaluated on write: an idle service creates no empty files, and backups are stamped with the boundary that ended their period, not the arbitrary moment the next write happened.
  • Restart-aware. The current rotation period is recovered from the log file's modification time, so a leftover file from yesterday is rotated out at startup instead of collecting today's records.
  • Three retention dimensions: WithMaxBackups (count), WithMaxAge (a time.Duration, not "days"), and WithMaxTotalSize — a hard disk quota for all backups combined, enforced on post-compression sizes.
  • Crash-safe compression. Backups are compressed in the background to a temporary file, fsynced, then renamed into place; an interrupted pass is detected and finished on the next run. gzip is built in; any algorithm (zstd, lz4, …) plugs in through the two-method Compressor interface without adding dependencies to this package.
  • Collision-proof backup names. Rotating twice within one timestamp granule appends a sequence (app-2026-07-07.1.log), so backups are never overwritten. Empty files are never rotated into empty backups.
  • Observable background work. Compression and cleanup errors go to your WithErrorHandler callback instead of being lost.
  • Clean lifecycle. Close is idempotent, stops the maintenance goroutine, and waits for pending compression to finish; writes after close fail fast with ErrClosed. Sync satisfies zap's WriteSyncer; Reopen cooperates with external tools like logrotate(8).
  • Fast. One mutex, no allocations on the write path (0 B/op), ~50 ns added by schedule checks.

Installation

go get github.com/libtnb/logrotate

Quick start

package main

import (
	"log"
	"time"

	"github.com/libtnb/logrotate"
)

func main() {
	w, err := logrotate.New("/var/log/myapp/app.log",
		logrotate.WithMaxSize(64*logrotate.MB),     // rotate past 64 MB…
		logrotate.WithRotateEvery(24*time.Hour),    // …or at midnight, whichever first
		logrotate.WithMaxBackups(14),               // keep at most 14 backups
		logrotate.WithMaxAge(14*logrotate.Day),     // and none older than two weeks
		logrotate.WithMaxTotalSize(1*logrotate.GB), // bounded to 1 GB of disk
		logrotate.WithCompress(),                   // gzipped in the background
	)
	if err != nil {
		log.Fatal(err) // bad config or unwritable path surfaces here
	}
	defer w.Close()

	log.SetOutput(w)
	log.Println("ready")
}

The file passed to New is always the active log file. Rotation renames it to app-2026-07-07T00-00-00.000.log (then .log.gz once compressed) in the same directory and reopens the original path, so tail -F and shippers keep working. Every default is safe: 100 MB size cap, no time rotation, keep everything, no compression, UTC timestamps, 0o600 files.

Rotation triggers

Trigger Option Backup timestamp
File reaches the size cap WithMaxSize(n) time of rotation
Wall-clock boundary passes WithRotateEvery(d) the boundary itself
Time of day passes WithRotateAt("HH:MM", ...) the boundary itself
Manual w.Rotate() time of rotation

Time-based triggers fire on the first write after the boundary. That write is placed in the new file; everything before it is rotated out under the boundary's timestamp. If several boundaries pass while the process is idle or down, only one rotation happens — no empty catch-up files.

WithRotateEvery accepts 1s to 24h and anchors at midnight in the configured time zone (UTC by default, any zone with WithLocation), so every day repeats the same boundary sequence regardless of when the process started.

Retention and compression

After every rotation a single background goroutine:

  1. removes orphaned temporary files from an interrupted compression,
  2. deletes backups beyond WithMaxBackups or older than WithMaxAge,
  3. compresses the survivors (if a compressor is configured),
  4. deletes the oldest backups until the rest fit in WithMaxTotalSize.

Failures affect only the file involved, are reported to WithErrorHandler, and are retried on the next pass. The total-size quota always reflects actual bytes on disk — a backup whose compression failed counts at its uncompressed size, so the disk bound holds even when compression cannot make progress. Files in the directory that don't match this writer's backup pattern are never touched.

To plug in another algorithm, implement two methods:

type ZstdCompressor struct{}

func (ZstdCompressor) Compress(dst io.Writer, src io.Reader) error {
	enc, err := zstd.NewWriter(dst)
	if err != nil {
		return err
	}
	if _, err := io.Copy(enc, src); err != nil {
		enc.Close()
		return err
	}
	return enc.Close()
}

func (ZstdCompressor) Extension() string { return ".zst" }

// logrotate.New(path, logrotate.WithCompressor(ZstdCompressor{}))

Integrations

slog

logger := slog.New(slog.NewJSONHandler(w, nil))

zap*Writer implements zapcore.WriteSyncer directly:

core := zapcore.NewCore(zapcore.NewJSONEncoder(cfg), w, zap.InfoLevel)

Very high volume — the writer deliberately performs one write syscall per record, so a record handed to a successful Write is in the kernel, not in a user-space buffer that a crash would lose. If you log at a rate where syscalls dominate, add your stack's buffering layer on top — it composes cleanly and keeps the durability trade-off in your hands:

ws := &zapcore.BufferedWriteSyncer{WS: w, Size: 256 * 1024} // zap
// or bufio.NewWriter(w) with a periodic Flush for other stacks

SIGHUP (rotate in-process) and logrotate(8) (rotate externally, Reopen on signal) patterns are shown in the package examples.

Design notes

  • Rotation is evaluated on write, never by a timer. A background timer has to coordinate with writers, wakes idle processes, and rotates files nobody is writing to. Checking the boundary at write time costs one time comparison, produces identical files, and makes the whole schedule trivially testable.
  • Backups are data, not metadata. The file name carries exactly one fact — when its period ended (plus a collision sequence). Rotation reasons, hostnames and the like belong in the log records themselves; encoding them in file names makes cleanup parsing fragile.
  • Everything the writer does in the background is bounded and observable. One maintenance goroutine, woken only by rotations, whose failures reach your error handler and whose shutdown is drained by Close.
  • One process owns a given log file. Cooperative multi-process rotation needs file locks and is out of scope; use Reopen with an external rotator if another process must drive rotation.

License

MIT

Documentation

Overview

Package logrotate provides a rotating file writer for logs.

It is a pluggable io.WriteCloser sitting at the bottom of a logging stack — pass it to log.SetOutput, slog.NewJSONHandler or zapcore.AddSync — that rotates the file it writes to by size, by wall-clock schedule, or on demand, and prunes old backups by count, age and total disk usage, with optional background compression.

w, err := logrotate.New("/var/log/myapp/app.log",
	logrotate.WithMaxSize(64*logrotate.MB),
	logrotate.WithRotateEvery(24*time.Hour),
	logrotate.WithMaxBackups(14),
	logrotate.WithMaxTotalSize(1*logrotate.GB),
	logrotate.WithCompress(),
)
if err != nil {
	// configuration and permission errors surface here, not mid-flight
}
defer w.Close()
log.SetOutput(w)

The active file keeps its configured name at all times; rotation renames it to "app-<timestamp>.log" (compressed to "app-<timestamp>.log.gz") in the same directory. Time-based rotation is evaluated on write rather than by a background timer: an idle process creates no empty files, backups are stamped with the boundary that ended their period, and after a restart the previous period is recovered from the file's modification time.

logrotate assumes a single process writes to a given file. It has no third-party dependencies.

Example

The zero-configuration setup: rotate at 100 MB, keep every backup.

package main

import (
	"log"
	"os"
	"path/filepath"

	"github.com/libtnb/logrotate"
)

func main() {
	w, err := logrotate.New(filepath.Join(os.TempDir(), "example", "app.log"))
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = w.Close() }()

	log.SetOutput(w)
	log.Println("application started")
}
Example (Production)

A production setup: daily rotation at midnight plus a size cap, two weeks of compressed backups bounded to 1 GB of disk.

package main

import (
	"log"
	"log/slog"
	"time"

	"github.com/libtnb/logrotate"
)

func main() {
	w, err := logrotate.New("/var/log/myapp/app.log",
		logrotate.WithMaxSize(64*logrotate.MB),
		logrotate.WithRotateEvery(24*time.Hour),
		logrotate.WithMaxBackups(14),
		logrotate.WithMaxAge(14*logrotate.Day),
		logrotate.WithMaxTotalSize(1*logrotate.GB),
		logrotate.WithCompress(),
		logrotate.WithErrorHandler(func(err error) {
			slog.Warn("log maintenance", "err", err)
		}),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = w.Close() }()

	logger := slog.New(slog.NewJSONHandler(w, nil))
	logger.Info("application started")
}

Index

Examples

Constants

View Source
const (
	KB int64 = 1 << 10
	MB int64 = 1 << 20
	GB int64 = 1 << 30
)

Byte size units for WithMaxSize and WithMaxTotalSize.

View Source
const Day = 24 * time.Hour

Day is a convenience duration for retention options, e.g. WithMaxAge(14*logrotate.Day). Retention compares plain durations; no calendar or DST semantics are implied.

Variables

View Source
var ErrClosed = errors.New("logrotate: writer is closed")

ErrClosed is returned by Write, Rotate and Reopen after Close.

Functions

This section is empty.

Types

type Clock

type Clock interface {
	Now() time.Time
}

Clock supplies the current time. The default clock uses time.Now; tests can substitute a fake via WithClock.

type Compressor

type Compressor interface {
	// Compress reads src to EOF and writes the compressed form to dst.
	Compress(dst io.Writer, src io.Reader) error
	// Extension is the suffix appended to compressed backups, e.g. ".gz".
	// It must start with a dot.
	Extension() string
}

Compressor compresses rotated backups. Implementations are invoked from a single background goroutine, one file at a time.

The interface is deliberately stream-based so third-party algorithms plug in without this package depending on them; see the package example for a zstd adapter.

type GzipCompressor

type GzipCompressor struct {
	// Level is a compress/gzip compression level. The zero value selects
	// gzip.DefaultCompression.
	Level int
}

GzipCompressor implements Compressor with compress/gzip.

func (GzipCompressor) Compress

func (g GzipCompressor) Compress(dst io.Writer, src io.Reader) error

func (GzipCompressor) Extension

func (GzipCompressor) Extension() string

type Option

type Option func(*config)

Option configures a Writer. Options are applied by New and validated together; an invalid combination makes New return an error instead of degrading silently at runtime.

func WithBackupTimeFormat

func WithBackupTimeFormat(layout string) Option

WithBackupTimeFormat sets the time layout embedded in backup file names. The default is "2006-01-02T15-04-05.000". The layout is validated by New: it must survive a format/parse round-trip and may not produce path separators. A layout coarser than the rotation frequency is fine; colliding names get a numeric sequence suffix (name-2006-01-02.1.log).

func WithClock

func WithClock(clock Clock) Option

WithClock substitutes the time source, letting tests drive rotation deterministically.

func WithCompress

func WithCompress() Option

WithCompress gzip-compresses rotated backups in the background. Compression is atomic: a backup is written to a temporary file and renamed into place, so a crash never leaves a half-written archive behind.

func WithCompressor

func WithCompressor(comp Compressor) Option

WithCompressor compresses rotated backups with a custom Compressor, for example a zstd implementation. Passing nil disables compression (the default).

Example

Trade compression speed for ratio with a tuned built-in compressor. Any algorithm plugs in the same way; see the README for a zstd adapter.

package main

import (
	"log"
	"os"
	"path/filepath"

	"github.com/libtnb/logrotate"
)

func main() {
	w, err := logrotate.New(filepath.Join(os.TempDir(), "example", "app.log"),
		logrotate.WithCompressor(logrotate.GzipCompressor{Level: 9}),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = w.Close() }()
}

func WithErrorHandler

func WithErrorHandler(fn func(error)) Option

WithErrorHandler registers fn to receive errors from background maintenance (compression, retention cleanup) and from deferred rotations that could not be reported through a Write return value. fn is called from the background goroutine and must not call back into the Writer. Without a handler such errors are dropped.

func WithFileMode

func WithFileMode(mode fs.FileMode) Option

WithFileMode sets the permission bits for newly created log files. The mode is applied exactly, unaffected by the process umask. When unset, a new file inherits the permissions of the file it replaces, or 0o600 if there is none. On Windows, which only models a read-only flag, non-writable modes map to read-only and everything else is best-effort.

func WithLocation

func WithLocation(loc *time.Location) Option

WithLocation sets the time zone for backup timestamps and time-based rotation boundaries: WithLocation(time.Local) follows the host zone, and any fixed zone works regardless of where the process runs. The default is UTC.

func WithMaxAge

func WithMaxAge(d time.Duration) Option

WithMaxAge removes backups whose rotation timestamp is older than d. 0, the default, disables age-based removal.

func WithMaxBackups

func WithMaxBackups(n int) Option

WithMaxBackups limits how many rotated backups are retained; the oldest are removed first. 0, the default, keeps all backups. A backup and its compressed form count as one.

func WithMaxSize

func WithMaxSize(n int64) Option

WithMaxSize sets the maximum size, in bytes, the log file may reach before being rotated. The KB, MB and GB constants help readability, e.g. WithMaxSize(64*logrotate.MB). A value of 0 disables size-based rotation. The default is 100*MB.

A single record larger than the limit is still written in full (rejecting log records would lose data); it gets a file of its own, which is rotated out immediately after the write.

func WithMaxTotalSize

func WithMaxTotalSize(n int64) Option

WithMaxTotalSize caps the combined size, in bytes, of all retained backups; the oldest are removed until the rest fit. The active log file does not count against the cap, so worst-case disk usage is roughly maxSize + maxTotalSize. 0, the default, disables the cap.

The cap is strict and reflects actual bytes on disk: if even the newest backup exceeds it on its own, that backup is removed too, and a backup whose compression failed counts at its uncompressed size until compression succeeds.

func WithRotateAt

func WithRotateAt(times ...string) Option

WithRotateAt rotates the log daily at the given wall-clock times, each in "HH:MM" 24-hour form (e.g. "00:00", "03:30"). Times are interpreted in the configured location (UTC unless WithLocation is set) and may be combined with WithRotateEvery; the earliest upcoming boundary wins. Repeated options accumulate.

The same lazy-boundary semantics as WithRotateEvery apply.

func WithRotateEvery

func WithRotateEvery(d time.Duration) Option

WithRotateEvery rotates the log on wall-clock boundaries every d, anchored at midnight in the configured location (UTC unless WithLocation is set): with d of one hour rotation happens on the hour, with 24h at midnight. The final interval of a day is shortened when d does not divide 24h evenly, so every day has the same boundary sequence. d must be between 1s and 24h; for calendar times of day use WithRotateAt instead.

Boundaries are checked when writes occur, so an idle writer does not create empty files; the first write after a boundary triggers the rotation, and the backup is stamped with the boundary time rather than the write time. Across restarts the current period is recovered from the log file's modification time: a leftover file whose last write falls in a previous period is rotated out before it can receive new records.

type Writer

type Writer struct {
	// contains filtered or unexported fields
}

Writer is a rotating file writer implementing io.WriteCloser.

The file given to New is always the active log file; rotation renames it to a backup in the same directory ("name-<timestamp>.ext", plus a ".<n>" sequence on collision and the compressor extension once compressed) and reopens the original path, so external tools can rely on a stable name. Rotation happens when the file reaches its size limit, when a wall-clock boundary configured via WithRotateEvery or WithRotateAt has passed, or on an explicit Rotate call. Compression and retention (WithMaxBackups, WithMaxAge, WithMaxTotalSize) run on a single background goroutine and never block writes.

A Writer is safe for concurrent use by multiple goroutines. Like all single-writer rotation schemes, it assumes it is the only process writing to the file.

func New

func New(filename string, opts ...Option) (*Writer, error)

New creates a Writer appending to filename, creating the file and any missing parent directories on the spot so configuration or permission problems surface here rather than at the first log record.

If time-based rotation is configured and filename already exists, the current rotation period is recovered from the file's modification time; a leftover file last written in a previous period is rotated out immediately.

func (*Writer) Close

func (w *Writer) Close() error

Close closes the active file and stops the background maintenance goroutine, waiting for in-flight compression or cleanup to finish. Close is idempotent. After Close, Write returns ErrClosed.

func (*Writer) Filename

func (w *Writer) Filename() string

Filename returns the cleaned path of the active log file.

func (*Writer) Reopen

func (w *Writer) Reopen() error

Reopen closes and reopens the active file path without renaming anything. It is meant for coordination with external tools that move or truncate the log themselves (e.g. logrotate(8)); after they signal the process, Reopen resumes logging into a fresh file at the original path.

Example

Let an external tool such as logrotate(8) move the file, then reopen the original path on SIGUSR1 instead of rotating in-process.

package main

import (
	"log"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"

	"github.com/libtnb/logrotate"
)

func main() {
	w, err := logrotate.New(filepath.Join(os.TempDir(), "example", "app.log"),
		logrotate.WithMaxSize(0), // rotation is fully delegated to the external tool
	)
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = w.Close() }()
	log.SetOutput(w)

	usr1 := make(chan os.Signal, 1)
	signal.Notify(usr1, syscall.SIGUSR1)
	go func() {
		for range usr1 {
			if err := w.Reopen(); err != nil {
				log.Printf("reopen: %v", err)
			}
		}
	}()
}

func (*Writer) Rotate

func (w *Writer) Rotate() error

Rotate rotates the log immediately regardless of size or schedule, for example in response to SIGHUP. The backup is stamped with the current time. Rotating an empty file produces no backup; the file is simply reused.

Example

Rotate on SIGHUP, the conventional signal for reopening logs.

package main

import (
	"log"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"

	"github.com/libtnb/logrotate"
)

func main() {
	w, err := logrotate.New(filepath.Join(os.TempDir(), "example", "app.log"))
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = w.Close() }()
	log.SetOutput(w)

	hup := make(chan os.Signal, 1)
	signal.Notify(hup, syscall.SIGHUP)
	go func() {
		for range hup {
			if err := w.Rotate(); err != nil {
				log.Printf("rotate: %v", err)
			}
		}
	}()
}

func (*Writer) Sync

func (w *Writer) Sync() error

Sync flushes the active file to stable storage, satisfying zapcore.WriteSyncer. It is a no-op when no file is open.

func (*Writer) Write

func (w *Writer) Write(p []byte) (n int, err error)

Write implements io.Writer. It performs any due rotation, then writes p in full to the active file. Writes never trigger compression or retention work synchronously.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL