• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DeRuina / timberjack / 23892535567

02 Apr 2026 08:55AM UTC coverage: 87.077% (-0.2%) from 87.319%
23892535567

push

github

DeRuina
docs: CHANGELOG

721 of 828 relevant lines covered (87.08%)

40.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.86
/timberjack.go
1
// Package timberjack provides a rolling logger with size-based and time-based rotation.
2
//
3
// Timberjack is a simple, pluggable component for log rotation. It can rotate
4
// the active log file when any of the following occur:
5
//   - the file grows beyond MaxSize (size-based)
6
//   - the configured RotationInterval elapses (interval-based)
7
//   - a scheduled time is reached via RotateAt or RotateAtMinutes (clock-based)
8
//   - rotation is triggered explicitly via Rotate() (manual)
9
//
10
// Rotated files can optionally be compressed with gzip or zstd.
11
// Cleanup is handled automatically. Old log files are removed based on MaxBackups and MaxAge.
12
//
13
// Import:
14
//
15
//        import "github.com/DeRuina/timberjack"
16
//
17
// Timberjack works with any logger that writes to an io.Writer, including the
18
// standard library’s log package.
19
//
20
// Concurrency note: timberjack assumes a single process writes to the target
21
// files. Reusing the same Logger configuration across multiple processes on the
22
// same machine may lead to improper behavior.
23
//
24
// Source code: https://github.com/DeRuina/timberjack
25
package timberjack
26

27
import (
28
        "cmp"
29
        "compress/gzip"
30
        "errors"
31
        "fmt"
32
        "io"
33
        "math"
34
        "os"
35
        "path/filepath"
36
        "slices"
37
        "sort"
38
        "strconv"
39
        "strings"
40
        "sync"
41
        "sync/atomic"
42
        "time"
43
        "unicode"
44

45
        "github.com/klauspost/compress/zstd"
46
)
47

48
const (
49
        backupTimeFormat = "2006-01-02T15-04-05.000"
50
        compressSuffix   = ".gz"
51
        zstdSuffix       = ".zst"
52
        defaultMaxSize   = 100
53
        defaultFileMode  = 0o640
54
)
55

56
// ensure we always implement io.WriteCloser
57
var _ io.WriteCloser = (*Logger)(nil)
58

59
// safeClose is a generic function that safely closes a channel of any type.
60
// It prevents "panic: close of closed channel" and "panic: close of nil channel".
61
//
62
// The type parameter [T any] allows this function to work with channels of any element type,
63
// for example, chan int, chan string, chan struct{}, etc.
64
func safeClose[T any](ch chan T) {
45✔
65
        defer func() {
90✔
66
                recover()
45✔
67
        }()
45✔
68
        close(ch)
45✔
69
}
70

71
type rotateAt [2]int
72

73
// Logger is an io.WriteCloser that writes to the specified filename.
74
//
75
// Logger opens or creates the logfile on the first Write.
76
// If the file exists and is smaller than MaxSize megabytes, timberjack will open and append to that file.
77
// If the file's size exceeds MaxSize, or if the configured RotationInterval has elapsed since the last rotation,
78
// the file is closed, renamed with a timestamp, and a new logfile is created using the original filename.
79
//
80
// Thus, the filename you give Logger is always the "current" log file.
81
//
82
// Backups use the log file name given to Logger, in the form:
83
// `name-timestamp-<reason>.ext` where `name` is the filename without the extension,
84
// `timestamp` is the time of rotation formatted as `2006-01-02T15-04-05.000`,
85
// `reason` is "size" or "time" (Rotate/auto), or a custom tag (RotateWithReason), and `ext` is the original extension.
86
// For example, if your Logger.Filename is `/var/log/foo/server.log`, a backup created at 6:30pm on Nov 11 2016
87
// due to size would use the filename `/var/log/foo/server-2016-11-04T18-30-00.000-size.log`.
88
//
89
// # Cleaning Up Old Log Files
90
//
91
// Whenever a new logfile is created, old log files may be deleted based on MaxBackups and MaxAge.
92
// The most recent files (according to the timestamp) will be retained up to MaxBackups (or all files if MaxBackups is 0).
93
// Any files with a timestamp older than MaxAge days are deleted, regardless of MaxBackups.
94
// Note that the timestamp is the rotation time, not necessarily the last write time.
95
//
96
// If MaxBackups and MaxAge are both 0, no old log files will be deleted.
97
//
98
// timberjack assumes only a single process is writing to the log files at a time.
99
type Logger struct {
100
        // Filename is the file to write logs to.  Backup log files will be retained
101
        // in the same directory.  It uses <processname>-timberjack.log in
102
        // os.TempDir() if empty.
103
        Filename string `json:"filename" yaml:"filename"`
104

105
        // MaxSize is the maximum size in megabytes of the log file before it gets
106
        // rotated. It defaults to 100 megabytes.
107
        MaxSize int `json:"maxsize" yaml:"maxsize"`
108

109
        // MaxAge is the maximum number of days to retain old log files based on the
110
        // timestamp encoded in their filename.  Note that a day is defined as 24
111
        // hours and may not exactly correspond to calendar days due to daylight
112
        // savings, leap seconds, etc. The default is not to remove old log files
113
        // based on age.
114
        MaxAge int `json:"maxage" yaml:"maxage"`
115

116
        // MaxBackups is the maximum number of old log files to retain.  The default
117
        // is to retain all old log files (though MaxAge may still cause them to get
118
        // deleted.) MaxBackups counts distinct rotation events (timestamps).
119
        MaxBackups int `json:"maxbackups" yaml:"maxbackups"`
120

121
        // LocalTime determines if the time used for formatting the timestamps in
122
        // backup files is the computer's local time.  The default is to use UTC
123
        // time.
124
        LocalTime bool `json:"localtime" yaml:"localtime"`
125

126
        // Deprecated: use Compression instead ("none" | "gzip" | "zstd").
127
        Compress bool `json:"compress,omitempty" yaml:"compress,omitempty"`
128

129
        // Compression selects the algorithm. If empty, legacy Compress is used.
130
        // Allowed values: "none", "gzip", "zstd". Unknown => "none" (with a warning).
131
        Compression string `json:"compression,omitempty" yaml:"compression,omitempty"`
132

133
        // RotationInterval is the maximum duration between log rotations.
134
        // If the elapsed time since the last rotation exceeds this interval,
135
        // the log file is rotated, even if the file size has not reached MaxSize.
136
        // The minimum recommended value is 1 minute. If set to 0, time-based rotation is disabled.
137
        //
138
        // Example: RotationInterval = time.Hour * 24 will rotate logs daily.
139
        RotationInterval time.Duration `json:"rotationinterval" yaml:"rotationinterval"`
140

141
        // BackupTimeFormat defines the layout for the timestamp appended to rotated file names.
142
        // While other formats are allowed, it is recommended to follow the standard Go time layout
143
        // (https://pkg.go.dev/time#pkg-constants). Use the ValidateBackupTimeFormat() method to check
144
        // if the value is valid. It is recommended to call this method before using the Logger instance.
145
        //
146
        // WARNING: This field is assumed to be constant after initialization.
147
        // WARNING: If invalid value is supplied then default format `2006-01-02T15-04-05.000` will be used.
148
        //
149
        // Example:
150
        // BackupTimeFormat = `2006-01-02-15-04-05`
151
        // will generate rotated backup files in the format:
152
        // <logfilename>-2006-01-02-15-04-05-<rotationCriterion>-timberjack.log
153
        // where `rotationCriterion` could be `time` or `size`.
154
        BackupTimeFormat string `json:"backuptimeformat" yaml:"backuptimeformat"`
155

156
        // RotateAtMinutes defines specific minutes within an hour (0-59) to trigger a rotation.
157
        // For example, []int{0} for top of the hour, []int{0, 30} for top and half-past the hour.
158
        // Rotations are aligned to the clock minute (second 0).
159
        // This operates in addition to RotationInterval and MaxSize.
160
        // If multiple rotation conditions are met, the first one encountered typically triggers.
161
        RotateAtMinutes []int `json:"rotateAtMinutes" yaml:"rotateAtMinutes"`
162

163
        // RotateAt defines specific time within a day to trigger a rotation.
164
        // For example, []string{'00:00'} for midnight, []string{'00:00', '12:00'} for
165
        // midnight and midday.
166
        // Rotations are aligned to the clock minute (second 0).
167
        // This operates in addition to RotationInterval and MaxSize.
168
        // If multiple rotation conditions are met, the first one encountered typically triggers.
169
        RotateAt []string `json:"rotateAt" yaml:"rotateAt"`
170

171
        // AppendTimeAfterExt controls where the timestamp/reason go.
172
        // false (default):  <name>-<timestamp>-<reason>.log
173
        // true:             <name>.log-<timestamp>-<reason>
174
        AppendTimeAfterExt bool `json:"appendTimeAfterExt" yaml:"appendTimeAfterExt"`
175

176
        // FileMode sets the permissions to use when creating new log files.
177
        // It will be inherited by rotated files.
178
        // If zero, the default of 0o640 is used.
179
        //
180
        // Note that a local umask may alter the final permissions.
181
        // Also, on non-Linux systems this might not have the desired effect.
182
        FileMode os.FileMode `json:"filemode" yaml:"filemode"`
183

184
        // Internal fields
185
        size                   int64     // current size of the log file
186
        file                   *os.File  // current log file
187
        lastRotationTime       time.Time // records the last time a rotation happened (for interval/scheduled).
188
        logStartTime           time.Time // start time of the current logging period (used for backup filename timestamp).
189
        cfgOnce                sync.Once
190
        resolvedBackupLayout   string
191
        resolvedAppendAfterExt bool
192
        resolvedLocalTime      bool
193
        resolvedCompression    string
194

195
        mu sync.Mutex // ensures atomic writes and rotations
196

197
        // For mill goroutine (backups, compression cleanup)
198
        millCh        chan bool      // channel to signal the mill goroutine
199
        startMill     sync.Once      // ensures mill goroutine is started only once
200
        millWg        sync.WaitGroup // waits for the mill goroutine to finish
201
        millWGStarted bool
202

203
        // For scheduled rotation goroutine (RotateAt)
204
        startScheduledRotationOnce sync.Once      // ensures scheduled rotation goroutine is started only once
205
        scheduledRotationQuitCh    chan struct{}  // channel to signal the scheduled rotation goroutine to stop
206
        scheduledRotationWg        sync.WaitGroup // waits for the scheduled rotation goroutine to finish
207
        processedRotateAt          []rotateAt     // internal storage for sorted and validated RotateAt
208
        isClosed                   uint32
209

210
        // snapshots of globals to avoid races
211
        resolvedTimeNow func() time.Time
212
        resolvedStat    func(string) (os.FileInfo, error)
213
        resolvedRename  func(string, string) error
214
        resolvedRemove  func(string) error
215
}
216

217
var (
218
        // currentTime exists so it can be mocked out by tests.
219
        currentTime = time.Now
220

221
        // osStat exists so it can be mocked out by tests.
222
        osStat = os.Stat
223

224
        // megabyte is the conversion factor between MaxSize and bytes.  It is a
225
        // variable so tests can mock it out and not need to write megabytes of data
226
        // to disk.
227
        megabyte = 1024 * 1024
228

229
        osRename = os.Rename
230

231
        osRemove = os.Remove
232

233
        // empty BackupTimeFormatField
234
        ErrEmptyBackupTimeFormatField = errors.New("empty backupformat field")
235
)
236

237
func (l *Logger) resolveConfigLocked() {
524✔
238
        l.cfgOnce.Do(func() {
606✔
239
                // Resolve time format
82✔
240
                layout := l.BackupTimeFormat
82✔
241
                if layout == "" {
163✔
242
                        layout = backupTimeFormat
81✔
243
                } else if err := l.ValidateBackupTimeFormat(); err != nil {
82✔
244
                        fmt.Fprintf(os.Stderr,
×
245
                                "timberjack: invalid BackupTimeFormat: %v — falling back to default format: %s\n",
×
246
                                err, backupTimeFormat)
×
247
                        layout = backupTimeFormat
×
248
                }
×
249
                l.resolvedBackupLayout = layout
82✔
250
                l.resolvedAppendAfterExt = l.AppendTimeAfterExt
82✔
251
                l.resolvedLocalTime = l.LocalTime
82✔
252

82✔
253
                // snapshot global funcs so tests can fiddle with the globals safely
82✔
254
                l.resolvedTimeNow = currentTime
82✔
255
                l.resolvedStat = osStat
82✔
256
                l.resolvedRename = osRename
82✔
257
                l.resolvedRemove = osRemove
82✔
258

82✔
259
                // Freeze compression (prevents races if toggled later)
82✔
260
                l.resolvedCompression = l.effectiveCompression()
82✔
261
        })
262
}
263

264
// Write implements io.Writer.
265
// It writes the provided bytes to the current log file.
266
// If the log file exceeds MaxSize after writing, or if the configured RotationInterval has elapsed
267
// since the last rotation, or if a scheduled rotation time (RotateAtMinutes) has been reached,
268
// the file is closed, renamed to include a timestamp, and a new log file is created
269
// using the original filename.
270
// If the size of a single write exceeds MaxSize, the write is rejected and an error is returned.
271
func (l *Logger) Write(p []byte) (n int, err error) {
63✔
272
        l.mu.Lock()
63✔
273
        defer l.mu.Unlock()
63✔
274

63✔
275
        // Ensure configuration is resolved once
63✔
276
        l.resolveConfigLocked()
63✔
277

63✔
278
        // Handle writes to a closed logger.
63✔
279
        if atomic.LoadUint32(&l.isClosed) == 1 {
64✔
280
                // The logger is closed. To ensure the write succeeds, we perform a
1✔
281
                // single open-write-close cycle. This does not perform rotation
1✔
282
                // and does not restart the background goroutines. l.file remains nil.
1✔
283
                file, openErr := os.OpenFile(l.filename(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
1✔
284
                if openErr != nil {
1✔
285
                        return 0, fmt.Errorf("timberjack: write on closed logger failed to open file: %w", openErr)
×
286
                }
×
287

288
                n, writeErr := file.Write(p)
1✔
289

1✔
290
                closeErr := file.Close()
1✔
291

1✔
292
                if writeErr != nil {
1✔
293
                        return n, writeErr
×
294
                }
×
295
                return n, closeErr
1✔
296
        }
297

298
        // Ensure the scheduled-rotation goroutine is running (if you've still got one).
299
        l.ensureScheduledRotationLoopRunning()
62✔
300

62✔
301
        // Snapshot
62✔
302
        now := l.resolvedTimeNow().In(l.location())
62✔
303

62✔
304
        writeLen := int64(len(p))
62✔
305
        if writeLen > l.max() {
63✔
306
                return 0, fmt.Errorf("write length %d exceeds maximum file size %d", writeLen, l.max())
1✔
307
        }
1✔
308

309
        // Open (or create) the file on first write.
310
        if l.file == nil {
102✔
311
                if err = l.openExistingOrNew(len(p)); err != nil {
44✔
312
                        return 0, err
3✔
313
                }
3✔
314
                if l.lastRotationTime.IsZero() {
74✔
315
                        // Initialize to 'now' so interval/minute checks start from here.
36✔
316
                        l.lastRotationTime = now
36✔
317
                }
36✔
318
        }
319

320
        // 1) Interval-based rotation
321
        if l.RotationInterval > 0 && now.Sub(l.lastRotationTime) >= l.RotationInterval {
61✔
322
                if err := l.rotate("time"); err != nil {
5✔
323
                        return 0, fmt.Errorf("interval rotation failed: %w", err)
2✔
324
                }
2✔
325
                l.lastRotationTime = now
1✔
326
        }
327

328
        // 2) Scheduled time based rotation (RotateAt)
329
        if len(l.processedRotateAt) > 0 {
62✔
330
                for _, m := range l.processedRotateAt {
170✔
331
                        mark := time.Date(now.Year(), now.Month(), now.Day(),
164✔
332
                                m[0], m[1], 0, 0, l.location())
164✔
333
                        // If we've crossed that mark since the last rotation, fire one rotation.
164✔
334
                        if l.lastRotationTime.Before(mark) && (mark.Before(now) || mark.Equal(now)) {
168✔
335
                                if err := l.rotate("time"); err != nil {
4✔
336
                                        return 0, fmt.Errorf("scheduled-minute rotation failed: %w", err)
×
337
                                }
×
338
                                // Record the logical mark—so we don’t rerun until next slot.
339
                                l.lastRotationTime = mark
4✔
340
                                break
4✔
341
                        }
342
                }
343
        }
344

345
        // 3) Size-based rotation
346
        if l.size+writeLen > l.max() {
68✔
347
                if err := l.rotate("size"); err != nil {
12✔
348
                        return 0, fmt.Errorf("size rotation failed: %w", err)
×
349
                }
×
350
                // Note: we leave lastRotationTime untouched for size rotations.
351
        }
352

353
        // Finally, write the bytes and update size.
354
        n, err = l.file.Write(p)
56✔
355
        l.size += int64(n)
56✔
356
        return n, err
56✔
357
}
358

359
// ValidateBackupTimeFormat checks if the configured BackupTimeFormat is a valid time layout.
360
// While other formats are allowed, it is recommended to follow the standard time layout
361
// rules as defined here: https://pkg.go.dev/time#pkg-constants
362
//
363
// WARNING: Assumes that BackupTimeFormat value remains constant after initialization.
364
func (l *Logger) ValidateBackupTimeFormat() error {
8✔
365
        if len(l.BackupTimeFormat) == 0 {
9✔
366
                return ErrEmptyBackupTimeFormatField
1✔
367
        }
1✔
368
        // 2025-05-22 23:41:59.987654321 +0000 UTC
369
        now := time.Date(2025, 5, 22, 23, 41, 59, 987_654_321, time.UTC)
7✔
370

7✔
371
        layoutPrecision := countDigitsAfterDot(l.BackupTimeFormat)
7✔
372

7✔
373
        now, err := truncateFractional(now, layoutPrecision)
7✔
374
        if err != nil {
7✔
375
                return err
×
376
        }
×
377
        formatted := now.Format(l.BackupTimeFormat)
7✔
378
        parsedT, err := time.Parse(l.BackupTimeFormat, formatted)
7✔
379
        if err != nil {
7✔
380
                return fmt.Errorf("invalid BackupTimeFormat: %w", err)
×
381
        }
×
382
        if !parsedT.Equal(now) {
9✔
383
                return errors.New("invalid BackupTimeFormat: time.Time parsed from the format does not match the time.Time supplied")
2✔
384
        }
2✔
385

386
        return nil
5✔
387
}
388

389
// location returns the time.Location (UTC or Local) to use for timestamps in backup filenames.
390
func (l *Logger) location() *time.Location {
229✔
391
        l.resolveConfigLocked()
229✔
392
        if l.resolvedLocalTime {
231✔
393
                return time.Local
2✔
394
        }
2✔
395
        return time.UTC
227✔
396
}
397

398
func parseTime(s string) (*rotateAt, error) {
11✔
399
        parts := strings.Split(s, ":")
11✔
400
        if len(parts) != 2 {
13✔
401
                return nil, errors.New("invalid time")
2✔
402
        }
2✔
403
        hs, ms := parts[0], parts[1]
9✔
404
        h, err := strconv.Atoi(hs)
9✔
405
        if err != nil {
10✔
406
                return nil, err
1✔
407
        }
1✔
408
        m, err := strconv.Atoi(ms)
8✔
409
        if err != nil {
8✔
410
                return nil, err
×
411
        }
×
412

413
        if m < 0 || m > 59 || h < 0 || h > 23 {
12✔
414
                return nil, errors.New("invalid time")
4✔
415
        }
4✔
416

417
        return &rotateAt{h, m}, nil
4✔
418
}
419

420
func compareTime(a, b rotateAt) int {
817✔
421
        h1, m1 := a[0], a[1]
817✔
422
        h2, m2 := b[0], b[1]
817✔
423
        if h1 == h2 {
989✔
424
                return cmp.Compare(m1, m2)
172✔
425
        }
172✔
426
        return cmp.Compare(h1, h2)
645✔
427
}
428

429
// ensureScheduledRotationLoopRunning starts the scheduled rotation goroutine if RotateAtMinutes is configured
430
// and the goroutine is not already running.
431
func (l *Logger) ensureScheduledRotationLoopRunning() {
67✔
432
        if len(l.RotateAtMinutes)+len(l.RotateAt) == 0 {
124✔
433
                return // No scheduled rotations configured
57✔
434
        }
57✔
435

436
        l.startScheduledRotationOnce.Do(func() {
16✔
437
                var processedRotateAt []rotateAt
6✔
438
                for _, m := range l.RotateAtMinutes {
18✔
439
                        if m < 0 || m > 59 {
19✔
440
                                fmt.Fprintf(os.Stderr, "timberjack: [%d] No valid minute specified for RotateAtMinutes.\n", m)
7✔
441
                                continue
7✔
442
                        }
443
                        for h := 0; h < 24; h++ {
125✔
444
                                processedRotateAt = append(processedRotateAt, rotateAt{h, m})
120✔
445
                        }
120✔
446
                }
447

448
                for _, t := range l.RotateAt {
17✔
449
                        r, err := parseTime(t)
11✔
450
                        if err != nil {
18✔
451
                                fmt.Fprintf(os.Stderr, "timberjack: [%s] No valid time specified for RotateAt.\n", t)
7✔
452
                                continue
7✔
453
                        }
454
                        processedRotateAt = append(processedRotateAt, *r)
4✔
455
                }
456

457
                if len(processedRotateAt) == 0 {
9✔
458
                        // Optionally log that no valid minutes were found, preventing goroutine start
3✔
459
                        // fmt.Fprintf(os.Stderr, "timberjack: [%s] No valid minutes specified for RotateAtMinutes.\n", l.Filename)
3✔
460
                        return
3✔
461
                }
3✔
462

463
                // Sort for predictable order in calculating next rotation
464
                slices.SortFunc(processedRotateAt, compareTime)
3✔
465
                processedRotateAt = slices.CompactFunc(processedRotateAt, func(a, b rotateAt) bool {
124✔
466
                        return compareTime(a, b) == 0
121✔
467
                })
121✔
468

469
                l.processedRotateAt = processedRotateAt
3✔
470
                quitCh := make(chan struct{})
3✔
471
                l.scheduledRotationQuitCh = quitCh
3✔
472

3✔
473
                // Snapshot immutable inputs for the goroutine to avoid reading fields later.
3✔
474
                slots := l.processedRotateAt
3✔
475
                loc := l.location()
3✔
476
                nowFn := l.resolvedTimeNow
3✔
477

3✔
478
                l.scheduledRotationWg.Add(1)
3✔
479
                go l.runScheduledRotations(quitCh, slots, loc, nowFn)
3✔
480
        })
481
}
482

483
// runScheduledRotations is the main loop for handling rotations at specific minute marks
484
// as defined in RotateAtMinutes. It runs in a separate goroutine.
485
func (l *Logger) runScheduledRotations(quit <-chan struct{}, slots []rotateAt, loc *time.Location, nowFn func() time.Time) {
16✔
486
        defer l.scheduledRotationWg.Done()
16✔
487

16✔
488
        // No slots to process, exit immediately.
16✔
489
        if len(slots) == 0 {
17✔
490
                return
1✔
491
        }
1✔
492

493
        timer := time.NewTimer(0) // Timer will be reset with the correct duration in the loop
15✔
494
        if !timer.Stop() {
16✔
495
                // Drain the channel if the timer fired prematurely (e.g., duration was 0 on first NewTimer)
1✔
496
                select {
1✔
497
                case <-timer.C:
1✔
498
                default:
×
499
                }
500
        }
501

502
        for {
30✔
503
                now := nowFn()
15✔
504
                nowInLocation := now.In(loc)
15✔
505
                nextRotationAbsoluteTime := time.Time{}
15✔
506
                foundNextSlot := false
15✔
507

15✔
508
        determineNextSlot:
15✔
509
                // Calculate the next rotation time based on the current time and processedRotateAt.
15✔
510
                // Iterate through the current hour, then subsequent hours (up to 24h ahead for robustness
15✔
511
                // against system sleep or large clock jumps).
15✔
512
                for hourOffset := 0; hourOffset <= 24; hourOffset++ {
230✔
513
                        // Base time for the hour we are checking (e.g., if now is 10:35, current hour base is 10:00)
215✔
514
                        hourToCheck := time.Date(nowInLocation.Year(), nowInLocation.Month(), nowInLocation.Day(), nowInLocation.Hour(), 0, 0, 0, loc).Add(time.Duration(hourOffset) * time.Hour)
215✔
515

215✔
516
                        for _, mark := range slots {
498✔
517
                                candidateTime := time.Date(hourToCheck.Year(), hourToCheck.Month(), hourToCheck.Day(), mark[0], mark[1], 0, 0, loc)
283✔
518

283✔
519
                                if candidateTime.After(now) { // Found the earliest future slot
298✔
520
                                        nextRotationAbsoluteTime = candidateTime
15✔
521
                                        foundNextSlot = true
15✔
522
                                        break determineNextSlot // Exit both loops
15✔
523
                                }
524
                        }
525
                }
526

527
                if !foundNextSlot {
15✔
528
                        // This should ideally not happen if processedRotateAt is valid and non-empty.
×
529
                        // Could occur if currentTime() is unreliable or jumps massively backward.
×
530
                        // Log an error and retry calculation after a fallback delay.
×
531
                        fmt.Fprintf(os.Stderr, "timberjack: [%s] Could not determine next scheduled rotation time for %v with marks %v. Retrying calculation in 1 minute.\n", l.Filename, nowInLocation, slots)
×
532
                        select {
×
533
                        case <-time.After(time.Minute): // Wait a bit before retrying calculation
×
534
                                continue // Restart the outer loop to recalculate
×
535
                        case <-quit:
×
536
                                return
×
537
                        }
538
                }
539

540
                sleepDuration := nextRotationAbsoluteTime.Sub(now)
15✔
541
                timer.Reset(sleepDuration)
15✔
542

15✔
543
                select {
15✔
544
                case <-timer.C: // Timer fired, it's time for a scheduled rotation
×
545
                        l.mu.Lock()
×
546
                        // Only rotate if the last rotation time was before this specific scheduled mark.
×
547
                        // This prevents redundant rotations if another rotation (e.g., size/interval) happened
×
548
                        // very close to, but just before or at, this scheduled time for the same mark.
×
549
                        if l.lastRotationTime.Before(nextRotationAbsoluteTime) {
×
550
                                if err := l.rotate("time"); err != nil { // Scheduled rotations are "time" based for filename
×
551
                                        fmt.Fprintf(os.Stderr, "timberjack: [%s] scheduled rotation failed: %v\n", l.Filename, err)
×
552
                                } else {
×
553
                                        l.lastRotationTime = nowFn()
×
554
                                }
×
555
                        }
556
                        l.mu.Unlock()
×
557
                // Loop will continue and recalculate the next slot from the new "now"
558

559
                case <-quit: // Signal to quit from Close()
14✔
560
                        if !timer.Stop() {
14✔
561
                                // If Stop() returns false, the timer has already fired or been stopped.
×
562
                                // If it fired, its channel might have a value, so drain it.
×
563
                                select {
×
564
                                case <-timer.C:
×
565
                                default:
×
566
                                }
567
                        }
568
                        return // Exit goroutine
14✔
569
                }
570
        }
571
}
572

573
// Close implements io.Closer, and closes the current logfile.
574
// It also signals any running goroutines (like scheduled rotation or mill) to stop.
575
func (l *Logger) Close() error {
46✔
576
        l.mu.Lock()
46✔
577

46✔
578
        if atomic.LoadUint32(&l.isClosed) == 1 {
47✔
579
                l.mu.Unlock()
1✔
580
                return nil
1✔
581
        }
1✔
582
        atomic.StoreUint32(&l.isClosed, 1)
45✔
583

45✔
584
        // Stop the scheduled rotation goroutine
45✔
585
        var quitCh chan struct{}
45✔
586
        if l.scheduledRotationQuitCh != nil {
48✔
587
                quitCh = l.scheduledRotationQuitCh
3✔
588
                l.scheduledRotationQuitCh = nil // clear under lock
3✔
589
        }
3✔
590

591
        // Stop the mill goroutine (doesn't use l.mu, no deadlock risk)
592
        var millCh chan bool
45✔
593
        if l.millCh != nil {
87✔
594
                millCh = l.millCh
42✔
595
                // don't nil it; startMill.Do prevents restarts
42✔
596
        }
42✔
597

598
        // Close file under lock (safe and quick)
599
        err := l.closeFile()
45✔
600

45✔
601
        l.mu.Unlock()
45✔
602

45✔
603
        // Now signal and wait outside the lock
45✔
604
        if quitCh != nil {
48✔
605
                safeClose(quitCh)
3✔
606
                l.scheduledRotationWg.Wait()
3✔
607
        }
3✔
608
        if millCh != nil {
87✔
609
                safeClose(millCh)
42✔
610
                l.millWg.Wait()
42✔
611
        }
42✔
612

613
        return err
45✔
614
}
615

616
// closeFile closes the file if it is open. This is an internal method.
617
// It expects l.mu to be held.
618
func (l *Logger) closeFile() error {
86✔
619
        if l.file == nil {
101✔
620
                return nil
15✔
621
        }
15✔
622
        err := l.file.Close()
71✔
623
        l.file = nil // Set to nil to indicate it's closed.
71✔
624
        return err
71✔
625
}
626

627
// Rotate forces an immediate rotation using the legacy auto-reason logic.
628
// (empty reason => "time" if an interval rotation is due, otherwise "size")
629
func (l *Logger) Rotate() error {
11✔
630
        return l.RotateWithReason("")
11✔
631
}
11✔
632

633
// rotate closes the current file, moves it aside with a timestamp in the name,
634
// (if it exists), opens a new file with the original filename, and then runs
635
// post-rotation processing and removal (mill).
636
// It expects l.mu to be held by the caller.
637
// Takes an explicit reason for the rotation which is used in the backup filename.
638
func (l *Logger) rotate(reason string) error {
41✔
639
        if err := l.closeFile(); err != nil {
42✔
640
                return err
1✔
641
        }
1✔
642
        // Pass the determined reason to openNew so it's used in the backup filename
643
        if err := l.openNew(reason); err != nil {
45✔
644
                return err
5✔
645
        }
5✔
646
        l.mill()
35✔
647
        return nil
35✔
648
}
649

650
// RotateWithReason forces a rotation immediately and tags the backup filename
651
// with the provided reason (after sanitization). If the sanitized reason is
652
// empty, it falls back to the default behavior used by Rotate(): "time" if an
653
// interval rotation is due, otherwise "size".
654
//
655
// After a successful rotation, lastRotationTime is updated to the current time
656
// so that a subsequent Write() does not trigger a duplicate interval-based or
657
// scheduled rotation.
658
func (l *Logger) RotateWithReason(reason string) error {
15✔
659
        l.mu.Lock()
15✔
660
        defer l.mu.Unlock()
15✔
661

15✔
662
        if atomic.LoadUint32(&l.isClosed) == 1 {
15✔
663
                return errors.New("logger closed")
×
664
        }
×
665

666
        r := sanitizeReason(reason)
15✔
667
        if r == "" {
28✔
668
                // keep legacy Rotate() semantics
13✔
669
                r = "size"
13✔
670
                if l.shouldTimeRotate() {
17✔
671
                        r = "time"
4✔
672
                }
4✔
673
        }
674

675
        if err := l.rotate(r); err != nil {
16✔
676
                return err
1✔
677
        }
1✔
678
        l.lastRotationTime = l.resolvedTimeNow()
14✔
679
        return nil
14✔
680
}
681

682
func backupNameWithResolved(name string, local bool, reason string, t time.Time, layout string, afterExt bool) string {
38✔
683
        dir := filepath.Dir(name)
38✔
684
        filename := filepath.Base(name)
38✔
685
        ext := filepath.Ext(filename)
38✔
686
        prefix := filename[:len(filename)-len(ext)]
38✔
687

38✔
688
        loc := time.UTC
38✔
689
        if local {
39✔
690
                loc = time.Local
1✔
691
        }
1✔
692
        ts := t.In(loc).Format(layout)
38✔
693

38✔
694
        if afterExt {
39✔
695
                // <name><ext>-<ts>-<reason>
1✔
696
                return filepath.Join(dir, fmt.Sprintf("%s%s-%s-%s", prefix, ext, ts, reason))
1✔
697
        }
1✔
698
        // <name>-<ts>-<reason><ext>
699
        return filepath.Join(dir, fmt.Sprintf("%s-%s-%s%s", prefix, ts, reason, ext))
37✔
700
}
701

702
// openNew creates a new log file for writing.
703
// If an old log file already exists, it is moved aside by renaming it with a timestamp.
704
// This method assumes that l.mu is held and the old file (if any) has already been closed.
705
// The reasonForBackup parameter is used in the backup filename.
706
func (l *Logger) openNew(reasonForBackup string) error {
73✔
707
        l.resolveConfigLocked() // no-op after first time
73✔
708

73✔
709
        if err := os.MkdirAll(l.dir(), 0o755); err != nil {
75✔
710
                return fmt.Errorf("can't make directories for new logfile: %s", err)
2✔
711
        }
2✔
712

713
        name := l.filename()
71✔
714

71✔
715
        finalMode := l.FileMode
71✔
716
        if finalMode == 0 {
139✔
717
                finalMode = os.FileMode(defaultFileMode)
68✔
718
        }
68✔
719

720
        var oldInfo os.FileInfo
71✔
721
        info, err := l.resolvedStat(name)
71✔
722
        if err == nil {
109✔
723
                oldInfo = info
38✔
724
                // Only use the existing file's mode when no explicit FileMode is configured.
38✔
725
                if l.FileMode == 0 {
76✔
726
                        finalMode = oldInfo.Mode()
38✔
727
                }
38✔
728

729
                rotationTimeForBackup := l.resolvedTimeNow()
38✔
730

38✔
731
                // Build the rotated name from the immutable snapshot (no public field writes).
38✔
732
                newname := backupNameWithResolved(
38✔
733
                        name,
38✔
734
                        l.resolvedLocalTime,
38✔
735
                        reasonForBackup,
38✔
736
                        rotationTimeForBackup,
38✔
737
                        l.resolvedBackupLayout,
38✔
738
                        l.resolvedAppendAfterExt,
38✔
739
                )
38✔
740

38✔
741
                if errRename := l.resolvedRename(name, newname); errRename != nil {
43✔
742
                        return fmt.Errorf("can't rename log file: %s", errRename)
5✔
743
                }
5✔
744
                l.logStartTime = rotationTimeForBackup
33✔
745
        } else if os.IsNotExist(err) {
65✔
746
                l.logStartTime = l.resolvedTimeNow()
32✔
747
                oldInfo = nil
32✔
748
        } else {
33✔
749
                return fmt.Errorf("failed to stat log file %s: %w", name, err)
1✔
750
        }
1✔
751

752
        // Create and open the new log file at path `name`.
753
        f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, finalMode)
65✔
754
        if err != nil {
65✔
755
                return fmt.Errorf("can't open new logfile %s: %s", name, err)
×
756
        }
×
757
        // Apply the exact mode via chmod so the process umask cannot mask bits.
758
        if err := os.Chmod(name, finalMode); err != nil {
65✔
759
                closeErr := f.Close()
×
760
                if closeErr != nil {
×
761
                        return fmt.Errorf("can't set mode on new logfile %s: %s (also failed to close: %v)", name, err, closeErr)
×
762
                }
×
763
                return fmt.Errorf("can't set mode on new logfile %s: %s", name, err)
×
764
        }
765
        l.file = f
65✔
766
        l.size = 0
65✔
767

65✔
768
        // Try to chown the new file to match the old file's owner/group (if there was an old file).
65✔
769
        if oldInfo != nil {
98✔
770
                if errChown := chown(name, oldInfo); errChown != nil {
33✔
771
                        fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to chown new log file %s: %v\n", l.Filename, name, errChown)
×
772
                }
×
773
        }
774

775
        return nil
65✔
776
}
777

778
// shouldTimeRotate checks if the time-based rotation interval has elapsed
779
// since the last rotation. This is used for RotationInterval logic.
780
func (l *Logger) shouldTimeRotate() bool {
16✔
781
        l.resolveConfigLocked()
16✔
782
        if l.RotationInterval == 0 { // Time-based rotation (interval) is disabled
24✔
783
                return false
8✔
784
        }
8✔
785
        // If lastRotationTime is zero (e.g., logger just started, no writes/rotations yet),
786
        // then it's not yet time for an interval-based rotation.
787
        if l.lastRotationTime.IsZero() {
10✔
788
                return false
2✔
789
        }
2✔
790
        return l.resolvedTimeNow().Sub(l.lastRotationTime) >= l.RotationInterval
6✔
791
}
792

793
// backupName creates a new backup filename by inserting a timestamp and a rotation reason
794
// ("time" or "size") between the filename prefix and the extension.
795
// It uses the local time if requested (otherwise UTC).
796
func backupName(name string, local bool, reason string, t time.Time, fileTimeFormat string, appendTimeAfterExt bool) string {
2✔
797
        dir := filepath.Dir(name)
2✔
798
        filename := filepath.Base(name)
2✔
799
        ext := filepath.Ext(filename)
2✔
800
        prefix := filename[:len(filename)-len(ext)]
2✔
801

2✔
802
        currentLoc := time.UTC
2✔
803
        if local {
2✔
804
                currentLoc = time.Local
×
805
        }
×
806
        // Format the timestamp for the backup file.
807
        timestamp := t.In(currentLoc).Format(fileTimeFormat)
2✔
808

2✔
809
        if appendTimeAfterExt {
3✔
810
                // <name><ext>-<ts>-<reason>
1✔
811
                // e.g. httpd.log-2025-01-01T00-00-00.000-size
1✔
812
                return filepath.Join(dir, fmt.Sprintf("%s%s-%s-%s", prefix, ext, timestamp, reason))
1✔
813
        }
1✔
814

815
        // default: <name>-<ts>-<reason><ext>
816
        return filepath.Join(dir, fmt.Sprintf("%s-%s-%s%s", prefix, timestamp, reason, ext))
1✔
817
}
818

819
// openExistingOrNew opens the existing logfile if it exists and the current write
820
// would not cause it to exceed MaxSize. If the file does not exist, or if writing
821
// would exceed MaxSize, the current file is rotated (if it exists) and a new logfile is created.
822
// It expects l.mu to be held by the caller.
823
func (l *Logger) openExistingOrNew(writeLen int) error {
43✔
824
        l.resolveConfigLocked()
43✔
825
        l.mill() // Perform house-keeping for old logs (compression, deletion) first.
43✔
826

43✔
827
        filename := l.filename()
43✔
828
        info, err := l.resolvedStat(filename)
43✔
829
        if os.IsNotExist(err) {
72✔
830
                // File doesn't exist, so openNew is creating a new file.
29✔
831
                // The 'reason' passed to openNew here ("initial") won't affect a backup filename
29✔
832
                // as no backup (renaming) is happening.
29✔
833
                return l.openNew("initial")
29✔
834
        }
29✔
835
        if err != nil {
16✔
836
                return fmt.Errorf("error getting log file info: %s", err)
2✔
837
        }
2✔
838

839
        // Check if rotation is needed due to size before opening/appending.
840
        if info.Size()+int64(writeLen) >= l.max() {
17✔
841
                return l.rotate("size") // This rotation is explicitly due to "size"
5✔
842
        }
5✔
843

844
        // Open existing file for appending.
845
        file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0)
7✔
846
        if err != nil {
7✔
847
                // If opening existing fails (e.g., permissions, corruption), try to create a new one.
×
848
                return l.openNew("initial") // Fallback if append fails
×
849
        }
×
850
        l.file = file
7✔
851
        l.size = info.Size()
7✔
852
        // Note: l.logStartTime is NOT updated here if we successfully open an existing file without rotating.
7✔
853
        // It retains its value from when this current log segment was created (by a previous openNew).
7✔
854
        // l.lastRotationTime is also NOT updated here; it's handled by rotation trigger logic.
7✔
855
        return nil
7✔
856
}
857

858
// filename returns the current log filename, using the configured Filename,
859
// or a default based on the process name if Filename is empty.
860
func (l *Logger) filename() string {
285✔
861
        if l.Filename != "" {
567✔
862
                return l.Filename
282✔
863
        }
282✔
864
        name := filepath.Base(os.Args[0]) + "-timberjack.log"
3✔
865
        return filepath.Join(os.TempDir(), name)
3✔
866
}
867

868
// millRunOnce performs one cycle of compression and removal of old log files.
869
// If compression is enabled, uncompressed backups are compressed using gzip.
870
// Old backup files are deleted to enforce MaxBackups and MaxAge limits.
871
func (l *Logger) millRunOnce() error {
81✔
872
        l.resolveConfigLocked()
81✔
873
        comp := l.resolvedCompression
81✔
874
        if l.MaxBackups == 0 && l.MaxAge == 0 && comp == "none" {
126✔
875
                return nil // Nothing to do if all cleanup options are disabled.
45✔
876
        }
45✔
877

878
        now := l.resolvedTimeNow()
36✔
879

36✔
880
        files, err := l.oldLogFiles() // Gets LogInfo structs, sorted newest first by timestamp
36✔
881
        if err != nil {
36✔
882
                return err
×
883
        }
×
884

885
        filesToProcess := files     // Start with all found old log files
36✔
886
        var filesToRemove []logInfo // Accumulates files to be deleted
36✔
887

36✔
888
        // MaxBackups filtering: Keep files belonging to the MaxBackups newest distinct timestamps
36✔
889
        if l.MaxBackups > 0 {
57✔
890
                uniqueTimestamps := make([]time.Time, 0)
21✔
891
                timestampMap := make(map[time.Time]bool)
21✔
892
                for _, f := range filesToProcess { // filesToProcess is sorted newest first
41✔
893
                        if !timestampMap[f.timestamp] {
39✔
894
                                timestampMap[f.timestamp] = true
19✔
895
                                uniqueTimestamps = append(uniqueTimestamps, f.timestamp)
19✔
896
                        }
19✔
897
                }
898

899
                if len(uniqueTimestamps) > l.MaxBackups {
25✔
900
                        // Determine the set of timestamps to keep (the MaxBackups newest ones)
4✔
901
                        keptTimestampsSet := make(map[time.Time]bool)
4✔
902
                        for i := 0; i < l.MaxBackups; i++ {
8✔
903
                                keptTimestampsSet[uniqueTimestamps[i]] = true
4✔
904
                        }
4✔
905

906
                        var filteredFiles []logInfo // Files that pass this MaxBackups filter
4✔
907
                        for _, f := range filesToProcess {
15✔
908
                                if keptTimestampsSet[f.timestamp] {
16✔
909
                                        filteredFiles = append(filteredFiles, f)
5✔
910
                                } else {
11✔
911
                                        filesToRemove = append(filesToRemove, f) // Mark for removal
6✔
912
                                }
6✔
913
                        }
914
                        filesToProcess = filteredFiles // Update filesToProcess for subsequent filters
4✔
915
                }
916
                // If len(uniqueTimestamps) <= l.MaxBackups, all files pass this MaxBackups filter.
917
        }
918

919
        // MaxAge filtering (operates on files that passed MaxBackups filter)
920
        if l.MaxAge > 0 {
41✔
921
                diff := time.Duration(int64(24*time.Hour) * int64(l.MaxAge))
5✔
922
                cutoff := now.Add(-diff)
5✔
923
                var filteredFiles []logInfo // Files that pass this MaxAge filter
5✔
924
                for _, f := range filesToProcess {
10✔
925
                        if f.timestamp.Before(cutoff) {
8✔
926
                                // avoid duplicates
3✔
927
                                isAlreadyMarked := false
3✔
928
                                for _, rmf := range filesToRemove {
3✔
929
                                        if rmf.Name() == f.Name() {
×
930
                                                isAlreadyMarked = true
×
931
                                                break
×
932
                                        }
933
                                }
934
                                if !isAlreadyMarked {
6✔
935
                                        filesToRemove = append(filesToRemove, f)
3✔
936
                                }
3✔
937
                        } else {
2✔
938
                                filteredFiles = append(filteredFiles, f)
2✔
939
                        }
2✔
940
                }
941
                filesToProcess = filteredFiles
5✔
942
        }
943

944
        // Compression task identification (operates on files that passed MaxBackups and MaxAge)
945
        var filesToCompress []logInfo
36✔
946
        if comp != "none" {
53✔
947
                for _, f := range filesToProcess {
33✔
948
                        name := f.Name()
16✔
949
                        if strings.HasSuffix(name, compressSuffix) || strings.HasSuffix(name, zstdSuffix) {
21✔
950
                                continue // already compressed
5✔
951
                        }
952
                        isMarked := false
11✔
953
                        for _, rmf := range filesToRemove {
11✔
954
                                if rmf.Name() == name {
×
955
                                        isMarked = true
×
956
                                        break
×
957
                                }
958
                        }
959
                        if !isMarked {
22✔
960
                                filesToCompress = append(filesToCompress, f)
11✔
961
                        }
11✔
962
                }
963
        }
964

965
        // Execute removals (ensure unique removals)
966
        finalUniqueRemovals := make(map[string]logInfo)
36✔
967
        for _, f := range filesToRemove {
45✔
968
                finalUniqueRemovals[f.Name()] = f
9✔
969
        }
9✔
970
        for _, f := range finalUniqueRemovals {
45✔
971
                errRemove := l.resolvedRemove(filepath.Join(l.dir(), f.Name()))
9✔
972
                if errRemove != nil && !os.IsNotExist(errRemove) {
9✔
973
                        fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to remove old log file %s: %v\n", l.Filename, f.Name(), errRemove)
×
974
                }
×
975
        }
976

977
        // Execute compressions (suffix also comes from snapshot via compressedSuffix)
978
        suffix := l.compressedSuffix()
36✔
979
        for _, f := range filesToCompress {
47✔
980
                fn := filepath.Join(l.dir(), f.Name())
11✔
981
                if errCompress := l.compressLogFile(fn, fn+suffix); errCompress != nil {
11✔
982
                        fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to compress log file %s: %v\n", l.Filename, f.Name(), errCompress)
×
983
                }
×
984
        }
985
        return nil
36✔
986
}
987

988
// millRun runs in a goroutine to manage post-rotation compression and removal
989
// of old log files. It listens on millCh for signals to run millRunOnce.
990
func (l *Logger) millRun() {
48✔
991
        if l.millWGStarted {
94✔
992
                defer l.millWg.Done()
46✔
993
        }
46✔
994
        ch := l.millCh
48✔
995
        for range ch {
124✔
996
                _ = l.millRunOnce()
76✔
997
        }
76✔
998
}
999

1000
// mill performs post-rotation compression and removal of stale log files,
1001
// starting the mill goroutine if necessary and sending a signal to it.
1002
func (l *Logger) mill() {
78✔
1003
        if atomic.LoadUint32(&l.isClosed) == 1 {
78✔
1004
                return
×
1005
        }
×
1006
        l.startMill.Do(func() {
124✔
1007
                l.millWGStarted = true
46✔
1008
                l.millCh = make(chan bool, 1)
46✔
1009
                l.millWg.Add(1)
46✔
1010
                go l.millRun()
46✔
1011
        })
46✔
1012
        select {
78✔
1013
        case l.millCh <- true:
73✔
1014
        default:
5✔
1015
        }
1016
}
1017

1018
// oldLogFiles returns the list of backup log files stored in the same
1019
// directory as the current log file, sorted by their embedded timestamp (newest first).
1020
func (l *Logger) oldLogFiles() ([]logInfo, error) {
37✔
1021
        entries, err := os.ReadDir(l.dir()) // ReadDir is generally preferred over ReadFile for directory listings
37✔
1022
        if err != nil {
37✔
1023
                return nil, fmt.Errorf("can't read log file directory: %s", err)
×
1024
        }
×
1025
        var logFiles []logInfo
37✔
1026

37✔
1027
        prefix, ext := l.prefixAndExt() // Get prefix like "filename-" and original extension like ".log"
37✔
1028

37✔
1029
        for _, e := range entries {
130✔
1030
                if e.IsDir() { // Skip directories
96✔
1031
                        continue
3✔
1032
                }
1033
                name := e.Name()
90✔
1034
                info, errInfo := e.Info() // Get FileInfo for modification time and other details
90✔
1035
                if errInfo != nil {
90✔
1036
                        // fmt.Fprintf(os.Stderr, "timberjack: failed to get FileInfo for %s: %v\n", name, errInfo)
×
1037
                        continue // Skip files we can't stat
×
1038
                }
1039

1040
                // Attempt to parse timestamp from filename (e.g., from "filename-timestamp-reason.log")
1041
                if t, errTime := l.timeFromName(name, prefix, ext); errTime == nil {
123✔
1042
                        logFiles = append(logFiles, logInfo{t, info})
33✔
1043
                        continue
33✔
1044
                }
1045
                // Attempt to parse timestamp from compressed gzip filename (e.g., from "filename-timestamp-reason.log.gz")
1046
                if t, errTime := l.timeFromName(name, prefix, ext+compressSuffix); errTime == nil {
64✔
1047
                        logFiles = append(logFiles, logInfo{t, info})
7✔
1048
                        continue
7✔
1049
                }
1050
                // Attempt to parse timestamp from compressed zstd filename (e.g., from "filename-timestamp-reason.log.zst")
1051
                if t, errTime := l.timeFromName(name, prefix, ext+zstdSuffix); errTime == nil {
50✔
1052
                        logFiles = append(logFiles, logInfo{t, info})
×
1053
                        continue
×
1054
                }
1055
                // Files that don't match the expected backup pattern are ignored.
1056
        }
1057

1058
        sort.Sort(byFormatTime(logFiles)) // Sorts newest first based on parsed timestamp
37✔
1059
        return logFiles, nil
37✔
1060
}
1061

1062
// timeFromName extracts the formatted timestamp from the backup filename.
1063
// It expects filenames like "prefix-YYYY-MM-DDTHH-MM-SS.mmm-reason.ext" or "prefix.ext-YYYY-MM-DDTHH-MM-SS.mmm-reason[.gz]"
1064
func (l *Logger) timeFromName(filename, prefix, ext string) (time.Time, error) {
205✔
1065
        layout := l.resolvedBackupLayout
205✔
1066
        if layout == "" {
218✔
1067
                // defensive default if called very early
13✔
1068
                layout = backupTimeFormat
13✔
1069
        }
13✔
1070
        loc := time.UTC
205✔
1071
        if l.resolvedLocalTime {
205✔
1072
                loc = time.Local
×
1073
        }
×
1074

1075
        if !l.resolvedAppendAfterExt {
406✔
1076

201✔
1077
                // Keep legacy behavior for error messages to satisfy existing tests
201✔
1078
                if !strings.HasPrefix(filename, prefix) {
351✔
1079
                        return time.Time{}, errors.New("mismatched prefix")
150✔
1080
                }
150✔
1081
                if !strings.HasSuffix(filename, ext) {
60✔
1082
                        return time.Time{}, errors.New("mismatched extension")
9✔
1083
                }
9✔
1084
                // "<prefix><timestamp>-<reason><ext>"
1085
                trimmed := filename[len(prefix) : len(filename)-len(ext)]
42✔
1086
                lastHyphenIdx := strings.LastIndex(trimmed, "-")
42✔
1087
                if lastHyphenIdx == -1 {
43✔
1088
                        return time.Time{}, fmt.Errorf("malformed backup filename: missing reason separator in %q", trimmed)
1✔
1089
                }
1✔
1090
                ts := trimmed[:lastHyphenIdx]
41✔
1091
                return time.ParseInLocation(layout, ts, loc)
41✔
1092
        }
1093

1094
        // After-ext parsing:
1095
        // base is "<name><ext>" (e.g., "foo.log")
1096
        base := prefix[:len(prefix)-1] + ext
4✔
1097

4✔
1098
        // Allow optional trailing compression suffix (".gz" or ".zst")
4✔
1099
        nameNoComp := trimCompressionSuffix(filename)
4✔
1100

4✔
1101
        // nameNoComp must start with "<base>-"
4✔
1102
        if !strings.HasPrefix(nameNoComp, base+"-") {
7✔
1103
                return time.Time{}, fmt.Errorf("malformed backup filename: %q", filename)
3✔
1104
        }
3✔
1105

1106
        // nameNoComp = "<base>-<timestamp>-<reason>"
1107
        trimmed := nameNoComp[len(base)+1:]
1✔
1108

1✔
1109
        lastHyphenIdx := strings.LastIndex(trimmed, "-")
1✔
1110
        if lastHyphenIdx == -1 {
1✔
1111
                return time.Time{}, fmt.Errorf("malformed backup filename: %q", filename)
×
1112
        }
×
1113
        ts := trimmed[:lastHyphenIdx]
1✔
1114
        return time.ParseInLocation(layout, ts, loc)
1✔
1115
}
1116

1117
// max returns the maximum size in bytes of log files before rolling.
1118
func (l *Logger) max() int64 {
133✔
1119
        if l.MaxSize == 0 { // If MaxSize is 0, use default.
170✔
1120
                return int64(defaultMaxSize * megabyte)
37✔
1121
        }
37✔
1122
        return int64(l.MaxSize) * int64(megabyte)
96✔
1123
}
1124

1125
// dir returns the directory for the current filename.
1126
func (l *Logger) dir() string {
130✔
1127
        return filepath.Dir(l.filename())
130✔
1128
}
130✔
1129

1130
// prefixAndExt returns the filename part (up to the extension, with a trailing dash for backups)
1131
// and extension part from the Logger's filename.
1132
// e.g., for "foo.log", returns "foo-", ".log"
1133
func (l *Logger) prefixAndExt() (prefix, ext string) {
40✔
1134
        filename := filepath.Base(l.filename())
40✔
1135
        ext = filepath.Ext(filename)
40✔
1136
        prefix = filename[:len(filename)-len(ext)] + "-" // Add dash as backup filenames include it after original prefix
40✔
1137
        return prefix, ext
40✔
1138
}
40✔
1139

1140
// countDigitsAfterDot returns the number of consecutive digit characters
1141
// immediately following the first '.' in the input.
1142
// It skips all characters before the '.' and stops counting at the first non-digit
1143
// character after the '.'.
1144

1145
// Example: `prefix.0012304123suffix` would return 10
1146
// Example: `prefix.0012304_middle_123_suffix` would return 7
1147
func countDigitsAfterDot(layout string) int {
17✔
1148
        for i, ch := range layout {
297✔
1149
                if ch == '.' {
293✔
1150
                        count := 0
13✔
1151
                        for _, c := range layout[i+1:] {
64✔
1152
                                if unicode.IsDigit(c) {
99✔
1153
                                        count++
48✔
1154
                                } else {
51✔
1155
                                        break
3✔
1156
                                }
1157
                        }
1158
                        return count
13✔
1159
                }
1160
        }
1161
        return 0 // no '.' found or no digits after dot
4✔
1162
}
1163

1164
// truncateFractional truncates time t to n fractional digits of seconds.
1165
// n=0 → truncate to seconds, n=3 → milliseconds, n=6 → microseconds, etc.
1166
func truncateFractional(t time.Time, n int) (time.Time, error) {
16✔
1167
        if n < 0 || n > 9 {
18✔
1168
                return time.Time{}, fmt.Errorf("unsupported fractional precision: %d", n)
2✔
1169
        }
2✔
1170

1171
        // number of nanoseconds to keep
1172
        factor := math.Pow10(9 - n) // e.g. for n=3, factor=10^(9-3)=1,000,000
14✔
1173

14✔
1174
        nanos := t.Nanosecond()
14✔
1175
        truncatedNanos := int((int64(nanos) / int64(factor)) * int64(factor))
14✔
1176

14✔
1177
        return time.Date(
14✔
1178
                t.Year(), t.Month(), t.Day(),
14✔
1179
                t.Hour(), t.Minute(), t.Second(),
14✔
1180
                truncatedNanos,
14✔
1181
                t.Location(),
14✔
1182
        ), nil
14✔
1183
}
1184

1185
// compressLogFile compresses the given source log file (src) to a destination file (dst),
1186
// removing the source file if compression is successful.
1187
func (l *Logger) compressLogFile(src, dst string) error {
21✔
1188
        srcFile, err := os.Open(src)
21✔
1189
        if err != nil {
26✔
1190
                return fmt.Errorf("failed to open source log file %s for compression: %v", src, err)
5✔
1191
        }
5✔
1192
        defer srcFile.Close()
16✔
1193

16✔
1194
        srcInfo, err := l.resolvedStat(src)
16✔
1195
        if err != nil {
17✔
1196
                return fmt.Errorf("failed to stat source log file %s: %v", src, err)
1✔
1197
        }
1✔
1198

1199
        // Create or open the destination file for writing the compressed content
1200
        dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, srcInfo.Mode())
15✔
1201
        if err != nil {
16✔
1202
                return fmt.Errorf("failed to open destination compressed log file %s: %v", dst, err)
1✔
1203
        }
1✔
1204
        // No `defer dstFile.Close()` here, explicit closing in sequence is critical.
1205

1206
        var copyErr error // To capture error from io.Copy
14✔
1207

14✔
1208
        // Choose compression algorithm based on dst suffix
14✔
1209
        // Default to gzip if no recognized suffix
14✔
1210
        // This allows future extension to other algorithms by checking dst suffix
14✔
1211
        if strings.HasSuffix(dst, zstdSuffix) {
17✔
1212
                enc, err := zstd.NewWriter(dstFile)
3✔
1213
                if err != nil { // Error creating zstd writer
3✔
1214
                        _ = dstFile.Close() // Close dstFile before removing
×
1215
                        _ = l.resolvedRemove(dst)
×
1216
                        return fmt.Errorf("failed to init zstd writer for %s: %v", dst, err)
×
1217
                }
×
1218
                _, copyErr = io.Copy(enc, srcFile) // Copy data from source file to zstd writer
3✔
1219
                closeErr := enc.Close()            // Close zstd writer to flush data
3✔
1220
                if copyErr == nil && closeErr != nil {
3✔
1221
                        copyErr = closeErr
×
1222
                }
×
1223
        } else {
11✔
1224
                gz := gzip.NewWriter(dstFile)     // Default to gzip
11✔
1225
                _, copyErr = io.Copy(gz, srcFile) // Copy data from source file to gzip writer
11✔
1226
                closeErr := gz.Close()            // Close gzip writer to flush data
11✔
1227
                if copyErr == nil && closeErr != nil {
11✔
1228
                        copyErr = closeErr
×
1229
                }
×
1230
        }
1231

1232
        if copyErr != nil { // Error during copy or close
14✔
1233
                _ = dstFile.Close()       // Try to close destination file
×
1234
                _ = l.resolvedRemove(dst) // Try to remove potentially partial destination file
×
1235
                return fmt.Errorf("failed to write compressed data to %s: %w", dst, copyErr)
×
1236
        }
×
1237

1238
        if err := dstFile.Close(); err != nil { // Close destination file
14✔
1239
                // Data is likely written and compressor closed successfully, but closing the file descriptor failed.
×
1240
                // The destination file might still be valid on disk. We typically wouldn't remove dst here
×
1241
                // as the data might be recoverable or fully written despite the close error.
×
1242
                return fmt.Errorf("failed to close destination compressed file %s: %w", dst, err)
×
1243
        }
×
1244

1245
        if errChown := chown(dst, srcInfo); errChown != nil { // Attempt to chown the destination file
15✔
1246
                // Log the chown error, but don't make it a fatal error for the compression process itself,
1✔
1247
                // as the compressed file is valid. The original source file will still be removed.
1✔
1248
                fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to chown compressed log file %s: %v (source %s)\n",
1✔
1249
                        filepath.Base(src), dst, errChown, src)
1✔
1250
                // Note: Depending on requirements, a chown failure could be considered critical.
1✔
1251
                // For now, it's logged, and compression proceeds to remove the source.
1✔
1252
        }
1✔
1253

1254
        // Windows file locking fix
1255
        // Close the source file explicitly BEFORE attempting to remove it.
1256
        // On Windows, you cannot delete an open file. The defer srcFile.Close() won't execute
1257
        // until this function returns, so we must close it here before calling resolvedRemove().
1258
        // This prevents "The process cannot access the file because it is being used" errors.
1259
        if err := srcFile.Close(); err != nil {
14✔
1260
                // Log the close error but continue with removal attempt
×
1261
                fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to close source file before removal: %v\n", filepath.Base(src), err)
×
1262
        }
×
1263

1264
        // Finally, after successful compression and closing (and optional chown), remove the original source file.
1265
        if err = l.resolvedRemove(src); err != nil {
15✔
1266
                // This is a more significant error if the original isn't removed, as it might be re-processed.
1✔
1267
                return fmt.Errorf("failed to remove original source log file %s after compression: %w", src, err)
1✔
1268
        }
1✔
1269
        return nil // Compression successful
13✔
1270
}
1271

1272
// effectiveCompression returns "none" | "gzip" | "zstd".
1273
// Rule: if Compression is set, it wins; if empty, fallback to legacy Compress.
1274
// Unknown strings default to "none" (and warn once).
1275
func (l *Logger) effectiveCompression() string {
82✔
1276
        alg := strings.ToLower(strings.TrimSpace(l.Compression))
82✔
1277
        switch alg {
82✔
1278
        case "gzip", "zstd":
3✔
1279
                return alg
3✔
1280
        case "none", "":
78✔
1281
                if alg == "" && l.Compress {
87✔
1282
                        return "gzip"
9✔
1283
                }
9✔
1284
                return "none"
69✔
1285
        default:
1✔
1286
                fmt.Fprintf(os.Stderr, "timberjack: invalid compression %q — using none\n", alg)
1✔
1287
                return "none"
1✔
1288
        }
1289
}
1290

1291
// compressedSuffix returns ".gz" / ".zst" or "" if none.
1292
func (l *Logger) compressedSuffix() string {
36✔
1293
        switch l.resolvedCompression {
36✔
1294
        case "gzip":
14✔
1295
                return compressSuffix
14✔
1296
        case "zstd":
3✔
1297
                return zstdSuffix
3✔
1298
        default:
19✔
1299
                return ""
19✔
1300
        }
1301
}
1302

1303
// trimCompressionSuffix strips one known compression suffix (".gz" or ".zst").
1304
func trimCompressionSuffix(name string) string {
4✔
1305
        name = strings.TrimSuffix(name, compressSuffix)
4✔
1306
        name = strings.TrimSuffix(name, zstdSuffix)
4✔
1307
        return name
4✔
1308
}
4✔
1309

1310
// sanitizeReason turns an arbitrary string into a safe, short tag for filenames.
1311
// Allowed: [a-z0-9_-]. Everything else becomes '-'. Collapses repeats, trims edges.
1312
// Returns empty string if nothing usable remains.
1313
func sanitizeReason(s string) string {
15✔
1314
        s = strings.TrimSpace(strings.ToLower(s))
15✔
1315
        if s == "" {
28✔
1316
                return ""
13✔
1317
        }
13✔
1318
        const max = 32
2✔
1319

2✔
1320
        var b strings.Builder
2✔
1321
        lastDash := false
2✔
1322
        for _, r := range s {
24✔
1323
                ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_'
22✔
1324
                if ok {
39✔
1325
                        b.WriteRune(r)
17✔
1326
                        lastDash = (r == '-')
17✔
1327
                } else {
22✔
1328
                        // replace anything else (including whitespace) with a single '-'
5✔
1329
                        if !lastDash && b.Len() > 0 {
7✔
1330
                                b.WriteByte('-')
2✔
1331
                                lastDash = true
2✔
1332
                        }
2✔
1333
                }
1334
                if b.Len() >= max {
22✔
1335
                        break
×
1336
                }
1337
        }
1338
        out := strings.Trim(b.String(), "-_")
2✔
1339
        return out
2✔
1340
}
1341

1342
// logInfo is a convenience struct to return the filename and its embedded
1343
// timestamp, along with its os.FileInfo.
1344
type logInfo struct {
1345
        timestamp   time.Time // Parsed timestamp from the filename
1346
        os.FileInfo           // Full FileInfo
1347
}
1348

1349
// byFormatTime sorts a slice of logInfo structs by their parsed timestamp in descending order (newest first).
1350
type byFormatTime []logInfo
1351

1352
func (b byFormatTime) Less(i, j int) bool {
20✔
1353
        // Handle cases where timestamps might be zero (e.g., parsing failed, though timeFromName should error out)
20✔
1354
        if b[i].timestamp.IsZero() && !b[j].timestamp.IsZero() {
21✔
1355
                return false
1✔
1356
        } // Treat zero time as oldest
1✔
1357
        if !b[i].timestamp.IsZero() && b[j].timestamp.IsZero() {
20✔
1358
                return true
1✔
1359
        } // Non-zero is newer than zero
1✔
1360
        if b[i].timestamp.IsZero() && b[j].timestamp.IsZero() {
19✔
1361
                return false
1✔
1362
        } // Equal if both are zero (order doesn't matter)
1✔
1363
        return b[i].timestamp.After(b[j].timestamp) // Sort newest first
17✔
1364
}
1365
func (b byFormatTime) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
16✔
1366
func (b byFormatTime) Len() int      { return len(b) }
40✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc