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

DeRuina / timberjack / 18554165489

16 Oct 2025 07:47AM UTC coverage: 87.793% (-0.2%) from 88.039%
18554165489

push

github

DeRuina
ci: staticcheck (Go 1.22 compatible)

712 of 811 relevant lines covered (87.79%)

39.53 hits per line

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

87.58
/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
)
54

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

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

70
type rotateAt [2]int
71

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

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

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

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

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

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

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

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

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

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

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

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

175
        // Internal fields
176
        size                   int64     // current size of the log file
177
        file                   *os.File  // current log file
178
        lastRotationTime       time.Time // records the last time a rotation happened (for interval/scheduled).
179
        logStartTime           time.Time // start time of the current logging period (used for backup filename timestamp).
180
        cfgOnce                sync.Once
181
        resolvedBackupLayout   string
182
        resolvedAppendAfterExt bool
183
        resolvedLocalTime      bool
184
        resolvedCompression    string
185

186
        mu sync.Mutex // ensures atomic writes and rotations
187

188
        // For mill goroutine (backups, compression cleanup)
189
        millCh        chan bool      // channel to signal the mill goroutine
190
        startMill     sync.Once      // ensures mill goroutine is started only once
191
        millWg        sync.WaitGroup // waits for the mill goroutine to finish
192
        millWGStarted bool
193

194
        // For scheduled rotation goroutine (RotateAt)
195
        startScheduledRotationOnce sync.Once      // ensures scheduled rotation goroutine is started only once
196
        scheduledRotationQuitCh    chan struct{}  // channel to signal the scheduled rotation goroutine to stop
197
        scheduledRotationWg        sync.WaitGroup // waits for the scheduled rotation goroutine to finish
198
        processedRotateAt          []rotateAt     // internal storage for sorted and validated RotateAt
199
        isClosed                   uint32
200

201
        // snapshots of globals to avoid races
202
        resolvedTimeNow func() time.Time
203
        resolvedStat    func(string) (os.FileInfo, error)
204
        resolvedRename  func(string, string) error
205
        resolvedRemove  func(string) error
206
}
207

208
var (
209
        // currentTime exists so it can be mocked out by tests.
210
        currentTime = time.Now
211

212
        // osStat exists so it can be mocked out by tests.
213
        osStat = os.Stat
214

215
        // megabyte is the conversion factor between MaxSize and bytes.  It is a
216
        // variable so tests can mock it out and not need to write megabytes of data
217
        // to disk.
218
        megabyte = 1024 * 1024
219

220
        osRename = os.Rename
221

222
        osRemove = os.Remove
223

224
        // empty BackupTimeFormatField
225
        ErrEmptyBackupTimeFormatField = errors.New("empty backupformat field")
226
)
227

228
func (l *Logger) resolveConfigLocked() {
490✔
229
        l.cfgOnce.Do(func() {
567✔
230
                // Resolve time format
77✔
231
                layout := l.BackupTimeFormat
77✔
232
                if layout == "" {
153✔
233
                        layout = backupTimeFormat
76✔
234
                } else if err := l.ValidateBackupTimeFormat(); err != nil {
77✔
235
                        fmt.Fprintf(os.Stderr,
×
236
                                "timberjack: invalid BackupTimeFormat: %v — falling back to default format: %s\n",
×
237
                                err, backupTimeFormat)
×
238
                        layout = backupTimeFormat
×
239
                }
×
240
                l.resolvedBackupLayout = layout
77✔
241
                l.resolvedAppendAfterExt = l.AppendTimeAfterExt
77✔
242
                l.resolvedLocalTime = l.LocalTime
77✔
243

77✔
244
                // snapshot global funcs so tests can fiddle with the globals safely
77✔
245
                l.resolvedTimeNow = currentTime
77✔
246
                l.resolvedStat = osStat
77✔
247
                l.resolvedRename = osRename
77✔
248
                l.resolvedRemove = osRemove
77✔
249

77✔
250
                // Freeze compression (prevents races if toggled later)
77✔
251
                l.resolvedCompression = l.effectiveCompression()
77✔
252
        })
253
}
254

255
// Write implements io.Writer.
256
// It writes the provided bytes to the current log file.
257
// If the log file exceeds MaxSize after writing, or if the configured RotationInterval has elapsed
258
// since the last rotation, or if a scheduled rotation time (RotateAtMinutes) has been reached,
259
// the file is closed, renamed to include a timestamp, and a new log file is created
260
// using the original filename.
261
// If the size of a single write exceeds MaxSize, the write is rejected and an error is returned.
262
func (l *Logger) Write(p []byte) (n int, err error) {
55✔
263
        l.mu.Lock()
55✔
264
        defer l.mu.Unlock()
55✔
265

55✔
266
        // Ensure configuration is resolved once
55✔
267
        l.resolveConfigLocked()
55✔
268

55✔
269
        // Handle writes to a closed logger.
55✔
270
        if atomic.LoadUint32(&l.isClosed) == 1 {
56✔
271
                // The logger is closed. To ensure the write succeeds, we perform a
1✔
272
                // single open-write-close cycle. This does not perform rotation
1✔
273
                // and does not restart the background goroutines. l.file remains nil.
1✔
274
                file, openErr := os.OpenFile(l.filename(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
1✔
275
                if openErr != nil {
1✔
276
                        return 0, fmt.Errorf("timberjack: write on closed logger failed to open file: %w", openErr)
×
277
                }
×
278

279
                n, writeErr := file.Write(p)
1✔
280

1✔
281
                closeErr := file.Close()
1✔
282

1✔
283
                if writeErr != nil {
1✔
284
                        return n, writeErr
×
285
                }
×
286
                return n, closeErr
1✔
287
        }
288

289
        // Ensure the scheduled-rotation goroutine is running (if you've still got one).
290
        l.ensureScheduledRotationLoopRunning()
54✔
291

54✔
292
        // Snapshot
54✔
293
        now := l.resolvedTimeNow().In(l.location())
54✔
294

54✔
295
        writeLen := int64(len(p))
54✔
296
        if writeLen > l.max() {
55✔
297
                return 0, fmt.Errorf("write length %d exceeds maximum file size %d", writeLen, l.max())
1✔
298
        }
1✔
299

300
        // Open (or create) the file on first write.
301
        if l.file == nil {
88✔
302
                if err = l.openExistingOrNew(len(p)); err != nil {
38✔
303
                        return 0, err
3✔
304
                }
3✔
305
                if l.lastRotationTime.IsZero() {
62✔
306
                        // Initialize to 'now' so interval/minute checks start from here.
30✔
307
                        l.lastRotationTime = now
30✔
308
                }
30✔
309
        }
310

311
        // 1) Interval-based rotation
312
        if l.RotationInterval > 0 && now.Sub(l.lastRotationTime) >= l.RotationInterval {
53✔
313
                if err := l.rotate("time"); err != nil {
5✔
314
                        return 0, fmt.Errorf("interval rotation failed: %w", err)
2✔
315
                }
2✔
316
                l.lastRotationTime = now
1✔
317
        }
318

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

336
        // 3) Size-based rotation
337
        if l.size+writeLen > l.max() {
60✔
338
                if err := l.rotate("size"); err != nil {
12✔
339
                        return 0, fmt.Errorf("size rotation failed: %w", err)
×
340
                }
×
341
                // Note: we leave lastRotationTime untouched for size rotations.
342
        }
343

344
        // Finally, write the bytes and update size.
345
        n, err = l.file.Write(p)
48✔
346
        l.size += int64(n)
48✔
347
        return n, err
48✔
348
}
349

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

7✔
362
        layoutPrecision := countDigitsAfterDot(l.BackupTimeFormat)
7✔
363

7✔
364
        now, err := truncateFractional(now, layoutPrecision)
7✔
365

7✔
366
        if err != nil {
7✔
367
                return err
×
368
        }
×
369
        formatted := now.Format(l.BackupTimeFormat)
7✔
370
        parsedT, err := time.Parse(l.BackupTimeFormat, formatted)
7✔
371
        if err != nil {
7✔
372
                return fmt.Errorf("invalid BackupTimeFormat: %w", err)
×
373
        }
×
374
        if !parsedT.Equal(now) {
9✔
375
                return errors.New("invalid BackupTimeFormat: time.Time parsed from the format does not match the time.Time supplied")
2✔
376
        }
2✔
377

378
        return nil
5✔
379
}
380

381
// location returns the time.Location (UTC or Local) to use for timestamps in backup filenames.
382
func (l *Logger) location() *time.Location {
221✔
383
        l.resolveConfigLocked()
221✔
384
        if l.resolvedLocalTime {
223✔
385
                return time.Local
2✔
386
        }
2✔
387
        return time.UTC
219✔
388
}
389

390
func parseTime(s string) (*rotateAt, error) {
11✔
391
        parts := strings.Split(s, ":")
11✔
392
        if len(parts) != 2 {
13✔
393
                return nil, errors.New("invalid time")
2✔
394
        }
2✔
395
        hs, ms := parts[0], parts[1]
9✔
396
        h, err := strconv.Atoi(hs)
9✔
397
        if err != nil {
10✔
398
                return nil, err
1✔
399
        }
1✔
400
        m, err := strconv.Atoi(ms)
8✔
401
        if err != nil {
8✔
402
                return nil, err
×
403
        }
×
404

405
        if m < 0 || m > 59 || h < 0 || h > 23 {
12✔
406
                return nil, errors.New("invalid time")
4✔
407
        }
4✔
408

409
        return &rotateAt{h, m}, nil
4✔
410
}
411

412
func compareTime(a, b rotateAt) int {
817✔
413
        h1, m1 := a[0], a[1]
817✔
414
        h2, m2 := b[0], b[1]
817✔
415
        if h1 == h2 {
989✔
416
                return cmp.Compare(m1, m2)
172✔
417
        }
172✔
418
        return cmp.Compare(h1, h2)
645✔
419
}
420

421
// ensureScheduledRotationLoopRunning starts the scheduled rotation goroutine if RotateAtMinutes is configured
422
// and the goroutine is not already running.
423
func (l *Logger) ensureScheduledRotationLoopRunning() {
59✔
424
        if len(l.RotateAtMinutes)+len(l.RotateAt) == 0 {
108✔
425
                return // No scheduled rotations configured
49✔
426
        }
49✔
427

428
        l.startScheduledRotationOnce.Do(func() {
16✔
429
                var processedRotateAt []rotateAt
6✔
430
                for _, m := range l.RotateAtMinutes {
18✔
431
                        if m < 0 || m > 59 {
19✔
432
                                fmt.Fprintf(os.Stderr, "timberjack: [%d] No valid minute specified for RotateAtMinutes.\n", m)
7✔
433
                                continue
7✔
434
                        }
435
                        for h := 0; h < 24; h++ {
125✔
436
                                processedRotateAt = append(processedRotateAt, rotateAt{h, m})
120✔
437
                        }
120✔
438
                }
439

440
                for _, t := range l.RotateAt {
17✔
441
                        r, err := parseTime(t)
11✔
442
                        if err != nil {
18✔
443
                                fmt.Fprintf(os.Stderr, "timberjack: [%s] No valid time specified for RotateAt.\n", t)
7✔
444
                                continue
7✔
445
                        }
446
                        processedRotateAt = append(processedRotateAt, *r)
4✔
447
                }
448

449
                if len(processedRotateAt) == 0 {
9✔
450
                        // Optionally log that no valid minutes were found, preventing goroutine start
3✔
451
                        // fmt.Fprintf(os.Stderr, "timberjack: [%s] No valid minutes specified for RotateAtMinutes.\n", l.Filename)
3✔
452
                        return
3✔
453
                }
3✔
454

455
                // Sort for predictable order in calculating next rotation
456
                slices.SortFunc(processedRotateAt, compareTime)
3✔
457
                processedRotateAt = slices.CompactFunc(processedRotateAt, func(a, b rotateAt) bool {
124✔
458
                        return compareTime(a, b) == 0
121✔
459
                })
121✔
460

461
                l.processedRotateAt = processedRotateAt
3✔
462
                quitCh := make(chan struct{})
3✔
463
                l.scheduledRotationQuitCh = quitCh
3✔
464

3✔
465
                // Snapshot immutable inputs for the goroutine to avoid reading fields later.
3✔
466
                slots := l.processedRotateAt
3✔
467
                loc := l.location()
3✔
468
                nowFn := l.resolvedTimeNow
3✔
469

3✔
470
                l.scheduledRotationWg.Add(1)
3✔
471
                go l.runScheduledRotations(quitCh, slots, loc, nowFn)
3✔
472
        })
473
}
474

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

16✔
480
        // No slots to process, exit immediately.
16✔
481
        if len(slots) == 0 {
17✔
482
                return
1✔
483
        }
1✔
484

485
        timer := time.NewTimer(0) // Timer will be reset with the correct duration in the loop
15✔
486
        if !timer.Stop() {
18✔
487
                // Drain the channel if the timer fired prematurely (e.g., duration was 0 on first NewTimer)
3✔
488
                select {
3✔
489
                case <-timer.C:
3✔
490
                default:
×
491
                }
492
        }
493

494
        for {
30✔
495
                now := nowFn()
15✔
496
                nowInLocation := now.In(loc)
15✔
497
                nextRotationAbsoluteTime := time.Time{}
15✔
498
                foundNextSlot := false
15✔
499

15✔
500
        determineNextSlot:
15✔
501
                // Calculate the next rotation time based on the current time and processedRotateAt.
15✔
502
                // Iterate through the current hour, then subsequent hours (up to 24h ahead for robustness
15✔
503
                // against system sleep or large clock jumps).
15✔
504
                for hourOffset := 0; hourOffset <= 24; hourOffset++ {
231✔
505
                        // Base time for the hour we are checking (e.g., if now is 10:35, current hour base is 10:00)
216✔
506
                        hourToCheck := time.Date(nowInLocation.Year(), nowInLocation.Month(), nowInLocation.Day(), nowInLocation.Hour(), 0, 0, 0, loc).Add(time.Duration(hourOffset) * time.Hour)
216✔
507

216✔
508
                        for _, mark := range slots {
500✔
509
                                candidateTime := time.Date(hourToCheck.Year(), hourToCheck.Month(), hourToCheck.Day(), mark[0], mark[1], 0, 0, loc)
284✔
510

284✔
511
                                if candidateTime.After(now) { // Found the earliest future slot
299✔
512
                                        nextRotationAbsoluteTime = candidateTime
15✔
513
                                        foundNextSlot = true
15✔
514
                                        break determineNextSlot // Exit both loops
15✔
515
                                }
516
                        }
517
                }
518

519
                if !foundNextSlot {
15✔
520
                        // This should ideally not happen if processedRotateAt is valid and non-empty.
×
521
                        // Could occur if currentTime() is unreliable or jumps massively backward.
×
522
                        // Log an error and retry calculation after a fallback delay.
×
523
                        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)
×
524
                        select {
×
525
                        case <-time.After(time.Minute): // Wait a bit before retrying calculation
×
526
                                continue // Restart the outer loop to recalculate
×
527
                        case <-quit:
×
528
                                return
×
529
                        }
530
                }
531

532
                sleepDuration := nextRotationAbsoluteTime.Sub(now)
15✔
533
                timer.Reset(sleepDuration)
15✔
534

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

551
                case <-quit: // Signal to quit from Close()
14✔
552
                        if !timer.Stop() {
14✔
553
                                // If Stop() returns false, the timer has already fired or been stopped.
×
554
                                // If it fired, its channel might have a value, so drain it.
×
555
                                select {
×
556
                                case <-timer.C:
×
557
                                default:
×
558
                                }
559
                        }
560
                        return // Exit goroutine
14✔
561
                }
562
        }
563
}
564

565
// Close implements io.Closer, and closes the current logfile.
566
// It also signals any running goroutines (like scheduled rotation or mill) to stop.
567
func (l *Logger) Close() error {
40✔
568
        l.mu.Lock()
40✔
569

40✔
570
        if atomic.LoadUint32(&l.isClosed) == 1 {
41✔
571
                l.mu.Unlock()
1✔
572
                return nil
1✔
573
        }
1✔
574
        atomic.StoreUint32(&l.isClosed, 1)
39✔
575

39✔
576
        // Stop the scheduled rotation goroutine
39✔
577
        var quitCh chan struct{}
39✔
578
        if l.scheduledRotationQuitCh != nil {
42✔
579
                quitCh = l.scheduledRotationQuitCh
3✔
580
                l.scheduledRotationQuitCh = nil // clear under lock
3✔
581
        }
3✔
582

583
        // Stop the mill goroutine (doesn't use l.mu, no deadlock risk)
584
        var millCh chan bool
39✔
585
        if l.millCh != nil {
75✔
586
                millCh = l.millCh
36✔
587
                // don't nil it; startMill.Do prevents restarts
36✔
588
        }
36✔
589

590
        // Close file under lock (safe and quick)
591
        err := l.closeFile()
39✔
592

39✔
593
        l.mu.Unlock()
39✔
594

39✔
595
        // Now signal and wait outside the lock
39✔
596
        if quitCh != nil {
42✔
597
                safeClose(quitCh)
3✔
598
                l.scheduledRotationWg.Wait()
3✔
599
        }
3✔
600
        if millCh != nil {
75✔
601
                safeClose(millCh)
36✔
602
                l.millWg.Wait()
36✔
603
        }
36✔
604

605
        return err
39✔
606
}
607

608
// closeFile closes the file if it is open. This is an internal method.
609
// It expects l.mu to be held.
610
func (l *Logger) closeFile() error {
78✔
611
        if l.file == nil {
93✔
612
                return nil
15✔
613
        }
15✔
614
        err := l.file.Close()
63✔
615
        l.file = nil // Set to nil to indicate it's closed.
63✔
616
        return err
63✔
617
}
618

619
// Rotate forces an immediate rotation using the legacy auto-reason logic.
620
// (empty reason => "time" if an interval rotation is due, otherwise "size")
621
func (l *Logger) Rotate() error {
10✔
622
        return l.RotateWithReason("")
10✔
623
}
10✔
624

625
// rotate closes the current file, moves it aside with a timestamp in the name,
626
// (if it exists), opens a new file with the original filename, and then runs
627
// post-rotation processing and removal (mill).
628
// It expects l.mu to be held by the caller.
629
// Takes an explicit reason for the rotation which is used in the backup filename.
630
func (l *Logger) rotate(reason string) error {
39✔
631
        if err := l.closeFile(); err != nil {
40✔
632
                return err
1✔
633
        }
1✔
634
        // Pass the determined reason to openNew so it's used in the backup filename
635
        if err := l.openNew(reason); err != nil {
43✔
636
                return err
5✔
637
        }
5✔
638
        l.mill()
33✔
639
        return nil
33✔
640
}
641

642
// RotateWithReason forces a rotation immediately and tags the backup filename
643
// with the provided reason (after sanitization). If the sanitized reason is
644
// empty, it falls back to the default behavior used by Rotate(): "time" if an
645
// interval rotation is due, otherwise "size".
646
//
647
// NOTE: Like Rotate(), this does not modify lastRotationTime. If an interval
648
// rotation is already due, a subsequent write may still trigger another
649
// interval-based rotation.
650
func (l *Logger) RotateWithReason(reason string) error {
13✔
651
        l.mu.Lock()
13✔
652
        defer l.mu.Unlock()
13✔
653

13✔
654
        if atomic.LoadUint32(&l.isClosed) == 1 {
13✔
655
                return errors.New("logger closed")
×
656
        }
×
657

658
        r := sanitizeReason(reason)
13✔
659
        if r == "" {
25✔
660
                // keep legacy Rotate() semantics
12✔
661
                r = "size"
12✔
662
                if l.shouldTimeRotate() {
15✔
663
                        r = "time"
3✔
664
                }
3✔
665
        }
666

667
        return l.rotate(r)
13✔
668
}
669

670
func backupNameWithResolved(name string, local bool, reason string, t time.Time, layout string, afterExt bool) string {
36✔
671
        dir := filepath.Dir(name)
36✔
672
        filename := filepath.Base(name)
36✔
673
        ext := filepath.Ext(filename)
36✔
674
        prefix := filename[:len(filename)-len(ext)]
36✔
675

36✔
676
        loc := time.UTC
36✔
677
        if local {
37✔
678
                loc = time.Local
1✔
679
        }
1✔
680
        ts := t.In(loc).Format(layout)
36✔
681

36✔
682
        if afterExt {
37✔
683
                // <name><ext>-<ts>-<reason>
1✔
684
                return filepath.Join(dir, fmt.Sprintf("%s%s-%s-%s", prefix, ext, ts, reason))
1✔
685
        }
1✔
686
        // <name>-<ts>-<reason><ext>
687
        return filepath.Join(dir, fmt.Sprintf("%s-%s-%s%s", prefix, ts, reason, ext))
35✔
688
}
689

690
// openNew creates a new log file for writing.
691
// If an old log file already exists, it is moved aside by renaming it with a timestamp.
692
// This method assumes that l.mu is held and the old file (if any) has already been closed.
693
// The reasonForBackup parameter is used in the backup filename.
694
func (l *Logger) openNew(reasonForBackup string) error {
66✔
695
        l.resolveConfigLocked() // no-op after first time
66✔
696

66✔
697
        if err := os.MkdirAll(l.dir(), 0755); err != nil {
68✔
698
                return fmt.Errorf("can't make directories for new logfile: %s", err)
2✔
699
        }
2✔
700

701
        name := l.filename()
64✔
702
        finalMode := os.FileMode(0640)
64✔
703
        var oldInfo os.FileInfo
64✔
704

64✔
705
        info, err := l.resolvedStat(name)
64✔
706
        if err == nil {
100✔
707
                oldInfo = info
36✔
708
                finalMode = oldInfo.Mode()
36✔
709

36✔
710
                rotationTimeForBackup := l.resolvedTimeNow()
36✔
711

36✔
712
                // Build the rotated name from the immutable snapshot (no public field writes).
36✔
713
                newname := backupNameWithResolved(
36✔
714
                        name,
36✔
715
                        l.resolvedLocalTime,
36✔
716
                        reasonForBackup,
36✔
717
                        rotationTimeForBackup,
36✔
718
                        l.resolvedBackupLayout,
36✔
719
                        l.resolvedAppendAfterExt,
36✔
720
                )
36✔
721

36✔
722
                if errRename := l.resolvedRename(name, newname); errRename != nil {
41✔
723
                        return fmt.Errorf("can't rename log file: %s", errRename)
5✔
724
                }
5✔
725
                l.logStartTime = rotationTimeForBackup
31✔
726
        } else if os.IsNotExist(err) {
55✔
727
                l.logStartTime = l.resolvedTimeNow()
27✔
728
                oldInfo = nil
27✔
729
        } else {
28✔
730
                return fmt.Errorf("failed to stat log file %s: %w", name, err)
1✔
731
        }
1✔
732

733
        // Create and open the new log file at path `name`.
734
        f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, finalMode)
58✔
735
        if err != nil {
58✔
736
                return fmt.Errorf("can't open new logfile %s: %s", name, err)
×
737
        }
×
738
        l.file = f
58✔
739
        l.size = 0
58✔
740

58✔
741
        // Try to chown the new file to match the old file's owner/group (if there was an old file).
58✔
742
        if oldInfo != nil {
89✔
743
                if errChown := chown(name, oldInfo); errChown != nil {
31✔
744
                        fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to chown new log file %s: %v\n", l.Filename, name, errChown)
×
745
                }
×
746
        }
747

748
        return nil
58✔
749
}
750

751
// shouldTimeRotate checks if the time-based rotation interval has elapsed
752
// since the last rotation. This is used for RotationInterval logic.
753
func (l *Logger) shouldTimeRotate() bool {
15✔
754
        l.resolveConfigLocked()
15✔
755
        if l.RotationInterval == 0 { // Time-based rotation (interval) is disabled
23✔
756
                return false
8✔
757
        }
8✔
758
        // If lastRotationTime is zero (e.g., logger just started, no writes/rotations yet),
759
        // then it's not yet time for an interval-based rotation.
760
        if l.lastRotationTime.IsZero() {
9✔
761
                return false
2✔
762
        }
2✔
763
        return l.resolvedTimeNow().Sub(l.lastRotationTime) >= l.RotationInterval
5✔
764
}
765

766
// backupName creates a new backup filename by inserting a timestamp and a rotation reason
767
// ("time" or "size") between the filename prefix and the extension.
768
// It uses the local time if requested (otherwise UTC).
769
func backupName(name string, local bool, reason string, t time.Time, fileTimeFormat string, appendTimeAfterExt bool) string {
2✔
770

2✔
771
        dir := filepath.Dir(name)
2✔
772
        filename := filepath.Base(name)
2✔
773
        ext := filepath.Ext(filename)
2✔
774
        prefix := filename[:len(filename)-len(ext)]
2✔
775

2✔
776
        currentLoc := time.UTC
2✔
777
        if local {
2✔
778
                currentLoc = time.Local
×
779
        }
×
780
        // Format the timestamp for the backup file.
781
        timestamp := t.In(currentLoc).Format(fileTimeFormat)
2✔
782

2✔
783
        if appendTimeAfterExt {
3✔
784
                // <name><ext>-<ts>-<reason>
1✔
785
                // e.g. httpd.log-2025-01-01T00-00-00.000-size
1✔
786
                return filepath.Join(dir, fmt.Sprintf("%s%s-%s-%s", prefix, ext, timestamp, reason))
1✔
787
        }
1✔
788

789
        // default: <name>-<ts>-<reason><ext>
790
        return filepath.Join(dir, fmt.Sprintf("%s-%s-%s%s", prefix, timestamp, reason, ext))
1✔
791
}
792

793
// openExistingOrNew opens the existing logfile if it exists and the current write
794
// would not cause it to exceed MaxSize. If the file does not exist, or if writing
795
// would exceed MaxSize, the current file is rotated (if it exists) and a new logfile is created.
796
// It expects l.mu to be held by the caller.
797
func (l *Logger) openExistingOrNew(writeLen int) error {
37✔
798
        l.resolveConfigLocked()
37✔
799
        l.mill() // Perform house-keeping for old logs (compression, deletion) first.
37✔
800

37✔
801
        filename := l.filename()
37✔
802
        info, err := l.resolvedStat(filename)
37✔
803
        if os.IsNotExist(err) {
60✔
804
                // File doesn't exist, so openNew is creating a new file.
23✔
805
                // The 'reason' passed to openNew here ("initial") won't affect a backup filename
23✔
806
                // as no backup (renaming) is happening.
23✔
807
                return l.openNew("initial")
23✔
808
        }
23✔
809
        if err != nil {
16✔
810
                return fmt.Errorf("error getting log file info: %s", err)
2✔
811
        }
2✔
812

813
        // Check if rotation is needed due to size before opening/appending.
814
        if info.Size()+int64(writeLen) >= l.max() {
17✔
815
                return l.rotate("size") // This rotation is explicitly due to "size"
5✔
816
        }
5✔
817

818
        // Open existing file for appending.
819
        file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0644) // Mode 0644 is common for append.
7✔
820
        if err != nil {
7✔
821
                // If opening existing fails (e.g., permissions, corruption), try to create a new one.
×
822
                return l.openNew("initial") // Fallback if append fails
×
823
        }
×
824
        l.file = file
7✔
825
        l.size = info.Size()
7✔
826
        // Note: l.logStartTime is NOT updated here if we successfully open an existing file without rotating.
7✔
827
        // It retains its value from when this current log segment was created (by a previous openNew).
7✔
828
        // l.lastRotationTime is also NOT updated here; it's handled by rotation trigger logic.
7✔
829
        return nil
7✔
830
}
831

832
// filename returns the current log filename, using the configured Filename,
833
// or a default based on the process name if Filename is empty.
834
func (l *Logger) filename() string {
273✔
835
        if l.Filename != "" {
543✔
836
                return l.Filename
270✔
837
        }
270✔
838
        name := filepath.Base(os.Args[0]) + "-timberjack.log"
3✔
839
        return filepath.Join(os.TempDir(), name)
3✔
840
}
841

842
// millRunOnce performs one cycle of compression and removal of old log files.
843
// If compression is enabled, uncompressed backups are compressed using gzip.
844
// Old backup files are deleted to enforce MaxBackups and MaxAge limits.
845
func (l *Logger) millRunOnce() error {
77✔
846
        l.resolveConfigLocked()
77✔
847
        comp := l.resolvedCompression
77✔
848
        if l.MaxBackups == 0 && l.MaxAge == 0 && comp == "none" {
114✔
849
                return nil // Nothing to do if all cleanup options are disabled.
37✔
850
        }
37✔
851

852
        now := l.resolvedTimeNow()
40✔
853

40✔
854
        files, err := l.oldLogFiles() // Gets LogInfo structs, sorted newest first by timestamp
40✔
855
        if err != nil {
40✔
856
                return err
×
857
        }
×
858

859
        var filesToProcess = files  // Start with all found old log files
40✔
860
        var filesToRemove []logInfo // Accumulates files to be deleted
40✔
861

40✔
862
        // MaxBackups filtering: Keep files belonging to the MaxBackups newest distinct timestamps
40✔
863
        if l.MaxBackups > 0 {
62✔
864
                uniqueTimestamps := make([]time.Time, 0)
22✔
865
                timestampMap := make(map[time.Time]bool)
22✔
866
                for _, f := range filesToProcess { // filesToProcess is sorted newest first
44✔
867
                        if !timestampMap[f.timestamp] {
43✔
868
                                timestampMap[f.timestamp] = true
21✔
869
                                uniqueTimestamps = append(uniqueTimestamps, f.timestamp)
21✔
870
                        }
21✔
871
                }
872

873
                if len(uniqueTimestamps) > l.MaxBackups {
26✔
874
                        // Determine the set of timestamps to keep (the MaxBackups newest ones)
4✔
875
                        keptTimestampsSet := make(map[time.Time]bool)
4✔
876
                        for i := 0; i < l.MaxBackups; i++ {
8✔
877
                                keptTimestampsSet[uniqueTimestamps[i]] = true
4✔
878
                        }
4✔
879

880
                        var filteredFiles []logInfo // Files that pass this MaxBackups filter
4✔
881
                        for _, f := range filesToProcess {
15✔
882
                                if keptTimestampsSet[f.timestamp] {
16✔
883
                                        filteredFiles = append(filteredFiles, f)
5✔
884
                                } else {
11✔
885
                                        filesToRemove = append(filesToRemove, f) // Mark for removal
6✔
886
                                }
6✔
887
                        }
888
                        filesToProcess = filteredFiles // Update filesToProcess for subsequent filters
4✔
889
                }
890
                // If len(uniqueTimestamps) <= l.MaxBackups, all files pass this MaxBackups filter.
891
        }
892

893
        // MaxAge filtering (operates on files that passed MaxBackups filter)
894
        if l.MaxAge > 0 {
45✔
895
                diff := time.Duration(int64(24*time.Hour) * int64(l.MaxAge))
5✔
896
                cutoff := now.Add(-diff)
5✔
897
                var filteredFiles []logInfo // Files that pass this MaxAge filter
5✔
898
                for _, f := range filesToProcess {
10✔
899
                        if f.timestamp.Before(cutoff) {
8✔
900
                                // avoid duplicates
3✔
901
                                isAlreadyMarked := false
3✔
902
                                for _, rmf := range filesToRemove {
3✔
903
                                        if rmf.Name() == f.Name() {
×
904
                                                isAlreadyMarked = true
×
905
                                                break
×
906
                                        }
907
                                }
908
                                if !isAlreadyMarked {
6✔
909
                                        filesToRemove = append(filesToRemove, f)
3✔
910
                                }
3✔
911
                        } else {
2✔
912
                                filteredFiles = append(filteredFiles, f)
2✔
913
                        }
2✔
914
                }
915
                filesToProcess = filteredFiles
5✔
916
        }
917

918
        // Compression task identification (operates on files that passed MaxBackups and MaxAge)
919
        var filesToCompress []logInfo
40✔
920
        if comp != "none" {
60✔
921
                for _, f := range filesToProcess {
39✔
922
                        name := f.Name()
19✔
923
                        if strings.HasSuffix(name, compressSuffix) || strings.HasSuffix(name, zstdSuffix) {
27✔
924
                                continue // already compressed
8✔
925
                        }
926
                        isMarked := false
11✔
927
                        for _, rmf := range filesToRemove {
11✔
928
                                if rmf.Name() == name {
×
929
                                        isMarked = true
×
930
                                        break
×
931
                                }
932
                        }
933
                        if !isMarked {
22✔
934
                                filesToCompress = append(filesToCompress, f)
11✔
935
                        }
11✔
936
                }
937
        }
938

939
        // Execute removals (ensure unique removals)
940
        finalUniqueRemovals := make(map[string]logInfo)
40✔
941
        for _, f := range filesToRemove {
49✔
942
                finalUniqueRemovals[f.Name()] = f
9✔
943
        }
9✔
944
        for _, f := range finalUniqueRemovals {
49✔
945
                errRemove := l.resolvedRemove(filepath.Join(l.dir(), f.Name()))
9✔
946
                if errRemove != nil && !os.IsNotExist(errRemove) {
9✔
947
                        fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to remove old log file %s: %v\n", l.Filename, f.Name(), errRemove)
×
948
                }
×
949
        }
950

951
        // Execute compressions (suffix also comes from snapshot via compressedSuffix)
952
        suffix := l.compressedSuffix()
40✔
953
        for _, f := range filesToCompress {
51✔
954
                fn := filepath.Join(l.dir(), f.Name())
11✔
955
                if errCompress := l.compressLogFile(fn, fn+suffix); errCompress != nil {
11✔
956
                        fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to compress log file %s: %v\n", l.Filename, f.Name(), errCompress)
×
957
                }
×
958
        }
959
        return nil
40✔
960
}
961

962
// millRun runs in a goroutine to manage post-rotation compression and removal
963
// of old log files. It listens on millCh for signals to run millRunOnce.
964
func (l *Logger) millRun() {
42✔
965
        if l.millWGStarted {
82✔
966
                defer l.millWg.Done()
40✔
967
        }
40✔
968
        ch := l.millCh
42✔
969
        for range ch {
114✔
970
                _ = l.millRunOnce()
72✔
971
        }
72✔
972
}
973

974
// mill performs post-rotation compression and removal of stale log files,
975
// starting the mill goroutine if necessary and sending a signal to it.
976
func (l *Logger) mill() {
70✔
977
        if atomic.LoadUint32(&l.isClosed) == 1 {
70✔
978
                return
×
979
        }
×
980
        l.startMill.Do(func() {
110✔
981
                l.millWGStarted = true
40✔
982
                l.millCh = make(chan bool, 1)
40✔
983
                l.millWg.Add(1)
40✔
984
                go l.millRun()
40✔
985
        })
40✔
986
        select {
70✔
987
        case l.millCh <- true:
69✔
988
        default:
1✔
989
        }
990
}
991

992
// oldLogFiles returns the list of backup log files stored in the same
993
// directory as the current log file, sorted by their embedded timestamp (newest first).
994
func (l *Logger) oldLogFiles() ([]logInfo, error) {
41✔
995
        entries, err := os.ReadDir(l.dir()) // ReadDir is generally preferred over ReadFile for directory listings
41✔
996
        if err != nil {
41✔
997
                return nil, fmt.Errorf("can't read log file directory: %s", err)
×
998
        }
×
999
        var logFiles []logInfo
41✔
1000

41✔
1001
        prefix, ext := l.prefixAndExt() // Get prefix like "filename-" and original extension like ".log"
41✔
1002

41✔
1003
        for _, e := range entries {
140✔
1004
                if e.IsDir() { // Skip directories
102✔
1005
                        continue
3✔
1006
                }
1007
                name := e.Name()
96✔
1008
                info, errInfo := e.Info() // Get FileInfo for modification time and other details
96✔
1009
                if errInfo != nil {
96✔
1010
                        // fmt.Fprintf(os.Stderr, "timberjack: failed to get FileInfo for %s: %v\n", name, errInfo)
×
1011
                        continue // Skip files we can't stat
×
1012
                }
1013

1014
                // Attempt to parse timestamp from filename (e.g., from "filename-timestamp-reason.log")
1015
                if t, errTime := l.timeFromName(name, prefix, ext); errTime == nil {
130✔
1016
                        logFiles = append(logFiles, logInfo{t, info})
34✔
1017
                        continue
34✔
1018
                }
1019
                // Attempt to parse timestamp from compressed gzip filename (e.g., from "filename-timestamp-reason.log.gz")
1020
                if t, errTime := l.timeFromName(name, prefix, ext+compressSuffix); errTime == nil {
71✔
1021
                        logFiles = append(logFiles, logInfo{t, info})
9✔
1022
                        continue
9✔
1023
                }
1024
                // Attempt to parse timestamp from compressed zstd filename (e.g., from "filename-timestamp-reason.log.zst")
1025
                if t, errTime := l.timeFromName(name, prefix, ext+zstdSuffix); errTime == nil {
53✔
1026
                        logFiles = append(logFiles, logInfo{t, info})
×
1027
                        continue
×
1028
                }
1029
                // Files that don't match the expected backup pattern are ignored.
1030
        }
1031

1032
        sort.Sort(byFormatTime(logFiles)) // Sorts newest first based on parsed timestamp
41✔
1033
        return logFiles, nil
41✔
1034
}
1035

1036
// timeFromName extracts the formatted timestamp from the backup filename.
1037
// It expects filenames like "prefix-YYYY-MM-DDTHH-MM-SS.mmm-reason.ext" or "prefix.ext-YYYY-MM-DDTHH-MM-SS.mmm-reason[.gz]"
1038
func (l *Logger) timeFromName(filename, prefix, ext string) (time.Time, error) {
219✔
1039
        layout := l.resolvedBackupLayout
219✔
1040
        if layout == "" {
232✔
1041
                // defensive default if called very early
13✔
1042
                layout = backupTimeFormat
13✔
1043
        }
13✔
1044
        loc := time.UTC
219✔
1045
        if l.resolvedLocalTime {
219✔
1046
                loc = time.Local
×
1047
        }
×
1048

1049
        if !l.resolvedAppendAfterExt {
430✔
1050

211✔
1051
                // Keep legacy behavior for error messages to satisfy existing tests
211✔
1052
                if !strings.HasPrefix(filename, prefix) {
367✔
1053
                        return time.Time{}, errors.New("mismatched prefix")
156✔
1054
                }
156✔
1055
                if !strings.HasSuffix(filename, ext) {
66✔
1056
                        return time.Time{}, errors.New("mismatched extension")
11✔
1057
                }
11✔
1058
                // "<prefix><timestamp>-<reason><ext>"
1059
                trimmed := filename[len(prefix) : len(filename)-len(ext)]
44✔
1060
                lastHyphenIdx := strings.LastIndex(trimmed, "-")
44✔
1061
                if lastHyphenIdx == -1 {
45✔
1062
                        return time.Time{}, fmt.Errorf("malformed backup filename: missing reason separator in %q", trimmed)
1✔
1063
                }
1✔
1064
                ts := trimmed[:lastHyphenIdx]
43✔
1065
                return time.ParseInLocation(layout, ts, loc)
43✔
1066
        }
1067

1068
        // After-ext parsing:
1069
        // base is "<name><ext>" (e.g., "foo.log")
1070
        base := prefix[:len(prefix)-1] + ext
8✔
1071

8✔
1072
        // Allow optional trailing compression suffix (".gz" or ".zst")
8✔
1073
        nameNoComp := trimCompressionSuffix(filename)
8✔
1074

8✔
1075
        // nameNoComp must start with "<base>-"
8✔
1076
        if !strings.HasPrefix(nameNoComp, base+"-") {
14✔
1077
                return time.Time{}, fmt.Errorf("malformed backup filename: %q", filename)
6✔
1078
        }
6✔
1079

1080
        // nameNoComp = "<base>-<timestamp>-<reason>"
1081
        trimmed := nameNoComp[len(base)+1:]
2✔
1082

2✔
1083
        lastHyphenIdx := strings.LastIndex(trimmed, "-")
2✔
1084
        if lastHyphenIdx == -1 {
2✔
1085
                return time.Time{}, fmt.Errorf("malformed backup filename: %q", filename)
×
1086
        }
×
1087
        ts := trimmed[:lastHyphenIdx]
2✔
1088
        return time.ParseInLocation(layout, ts, loc)
2✔
1089
}
1090

1091
// max returns the maximum size in bytes of log files before rolling.
1092
func (l *Logger) max() int64 {
117✔
1093
        if l.MaxSize == 0 { // If MaxSize is 0, use default.
138✔
1094
                return int64(defaultMaxSize * megabyte)
21✔
1095
        }
21✔
1096
        return int64(l.MaxSize) * int64(megabyte)
96✔
1097
}
1098

1099
// dir returns the directory for the current filename.
1100
func (l *Logger) dir() string {
127✔
1101
        return filepath.Dir(l.filename())
127✔
1102
}
127✔
1103

1104
// prefixAndExt returns the filename part (up to the extension, with a trailing dash for backups)
1105
// and extension part from the Logger's filename.
1106
// e.g., for "foo.log", returns "foo-", ".log"
1107
func (l *Logger) prefixAndExt() (prefix, ext string) {
44✔
1108
        filename := filepath.Base(l.filename())
44✔
1109
        ext = filepath.Ext(filename)
44✔
1110
        prefix = filename[:len(filename)-len(ext)] + "-" // Add dash as backup filenames include it after original prefix
44✔
1111
        return prefix, ext
44✔
1112
}
44✔
1113

1114
// countDigitsAfterDot returns the number of consecutive digit characters
1115
// immediately following the first '.' in the input.
1116
// It skips all characters before the '.' and stops counting at the first non-digit
1117
// character after the '.'.
1118

1119
// Example: `prefix.0012304123suffix` would return 10
1120
// Example: `prefix.0012304_middle_123_suffix` would return 7
1121
func countDigitsAfterDot(layout string) int {
17✔
1122
        for i, ch := range layout {
297✔
1123
                if ch == '.' {
293✔
1124
                        count := 0
13✔
1125
                        for _, c := range layout[i+1:] {
64✔
1126
                                if unicode.IsDigit(c) {
99✔
1127
                                        count++
48✔
1128
                                } else {
51✔
1129
                                        break
3✔
1130
                                }
1131
                        }
1132
                        return count
13✔
1133
                }
1134
        }
1135
        return 0 // no '.' found or no digits after dot
4✔
1136
}
1137

1138
// truncateFractional truncates time t to n fractional digits of seconds.
1139
// n=0 → truncate to seconds, n=3 → milliseconds, n=6 → microseconds, etc.
1140
func truncateFractional(t time.Time, n int) (time.Time, error) {
16✔
1141
        if n < 0 || n > 9 {
18✔
1142
                return time.Time{}, fmt.Errorf("unsupported fractional precision: %d", n)
2✔
1143
        }
2✔
1144

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

14✔
1148
        nanos := t.Nanosecond()
14✔
1149
        truncatedNanos := int((int64(nanos) / int64(factor)) * int64(factor))
14✔
1150

14✔
1151
        return time.Date(
14✔
1152
                t.Year(), t.Month(), t.Day(),
14✔
1153
                t.Hour(), t.Minute(), t.Second(),
14✔
1154
                truncatedNanos,
14✔
1155
                t.Location(),
14✔
1156
        ), nil
14✔
1157
}
1158

1159
// compressLogFile compresses the given source log file (src) to a destination file (dst),
1160
// removing the source file if compression is successful.
1161
func (l *Logger) compressLogFile(src, dst string) error {
21✔
1162
        srcFile, err := os.Open(src)
21✔
1163
        if err != nil {
26✔
1164
                return fmt.Errorf("failed to open source log file %s for compression: %v", src, err)
5✔
1165
        }
5✔
1166
        defer srcFile.Close()
16✔
1167

16✔
1168
        srcInfo, err := l.resolvedStat(src)
16✔
1169
        if err != nil {
17✔
1170
                return fmt.Errorf("failed to stat source log file %s: %v", src, err)
1✔
1171
        }
1✔
1172

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

1180
        var copyErr error // To capture error from io.Copy
14✔
1181

14✔
1182
        // Choose compression algorithm based on dst suffix
14✔
1183
        // Default to gzip if no recognized suffix
14✔
1184
        // This allows future extension to other algorithms by checking dst suffix
14✔
1185
        if strings.HasSuffix(dst, zstdSuffix) {
17✔
1186
                enc, err := zstd.NewWriter(dstFile)
3✔
1187
                if err != nil { // Error creating zstd writer
3✔
1188
                        _ = dstFile.Close() // Close dstFile before removing
×
1189
                        _ = l.resolvedRemove(dst)
×
1190
                        return fmt.Errorf("failed to init zstd writer for %s: %v", dst, err)
×
1191
                }
×
1192
                _, copyErr = io.Copy(enc, srcFile) // Copy data from source file to zstd writer
3✔
1193
                closeErr := enc.Close()            // Close zstd writer to flush data
3✔
1194
                if copyErr == nil && closeErr != nil {
3✔
1195
                        copyErr = closeErr
×
1196
                }
×
1197
        } else {
11✔
1198
                gz := gzip.NewWriter(dstFile)     // Default to gzip
11✔
1199
                _, copyErr = io.Copy(gz, srcFile) // Copy data from source file to gzip writer
11✔
1200
                closeErr := gz.Close()            // Close gzip writer to flush data
11✔
1201
                if copyErr == nil && closeErr != nil {
11✔
1202
                        copyErr = closeErr
×
1203
                }
×
1204
        }
1205

1206
        if copyErr != nil { // Error during copy or close
14✔
1207
                _ = dstFile.Close()       // Try to close destination file
×
1208
                _ = l.resolvedRemove(dst) // Try to remove potentially partial destination file
×
1209
                return fmt.Errorf("failed to write compressed data to %s: %w", dst, copyErr)
×
1210
        }
×
1211

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

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

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

1235
}
1236

1237
// effectiveCompression returns "none" | "gzip" | "zstd".
1238
// Rule: if Compression is set, it wins; if empty, fallback to legacy Compress.
1239
// Unknown strings default to "none" (and warn once).
1240
func (l *Logger) effectiveCompression() string {
77✔
1241
        alg := strings.ToLower(strings.TrimSpace(l.Compression))
77✔
1242
        switch alg {
77✔
1243
        case "gzip", "zstd":
3✔
1244
                return alg
3✔
1245
        case "none", "":
73✔
1246
                if alg == "" && l.Compress {
82✔
1247
                        return "gzip"
9✔
1248
                }
9✔
1249
                return "none"
64✔
1250
        default:
1✔
1251
                fmt.Fprintf(os.Stderr, "timberjack: invalid compression %q — using none\n", alg)
1✔
1252
                return "none"
1✔
1253
        }
1254
}
1255

1256
// compressedSuffix returns ".gz" / ".zst" or "" if none.
1257
func (l *Logger) compressedSuffix() string {
40✔
1258
        switch l.resolvedCompression {
40✔
1259
        case "gzip":
14✔
1260
                return compressSuffix
14✔
1261
        case "zstd":
6✔
1262
                return zstdSuffix
6✔
1263
        default:
20✔
1264
                return ""
20✔
1265
        }
1266
}
1267

1268
// trimCompressionSuffix strips one known compression suffix (".gz" or ".zst").
1269
func trimCompressionSuffix(name string) string {
8✔
1270
        name = strings.TrimSuffix(name, compressSuffix)
8✔
1271
        name = strings.TrimSuffix(name, zstdSuffix)
8✔
1272
        return name
8✔
1273
}
8✔
1274

1275
// sanitizeReason turns an arbitrary string into a safe, short tag for filenames.
1276
// Allowed: [a-z0-9_-]. Everything else becomes '-'. Collapses repeats, trims edges.
1277
// Returns empty string if nothing usable remains.
1278
func sanitizeReason(s string) string {
13✔
1279
        s = strings.TrimSpace(strings.ToLower(s))
13✔
1280
        if s == "" {
25✔
1281
                return ""
12✔
1282
        }
12✔
1283
        const max = 32
1✔
1284

1✔
1285
        var b strings.Builder
1✔
1286
        lastDash := false
1✔
1287
        for _, r := range s {
17✔
1288
                ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_'
16✔
1289
                if ok {
27✔
1290
                        b.WriteRune(r)
11✔
1291
                        lastDash = (r == '-')
11✔
1292
                } else {
16✔
1293
                        // replace anything else (including whitespace) with a single '-'
5✔
1294
                        if !lastDash && b.Len() > 0 {
7✔
1295
                                b.WriteByte('-')
2✔
1296
                                lastDash = true
2✔
1297
                        }
2✔
1298
                }
1299
                if b.Len() >= max {
16✔
1300
                        break
×
1301
                }
1302
        }
1303
        out := strings.Trim(b.String(), "-_")
1✔
1304
        return out
1✔
1305
}
1306

1307
// logInfo is a convenience struct to return the filename and its embedded
1308
// timestamp, along with its os.FileInfo.
1309
type logInfo struct {
1310
        timestamp   time.Time // Parsed timestamp from the filename
1311
        os.FileInfo           // Full FileInfo
1312
}
1313

1314
// byFormatTime sorts a slice of logInfo structs by their parsed timestamp in descending order (newest first).
1315
type byFormatTime []logInfo
1316

1317
func (b byFormatTime) Less(i, j int) bool {
20✔
1318
        // Handle cases where timestamps might be zero (e.g., parsing failed, though timeFromName should error out)
20✔
1319
        if b[i].timestamp.IsZero() && !b[j].timestamp.IsZero() {
21✔
1320
                return false
1✔
1321
        } // Treat zero time as oldest
1✔
1322
        if !b[i].timestamp.IsZero() && b[j].timestamp.IsZero() {
20✔
1323
                return true
1✔
1324
        } // Non-zero is newer than zero
1✔
1325
        if b[i].timestamp.IsZero() && b[j].timestamp.IsZero() {
19✔
1326
                return false
1✔
1327
        } // Equal if both are zero (order doesn't matter)
1✔
1328
        return b[i].timestamp.After(b[j].timestamp) // Sort newest first
17✔
1329
}
1330
func (b byFormatTime) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
16✔
1331
func (b byFormatTime) Len() int      { return len(b) }
44✔
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

© 2025 Coveralls, Inc