diff --git a/lumberjack.go b/lumberjack.go index 3447cdc..b890f40 100644 --- a/lumberjack.go +++ b/lumberjack.go @@ -41,6 +41,11 @@ const ( defaultMaxSize = 100 ) +const ( + placeholderFilename = "%FILENAME%" + placeholderExtension = "%EXT%" +) + // ensure we always implement io.WriteCloser var _ io.WriteCloser = (*Logger)(nil) @@ -107,12 +112,25 @@ type Logger struct { // using gzip. The default is not to perform compression. Compress bool `json:"compress" yaml:"compress"` + // RotationInterval is the time duration after which the log file should be rotated. + // The default is set to 0, and the rotation will not be based on time. + RotationInterval time.Duration `json:"rotationtime" yaml:"rotationtime"` + + // FileNameFormat allows customization of the filename format. + // Use placeholders like %FILENAME%, %EXT%, 2006, 01, 02, 15, 04, 05, 000. + // + // Example: "%FILENAME%.2006-01-02-15-04%EXT%" + FileNameFormat string `json:"filenameformat" yaml:"filenameformat"` + size int64 file *os.File mu sync.Mutex millCh chan bool startMill sync.Once + + // nextRotateTime is the next scheduled time for rotation based RotationInterval. + nextRotateTime time.Time } var ( @@ -149,7 +167,7 @@ func (l *Logger) Write(p []byte) (n int, err error) { } } - if l.size+writeLen > l.max() { + if l.shouldRotate(writeLen) { if err := l.rotate(); err != nil { return 0, err } @@ -200,9 +218,33 @@ func (l *Logger) rotate() error { return err } l.mill() + l.updateNextRotateTime() return nil } +// shouldRotate checks if rotation is needed based on time. +func (l *Logger) shouldRotate(writeLen int64) bool { + if l.size+writeLen > l.max() { + return true + } + if l.RotationInterval > 0 { + if l.nextRotateTime.IsZero() { + l.updateNextRotateTime() + } + if currentTime().After(l.nextRotateTime) { + return true + } + } + return false +} + +// updateNextRotateTime sets or updates the nextRotateTime based on the current time and RotationInterval, if RotationInterval is enabled and non-zero. +func (l *Logger) updateNextRotateTime() { + if l.RotationInterval > 0 { + l.nextRotateTime = currentTime().Truncate(l.RotationInterval).Add(l.RotationInterval) + } +} + // openNew opens a new log file for writing, moving any old log file out of the // way. This methods assumes the file has already been closed. func (l *Logger) openNew() error { @@ -218,7 +260,7 @@ func (l *Logger) openNew() error { // Copy the mode off the old logfile. mode = info.Mode() // move the existing file - newname := backupName(name, l.LocalTime) + newname := l.backupName(name, l.LocalTime) if err := os.Rename(name, newname); err != nil { return fmt.Errorf("can't rename log file: %s", err) } @@ -244,7 +286,7 @@ func (l *Logger) openNew() error { // backupName creates a new filename from the given name, inserting a timestamp // between the filename and the extension, using the local time if requested // (otherwise UTC). -func backupName(name string, local bool) string { +func (l *Logger) backupName(name string, local bool) string { dir := filepath.Dir(name) filename := filepath.Base(name) ext := filepath.Ext(filename) @@ -255,7 +297,14 @@ func backupName(name string, local bool) string { } timestamp := t.Format(backupTimeFormat) - return filepath.Join(dir, fmt.Sprintf("%s-%s%s", prefix, timestamp, ext)) + fileFormat := fmt.Sprintf("%s-%s%s", prefix, timestamp, ext) + + if l.FileNameFormat != "" { + formattedName := t.Format(l.FileNameFormat) + fileFormat = strings.ReplaceAll(formattedName, placeholderFilename, prefix) + fileFormat = strings.ReplaceAll(fileFormat, placeholderExtension, ext) + } + return filepath.Join(dir, fileFormat) } // openExistingOrNew opens the logfile if it exists and if the current write diff --git a/lumberjack_test.go b/lumberjack_test.go index f89756c..9adde62 100644 --- a/lumberjack_test.go +++ b/lumberjack_test.go @@ -707,6 +707,81 @@ func TestJson(t *testing.T) { equals(true, l.Compress, t) } +func TestRotationInterval(t *testing.T) { + currentTime = fakeTime + dir := makeTempDir("TestRotationInterval", t) + defer os.RemoveAll(dir) + + filename := logFile(dir) + l := &Logger{ + Filename: filename, + MaxSize: 10, + RotationInterval: time.Minute, + } + defer l.Close() + + b := []byte("foo") + n, err := l.Write(b) + isNil(err, t) + equals(len(b), n, t) + existsWithContent(filename, b, t) + fileCount(dir, 1, t) + + // Advance time by 1 minute and 1 second + fakeCurrentTime = fakeCurrentTime.Add(time.Minute + time.Second) + + // Write new content, which should trigger rotation + // since the rotation interval has passed + b2 := []byte("bar-min!") + n, err = l.Write(b2) + isNil(err, t) + equals(len(b2), n, t) + + existsWithContent(filename, b2, t) + existsWithContent(backupFile(dir), b, t) + fileCount(dir, 2, t) +} + +func TestCustomFilenameFormat(t *testing.T) { + currentTime = fakeTime + dir := makeTempDir("TestCustomFilenameFormat", t) + defer os.RemoveAll(dir) + + customDateFormat := "2006-01-02" + customFormat := "%FILENAME%%EXT%." + customDateFormat + filename := logFile(dir) + l := &Logger{ + Filename: filename, + MaxSize: 10, + FileNameFormat: customFormat, + LocalTime: true, + } + defer l.Close() + + b := []byte("bar") + n, err := l.Write(b) + isNil(err, t) + equals(len(b), n, t) + existsWithContent(filename, b, t) + fileCount(dir, 1, t) + + // Advance time to trigger rotation. + newFakeTime() + l.rotate() + + b2 := []byte("foo") + n, err = l.Write(b2) + isNil(err, t) + equals(len(b2), n, t) + + // Verify rotation happened with custom format + expectedBackupFilename := filepath.Join(dir, fmt.Sprintf("foobar.log.%s", currentTime().Format(customDateFormat))) + existsWithContent(expectedBackupFilename, b, t) + existsWithContent(filename, b2, t) + fileCount(dir, 2, t) +} + + // makeTempDir creates a file with a semi-unique name in the OS temp directory. // It should be based on the name of the test, to keep parallel tests from // colliding, and must be cleaned up after the test is finished.