pax_global_header00006660000000000000000000000064151634274150014522gustar00rootroot0000000000000052 comment=b46646b9cc83bbac5ae7c05ab076d97e8b843904 timberjack-1.4.1/000077500000000000000000000000001516342741500136405ustar00rootroot00000000000000timberjack-1.4.1/.github/000077500000000000000000000000001516342741500152005ustar00rootroot00000000000000timberjack-1.4.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001516342741500173635ustar00rootroot00000000000000timberjack-1.4.1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012631516342741500220570ustar00rootroot00000000000000--- name: Bug report about: Report a reproducible problem title: "[BUG] brief description" labels: bug assignees: '' --- **Describe the bug** A clear, concise summary of the problem. --- ## Environment - **timberjack version:** v1.x.y - **Go version:** go1.22.x (output of `go version`) - **OS/Arch:** e.g., linux/amd64 - **Runtime:** container? k8s? bare metal? - **Filesystem (if relevant):** e.g., ext4, NFS, tmpfs - **Command used to repro:** e.g., `go test -count=1 ./...` --- ## Steps to reproduce 1. … 2. … 3. … ### Minimal code (required) > Provide the smallest program or test that reproduces the issue. ```go package main func main() { // minimal example here } timberjack-1.4.1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000012261516342741500231110ustar00rootroot00000000000000--- name: Feature request about: Suggest an enhancement or new capability title: "[FEATURE] brief description" labels: '' assignees: '' --- ### 💡 Feature request **Describe the problem** What problem are you trying to solve or what limitation did you encounter? --- **Proposed solution / API** Describe the new behavior or API and how you expect it to work. Include short examples or pseudo-code if helpful. ```go // Example tj := &timberjack.Logger{ Filename: "app.log", RotateEvery: time.Hour, // new option for fixed-interval rotation } ``` **Benefits** Explain why this change improves the library, user experience, or performance. timberjack-1.4.1/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000006641516342741500210070ustar00rootroot00000000000000## Summary Briefly explain what this PR does. ## Type - [ ] Bug fix - [ ] Feature - [ ] Refactor / chore - [ ] Docs ## Details Explain how it works or what was changed. ## Tests - [ ] `go test ./...` passes locally - [ ] Added or updated tests - [ ] Verified behavior manually ## Backwards compatibility - [ ] No breaking changes - [ ] Includes breaking changes (documented) ## Related issues Fixes # (if applicable) timberjack-1.4.1/.github/dependabot.yml000066400000000000000000000014331516342741500200310ustar00rootroot00000000000000version: 2 updates: # Go modules (go.mod/go.sum at repo root) - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" day: "monday" time: "08:00" timezone: "Europe/Helsinki" open-pull-requests-limit: 10 labels: ["deps", "go"] commit-message: prefix: "chore(deps)" include: "scope" groups: go-minor-and-patch: applies-to: version-updates update-types: ["minor", "patch"] # GitHub Actions used in .github/workflows - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" day: "monday" time: "08:30" timezone: "Europe/Helsinki" open-pull-requests-limit: 5 labels: ["deps", "ci"] commit-message: prefix: "ci(deps)" timberjack-1.4.1/.github/workflows/000077500000000000000000000000001516342741500172355ustar00rootroot00000000000000timberjack-1.4.1/.github/workflows/audit.yaml000066400000000000000000000022321516342741500212260ustar00rootroot00000000000000name: Audit on: push: branches: [main] pull_request: branches: [main] jobs: audit: name: audit runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v6 with: go-version: 1.22 - name: Verify Dependencies run: go mod verify - name: Build run: go build -v ./... - name: Run go vet run: go vet ./... - name: Install staticcheck run: go install honnef.co/go/tools/cmd/staticcheck@v0.5.1 - name: Run staticcheck run: staticcheck ./... - name: Run Unit tests run: | go test -covermode atomic -coverprofile=covprofile ./... - name: Run Unit tests (race) run: | go test -race -count=1 -p 1 ./... - name: Install goveralls run: go install github.com/mattn/goveralls@latest - name: Send coverage (only on push to main) if: github.event_name == 'push' && github.ref == 'refs/heads/main' env: COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} run: goveralls -coverprofile=covprofile -service=github timberjack-1.4.1/.github/workflows/ci.yaml000066400000000000000000000007511516342741500205170ustar00rootroot00000000000000name: Go Versions on: push: branches: [main] pull_request: branches: [main] jobs: build: name: Go versions runs-on: ubuntu-latest strategy: matrix: go-version: [1.21.x, 1.22.x, stable] steps: - uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: Install dependencies run: go mod tidy - name: Run tests run: go test ./... timberjack-1.4.1/.github/workflows/release-please.yaml000066400000000000000000000012131516342741500230050ustar00rootroot00000000000000on: push: branches: - main permissions: contents: write pull-requests: write name: release-please jobs: release-please: runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@v4 with: # this assumes that you have created a personal access token # (PAT) and configured it as a GitHub action secret named # `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important). token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }} # this is a built-in strategy in release-please, see "Action Inputs" # for more options release-type: simpletimberjack-1.4.1/.github/workflows/release.yaml000066400000000000000000000014641516342741500215460ustar00rootroot00000000000000name: Trigger pkg.go.dev indexing on: release: types: [published] workflow_dispatch: {} jobs: poke-proxy: runs-on: ubuntu-latest steps: - name: Ping Go proxy for this tag env: MODULE_PATH: github.com/DeRuina/timberjack TAG: ${{ github.event.release.tag_name }} run: | set -euo pipefail # Escape uppercase per Go module rules (DeRuina -> !de!ruina) EMODULE=$(printf '%s' "$MODULE_PATH" | sed -E 's/[A-Z]/!\L&/g') # Trigger fetch & indexing via the proxy (this is the important one) curl -sSfL "https://proxy.golang.org/${EMODULE}/@v/${TAG}.info" || true # Optional: ping checksum DB too (fine if it’s already known) curl -sSfL "https://sum.golang.org/lookup/${EMODULE}@${TAG}" || true timberjack-1.4.1/.gitignore000066400000000000000000000004111516342741500156240ustar00rootroot00000000000000# Compiled Object files, Static and Dynamic libs (Shared Objects) *.o *.a *.so # Folders _obj _test # Architecture specific extensions/prefixes *.[568vq] [568vq].out *.cgo1.go *.cgo2.c _cgo_defun.c _cgo_gotypes.go _cgo_export.* _testmain.go *.exe *.test *.log timberjack-1.4.1/CHANGELOG.md000066400000000000000000000161461516342741500154610ustar00rootroot00000000000000# Changelog ## [1.4.1](https://github.com/DeRuina/timberjack/compare/v1.4.0...v1.4.1) (2026-04-02) ### Features * Fix preserve configured FileMode after rotation (umask bypass) ([#91](https://github.com/DeRuina/timberjack/pull/91)) ([b9823ec](https://github.com/DeRuina/timberjack/commit/b9823ec5950b3e5d8d104ce620c32414e83f5f22)) ## [1.4.0](https://github.com/DeRuina/timberjack/compare/v1.3.9...v1.4.0) (2026-03-11) ### Features * Fix duplicate rotation triggered by Write() after manual Rotate()/RotateWithReason() ([#84](https://github.com/DeRuina/timberjack/pull/84)) ([5aee542](https://github.com/DeRuina/timberjack/commit/5aee54253be72151c63904390195a34c0da05cf8)) * Fix Windows file locking in compressLogFile ([#65](https://github.com/DeRuina/timberjack/pull/65)) ([a560a29](https://github.com/DeRuina/timberjack/commit/a560a29cb76a7d6350c83f29b375a991c1a51a6d)) ## [1.3.9](https://github.com/DeRuina/timberjack/compare/v1.3.8...v1.3.9) (2025-10-21) ### Features * Make FileMode for newly created files configurable ([#59](https://github.com/DeRuina/timberjack/pull/59)) ([82320e6](https://github.com/DeRuina/timberjack/commit/82320e6d10084bf4cb32a80ced28175f66d15214)) ## [1.3.8](https://github.com/DeRuina/timberjack/compare/v1.3.7...v1.3.8) (2025-10-15) ### Fixes & Improvements * ([4c2c743](https://github.com/DeRuina/timberjack/commit/4c2c7433979b88b308dc927f10c95ee0fa221327)) * Eliminated multiple data races in concurrent rotations and mill goroutines. Internal logic now snapshots configuration and system functions once for each logger instance to ensure safe concurrent use. * Added deterministic shutdown for background goroutines (`mill` and `scheduled rotation`) via `WaitGroup` synchronization, preventing premature exits or leaks. * Strengthened `Close()` to wait safely for goroutine completion without holding locks. * Improved test suite: - Fake clock (`fakeCurrentTime`) is now lock-protected to avoid race conditions. - Tests force UTC for consistent local-time behavior. - CI now runs with `go test -race` to verify concurrency safety. ### Internal Changes * Introduced `resolveConfigLocked()` for snapshotting logger configuration (time, compression, stat/rename/remove functions) at initialization. * Simplified mill and rotation goroutine lifecycle management. * Minor refactoring for clarity and reduced global variable reads. ## [1.3.7](https://github.com/DeRuina/timberjack/compare/v1.3.6...v1.3.7) (2025-09-19) ### Features * `zstd` compression support for rotated files ([#38](https://github.com/DeRuina/timberjack/issues/38)) ([626a5bd](https://github.com/DeRuina/timberjack/pull/43/commits/626a5bd5c4b45eab8d73b906716cf4587ca5aa64)) * Manual rotate with custom reason ([#39](https://github.com/DeRuina/timberjack/issues/39)) ([cf751aa](https://github.com/DeRuina/timberjack/pull/43/commits/cf751aa14d312ecf8153234c9c57ff50ff277700)) ### Chnaged * Rename AppendAfterExt to AppendTimeAfterExt ([#37](https://github.com/DeRuina/timberjack/issues/37)) ([fea97b9](https://github.com/DeRuina/timberjack/pull/43/commits/fea97b9985f939a7f05df7a7f3f458c8b4ab02d9)) ### Deprecated - `Compress` (bool) is deprecated in favor of `Compression` (`"none" | "gzip" | "zstd"`). If `Compression` is set, it **wins**; if it’s empty and `Compress` is `true`, gzip is used. `Compress` will be removed in **v2**. ## [1.3.6](https://github.com/DeRuina/timberjack/compare/v1.3.5...v1.3.6) (2025-09-16) ### Features * Append the backupTimeFormat to the end of file name ([#37](https://github.com/DeRuina/timberjack/issues/37)) ([15c6d81](https://github.com/DeRuina/timberjack/commit/15c6d813214c9c7f1372af55f9b705d9d2a3a88e)) ## [1.3.5](https://github.com/DeRuina/timberjack/compare/v1.3.4...v1.3.5) (2025-08-19) ### Features * config option for daily rotation ([#33](https://github.com/DeRuina/timberjack/issues/33)) ([16955b7](https://github.com/DeRuina/timberjack/commit/16955b7e540f9562122590ae05f591dd43cd5860)) * bump go version to 1.21 ([9bdd903](https://github.com/DeRuina/timberjack/commit/9bdd9038638e72a7fb330fe97f8c730864b9cbd5)) ### Changed * update README ([4203c93](https://github.com/DeRuina/timberjack/commit/4203c93e80ece5d83ec387170bee3f5a69253daf)) ## [1.3.4](https://github.com/DeRuina/timberjack/compare/v1.3.3...v1.3.4) (2025-08-05) ### Features * read group permission on newly created files ([#30](https://github.com/DeRuina/timberjack/issues/30)) ([ee44715](https://github.com/DeRuina/timberjack/commit/ee447152a04d62ae12811a2212815f8960ca0d9d)) ## [1.3.3](https://github.com/DeRuina/timberjack/compare/v1.3.2...v1.3.3) (2025-07-24) ### Bug Fixes * Prevent panic on write after close and improve shutdown robustness ([#25](https://github.com/DeRuina/timberjack/issues/25)) ([332b9c2](https://github.com/DeRuina/timberjack/commit/332b9c2553d63f5eafdce47237d29b510609f823)) ## [1.3.2](https://github.com/DeRuina/timberjack/compare/v1.3.1...v1.3.2) (2025-07-21) ### Bug Fixes * millRun goroutine leak fix ([28bf784](https://github.com/DeRuina/timberjack/commit/28bf784b830e5f839054f7d82950087e323b958f)) ## [1.3.1](https://github.com/DeRuina/timberjack/compare/v1.3.0...v1.3.1) (2025-07-17) ### Features * `BackupTimeFormat` field is now required for Logger instance to work. Returns error if invalid value is passed. * Rotation Suffix Time Format ([e2c2211](https://github.com/DeRuina/timberjack/commit/e2c22115ae301c034e07c703ab9729d25b170a49)) ### Bug Fixes * truncateFractional bug fix ([9a6f908](https://github.com/DeRuina/timberjack/commit/9a6f908d270ddfa45df66621b0b12b1ff44ab28f)) ## [1.3.0](https://github.com/DeRuina/timberjack/compare/v1.2.0...v1.3.0) (2025-06-04) ### Features * **rotation:** add RotateAtMinutes support ([e4c22b6](https://github.com/DeRuina/timberjack/commit/e4c22b6858ea7ca2493a1c6af4a6032f5e2ea95c)) * **rotation:** add RotateAtMinutes support ([2e93add](https://github.com/DeRuina/timberjack/commit/2e93adddf122269e2043506a5b7a46b4106eea86)) ## [1.2.0](https://github.com/DeRuina/timberjack/compare/v1.1.0...v1.2.0) (2025-05-27) ### Features * release please script ([42d3575](https://github.com/DeRuina/timberjack/commit/42d35750d4f0f5cfac7c339ba9dcdee77527ab72)) * release please script ([7514015](https://github.com/DeRuina/timberjack/commit/751401565635ff4eecbaffdf82e2333973cfe18a)) ## [1.1.0] - 2025-05-27 ### Added - Support for time-based log rotation via `RotationInterval` configuration - Rotation reason (`-time`, `-size`) included in backup filenames - Platform-specific file ownership preservation (`chown_linux.go`) - Enhanced filename parsing to recognize timestamp and rotation reason - Extensive unit tests for time-based rotation, compression, and ownership - Default filename uses `-timberjack.log` if none is provided ### Changed - Refactored rotation logic to support time-based, size-based, and manual triggers uniformly - Replaced deprecated `ioutil.ReadDir` with modern `os.ReadDir` - Improved compression logic to handle chown and cleanup safely ### Fixed - Preserved original file mode and ownership during rotation and compression - Resolved edge cases in backup name parsing with improved robustness ### Removed - Legacy logic relying solely on size-based rotation timberjack-1.4.1/CONTRIBUTING.md000066400000000000000000000055271516342741500161020ustar00rootroot00000000000000# Contributing to Timberjack Thank you for considering contributing to **Timberjack**! Please take a moment to read through these guidelines. ## Guidelines - All contributions must follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) format. - Ensure all tests pass before pushing or opening a pull request. - Document new functionality clearly in code comments and README if applicable. - Avoid unnecessary commits or unrelated changes. ## Commit Message Format We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard: ``` [optional scope]: ``` ### Examples - `feat: add Rotate feature` - `fix: correct rotation timestamp logic` - `docs: update README with usage example` - `test: cover edge case for rotation overlap` ### Allowed Types | Type | Use For | |-----------|-------------------------------------------------------------------------| | `feat` | A new feature | | `fix` | A bug fix | | `chore` | Build process, CI, tooling | | `docs` | Changes to documentation (README, comments) | | `test` | Adding or modifying tests | | `refactor`| Code refactoring without behavior change | | `style` | Code style changes (whitespace, etc) | | `perf` | Performance improvements | | `ci` | Changes to GitHub Actions, CI/CD workflows | ## Scopes (Optional) Use a **scope** in parentheses after the type to clarify what part of the code is affected. Examples: - `fix(logger): correct timestamp handling` - `feat(rotation): add weekday scheduling` - `docs(readme): document Rotate usage` ### Breaking Changes If your commit introduces a **breaking change** — something that would require users to modify their code — add a `!` immediately after the type: - `feat!: remove support for deprecated config field` This will mark the commit as a major version bump when release automation is used. ### Other Guidelines - Run all tests before pushing: `go test ./...` - Keep PRs focused and self-contained - Use clear, descriptive commit messages - If you add a new feature, consider adding an example in the README. ## Code Coverage Requirements To maintain high-quality tests, all pull requests must meet the following **code coverage** rules: - Overall coverage must be **at least 85%** - Coverage **must not decrease by more than 2.5%** ### How to Check Locally Before opening a PR, run this to verify your test coverage: ```bash go test -covermode=atomic -coverprofile=covprofile ./... ```timberjack-1.4.1/LICENSE000066400000000000000000000020651516342741500146500ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2025 Dean Ruina Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.timberjack-1.4.1/README.md000066400000000000000000000333471516342741500151310ustar00rootroot00000000000000# timberjack [![Go Reference](https://pkg.go.dev/badge/github.com/DeRuina/timberjack.svg)](https://pkg.go.dev/github.com/DeRuina/timberjack) [![Go Report Card](https://goreportcard.com/badge/github.com/DeRuina/timberjack)](https://goreportcard.com/report/github.com/DeRuina/timberjack) ![Audit](https://github.com/DeRuina/timberjack/actions/workflows/audit.yaml/badge.svg) ![Version](https://img.shields.io/github/v/tag/DeRuina/timberjack?sort=semver) [![Coverage Status](https://coveralls.io/repos/github/DeRuina/timberjack/badge.svg)](https://coveralls.io/github/DeRuina/timberjack) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) ### Timberjack is a Go package for writing logs to rolling files. Timberjack is a forked and enhanced version of [`lumberjack`](https://github.com/natefinch/lumberjack), adding time-based rotation, clock-scheduled rotation, and opt-in compression (gzip or zstd). Package `timberjack` provides a rolling logger with support for size-based and time-based log rotation. ## Installation ```bash go get github.com/DeRuina/timberjack ``` ## Import ```go import "github.com/DeRuina/timberjack" ``` Timberjack is a pluggable component that manages log file writing and rotation. It works with any logger that writes to an `io.Writer`, including the standard library’s `log` package. > ⚠️ Timberjack assumes **one process** writes to a given file. Reusing the same config from multiple > processes on the same machine may lead to unexpected behavior. ## Example To use timberjack with the standard library's `log` package, including interval-based and scheduled minute/daily rotation: ```go import ( "log" "time" "github.com/DeRuina/timberjack" ) func main() { logger := &timberjack.Logger{ Filename: "/var/log/myapp/foo.log", // Choose an appropriate path MaxSize: 500, // megabytes MaxBackups: 3, // backups MaxAge: 28, // days Compression: "gzip", // "none" | "gzip" | "zstd" (preferred over legacy Compress) LocalTime: true, // default: false (use UTC) RotationInterval: 24 * time.Hour, // Rotate daily if no other rotation met RotateAtMinutes: []int{0, 15, 30, 45}, // Also rotate at HH:00, HH:15, HH:30, HH:45 RotateAt: []string{"00:00", "12:00"}, // Also rotate at 00:00 and 12:00 each day BackupTimeFormat: "2006-01-02-15-04-05", // Rotated files will have format -2006-01-02-15-04-05-.log AppendTimeAfterExt: true, // put timestamp after ".log" (foo.log--) FileMode: 0o644, // Custom permissions for newly created files. If unset or 0, defaults to 640. } log.SetOutput(logger) defer logger.Close() // Ensure logger is closed on application exit to stop goroutines log.Println("Application started") // ... your application logic ... log.Println("Application shutting down") } ``` Manual rotation (e.g. on `SIGHUP`): ```go import ( "log" "os" "os/signal" "syscall" "github.com/DeRuina/timberjack" ) func main() { l := &timberjack.Logger{ Filename: "/var/log/myapp/foo.log" } log.SetOutput(l) defer l.Close() // Manual rotation on SIGHUP c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGHUP) go func() { for range c { // 1) Classic behavior: auto-pick "time" (if due) or "size" // _ = l.Rotate() // 2) New: tag the backup with your own reason _ = l.RotateWithReason("reload") } }() // ... } ``` ## Logger Configuration ```go type Logger struct { Filename string // File to write logs to MaxSize int // Max size (MB) before rotation (default: 100) MaxAge int // Max age (days) to retain old logs MaxBackups int // Max number of backups to keep LocalTime bool // Use local time in rotated filenames // Compression controls post-rotation compression: // "none" | "gzip" | "zstd" // Unknown/empty default to "none", unless the legacy Compress is true (see below). Compression string // Deprecated: prefer Compression. // If Compression is empty and Compress is true = gzip compression. // Back-compat shim for old configs; will be removed in v2. Compress bool RotationInterval time.Duration // Rotate after this duration (if > 0) RotateAtMinutes []int // Specific minutes within an hour (0–59) to trigger rotation RotateAt []string // Specific daily times (HH:MM, 24-hour) to trigger rotation BackupTimeFormat string // Optional. If unset or invalid, defaults to 2006-01-02T15-04-05.000 (with fallback warning) AppendTimeAfterExt bool // if true, name backups like foo.log-- defaults to foo--.log FileMode os.FileMode // Use custom permissions for newly created files. If zero (unset or 0), defaults to 640. } ``` ## How Rotation Works 1. **Size-Based**: If a write operation causes the current log file to exceed `MaxSize`, the file is rotated before the write. The backup filename will include `-size` as the reason. 2. **Time-Based (Interval)**: If `RotationInterval` is set (e.g., `24 * time.Hour` for daily rotation) and this duration has passed since the last rotation (of any type that updates the interval timer), the file is rotated upon the next write. The backup filename will include `-time` as the reason. 3. **Scheduled (Clock-Aligned)**: If `RotateAtMinutes` and/or `RotateAt` are configured (e.g., `[]int{0,30}` → rotate at `HH:00` and `HH:30`; or `[]string{"00:00"}` → rotate at midnight), a background goroutine triggers rotation at those times. These rotations use `-time` as the reason. 4. **Manual**: - `Logger.Rotate()` forces rotation now. The backup reason will be `"time"` if an interval rotation is due, otherwise `"size"`. - `Logger.RotateWithReason("your-reason")` forces rotation and tags the backup with your **sanitized** reason (see below). If the provided reason is empty after sanitization, it falls back to the same behavior as Rotate(). Rotated files are renamed using the pattern: By default, rotated files are named: ``` --.log ``` For example: ``` /var/log/myapp/foo-2025-04-30T15-00-00.000-size.log /var/log/myapp/foo-2025-04-30T22-15-42.123-time.log /var/log/myapp/foo-2025-05-01T10-30-00.000-time.log.gz (if compressed) ``` If you prefer the extension to stay attached to the live name (better shell TAB completion), set `AppendTimeAfterExt: true`: ``` .log-- ``` For example: ``` /var/log/myapp/foo.log-2025-04-30T15-00-00.000-size /var/log/myapp/foo.log-2025-04-30T22-15-42.123-time /var/log/myapp/foo.log-2025-05-01T10-30-00.000-time.gz (if compressed) ``` Manual rotation with a custom reason `_ = logger.RotateWithReason("reload-now v2")` For example: ``` foo-2025-05-01T10-30-00.000-reload-now-v2.log ``` ### Compression - Pick the algorithm with `Compression: "none" | "gzip" | "zstd"`. - **Precedence**: If Compression is set, it **wins**. If it’s empty, legacy `Compress: true` means gzip; else no compression. - Outputs use `.gz` or `.zst` suffix accordingly. - Compression happens after rotation in a background goroutine. - **Deprecation**: `Compress` is kept only for backward compatibility with old configs. It’s ignored when `Compression` is set. **It will be removed in v2**. ### Cleanup On each new log file creation, timberjack: - Deletes backups exceeding `MaxBackups` (keeps the newest rotations). - Deletes backups older than `MaxAge` days. - Compresses uncompressed backups if compression is enabled. ### Rotation modes at a glance | Mode | Configure with | Trigger | Anchor | Background goroutine? | Rotates with zero writes? | Updates `lastRotationTime` | Backup suffix | Notes | | ------------------------------ | --------------------------------------------- | ------------------------------------------------------------------- | ---------------------------- | :-------------------: | :-----------------------: | :------------------------: | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | **Size-based** | `MaxSize` | A write would exceed `MaxSize` | N/A | No | No | **No** | `-size` | Always active. A single write larger than `MaxSize` returns an error. | | **Interval-based** | `RotationInterval > 0` | On **next write** after `now - lastRotationTime ≥ RotationInterval` | Duration since last rotation | No | No | **Yes** (to `now`) | `-time` | “Every N” rotations; not aligned to the wall clock. | | **Scheduled minute-based** | `RotateAtMinutes` (e.g. `[]int{0,30}`) | At each `HH:MM` where minute matches | Clock minute marks | **Yes** | **Yes** | **Yes** | `-time` | Expands minutes across all 24 hours. Invalid minutes are ignored **with a warning**. De-duplicated vs `RotateAt`. | | **Scheduled daily fixed time** | `RotateAt` (e.g. `[]string{"00:00","12:00"}`) | At each listed `HH:MM` daily | Clock minute marks | **Yes** | **Yes** | **Yes** | `-time` | Ideal for “rotate at midnight”. De-duplicated vs `RotateAtMinutes`. | | **Manual** | `Logger.Rotate()` | When called | Immediate | No | N/A | **No** | `-time` if an interval rotation is due; otherwise `-size` | Handy for SIGHUP. | **Manual (custom reason)** | `Logger.RotateWithReason(s)` | When called | Immediate | No | N/A | **No** | `-` | Falls back to `Rotate()` behavior if `s` sanitizes to empty. | > **Time zone:** scheduling and filename timestamps use UTC by default, or local time if `LocalTime: true`. > **Sanitized reason:** lowercase; `[a-z0-9_-]` only, trims edge, max 32. ## ⚠️ Rotation Notes & Warnings * **`BackupTimeFormat` Values must be valid and should not change after initialization** The `BackupTimeFormat` value **must be valid** and must follow the timestamp layout rules specified here: https://pkg.go.dev/time#pkg-constants. `BackupTimeFormat` supports more formats but it's recommended to use standard formats. If an **invalid** `BackupTimeFormat` is configured, Timberjack logs a warning to `os.Stderr` and falls back to the default format: `2006-01-02T15-04-05.000`. Rotation will still work, but the resulting filenames may not match your expectations. * **Invalid `RotateAtMinutes`/`RotateAt` Values** Values outside the valid range (`0–59`) for `RotateAtMinutes` or invalid time (`HH:MM`) for `RotateAt` or duplicates in `RotateAtMinutes`/`RotateAt` are ignored with a warning to stderr. Rotation continues with the valid schedule. * **Logger Must Be Closed** Always call `logger.Close()` when done logging. This shuts down internal goroutines used for scheduled rotation and cleanup. Failing to close the logger can result in orphaned background processes, open file handles, and memory leaks. * **Size-Based Rotation Is Always Active** Regardless of `RotationInterval` or `RotateAtMinutes`/`RotateAt`, size-based rotation is always enforced. If a write causes the log to exceed `MaxSize` (default: 100MB), it triggers an immediate rotation. * **If Only `RotationInterval` Is Set** The logger rotates after the configured time has passed since the **last rotation**, regardless of file size. * **If Only `RotateAtMinutes`/`RotateAt` Is Set** The logger rotates **at the clock times** specified, regardless of file size or duration passed. This is handled by a background goroutine. Rotated logs can be empty if no write has occurred. * **If Both Are Set** Both time-based strategies (`RotationInterval` and `RotateAtMinutes`) are evaluated. Whichever condition occurs first triggers rotation. However: * Both update the internal `lastRotationTime` field. * This means if a rotation happens due to `RotateAtMinutes`/`RotateAt`, it resets the interval timer, potentially **delaying or preventing** a `RotationInterval`-based rotation. This behavior ensures you won’t get redundant rotations, but it may make `RotationInterval` feel unpredictable if `RotateAtMinutes`/`RotateAt` is also configured. ## Contributing We welcome contributions! Please see our [contributing guidelines](CONTRIBUTING.md) before submitting a pull request. ## License MIT timberjack-1.4.1/SECURITY.md000066400000000000000000000003631516342741500154330ustar00rootroot00000000000000## Security If you think you’ve found a vulnerability in timberjack, please open a **private** security advisory: https://github.com/DeRuina/timberjack/security/advisories/new We’ll acknowledge within a few days and work on a fix. Thanks! timberjack-1.4.1/chown.go000066400000000000000000000004141516342741500153040ustar00rootroot00000000000000//go:build !linux // +build !linux // Stub chown implementation for non-Linux systems. // This file is excluded on Linux, where chown behavior is handled natively. package timberjack import ( "os" ) var chown = func(_ string, _ os.FileInfo) error { return nil } timberjack-1.4.1/chown_linux.go000066400000000000000000000012301516342741500165200ustar00rootroot00000000000000package timberjack import ( "fmt" "os" "syscall" ) var osChown = os.Chown // Keep for testing var chown = func(name string, info os.FileInfo) error { //// Should not be opening and truncating the file. It should just perform the osChown operation on the existing file name. //// This is the primary cause of tests seeing 0 bytes. // f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) // if err != nil { // return err // } // f.Close() stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return fmt.Errorf("failed to get syscall.Stat_t from FileInfo for %s", name) } return osChown(name, int(stat.Uid), int(stat.Gid)) } timberjack-1.4.1/example_test.go000066400000000000000000000011271516342741500166620ustar00rootroot00000000000000package timberjack import ( "log" "time" ) // To use timberjack with the standard library's log package, just pass it into // the SetOutput function when your application starts. func Example() { log.SetOutput(&Logger{ Filename: "/var/log/myapp/foo.log", MaxSize: 500, // megabytes MaxBackups: 3, // number of backups MaxAge: 28, // days Compress: true, // disabled by default LocalTime: true, // use the local timezone RotationInterval: time.Hour * 24, // rotate daily }) } timberjack-1.4.1/go.mod000066400000000000000000000002131516342741500147420ustar00rootroot00000000000000module github.com/DeRuina/timberjack go 1.21 require github.com/fortytw2/leaktest v1.3.0 require github.com/klauspost/compress v1.17.11 timberjack-1.4.1/go.sum000066400000000000000000000005441516342741500147760ustar00rootroot00000000000000github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= timberjack-1.4.1/linux_test.go000066400000000000000000000115661516342741500163760ustar00rootroot00000000000000//go:build linux // +build linux package timberjack import ( "os" "strings" "sync" "syscall" "testing" "time" ) type fakeFile struct { uid int gid int } type fakeFS struct { mu sync.Mutex files map[string]fakeFile } func newFakeFS() *fakeFS { return &fakeFS{files: make(map[string]fakeFile)} } func (fs *fakeFS) Chown(name string, uid, gid int) error { fs.mu.Lock() fs.files[name] = fakeFile{uid: uid, gid: gid} fs.mu.Unlock() return nil } func (fs *fakeFS) Stat(name string) (os.FileInfo, error) { info, err := os.Stat(name) if err != nil { return nil, err } stat := info.Sys().(*syscall.Stat_t) stat.Uid = 555 stat.Gid = 666 return info, nil } // accessors for tests (so they don’t read the map without lock) func (fs *fakeFS) Owner(name string) (uid, gid int, ok bool) { fs.mu.Lock() defer fs.mu.Unlock() ff, ok := fs.files[name] return ff.uid, ff.gid, ok } func TestMaintainMode(t *testing.T) { currentTime = fakeTime dir := makeTempDir("TestMaintainMode", t) defer os.RemoveAll(dir) filename := logFile(dir) mode := os.FileMode(0600) f, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, mode) isNil(err, t) f.Close() l := &Logger{ Filename: filename, MaxBackups: 1, MaxSize: 100, // megabytes } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) newFakeTime() err = l.Rotate() isNil(err, t) filename2 := backupFileWithReason(dir, "size") info, err := os.Stat(filename) isNil(err, t) info2, err := os.Stat(filename2) isNil(err, t) equals(mode, info.Mode(), t) equals(mode, info2.Mode(), t) } func TestMaintainOwner(t *testing.T) { fakeFS := newFakeFS() osChown = fakeFS.Chown osStat = fakeFS.Stat defer func() { osChown = os.Chown osStat = os.Stat }() currentTime = fakeTime dir := makeTempDir("TestMaintainOwner", t) defer os.RemoveAll(dir) filename := logFile(dir) f, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, 0644) isNil(err, t) f.Close() l := &Logger{ Filename: filename, MaxBackups: 1, MaxSize: 100, // megabytes } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) newFakeTime() err = l.Rotate() isNil(err, t) uid, gid, ok := fakeFS.Owner(filename) if !ok { t.Fatalf("owner for %s not recorded", filename) } equals(555, uid, t) equals(666, gid, t) } func TestCompressMaintainMode(t *testing.T) { currentTime = fakeTime dir := makeTempDir("TestCompressMaintainMode", t) defer os.RemoveAll(dir) filename := logFile(dir) mode := os.FileMode(0600) f, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, mode) isNil(err, t) f.Close() l := &Logger{ Compress: true, Filename: filename, MaxBackups: 1, MaxSize: 100, // megabytes } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) newFakeTime() err = l.Rotate() isNil(err, t) // we need to wait a little bit since the files get compressed on a different // goroutine. <-time.After(10 * time.Millisecond) // a compressed version of the log file should now exist with the correct // mode. filename2 := backupFileWithReason(dir, "size") info, err := os.Stat(filename) isNil(err, t) info2, err := os.Stat(filename2 + compressSuffix) isNil(err, t) equals(mode, info.Mode(), t) equals(mode, info2.Mode(), t) } func TestCompressMaintainOwner(t *testing.T) { fakeFS := newFakeFS() osChown = fakeFS.Chown osStat = fakeFS.Stat defer func() { osChown = os.Chown osStat = os.Stat }() currentTime = fakeTime dir := makeTempDir("TestCompressMaintainOwner", t) defer os.RemoveAll(dir) filename := logFile(dir) f, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, 0644) isNil(err, t) f.Close() l := &Logger{ Compress: true, Filename: filename, MaxBackups: 1, MaxSize: 100, // megabytes } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) newFakeTime() err = l.Rotate() isNil(err, t) // compression happens in mill goroutine <-time.After(10 * time.Millisecond) // check owner of compressed backup filename2 := backupFileWithReason(dir, "size") name := filename2 + compressSuffix uid, gid, ok := fakeFS.Owner(name) if !ok { t.Fatalf("owner for %s not recorded", name) } equals(555, uid, t) equals(666, gid, t) } type badFileInfo struct{} func (badFileInfo) Name() string { return "bad" } func (badFileInfo) Size() int64 { return 0 } func (badFileInfo) Mode() os.FileMode { return 0644 } func (badFileInfo) ModTime() time.Time { return time.Now() } func (badFileInfo) IsDir() bool { return false } func (badFileInfo) Sys() interface{} { return nil } func TestChownInvalidSys(t *testing.T) { err := chown("fake.log", badFileInfo{}) if err == nil || !strings.Contains(err.Error(), "failed to get syscall.Stat_t") { t.Fatalf("expected chown to fail on invalid Sys(), got: %v", err) } } timberjack-1.4.1/rotate_test.go000066400000000000000000000007361516342741500165320ustar00rootroot00000000000000//go:build linux // +build linux // This example demonstrates log rotation on receiving SIGHUP. // It only runs on Linux because SIGHUP and syscall behavior are OS-specific. package timberjack import ( "log" "os" "os/signal" "syscall" ) // Example of how to rotate in response to SIGHUP. func ExampleLogger_Rotate() { l := &Logger{} log.SetOutput(l) c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGHUP) go func() { for { <-c l.Rotate() } }() } timberjack-1.4.1/testing_test.go000066400000000000000000000060341516342741500167060ustar00rootroot00000000000000package timberjack import ( "fmt" "path/filepath" "reflect" "runtime" "testing" ) // assert will log the given message if condition is false. func assert(condition bool, t testing.TB, msg string, v ...interface{}) { assertUp(condition, t, 1, msg, v...) } // assertUp is like assert, but used inside helper functions, to ensure that // the file and line number reported by failures corresponds to one or more // levels up the stack. func assertUp(condition bool, t testing.TB, caller int, msg string, v ...interface{}) { if !condition { _, file, line, _ := runtime.Caller(caller + 1) v = append([]interface{}{filepath.Base(file), line}, v...) fmt.Printf("%s:%d: "+msg+"\n", v...) t.FailNow() } } // equals tests that the two values are equal according to reflect.DeepEqual. func equals(exp, act interface{}, t testing.TB) { equalsUp(exp, act, t, 1) } // equalsUp is like equals, but used inside helper functions, to ensure that the // file and line number reported by failures corresponds to one or more levels // up the stack. func equalsUp(exp, act interface{}, t testing.TB, caller int) { if !reflect.DeepEqual(exp, act) { _, file, line, _ := runtime.Caller(caller + 1) fmt.Printf("%s:%d: exp: %v (%T), got: %v (%T)\n", filepath.Base(file), line, exp, exp, act, act) t.FailNow() } } // isNil reports a failure if the given value is not nil. Note that values // which cannot be nil will always fail this check. func isNil(obtained interface{}, t testing.TB) { isNilUp(obtained, t, 1) } // isNilUp is like isNil, but used inside helper functions, to ensure that the // file and line number reported by failures corresponds to one or more levels // up the stack. func isNilUp(obtained interface{}, t testing.TB, caller int) { if !_isNil(obtained) { _, file, line, _ := runtime.Caller(caller + 1) fmt.Printf("%s:%d: expected nil, got: %v\n", filepath.Base(file), line, obtained) t.FailNow() } } // notNil reports a failure if the given value is nil. func notNil(obtained interface{}, t testing.TB) { notNilUp(obtained, t, 1) } // notNilUp is like notNil, but used inside helper functions, to ensure that the // file and line number reported by failures corresponds to one or more levels // up the stack. func notNilUp(obtained interface{}, t testing.TB, caller int) { if _isNil(obtained) { _, file, line, _ := runtime.Caller(caller + 1) fmt.Printf("%s:%d: expected non-nil, got: %v\n", filepath.Base(file), line, obtained) t.FailNow() } } // _isNil is a helper function for isNil and notNil, and should not be used // directly. func _isNil(obtained interface{}) bool { if obtained == nil { return true } switch v := reflect.ValueOf(obtained); v.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: return v.IsNil() } return false } // backupFileWithReason returns a backup file name with the given reason. func backupFileWithReason(dir, reason string) string { return filepath.Join(dir, fmt.Sprintf("foobar-%s-%s.log", fakeTime().UTC().Format("2006-01-02T15-04-05.000"), reason)) } timberjack-1.4.1/timberjack.go000066400000000000000000001362411516342741500163110ustar00rootroot00000000000000// Package timberjack provides a rolling logger with size-based and time-based rotation. // // Timberjack is a simple, pluggable component for log rotation. It can rotate // the active log file when any of the following occur: // - the file grows beyond MaxSize (size-based) // - the configured RotationInterval elapses (interval-based) // - a scheduled time is reached via RotateAt or RotateAtMinutes (clock-based) // - rotation is triggered explicitly via Rotate() (manual) // // Rotated files can optionally be compressed with gzip or zstd. // Cleanup is handled automatically. Old log files are removed based on MaxBackups and MaxAge. // // Import: // // import "github.com/DeRuina/timberjack" // // Timberjack works with any logger that writes to an io.Writer, including the // standard library’s log package. // // Concurrency note: timberjack assumes a single process writes to the target // files. Reusing the same Logger configuration across multiple processes on the // same machine may lead to improper behavior. // // Source code: https://github.com/DeRuina/timberjack package timberjack import ( "cmp" "compress/gzip" "errors" "fmt" "io" "math" "os" "path/filepath" "slices" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "unicode" "github.com/klauspost/compress/zstd" ) const ( backupTimeFormat = "2006-01-02T15-04-05.000" compressSuffix = ".gz" zstdSuffix = ".zst" defaultMaxSize = 100 defaultFileMode = 0o640 ) // ensure we always implement io.WriteCloser var _ io.WriteCloser = (*Logger)(nil) // safeClose is a generic function that safely closes a channel of any type. // It prevents "panic: close of closed channel" and "panic: close of nil channel". // // The type parameter [T any] allows this function to work with channels of any element type, // for example, chan int, chan string, chan struct{}, etc. func safeClose[T any](ch chan T) { defer func() { recover() }() close(ch) } type rotateAt [2]int // Logger is an io.WriteCloser that writes to the specified filename. // // Logger opens or creates the logfile on the first Write. // If the file exists and is smaller than MaxSize megabytes, timberjack will open and append to that file. // If the file's size exceeds MaxSize, or if the configured RotationInterval has elapsed since the last rotation, // the file is closed, renamed with a timestamp, and a new logfile is created using the original filename. // // Thus, the filename you give Logger is always the "current" log file. // // Backups use the log file name given to Logger, in the form: // `name-timestamp-.ext` where `name` is the filename without the extension, // `timestamp` is the time of rotation formatted as `2006-01-02T15-04-05.000`, // `reason` is "size" or "time" (Rotate/auto), or a custom tag (RotateWithReason), and `ext` is the original extension. // For example, if your Logger.Filename is `/var/log/foo/server.log`, a backup created at 6:30pm on Nov 11 2016 // due to size would use the filename `/var/log/foo/server-2016-11-04T18-30-00.000-size.log`. // // # Cleaning Up Old Log Files // // Whenever a new logfile is created, old log files may be deleted based on MaxBackups and MaxAge. // The most recent files (according to the timestamp) will be retained up to MaxBackups (or all files if MaxBackups is 0). // Any files with a timestamp older than MaxAge days are deleted, regardless of MaxBackups. // Note that the timestamp is the rotation time, not necessarily the last write time. // // If MaxBackups and MaxAge are both 0, no old log files will be deleted. // // timberjack assumes only a single process is writing to the log files at a time. type Logger struct { // Filename is the file to write logs to. Backup log files will be retained // in the same directory. It uses -timberjack.log in // os.TempDir() if empty. Filename string `json:"filename" yaml:"filename"` // MaxSize is the maximum size in megabytes of the log file before it gets // rotated. It defaults to 100 megabytes. MaxSize int `json:"maxsize" yaml:"maxsize"` // MaxAge is the maximum number of days to retain old log files based on the // timestamp encoded in their filename. Note that a day is defined as 24 // hours and may not exactly correspond to calendar days due to daylight // savings, leap seconds, etc. The default is not to remove old log files // based on age. MaxAge int `json:"maxage" yaml:"maxage"` // MaxBackups is the maximum number of old log files to retain. The default // is to retain all old log files (though MaxAge may still cause them to get // deleted.) MaxBackups counts distinct rotation events (timestamps). MaxBackups int `json:"maxbackups" yaml:"maxbackups"` // LocalTime determines if the time used for formatting the timestamps in // backup files is the computer's local time. The default is to use UTC // time. LocalTime bool `json:"localtime" yaml:"localtime"` // Deprecated: use Compression instead ("none" | "gzip" | "zstd"). Compress bool `json:"compress,omitempty" yaml:"compress,omitempty"` // Compression selects the algorithm. If empty, legacy Compress is used. // Allowed values: "none", "gzip", "zstd". Unknown => "none" (with a warning). Compression string `json:"compression,omitempty" yaml:"compression,omitempty"` // RotationInterval is the maximum duration between log rotations. // If the elapsed time since the last rotation exceeds this interval, // the log file is rotated, even if the file size has not reached MaxSize. // The minimum recommended value is 1 minute. If set to 0, time-based rotation is disabled. // // Example: RotationInterval = time.Hour * 24 will rotate logs daily. RotationInterval time.Duration `json:"rotationinterval" yaml:"rotationinterval"` // BackupTimeFormat defines the layout for the timestamp appended to rotated file names. // While other formats are allowed, it is recommended to follow the standard Go time layout // (https://pkg.go.dev/time#pkg-constants). Use the ValidateBackupTimeFormat() method to check // if the value is valid. It is recommended to call this method before using the Logger instance. // // WARNING: This field is assumed to be constant after initialization. // WARNING: If invalid value is supplied then default format `2006-01-02T15-04-05.000` will be used. // // Example: // BackupTimeFormat = `2006-01-02-15-04-05` // will generate rotated backup files in the format: // -2006-01-02-15-04-05--timberjack.log // where `rotationCriterion` could be `time` or `size`. BackupTimeFormat string `json:"backuptimeformat" yaml:"backuptimeformat"` // RotateAtMinutes defines specific minutes within an hour (0-59) to trigger a rotation. // For example, []int{0} for top of the hour, []int{0, 30} for top and half-past the hour. // Rotations are aligned to the clock minute (second 0). // This operates in addition to RotationInterval and MaxSize. // If multiple rotation conditions are met, the first one encountered typically triggers. RotateAtMinutes []int `json:"rotateAtMinutes" yaml:"rotateAtMinutes"` // RotateAt defines specific time within a day to trigger a rotation. // For example, []string{'00:00'} for midnight, []string{'00:00', '12:00'} for // midnight and midday. // Rotations are aligned to the clock minute (second 0). // This operates in addition to RotationInterval and MaxSize. // If multiple rotation conditions are met, the first one encountered typically triggers. RotateAt []string `json:"rotateAt" yaml:"rotateAt"` // AppendTimeAfterExt controls where the timestamp/reason go. // false (default): --.log // true: .log-- AppendTimeAfterExt bool `json:"appendTimeAfterExt" yaml:"appendTimeAfterExt"` // FileMode sets the permissions to use when creating new log files. // It will be inherited by rotated files. // If zero, the default of 0o640 is used. // // Note that a local umask may alter the final permissions. // Also, on non-Linux systems this might not have the desired effect. FileMode os.FileMode `json:"filemode" yaml:"filemode"` // Internal fields size int64 // current size of the log file file *os.File // current log file lastRotationTime time.Time // records the last time a rotation happened (for interval/scheduled). logStartTime time.Time // start time of the current logging period (used for backup filename timestamp). cfgOnce sync.Once resolvedBackupLayout string resolvedAppendAfterExt bool resolvedLocalTime bool resolvedCompression string mu sync.Mutex // ensures atomic writes and rotations // For mill goroutine (backups, compression cleanup) millCh chan bool // channel to signal the mill goroutine startMill sync.Once // ensures mill goroutine is started only once millWg sync.WaitGroup // waits for the mill goroutine to finish millWGStarted bool // For scheduled rotation goroutine (RotateAt) startScheduledRotationOnce sync.Once // ensures scheduled rotation goroutine is started only once scheduledRotationQuitCh chan struct{} // channel to signal the scheduled rotation goroutine to stop scheduledRotationWg sync.WaitGroup // waits for the scheduled rotation goroutine to finish processedRotateAt []rotateAt // internal storage for sorted and validated RotateAt isClosed uint32 // snapshots of globals to avoid races resolvedTimeNow func() time.Time resolvedStat func(string) (os.FileInfo, error) resolvedRename func(string, string) error resolvedRemove func(string) error } var ( // currentTime exists so it can be mocked out by tests. currentTime = time.Now // osStat exists so it can be mocked out by tests. osStat = os.Stat // megabyte is the conversion factor between MaxSize and bytes. It is a // variable so tests can mock it out and not need to write megabytes of data // to disk. megabyte = 1024 * 1024 osRename = os.Rename osRemove = os.Remove // empty BackupTimeFormatField ErrEmptyBackupTimeFormatField = errors.New("empty backupformat field") ) func (l *Logger) resolveConfigLocked() { l.cfgOnce.Do(func() { // Resolve time format layout := l.BackupTimeFormat if layout == "" { layout = backupTimeFormat } else if err := l.ValidateBackupTimeFormat(); err != nil { fmt.Fprintf(os.Stderr, "timberjack: invalid BackupTimeFormat: %v — falling back to default format: %s\n", err, backupTimeFormat) layout = backupTimeFormat } l.resolvedBackupLayout = layout l.resolvedAppendAfterExt = l.AppendTimeAfterExt l.resolvedLocalTime = l.LocalTime // snapshot global funcs so tests can fiddle with the globals safely l.resolvedTimeNow = currentTime l.resolvedStat = osStat l.resolvedRename = osRename l.resolvedRemove = osRemove // Freeze compression (prevents races if toggled later) l.resolvedCompression = l.effectiveCompression() }) } // Write implements io.Writer. // It writes the provided bytes to the current log file. // If the log file exceeds MaxSize after writing, or if the configured RotationInterval has elapsed // since the last rotation, or if a scheduled rotation time (RotateAtMinutes) has been reached, // the file is closed, renamed to include a timestamp, and a new log file is created // using the original filename. // If the size of a single write exceeds MaxSize, the write is rejected and an error is returned. func (l *Logger) Write(p []byte) (n int, err error) { l.mu.Lock() defer l.mu.Unlock() // Ensure configuration is resolved once l.resolveConfigLocked() // Handle writes to a closed logger. if atomic.LoadUint32(&l.isClosed) == 1 { // The logger is closed. To ensure the write succeeds, we perform a // single open-write-close cycle. This does not perform rotation // and does not restart the background goroutines. l.file remains nil. file, openErr := os.OpenFile(l.filename(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if openErr != nil { return 0, fmt.Errorf("timberjack: write on closed logger failed to open file: %w", openErr) } n, writeErr := file.Write(p) closeErr := file.Close() if writeErr != nil { return n, writeErr } return n, closeErr } // Ensure the scheduled-rotation goroutine is running (if you've still got one). l.ensureScheduledRotationLoopRunning() // Snapshot now := l.resolvedTimeNow().In(l.location()) writeLen := int64(len(p)) if writeLen > l.max() { return 0, fmt.Errorf("write length %d exceeds maximum file size %d", writeLen, l.max()) } // Open (or create) the file on first write. if l.file == nil { if err = l.openExistingOrNew(len(p)); err != nil { return 0, err } if l.lastRotationTime.IsZero() { // Initialize to 'now' so interval/minute checks start from here. l.lastRotationTime = now } } // 1) Interval-based rotation if l.RotationInterval > 0 && now.Sub(l.lastRotationTime) >= l.RotationInterval { if err := l.rotate("time"); err != nil { return 0, fmt.Errorf("interval rotation failed: %w", err) } l.lastRotationTime = now } // 2) Scheduled time based rotation (RotateAt) if len(l.processedRotateAt) > 0 { for _, m := range l.processedRotateAt { mark := time.Date(now.Year(), now.Month(), now.Day(), m[0], m[1], 0, 0, l.location()) // If we've crossed that mark since the last rotation, fire one rotation. if l.lastRotationTime.Before(mark) && (mark.Before(now) || mark.Equal(now)) { if err := l.rotate("time"); err != nil { return 0, fmt.Errorf("scheduled-minute rotation failed: %w", err) } // Record the logical mark—so we don’t rerun until next slot. l.lastRotationTime = mark break } } } // 3) Size-based rotation if l.size+writeLen > l.max() { if err := l.rotate("size"); err != nil { return 0, fmt.Errorf("size rotation failed: %w", err) } // Note: we leave lastRotationTime untouched for size rotations. } // Finally, write the bytes and update size. n, err = l.file.Write(p) l.size += int64(n) return n, err } // ValidateBackupTimeFormat checks if the configured BackupTimeFormat is a valid time layout. // While other formats are allowed, it is recommended to follow the standard time layout // rules as defined here: https://pkg.go.dev/time#pkg-constants // // WARNING: Assumes that BackupTimeFormat value remains constant after initialization. func (l *Logger) ValidateBackupTimeFormat() error { if len(l.BackupTimeFormat) == 0 { return ErrEmptyBackupTimeFormatField } // 2025-05-22 23:41:59.987654321 +0000 UTC now := time.Date(2025, 5, 22, 23, 41, 59, 987_654_321, time.UTC) layoutPrecision := countDigitsAfterDot(l.BackupTimeFormat) now, err := truncateFractional(now, layoutPrecision) if err != nil { return err } formatted := now.Format(l.BackupTimeFormat) parsedT, err := time.Parse(l.BackupTimeFormat, formatted) if err != nil { return fmt.Errorf("invalid BackupTimeFormat: %w", err) } if !parsedT.Equal(now) { return errors.New("invalid BackupTimeFormat: time.Time parsed from the format does not match the time.Time supplied") } return nil } // location returns the time.Location (UTC or Local) to use for timestamps in backup filenames. func (l *Logger) location() *time.Location { l.resolveConfigLocked() if l.resolvedLocalTime { return time.Local } return time.UTC } func parseTime(s string) (*rotateAt, error) { parts := strings.Split(s, ":") if len(parts) != 2 { return nil, errors.New("invalid time") } hs, ms := parts[0], parts[1] h, err := strconv.Atoi(hs) if err != nil { return nil, err } m, err := strconv.Atoi(ms) if err != nil { return nil, err } if m < 0 || m > 59 || h < 0 || h > 23 { return nil, errors.New("invalid time") } return &rotateAt{h, m}, nil } func compareTime(a, b rotateAt) int { h1, m1 := a[0], a[1] h2, m2 := b[0], b[1] if h1 == h2 { return cmp.Compare(m1, m2) } return cmp.Compare(h1, h2) } // ensureScheduledRotationLoopRunning starts the scheduled rotation goroutine if RotateAtMinutes is configured // and the goroutine is not already running. func (l *Logger) ensureScheduledRotationLoopRunning() { if len(l.RotateAtMinutes)+len(l.RotateAt) == 0 { return // No scheduled rotations configured } l.startScheduledRotationOnce.Do(func() { var processedRotateAt []rotateAt for _, m := range l.RotateAtMinutes { if m < 0 || m > 59 { fmt.Fprintf(os.Stderr, "timberjack: [%d] No valid minute specified for RotateAtMinutes.\n", m) continue } for h := 0; h < 24; h++ { processedRotateAt = append(processedRotateAt, rotateAt{h, m}) } } for _, t := range l.RotateAt { r, err := parseTime(t) if err != nil { fmt.Fprintf(os.Stderr, "timberjack: [%s] No valid time specified for RotateAt.\n", t) continue } processedRotateAt = append(processedRotateAt, *r) } if len(processedRotateAt) == 0 { // Optionally log that no valid minutes were found, preventing goroutine start // fmt.Fprintf(os.Stderr, "timberjack: [%s] No valid minutes specified for RotateAtMinutes.\n", l.Filename) return } // Sort for predictable order in calculating next rotation slices.SortFunc(processedRotateAt, compareTime) processedRotateAt = slices.CompactFunc(processedRotateAt, func(a, b rotateAt) bool { return compareTime(a, b) == 0 }) l.processedRotateAt = processedRotateAt quitCh := make(chan struct{}) l.scheduledRotationQuitCh = quitCh // Snapshot immutable inputs for the goroutine to avoid reading fields later. slots := l.processedRotateAt loc := l.location() nowFn := l.resolvedTimeNow l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quitCh, slots, loc, nowFn) }) } // runScheduledRotations is the main loop for handling rotations at specific minute marks // as defined in RotateAtMinutes. It runs in a separate goroutine. func (l *Logger) runScheduledRotations(quit <-chan struct{}, slots []rotateAt, loc *time.Location, nowFn func() time.Time) { defer l.scheduledRotationWg.Done() // No slots to process, exit immediately. if len(slots) == 0 { return } timer := time.NewTimer(0) // Timer will be reset with the correct duration in the loop if !timer.Stop() { // Drain the channel if the timer fired prematurely (e.g., duration was 0 on first NewTimer) select { case <-timer.C: default: } } for { now := nowFn() nowInLocation := now.In(loc) nextRotationAbsoluteTime := time.Time{} foundNextSlot := false determineNextSlot: // Calculate the next rotation time based on the current time and processedRotateAt. // Iterate through the current hour, then subsequent hours (up to 24h ahead for robustness // against system sleep or large clock jumps). for hourOffset := 0; hourOffset <= 24; hourOffset++ { // Base time for the hour we are checking (e.g., if now is 10:35, current hour base is 10:00) hourToCheck := time.Date(nowInLocation.Year(), nowInLocation.Month(), nowInLocation.Day(), nowInLocation.Hour(), 0, 0, 0, loc).Add(time.Duration(hourOffset) * time.Hour) for _, mark := range slots { candidateTime := time.Date(hourToCheck.Year(), hourToCheck.Month(), hourToCheck.Day(), mark[0], mark[1], 0, 0, loc) if candidateTime.After(now) { // Found the earliest future slot nextRotationAbsoluteTime = candidateTime foundNextSlot = true break determineNextSlot // Exit both loops } } } if !foundNextSlot { // This should ideally not happen if processedRotateAt is valid and non-empty. // Could occur if currentTime() is unreliable or jumps massively backward. // Log an error and retry calculation after a fallback delay. 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) select { case <-time.After(time.Minute): // Wait a bit before retrying calculation continue // Restart the outer loop to recalculate case <-quit: return } } sleepDuration := nextRotationAbsoluteTime.Sub(now) timer.Reset(sleepDuration) select { case <-timer.C: // Timer fired, it's time for a scheduled rotation l.mu.Lock() // Only rotate if the last rotation time was before this specific scheduled mark. // This prevents redundant rotations if another rotation (e.g., size/interval) happened // very close to, but just before or at, this scheduled time for the same mark. if l.lastRotationTime.Before(nextRotationAbsoluteTime) { if err := l.rotate("time"); err != nil { // Scheduled rotations are "time" based for filename fmt.Fprintf(os.Stderr, "timberjack: [%s] scheduled rotation failed: %v\n", l.Filename, err) } else { l.lastRotationTime = nowFn() } } l.mu.Unlock() // Loop will continue and recalculate the next slot from the new "now" case <-quit: // Signal to quit from Close() if !timer.Stop() { // If Stop() returns false, the timer has already fired or been stopped. // If it fired, its channel might have a value, so drain it. select { case <-timer.C: default: } } return // Exit goroutine } } } // Close implements io.Closer, and closes the current logfile. // It also signals any running goroutines (like scheduled rotation or mill) to stop. func (l *Logger) Close() error { l.mu.Lock() if atomic.LoadUint32(&l.isClosed) == 1 { l.mu.Unlock() return nil } atomic.StoreUint32(&l.isClosed, 1) // Stop the scheduled rotation goroutine var quitCh chan struct{} if l.scheduledRotationQuitCh != nil { quitCh = l.scheduledRotationQuitCh l.scheduledRotationQuitCh = nil // clear under lock } // Stop the mill goroutine (doesn't use l.mu, no deadlock risk) var millCh chan bool if l.millCh != nil { millCh = l.millCh // don't nil it; startMill.Do prevents restarts } // Close file under lock (safe and quick) err := l.closeFile() l.mu.Unlock() // Now signal and wait outside the lock if quitCh != nil { safeClose(quitCh) l.scheduledRotationWg.Wait() } if millCh != nil { safeClose(millCh) l.millWg.Wait() } return err } // closeFile closes the file if it is open. This is an internal method. // It expects l.mu to be held. func (l *Logger) closeFile() error { if l.file == nil { return nil } err := l.file.Close() l.file = nil // Set to nil to indicate it's closed. return err } // Rotate forces an immediate rotation using the legacy auto-reason logic. // (empty reason => "time" if an interval rotation is due, otherwise "size") func (l *Logger) Rotate() error { return l.RotateWithReason("") } // rotate closes the current file, moves it aside with a timestamp in the name, // (if it exists), opens a new file with the original filename, and then runs // post-rotation processing and removal (mill). // It expects l.mu to be held by the caller. // Takes an explicit reason for the rotation which is used in the backup filename. func (l *Logger) rotate(reason string) error { if err := l.closeFile(); err != nil { return err } // Pass the determined reason to openNew so it's used in the backup filename if err := l.openNew(reason); err != nil { return err } l.mill() return nil } // RotateWithReason forces a rotation immediately and tags the backup filename // with the provided reason (after sanitization). If the sanitized reason is // empty, it falls back to the default behavior used by Rotate(): "time" if an // interval rotation is due, otherwise "size". // // After a successful rotation, lastRotationTime is updated to the current time // so that a subsequent Write() does not trigger a duplicate interval-based or // scheduled rotation. func (l *Logger) RotateWithReason(reason string) error { l.mu.Lock() defer l.mu.Unlock() if atomic.LoadUint32(&l.isClosed) == 1 { return errors.New("logger closed") } r := sanitizeReason(reason) if r == "" { // keep legacy Rotate() semantics r = "size" if l.shouldTimeRotate() { r = "time" } } if err := l.rotate(r); err != nil { return err } l.lastRotationTime = l.resolvedTimeNow() return nil } func backupNameWithResolved(name string, local bool, reason string, t time.Time, layout string, afterExt bool) string { dir := filepath.Dir(name) filename := filepath.Base(name) ext := filepath.Ext(filename) prefix := filename[:len(filename)-len(ext)] loc := time.UTC if local { loc = time.Local } ts := t.In(loc).Format(layout) if afterExt { // -- return filepath.Join(dir, fmt.Sprintf("%s%s-%s-%s", prefix, ext, ts, reason)) } // -- return filepath.Join(dir, fmt.Sprintf("%s-%s-%s%s", prefix, ts, reason, ext)) } // openNew creates a new log file for writing. // If an old log file already exists, it is moved aside by renaming it with a timestamp. // This method assumes that l.mu is held and the old file (if any) has already been closed. // The reasonForBackup parameter is used in the backup filename. func (l *Logger) openNew(reasonForBackup string) error { l.resolveConfigLocked() // no-op after first time if err := os.MkdirAll(l.dir(), 0o755); err != nil { return fmt.Errorf("can't make directories for new logfile: %s", err) } name := l.filename() finalMode := l.FileMode if finalMode == 0 { finalMode = os.FileMode(defaultFileMode) } var oldInfo os.FileInfo info, err := l.resolvedStat(name) if err == nil { oldInfo = info // Only use the existing file's mode when no explicit FileMode is configured. if l.FileMode == 0 { finalMode = oldInfo.Mode() } rotationTimeForBackup := l.resolvedTimeNow() // Build the rotated name from the immutable snapshot (no public field writes). newname := backupNameWithResolved( name, l.resolvedLocalTime, reasonForBackup, rotationTimeForBackup, l.resolvedBackupLayout, l.resolvedAppendAfterExt, ) if errRename := l.resolvedRename(name, newname); errRename != nil { return fmt.Errorf("can't rename log file: %s", errRename) } l.logStartTime = rotationTimeForBackup } else if os.IsNotExist(err) { l.logStartTime = l.resolvedTimeNow() oldInfo = nil } else { return fmt.Errorf("failed to stat log file %s: %w", name, err) } // Create and open the new log file at path `name`. f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, finalMode) if err != nil { return fmt.Errorf("can't open new logfile %s: %s", name, err) } // Apply the exact mode via chmod so the process umask cannot mask bits. if err := os.Chmod(name, finalMode); err != nil { closeErr := f.Close() if closeErr != nil { return fmt.Errorf("can't set mode on new logfile %s: %s (also failed to close: %v)", name, err, closeErr) } return fmt.Errorf("can't set mode on new logfile %s: %s", name, err) } l.file = f l.size = 0 // Try to chown the new file to match the old file's owner/group (if there was an old file). if oldInfo != nil { if errChown := chown(name, oldInfo); errChown != nil { fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to chown new log file %s: %v\n", l.Filename, name, errChown) } } return nil } // shouldTimeRotate checks if the time-based rotation interval has elapsed // since the last rotation. This is used for RotationInterval logic. func (l *Logger) shouldTimeRotate() bool { l.resolveConfigLocked() if l.RotationInterval == 0 { // Time-based rotation (interval) is disabled return false } // If lastRotationTime is zero (e.g., logger just started, no writes/rotations yet), // then it's not yet time for an interval-based rotation. if l.lastRotationTime.IsZero() { return false } return l.resolvedTimeNow().Sub(l.lastRotationTime) >= l.RotationInterval } // backupName creates a new backup filename by inserting a timestamp and a rotation reason // ("time" or "size") between the filename prefix and the extension. // It uses the local time if requested (otherwise UTC). func backupName(name string, local bool, reason string, t time.Time, fileTimeFormat string, appendTimeAfterExt bool) string { dir := filepath.Dir(name) filename := filepath.Base(name) ext := filepath.Ext(filename) prefix := filename[:len(filename)-len(ext)] currentLoc := time.UTC if local { currentLoc = time.Local } // Format the timestamp for the backup file. timestamp := t.In(currentLoc).Format(fileTimeFormat) if appendTimeAfterExt { // -- // e.g. httpd.log-2025-01-01T00-00-00.000-size return filepath.Join(dir, fmt.Sprintf("%s%s-%s-%s", prefix, ext, timestamp, reason)) } // default: -- return filepath.Join(dir, fmt.Sprintf("%s-%s-%s%s", prefix, timestamp, reason, ext)) } // openExistingOrNew opens the existing logfile if it exists and the current write // would not cause it to exceed MaxSize. If the file does not exist, or if writing // would exceed MaxSize, the current file is rotated (if it exists) and a new logfile is created. // It expects l.mu to be held by the caller. func (l *Logger) openExistingOrNew(writeLen int) error { l.resolveConfigLocked() l.mill() // Perform house-keeping for old logs (compression, deletion) first. filename := l.filename() info, err := l.resolvedStat(filename) if os.IsNotExist(err) { // File doesn't exist, so openNew is creating a new file. // The 'reason' passed to openNew here ("initial") won't affect a backup filename // as no backup (renaming) is happening. return l.openNew("initial") } if err != nil { return fmt.Errorf("error getting log file info: %s", err) } // Check if rotation is needed due to size before opening/appending. if info.Size()+int64(writeLen) >= l.max() { return l.rotate("size") // This rotation is explicitly due to "size" } // Open existing file for appending. file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0) if err != nil { // If opening existing fails (e.g., permissions, corruption), try to create a new one. return l.openNew("initial") // Fallback if append fails } l.file = file l.size = info.Size() // Note: l.logStartTime is NOT updated here if we successfully open an existing file without rotating. // It retains its value from when this current log segment was created (by a previous openNew). // l.lastRotationTime is also NOT updated here; it's handled by rotation trigger logic. return nil } // filename returns the current log filename, using the configured Filename, // or a default based on the process name if Filename is empty. func (l *Logger) filename() string { if l.Filename != "" { return l.Filename } name := filepath.Base(os.Args[0]) + "-timberjack.log" return filepath.Join(os.TempDir(), name) } // millRunOnce performs one cycle of compression and removal of old log files. // If compression is enabled, uncompressed backups are compressed using gzip. // Old backup files are deleted to enforce MaxBackups and MaxAge limits. func (l *Logger) millRunOnce() error { l.resolveConfigLocked() comp := l.resolvedCompression if l.MaxBackups == 0 && l.MaxAge == 0 && comp == "none" { return nil // Nothing to do if all cleanup options are disabled. } now := l.resolvedTimeNow() files, err := l.oldLogFiles() // Gets LogInfo structs, sorted newest first by timestamp if err != nil { return err } filesToProcess := files // Start with all found old log files var filesToRemove []logInfo // Accumulates files to be deleted // MaxBackups filtering: Keep files belonging to the MaxBackups newest distinct timestamps if l.MaxBackups > 0 { uniqueTimestamps := make([]time.Time, 0) timestampMap := make(map[time.Time]bool) for _, f := range filesToProcess { // filesToProcess is sorted newest first if !timestampMap[f.timestamp] { timestampMap[f.timestamp] = true uniqueTimestamps = append(uniqueTimestamps, f.timestamp) } } if len(uniqueTimestamps) > l.MaxBackups { // Determine the set of timestamps to keep (the MaxBackups newest ones) keptTimestampsSet := make(map[time.Time]bool) for i := 0; i < l.MaxBackups; i++ { keptTimestampsSet[uniqueTimestamps[i]] = true } var filteredFiles []logInfo // Files that pass this MaxBackups filter for _, f := range filesToProcess { if keptTimestampsSet[f.timestamp] { filteredFiles = append(filteredFiles, f) } else { filesToRemove = append(filesToRemove, f) // Mark for removal } } filesToProcess = filteredFiles // Update filesToProcess for subsequent filters } // If len(uniqueTimestamps) <= l.MaxBackups, all files pass this MaxBackups filter. } // MaxAge filtering (operates on files that passed MaxBackups filter) if l.MaxAge > 0 { diff := time.Duration(int64(24*time.Hour) * int64(l.MaxAge)) cutoff := now.Add(-diff) var filteredFiles []logInfo // Files that pass this MaxAge filter for _, f := range filesToProcess { if f.timestamp.Before(cutoff) { // avoid duplicates isAlreadyMarked := false for _, rmf := range filesToRemove { if rmf.Name() == f.Name() { isAlreadyMarked = true break } } if !isAlreadyMarked { filesToRemove = append(filesToRemove, f) } } else { filteredFiles = append(filteredFiles, f) } } filesToProcess = filteredFiles } // Compression task identification (operates on files that passed MaxBackups and MaxAge) var filesToCompress []logInfo if comp != "none" { for _, f := range filesToProcess { name := f.Name() if strings.HasSuffix(name, compressSuffix) || strings.HasSuffix(name, zstdSuffix) { continue // already compressed } isMarked := false for _, rmf := range filesToRemove { if rmf.Name() == name { isMarked = true break } } if !isMarked { filesToCompress = append(filesToCompress, f) } } } // Execute removals (ensure unique removals) finalUniqueRemovals := make(map[string]logInfo) for _, f := range filesToRemove { finalUniqueRemovals[f.Name()] = f } for _, f := range finalUniqueRemovals { errRemove := l.resolvedRemove(filepath.Join(l.dir(), f.Name())) if errRemove != nil && !os.IsNotExist(errRemove) { fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to remove old log file %s: %v\n", l.Filename, f.Name(), errRemove) } } // Execute compressions (suffix also comes from snapshot via compressedSuffix) suffix := l.compressedSuffix() for _, f := range filesToCompress { fn := filepath.Join(l.dir(), f.Name()) if errCompress := l.compressLogFile(fn, fn+suffix); errCompress != nil { fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to compress log file %s: %v\n", l.Filename, f.Name(), errCompress) } } return nil } // millRun runs in a goroutine to manage post-rotation compression and removal // of old log files. It listens on millCh for signals to run millRunOnce. func (l *Logger) millRun() { if l.millWGStarted { defer l.millWg.Done() } ch := l.millCh for range ch { _ = l.millRunOnce() } } // mill performs post-rotation compression and removal of stale log files, // starting the mill goroutine if necessary and sending a signal to it. func (l *Logger) mill() { if atomic.LoadUint32(&l.isClosed) == 1 { return } l.startMill.Do(func() { l.millWGStarted = true l.millCh = make(chan bool, 1) l.millWg.Add(1) go l.millRun() }) select { case l.millCh <- true: default: } } // oldLogFiles returns the list of backup log files stored in the same // directory as the current log file, sorted by their embedded timestamp (newest first). func (l *Logger) oldLogFiles() ([]logInfo, error) { entries, err := os.ReadDir(l.dir()) // ReadDir is generally preferred over ReadFile for directory listings if err != nil { return nil, fmt.Errorf("can't read log file directory: %s", err) } var logFiles []logInfo prefix, ext := l.prefixAndExt() // Get prefix like "filename-" and original extension like ".log" for _, e := range entries { if e.IsDir() { // Skip directories continue } name := e.Name() info, errInfo := e.Info() // Get FileInfo for modification time and other details if errInfo != nil { // fmt.Fprintf(os.Stderr, "timberjack: failed to get FileInfo for %s: %v\n", name, errInfo) continue // Skip files we can't stat } // Attempt to parse timestamp from filename (e.g., from "filename-timestamp-reason.log") if t, errTime := l.timeFromName(name, prefix, ext); errTime == nil { logFiles = append(logFiles, logInfo{t, info}) continue } // Attempt to parse timestamp from compressed gzip filename (e.g., from "filename-timestamp-reason.log.gz") if t, errTime := l.timeFromName(name, prefix, ext+compressSuffix); errTime == nil { logFiles = append(logFiles, logInfo{t, info}) continue } // Attempt to parse timestamp from compressed zstd filename (e.g., from "filename-timestamp-reason.log.zst") if t, errTime := l.timeFromName(name, prefix, ext+zstdSuffix); errTime == nil { logFiles = append(logFiles, logInfo{t, info}) continue } // Files that don't match the expected backup pattern are ignored. } sort.Sort(byFormatTime(logFiles)) // Sorts newest first based on parsed timestamp return logFiles, nil } // timeFromName extracts the formatted timestamp from the backup filename. // It expects filenames like "prefix-YYYY-MM-DDTHH-MM-SS.mmm-reason.ext" or "prefix.ext-YYYY-MM-DDTHH-MM-SS.mmm-reason[.gz]" func (l *Logger) timeFromName(filename, prefix, ext string) (time.Time, error) { layout := l.resolvedBackupLayout if layout == "" { // defensive default if called very early layout = backupTimeFormat } loc := time.UTC if l.resolvedLocalTime { loc = time.Local } if !l.resolvedAppendAfterExt { // Keep legacy behavior for error messages to satisfy existing tests if !strings.HasPrefix(filename, prefix) { return time.Time{}, errors.New("mismatched prefix") } if !strings.HasSuffix(filename, ext) { return time.Time{}, errors.New("mismatched extension") } // "-" trimmed := filename[len(prefix) : len(filename)-len(ext)] lastHyphenIdx := strings.LastIndex(trimmed, "-") if lastHyphenIdx == -1 { return time.Time{}, fmt.Errorf("malformed backup filename: missing reason separator in %q", trimmed) } ts := trimmed[:lastHyphenIdx] return time.ParseInLocation(layout, ts, loc) } // After-ext parsing: // base is "" (e.g., "foo.log") base := prefix[:len(prefix)-1] + ext // Allow optional trailing compression suffix (".gz" or ".zst") nameNoComp := trimCompressionSuffix(filename) // nameNoComp must start with "-" if !strings.HasPrefix(nameNoComp, base+"-") { return time.Time{}, fmt.Errorf("malformed backup filename: %q", filename) } // nameNoComp = "--" trimmed := nameNoComp[len(base)+1:] lastHyphenIdx := strings.LastIndex(trimmed, "-") if lastHyphenIdx == -1 { return time.Time{}, fmt.Errorf("malformed backup filename: %q", filename) } ts := trimmed[:lastHyphenIdx] return time.ParseInLocation(layout, ts, loc) } // max returns the maximum size in bytes of log files before rolling. func (l *Logger) max() int64 { if l.MaxSize == 0 { // If MaxSize is 0, use default. return int64(defaultMaxSize * megabyte) } return int64(l.MaxSize) * int64(megabyte) } // dir returns the directory for the current filename. func (l *Logger) dir() string { return filepath.Dir(l.filename()) } // prefixAndExt returns the filename part (up to the extension, with a trailing dash for backups) // and extension part from the Logger's filename. // e.g., for "foo.log", returns "foo-", ".log" func (l *Logger) prefixAndExt() (prefix, ext string) { filename := filepath.Base(l.filename()) ext = filepath.Ext(filename) prefix = filename[:len(filename)-len(ext)] + "-" // Add dash as backup filenames include it after original prefix return prefix, ext } // countDigitsAfterDot returns the number of consecutive digit characters // immediately following the first '.' in the input. // It skips all characters before the '.' and stops counting at the first non-digit // character after the '.'. // Example: `prefix.0012304123suffix` would return 10 // Example: `prefix.0012304_middle_123_suffix` would return 7 func countDigitsAfterDot(layout string) int { for i, ch := range layout { if ch == '.' { count := 0 for _, c := range layout[i+1:] { if unicode.IsDigit(c) { count++ } else { break } } return count } } return 0 // no '.' found or no digits after dot } // truncateFractional truncates time t to n fractional digits of seconds. // n=0 → truncate to seconds, n=3 → milliseconds, n=6 → microseconds, etc. func truncateFractional(t time.Time, n int) (time.Time, error) { if n < 0 || n > 9 { return time.Time{}, fmt.Errorf("unsupported fractional precision: %d", n) } // number of nanoseconds to keep factor := math.Pow10(9 - n) // e.g. for n=3, factor=10^(9-3)=1,000,000 nanos := t.Nanosecond() truncatedNanos := int((int64(nanos) / int64(factor)) * int64(factor)) return time.Date( t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), truncatedNanos, t.Location(), ), nil } // compressLogFile compresses the given source log file (src) to a destination file (dst), // removing the source file if compression is successful. func (l *Logger) compressLogFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { return fmt.Errorf("failed to open source log file %s for compression: %v", src, err) } defer srcFile.Close() srcInfo, err := l.resolvedStat(src) if err != nil { return fmt.Errorf("failed to stat source log file %s: %v", src, err) } // Create or open the destination file for writing the compressed content dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, srcInfo.Mode()) if err != nil { return fmt.Errorf("failed to open destination compressed log file %s: %v", dst, err) } // No `defer dstFile.Close()` here, explicit closing in sequence is critical. var copyErr error // To capture error from io.Copy // Choose compression algorithm based on dst suffix // Default to gzip if no recognized suffix // This allows future extension to other algorithms by checking dst suffix if strings.HasSuffix(dst, zstdSuffix) { enc, err := zstd.NewWriter(dstFile) if err != nil { // Error creating zstd writer _ = dstFile.Close() // Close dstFile before removing _ = l.resolvedRemove(dst) return fmt.Errorf("failed to init zstd writer for %s: %v", dst, err) } _, copyErr = io.Copy(enc, srcFile) // Copy data from source file to zstd writer closeErr := enc.Close() // Close zstd writer to flush data if copyErr == nil && closeErr != nil { copyErr = closeErr } } else { gz := gzip.NewWriter(dstFile) // Default to gzip _, copyErr = io.Copy(gz, srcFile) // Copy data from source file to gzip writer closeErr := gz.Close() // Close gzip writer to flush data if copyErr == nil && closeErr != nil { copyErr = closeErr } } if copyErr != nil { // Error during copy or close _ = dstFile.Close() // Try to close destination file _ = l.resolvedRemove(dst) // Try to remove potentially partial destination file return fmt.Errorf("failed to write compressed data to %s: %w", dst, copyErr) } if err := dstFile.Close(); err != nil { // Close destination file // Data is likely written and compressor closed successfully, but closing the file descriptor failed. // The destination file might still be valid on disk. We typically wouldn't remove dst here // as the data might be recoverable or fully written despite the close error. return fmt.Errorf("failed to close destination compressed file %s: %w", dst, err) } if errChown := chown(dst, srcInfo); errChown != nil { // Attempt to chown the destination file // Log the chown error, but don't make it a fatal error for the compression process itself, // as the compressed file is valid. The original source file will still be removed. fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to chown compressed log file %s: %v (source %s)\n", filepath.Base(src), dst, errChown, src) // Note: Depending on requirements, a chown failure could be considered critical. // For now, it's logged, and compression proceeds to remove the source. } // Windows file locking fix // Close the source file explicitly BEFORE attempting to remove it. // On Windows, you cannot delete an open file. The defer srcFile.Close() won't execute // until this function returns, so we must close it here before calling resolvedRemove(). // This prevents "The process cannot access the file because it is being used" errors. if err := srcFile.Close(); err != nil { // Log the close error but continue with removal attempt fmt.Fprintf(os.Stderr, "timberjack: [%s] failed to close source file before removal: %v\n", filepath.Base(src), err) } // Finally, after successful compression and closing (and optional chown), remove the original source file. if err = l.resolvedRemove(src); err != nil { // This is a more significant error if the original isn't removed, as it might be re-processed. return fmt.Errorf("failed to remove original source log file %s after compression: %w", src, err) } return nil // Compression successful } // effectiveCompression returns "none" | "gzip" | "zstd". // Rule: if Compression is set, it wins; if empty, fallback to legacy Compress. // Unknown strings default to "none" (and warn once). func (l *Logger) effectiveCompression() string { alg := strings.ToLower(strings.TrimSpace(l.Compression)) switch alg { case "gzip", "zstd": return alg case "none", "": if alg == "" && l.Compress { return "gzip" } return "none" default: fmt.Fprintf(os.Stderr, "timberjack: invalid compression %q — using none\n", alg) return "none" } } // compressedSuffix returns ".gz" / ".zst" or "" if none. func (l *Logger) compressedSuffix() string { switch l.resolvedCompression { case "gzip": return compressSuffix case "zstd": return zstdSuffix default: return "" } } // trimCompressionSuffix strips one known compression suffix (".gz" or ".zst"). func trimCompressionSuffix(name string) string { name = strings.TrimSuffix(name, compressSuffix) name = strings.TrimSuffix(name, zstdSuffix) return name } // sanitizeReason turns an arbitrary string into a safe, short tag for filenames. // Allowed: [a-z0-9_-]. Everything else becomes '-'. Collapses repeats, trims edges. // Returns empty string if nothing usable remains. func sanitizeReason(s string) string { s = strings.TrimSpace(strings.ToLower(s)) if s == "" { return "" } const max = 32 var b strings.Builder lastDash := false for _, r := range s { ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' if ok { b.WriteRune(r) lastDash = (r == '-') } else { // replace anything else (including whitespace) with a single '-' if !lastDash && b.Len() > 0 { b.WriteByte('-') lastDash = true } } if b.Len() >= max { break } } out := strings.Trim(b.String(), "-_") return out } // logInfo is a convenience struct to return the filename and its embedded // timestamp, along with its os.FileInfo. type logInfo struct { timestamp time.Time // Parsed timestamp from the filename os.FileInfo // Full FileInfo } // byFormatTime sorts a slice of logInfo structs by their parsed timestamp in descending order (newest first). type byFormatTime []logInfo func (b byFormatTime) Less(i, j int) bool { // Handle cases where timestamps might be zero (e.g., parsing failed, though timeFromName should error out) if b[i].timestamp.IsZero() && !b[j].timestamp.IsZero() { return false } // Treat zero time as oldest if !b[i].timestamp.IsZero() && b[j].timestamp.IsZero() { return true } // Non-zero is newer than zero if b[i].timestamp.IsZero() && b[j].timestamp.IsZero() { return false } // Equal if both are zero (order doesn't matter) return b[i].timestamp.After(b[j].timestamp) // Sort newest first } func (b byFormatTime) Swap(i, j int) { b[i], b[j] = b[j], b[i] } func (b byFormatTime) Len() int { return len(b) } timberjack-1.4.1/timberjack_test.go000066400000000000000000002447041516342741500173540ustar00rootroot00000000000000package timberjack import ( "bytes" "compress/gzip" "encoding/json" "fmt" "io" "os" "path/filepath" "runtime" "sort" "strings" "sync" "syscall" "testing" "time" "github.com/fortytw2/leaktest" "github.com/klauspost/compress/zstd" ) // !!!NOTE!!! // // Running these tests in parallel will almost certainly cause sporadic (or even // regular) failures, because they're all messing with the same global variable // that controls the logic's mocked time.Now. So... don't do that. // Since all the tests uses the time to determine filenames etc, we need to // control the wall clock as much as possible, which means having a wall clock // that doesn't change unless we want it to. var ( ftMu sync.Mutex fakeCurrentTime = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) ) // The logger's clock will read this. func fakeTime() time.Time { ftMu.Lock() defer ftMu.Unlock() return fakeCurrentTime } // Set the fake clock to an exact instant (handy in tests that assign fakeCurrentTime directly). func setFakeTime(t time.Time) { ftMu.Lock() fakeCurrentTime = t ftMu.Unlock() } // Advance the fake clock by a duration (defaults to 1 second if none is given). func newFakeTime(d ...time.Duration) { step := time.Second if len(d) > 0 { step = d[0] } ftMu.Lock() fakeCurrentTime = fakeCurrentTime.Add(step) ftMu.Unlock() } func TestNewFile(t *testing.T) { currentTime = fakeTime dir := makeTempDir("TestNewFile", t) defer os.RemoveAll(dir) l := &Logger{ Filename: logFile(dir), } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(logFile(dir), b, t) fileCount(dir, 1, t) } func TestOpenExisting(t *testing.T) { currentTime = fakeTime dir := makeTempDir("TestOpenExisting", t) defer os.RemoveAll(dir) filename := logFile(dir) data := []byte("foo!") err := os.WriteFile(filename, data, 0o644) isNil(err, t) existsWithContent(filename, data, t) l := &Logger{ Filename: filename, } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) // make sure the file got appended existsWithContent(filename, append(data, b...), t) // make sure permissions are retained hasPerm(filename, 0o644, t) // make sure no other files were created fileCount(dir, 1, t) } func TestWriteTooLong(t *testing.T) { currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestWriteTooLong", t) defer os.RemoveAll(dir) l := &Logger{ Filename: logFile(dir), MaxSize: 5, } defer l.Close() b := []byte("booooooooooooooo!") n, err := l.Write(b) notNil(err, t) equals(0, n, t) equals(err.Error(), fmt.Sprintf("write length %d exceeds maximum file size %d", len(b), l.MaxSize), t) _, err = os.Stat(logFile(dir)) assert(os.IsNotExist(err), t, "File exists, but should not have been created") } func TestMakeLogDir(t *testing.T) { currentTime = fakeTime dir := time.Now().Format("TestMakeLogDir" + backupTimeFormat) dir = filepath.Join(os.TempDir(), dir) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(logFile(dir), b, t) fileCount(dir, 1, t) } func TestDefaultFilename(t *testing.T) { currentTime = fakeTime dir := os.TempDir() filename := filepath.Join(dir, filepath.Base(os.Args[0])+"-timberjack.log") defer os.Remove(filename) l := &Logger{} defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(filename, b, t) } func TestAutoRotate(t *testing.T) { currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestAutoRotate", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, MaxSize: 10, } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(filename, b, t) fileCount(dir, 1, t) newFakeTime() b2 := []byte("foooooo!") n, err = l.Write(b2) isNil(err, t) equals(len(b2), n, t) // the old logfile should be moved aside and the main logfile should have // only the last write in it. existsWithContent(filename, b2, t) // the backup file will use the current fake time and have the old contents. existsWithContent(backupFileWithReason(dir, "size"), b, t) fileCount(dir, 2, t) } func TestFirstWriteRotate(t *testing.T) { currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestFirstWriteRotate", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, MaxSize: 10, } defer l.Close() start := []byte("boooooo!") err := os.WriteFile(filename, start, 0o600) isNil(err, t) newFakeTime() // this would make us rotate b := []byte("fooo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(filename, b, t) existsWithContent(backupFileWithReason(dir, "size"), start, t) fileCount(dir, 2, t) } func TestMaxBackups(t *testing.T) { currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestMaxBackups", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, MaxSize: 10, MaxBackups: 1, } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(filename, b, t) fileCount(dir, 1, t) newFakeTime() // this will put us over the max b2 := []byte("foooooo!") n, err = l.Write(b2) isNil(err, t) equals(len(b2), n, t) // this will use the new fake time secondFilename := backupFileWithReason(dir, "size") existsWithContent(secondFilename, b, t) // make sure the old file still exists with the same content. existsWithContent(filename, b2, t) fileCount(dir, 2, t) newFakeTime() // this will make us rotate again b3 := []byte("baaaaaar!") n, err = l.Write(b3) isNil(err, t) equals(len(b3), n, t) // this will use the new fake time thirdFilename := backupFileWithReason(dir, "size") existsWithContent(thirdFilename, b2, t) existsWithContent(filename, b3, t) // we need to wait a little bit since the files get deleted on a different // goroutine. <-time.After(time.Millisecond * 10) // should only have two files in the dir still fileCount(dir, 2, t) // second file name should still exist existsWithContent(thirdFilename, b2, t) // should have deleted the first backup notExist(secondFilename, t) // now test that we don't delete directories or non-logfile files newFakeTime() // create a file that is close to but different from the logfile name. // It shouldn't get caught by our deletion filters. notlogfile := logFile(dir) + ".foo" err = os.WriteFile(notlogfile, []byte("data"), 0o644) isNil(err, t) // Make a directory that exactly matches our log file filters... it still // shouldn't get caught by the deletion filter since it's a directory. notlogfiledir := backupFileWithReason(dir, "size") err = os.Mkdir(notlogfiledir, 0o700) isNil(err, t) newFakeTime() // this will use the new fake time fourthFilename := backupFileWithReason(dir, "size") // Create a log file that is/was being compressed - this should // not be counted since both the compressed and the uncompressed // log files still exist. compLogFile := fourthFilename + compressSuffix err = os.WriteFile(compLogFile, []byte("compress"), 0o644) isNil(err, t) // this will make us rotate again b4 := []byte("baaaaaaz!") n, err = l.Write(b4) isNil(err, t) equals(len(b4), n, t) existsWithContent(fourthFilename, b3, t) existsWithContent(fourthFilename+compressSuffix, []byte("compress"), t) // we need to wait a little bit since the files get deleted on a different // goroutine. <-time.After(time.Millisecond * 10) // We should have four things in the directory now - the 2 log files, the // not log file, and the directory fileCount(dir, 5, t) // third file name should still exist existsWithContent(filename, b4, t) existsWithContent(fourthFilename, b3, t) // should have deleted the first filename notExist(thirdFilename, t) // the not-a-logfile should still exist exists(notlogfile, t) // the directory exists(notlogfiledir, t) } func TestCleanupExistingBackups(t *testing.T) { // test that if we start with more backup files than we're supposed to have // in total, that extra ones get cleaned up when we rotate. currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestCleanupExistingBackups", t) defer os.RemoveAll(dir) // make 3 backup files data := []byte("data") backup := backupFileWithReason(dir, "size") err := os.WriteFile(backup, data, 0o644) isNil(err, t) newFakeTime() backup = backupFileWithReason(dir, "size") err = os.WriteFile(backup+compressSuffix, data, 0o644) isNil(err, t) newFakeTime() backup = backupFileWithReason(dir, "size") err = os.WriteFile(backup, data, 0o644) isNil(err, t) // now create a primary log file with some data filename := logFile(dir) err = os.WriteFile(filename, data, 0o644) isNil(err, t) l := &Logger{ Filename: filename, MaxSize: 10, MaxBackups: 1, } defer l.Close() newFakeTime() b2 := []byte("foooooo!") n, err := l.Write(b2) isNil(err, t) equals(len(b2), n, t) // we need to wait a little bit since the files get deleted on a different // goroutine. <-time.After(time.Millisecond * 10) // now we should only have 2 files left - the primary and one backup fileCount(dir, 2, t) } func TestMaxAge(t *testing.T) { currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestMaxAge", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, MaxSize: 10, MaxAge: 1, } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(filename, b, t) fileCount(dir, 1, t) // two days later newFakeTime(48 * time.Hour) b2 := []byte("foooooo!") n, err = l.Write(b2) isNil(err, t) equals(len(b2), n, t) existsWithContent(backupFileWithReason(dir, "size"), b, t) // we need to wait a little bit since the files get deleted on a different // goroutine. <-time.After(10 * time.Millisecond) // We should still have 2 log files, since the most recent backup was just // created. fileCount(dir, 2, t) existsWithContent(filename, b2, t) // we should have deleted the old file due to being too old existsWithContent(backupFileWithReason(dir, "size"), b, t) // two days later newFakeTime(48 * time.Hour) b3 := []byte("baaaaar!") n, err = l.Write(b3) isNil(err, t) equals(len(b3), n, t) existsWithContent(backupFileWithReason(dir, "size"), b2, t) // we need to wait a little bit since the files get deleted on a different // goroutine. <-time.After(10 * time.Millisecond) // We should have 2 log files - the main log file, and the most recent // backup. The earlier backup is past the cutoff and should be gone. fileCount(dir, 2, t) existsWithContent(filename, b3, t) // we should have deleted the old file due to being too old existsWithContent(backupFileWithReason(dir, "size"), b2, t) } func TestOldLogFiles(t *testing.T) { currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestOldLogFiles", t) defer os.RemoveAll(dir) filename := logFile(dir) data := []byte("data") err := os.WriteFile(filename, data, 0o7) isNil(err, t) // This gives us a time with the same precision as the time we get from the // timestamp in the name. t1, err := time.Parse(backupTimeFormat, fakeTime().UTC().Format(backupTimeFormat)) isNil(err, t) backup := backupFileWithReason(dir, "size") err = os.WriteFile(backup, data, 0o7) isNil(err, t) newFakeTime() t2, err := time.Parse(backupTimeFormat, fakeTime().UTC().Format(backupTimeFormat)) isNil(err, t) backup2 := backupFileWithReason(dir, "size") err = os.WriteFile(backup2, data, 0o7) isNil(err, t) l := &Logger{Filename: filename} files, err := l.oldLogFiles() isNil(err, t) equals(2, len(files), t) // should be sorted by newest file first, which would be t2 equals(t2, files[0].timestamp, t) equals(t1, files[1].timestamp, t) } func TestTimeFromName(t *testing.T) { l := &Logger{Filename: "/var/log/myfoo/foo.log"} prefix, ext := l.prefixAndExt() tests := []struct { filename string want time.Time wantErr bool }{ {"foo-2014-05-04T14-44-33.555-size.log", time.Date(2014, 5, 4, 14, 44, 33, 555000000, time.UTC), false}, {"foo-2014-05-04T14-44-33.555", time.Time{}, true}, {"2014-05-04T14-44-33.555.log", time.Time{}, true}, {"foo.log", time.Time{}, true}, } for _, test := range tests { got, err := l.timeFromName(test.filename, prefix, ext) equals(got, test.want, t) equals(err != nil, test.wantErr, t) } } func TestLocalTime(t *testing.T) { t.Setenv("TZ", "UTC") time.Local = time.UTC currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestLocalTime", t) defer os.RemoveAll(dir) l := &Logger{ Filename: logFile(dir), MaxSize: 10, LocalTime: true, } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) b2 := []byte("fooooooo!") n2, err := l.Write(b2) isNil(err, t) equals(len(b2), n2, t) existsWithContent(logFile(dir), b2, t) existsWithContent(backupFileLocal(dir), b, t) } func TestRotate(t *testing.T) { currentTime = fakeTime dir := makeTempDir("TestRotate", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, MaxBackups: 1, MaxSize: 100, // megabytes } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(filename, b, t) fileCount(dir, 1, t) newFakeTime() err = l.Rotate() isNil(err, t) // we need to wait a little bit since the files get deleted on a different // goroutine. <-time.After(10 * time.Millisecond) filename2 := backupFileWithReason(dir, "size") existsWithContent(filename2, b, t) existsWithContent(filename, []byte{}, t) fileCount(dir, 2, t) newFakeTime() err = l.Rotate() isNil(err, t) // we need to wait a little bit since the files get deleted on a different // goroutine. <-time.After(10 * time.Millisecond) filename3 := backupFileWithReason(dir, "size") existsWithContent(filename3, []byte{}, t) existsWithContent(filename, []byte{}, t) fileCount(dir, 2, t) b2 := []byte("foooooo!") n, err = l.Write(b2) isNil(err, t) equals(len(b2), n, t) // this will use the new fake time existsWithContent(filename, b2, t) } func TestCompressOnRotate(t *testing.T) { currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestCompressOnRotate", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Compress: true, Filename: filename, MaxSize: 10, } defer l.Close() b := []byte("boo!") n, err := l.Write(b) isNil(err, t) equals(len(b), n, t) existsWithContent(filename, b, t) fileCount(dir, 1, t) newFakeTime() err = l.Rotate() isNil(err, t) // the old logfile should be moved aside and the main logfile should have // nothing in it. existsWithContent(filename, []byte{}, t) // we need to wait a little bit since the files get compressed on a different // goroutine. <-time.After(300 * time.Millisecond) // a compressed version of the log file should now exist and the original // should have been removed. bc := new(bytes.Buffer) gz := gzip.NewWriter(bc) _, err = gz.Write(b) isNil(err, t) err = gz.Close() isNil(err, t) existsWithContent(backupFileWithReason(dir, "size")+compressSuffix, bc.Bytes(), t) notExist(backupFileWithReason(dir, "size"), t) fileCount(dir, 2, t) } func TestCompressOnResume(t *testing.T) { currentTime = fakeTime megabyte = 1 dir := makeTempDir("TestCompressOnResume", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Compress: true, Filename: filename, MaxSize: 10, } defer l.Close() // Create a backup file and empty "compressed" file. filename2 := backupFileWithReason(dir, "size") b := []byte("foo!") err := os.WriteFile(filename2, b, 0o644) isNil(err, t) err = os.WriteFile(filename2+compressSuffix, []byte{}, 0o644) isNil(err, t) newFakeTime() b2 := []byte("boo!") n, err := l.Write(b2) isNil(err, t) equals(len(b2), n, t) existsWithContent(filename, b2, t) // we need to wait a little bit since the files get compressed on a different // goroutine. <-time.After(300 * time.Millisecond) // The write should have started the compression - a compressed version of // the log file should now exist and the original should have been removed. bc := new(bytes.Buffer) gz := gzip.NewWriter(bc) _, err = gz.Write(b) isNil(err, t) err = gz.Close() isNil(err, t) existsWithContent(filename2+compressSuffix, bc.Bytes(), t) notExist(filename2, t) fileCount(dir, 2, t) } func TestJson(t *testing.T) { data := []byte(` { "filename": "foo", "maxsize": 5, "maxage": 10, "maxbackups": 3, "localtime": true, "compress": true }`[1:]) l := Logger{} err := json.Unmarshal(data, &l) isNil(err, t) equals("foo", l.Filename, t) equals(5, l.MaxSize, t) equals(10, l.MaxAge, t) equals(3, l.MaxBackups, t) equals(true, l.LocalTime, t) equals(true, l.Compress, t) } // makeTempDir creates a file with a semi-unique name in the OS temp directory. // It should be based on the name of the test, to keep parallel tests from // colliding, and must be cleaned up after the test is finished. func makeTempDir(name string, t testing.TB) string { dir := time.Now().Format(name + backupTimeFormat) dir = filepath.Join(os.TempDir(), dir) isNilUp(os.Mkdir(dir, 0o700), t, 1) return dir } // existsWithContent checks that the given file exists and has the correct content. func existsWithContent(path string, content []byte, t testing.TB) { info, err := os.Stat(path) isNilUp(err, t, 1) equalsUp(int64(len(content)), info.Size(), t, 1) b, err := os.ReadFile(path) isNilUp(err, t, 1) equalsUp(content, b, t, 1) } func hasPerm(path string, perm os.FileMode, t testing.TB) { info, err := os.Stat(path) isNilUp(err, t, 1) assertUp(info.Mode().Perm() == perm, t, 1, "expected file permissions %#o, but got %#o", perm, info.Mode().Perm()) } // logFile returns the log file name in the given directory for the current fake // time. func logFile(dir string) string { return filepath.Join(dir, "foobar.log") } func backupFileLocal(dir string) string { return filepath.Join(dir, "foobar-"+fakeTime().Format(backupTimeFormat)+"-size.log") } // fileCount checks that the number of files in the directory is exp. func fileCount(dir string, exp int, t testing.TB) { files, err := os.ReadDir(dir) isNilUp(err, t, 1) // Make sure no other files were created. equalsUp(exp, len(files), t, 1) } func notExist(path string, t testing.TB) { _, err := os.Stat(path) assertUp(os.IsNotExist(err), t, 1, "expected to get os.IsNotExist, but instead got %v", err) } func exists(path string, t testing.TB) { _, err := os.Stat(path) assertUp(err == nil, t, 1, "expected file to exist, but got error from os.Stat: %v", err) } func TestTimeBasedRotation(t *testing.T) { currentTime = fakeTime dir := makeTempDir("TestTimeBasedRotation", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, MaxSize: 10000, // disable size rotation RotationInterval: time.Second * 1, // short interval } defer l.Close() b1 := []byte("first write\n") n, err := l.Write(b1) isNil(err, t) equals(len(b1), n, t) newFakeTime(1 * time.Second) l.lastRotationTime = fakeTime().Add(-2 * time.Second) b2 := []byte("second write\n") n, err = l.Write(b2) isNil(err, t) equals(len(b2), n, t) time.Sleep(10 * time.Millisecond) existsWithContent(filename, b2, t) files, err := os.ReadDir(dir) isNil(err, t) var found bool for _, f := range files { if strings.HasPrefix(f.Name(), "foobar-") && strings.HasSuffix(f.Name(), ".log") && f.Name() != "foobar.log" { rotated := filepath.Join(dir, f.Name()) existsWithContent(rotated, b1, t) found = true break } } if !found { t.Fatalf("expected rotated backup file with original contents, but none found") } } // TestSizeBasedRotation specifically tests rotation when MaxSize is exceeded. func TestSizeBasedRotation(t *testing.T) { currentTime = fakeTime // Ensure our mock time is used megabyte = 1 // For testing with small byte sizes dir := makeTempDir("TestSizeBasedRotation", t) defer os.RemoveAll(dir) filename := logFile(dir) // e.g., /tmp/.../foobar.log l := &Logger{ Filename: filename, MaxSize: 10, // Max size of 10 bytes MaxBackups: 1, LocalTime: false, // To match backupFileWithReason which uses UTC } defer l.Close() // First write: 5 bytes, does not exceed MaxSize (10 bytes) content1 := []byte("Hello") // 5 bytes n, err := l.Write(content1) isNil(err, t) equals(len(content1), n, t) existsWithContent(filename, content1, t) fileCount(dir, 1, t) // Advance time for the backup timestamp. // Note: originalFakeTime variable was here and was unused. It has been removed. newFakeTime() // Advances the global fakeCurrentTime // Second write: 6 bytes. Current size (5) + new write (6) = 11 bytes, which exceeds MaxSize (10 bytes) content2 := []byte("World!") // 6 bytes n, err = l.Write(content2) isNil(err, t) equals(len(content2), n, t) // After rotation: // Current log file should contain only content2 existsWithContent(filename, content2, t) // Backup file should exist with content1. // backupFileWithReason uses the *current* fakeTime (which was advanced by newFakeTime) // to generate the timestamped name. The rotation timestamp (l.logStartTime for the // backed-up segment, used in backupName) is set to currentTime() when openNew is called. backupFilename := backupFileWithReason(dir, "size") existsWithContent(backupFilename, content1, t) fileCount(dir, 2, t) } // TestRotateAtMinutes func TestRotateAtMinutes(t *testing.T) { currentTime = fakeTime // use our mock clock // three distinct payloads content1 := []byte("first content\n") content2 := []byte("second content\n") content3 := []byte("third content\n") // configure 0, 15, and 30 minute marks marks := []int{0, 15, 30} // 1) Start just before the 14:00 mark (e.g. 14:00:59 UTC) initial := time.Date(2025, time.May, 12, 14, 0, 59, 0, time.UTC) setFakeTime(initial) dir := makeTempDir("TestRotateAtMinutes", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, RotateAtMinutes: marks, MaxSize: 1000, // disable size-based rotation LocalTime: false, // use UTC for backup timestamps } defer l.Close() // stop scheduling goroutine // 2) Write at 14:01 → no rotation yet setFakeTime(time.Date(2025, time.May, 12, 14, 1, 0, 0, time.UTC)) n, err := l.Write(content1) isNil(err, t) equals(len(content1), n, t) existsWithContent(filename, content1, t) fileCount(dir, 1, t) // only the live logfile // 3) Advance to 14:15 exactly, let the goroutine fire setFakeTime(time.Date(2025, time.May, 12, 14, 15, 0, 0, time.UTC)) time.Sleep(300 * time.Millisecond) // 4) Write at 14:16 → should be on a fresh file, and first-backup is content1 setFakeTime(time.Date(2025, time.May, 12, 14, 16, 0, 0, time.UTC)) n, err = l.Write(content2) isNil(err, t) equals(len(content2), n, t) existsWithContent(filename, content2, t) expected1 := backupFileWithReason(dir, "time") existsWithContent(expected1, content1, t) fileCount(dir, 2, t) // 5) Advance past the 14:30 mark without writing → no new rotation setFakeTime(time.Date(2025, time.May, 12, 14, 30, 0, 0, time.UTC)) time.Sleep(300 * time.Millisecond) fileCount(dir, 2, t) // still just the live log + one backup // 6) Write at 14:31 → triggers the 30-minute mark rotation, and rolls content2 setFakeTime(time.Date(2025, time.May, 12, 14, 31, 0, 0, time.UTC)) n, err = l.Write(content3) isNil(err, t) equals(len(content3), n, t) existsWithContent(filename, content3, t) expected2 := backupFileWithReason(dir, "time") existsWithContent(expected2, content2, t) fileCount(dir, 3, t) } // TestRotateAt func TestRotateAt(t *testing.T) { currentTime = fakeTime // use our mock clock // three distinct payloads content1 := []byte("first content\n") content2 := []byte("second content\n") content3 := []byte("third content\n") // configure 0, 15, and 30 minute marks marks := []string{"10:00"} // 1) Start just before the 10:00 mark (e.g. 10:00:59 UTC) initial := time.Date(2025, time.May, 12, 10, 0, 59, 0, time.UTC) setFakeTime(initial) dir := makeTempDir("TestRotateAt", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, RotateAt: marks, MaxSize: 1000, // disable size-based rotation LocalTime: false, // use UTC for backup timestamps } defer l.Close() // stop scheduling goroutine // 2) Write at 10:01 → no rotation yet setFakeTime(time.Date(2025, time.May, 12, 10, 1, 0, 0, time.UTC)) n, err := l.Write(content1) isNil(err, t) equals(len(content1), n, t) existsWithContent(filename, content1, t) fileCount(dir, 1, t) // only the live logfile // 3) Advance to next day 10:00 exactly, let the goroutine fire setFakeTime(time.Date(2025, time.May, 13, 10, 0, 0, 0, time.UTC)) time.Sleep(300 * time.Millisecond) // 4) Write at 10:01 → should be on a fresh file, and first-backup is content1 setFakeTime(time.Date(2025, time.May, 13, 10, 1, 0, 0, time.UTC)) n, err = l.Write(content2) isNil(err, t) equals(len(content2), n, t) existsWithContent(filename, content2, t) expected1 := backupFileWithReason(dir, "time") existsWithContent(expected1, content1, t) fileCount(dir, 2, t) // 5) Advance past the next day 10:00 mark without writing → no new rotation setFakeTime(time.Date(2025, time.May, 14, 10, 1, 0, 0, time.UTC)) time.Sleep(300 * time.Millisecond) fileCount(dir, 2, t) // still just the live log + one backup // 6) Write at 10:00 next day → triggers the mark rotation, and rolls content2 setFakeTime(time.Date(2025, time.May, 14, 10, 1, 0, 0, time.UTC)) n, err = l.Write(content3) isNil(err, t) equals(len(content3), n, t) existsWithContent(filename, content3, t) expected2 := backupFileWithReason(dir, "time") existsWithContent(expected2, content2, t) fileCount(dir, 3, t) } func TestSortByFormatTimeEdgeCases(t *testing.T) { t1 := time.Time{} // zero timestamp t2 := time.Now() // valid timestamp fi := dummyFileInfo{name: "dummy.log"} // minimal os.FileInfo impl tests := []struct { name string input []logInfo }{ { "zero and valid timestamps", []logInfo{{t1, fi}, {t2, fi}}, }, { "valid and zero timestamps", []logInfo{{t2, fi}, {t1, fi}}, }, { "both zero timestamps", []logInfo{{t1, fi}, {t1, fi}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sort.Sort(byFormatTime(tt.input)) // just ensure sorting does not panic and results are valid slice if len(tt.input) != 2 { t.Fatalf("unexpected sort result length: %d", len(tt.input)) } }) } } // dummyFileInfo is a stub for os.FileInfo type dummyFileInfo struct { name string } func (d dummyFileInfo) Name() string { return d.name } func (d dummyFileInfo) Size() int64 { return 0 } func (d dummyFileInfo) Mode() os.FileMode { return 0o644 } func (d dummyFileInfo) ModTime() time.Time { return time.Now() } func (d dummyFileInfo) IsDir() bool { return false } func (d dummyFileInfo) Sys() interface{} { return nil } func TestCompressLogFile_SourceOpenError(t *testing.T) { l := &Logger{} err := l.compressLogFile("nonexistent.log", "should-not-be-created.gz") if err == nil || !strings.Contains(err.Error(), "failed to open source log file") { t.Fatalf("expected error opening nonexistent file, got: %v", err) } } func TestOpenExistingOrNew_Fallback(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "readonly.log") logger := &Logger{ Filename: path, MaxSize: 1, } // Create a file with 0 perms so append will fail _ = os.WriteFile(logger.Filename, []byte("data"), 0o000) err := logger.openExistingOrNew(1) if err != nil { t.Fatalf("expected fallback to openNew, got error: %v", err) } // Clean up the recreated file if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) { t.Errorf("cleanup failed: %v", rmErr) } } func TestMillRunOnce_OldFilesRemoved(t *testing.T) { dir := t.TempDir() oldLog := filepath.Join(dir, "test-2000-01-01T00-00-00.000-size.log") _ = os.WriteFile(oldLog, []byte("data"), 0o644) logger := &Logger{ Filename: filepath.Join(dir, "test.log"), MaxAge: 1, Compress: false, MaxBackups: 0, } currentTime = func() time.Time { return time.Now().AddDate(0, 0, 10) } err := logger.millRunOnce() if err != nil { t.Fatalf("millRunOnce failed: %v", err) } if _, err := os.Stat(oldLog); !os.IsNotExist(err) { t.Errorf("expected old file to be deleted") } } func TestTimeFromName_InvalidFormat(t *testing.T) { logger := &Logger{Filename: "foo.log"} prefix, ext := logger.prefixAndExt() // Case 1: mismatched prefix _, err := logger.timeFromName("badname.log", prefix, ext) if err == nil || !strings.Contains(err.Error(), "mismatched prefix") { t.Fatalf("expected mismatched prefix error, got: %v", err) } // Case 2: mismatched extension _, err = logger.timeFromName("foo-2020-01-01T00-00-00.000-size.txt", prefix, ext) if err == nil || !strings.Contains(err.Error(), "mismatched extension") { t.Fatalf("expected mismatched extension error, got: %v", err) } // Case 3: malformed timestamp structure _, err = logger.timeFromName("foo-2020-01-01T00-00-size.log", prefix, ext) if err == nil || !strings.Contains(err.Error(), "cannot parse") { t.Fatalf("expected time parse error, got: %v", err) } } func TestBackupName(t *testing.T) { name := "/tmp/test.log" rotationTime := time.Date(2020, 1, 2, 3, 4, 5, 6_000_000, time.UTC) // default (before-ext) resultUTC := backupName(name, false, "size", rotationTime, backupTimeFormat, false) expectedUTC := "/tmp/test-2020-01-02T03-04-05.006-size.log" if resultUTC != expectedUTC { t.Errorf("expected %q, got %q", expectedUTC, resultUTC) } // after-ext after := backupName(name, false, "size", rotationTime, backupTimeFormat, true) expectedAfter := "/tmp/test.log-2020-01-02T03-04-05.006-size" if after != expectedAfter { t.Errorf("expected %q, got %q", expectedAfter, after) } } func TestShouldTimeRotate_WhenZero(t *testing.T) { l := &Logger{ RotationInterval: time.Second, } currentTime = func() time.Time { return time.Now() } if l.shouldTimeRotate() { t.Error("expected false when lastRotationTime is zero") } } func TestShouldTimeRotate_WhenElapsed(t *testing.T) { currentTime = func() time.Time { return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) } l := &Logger{ RotationInterval: time.Minute, lastRotationTime: time.Date(2025, 1, 1, 11, 58, 0, 0, time.UTC), } if !l.shouldTimeRotate() { t.Error("expected rotation due to elapsed time") } } func TestRunScheduledRotations_NoMarks(t *testing.T) { l := &Logger{} l.resolveConfigLocked() quit := make(chan struct{}) l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, nil, time.UTC, currentTime) done := make(chan struct{}) go func() { l.scheduledRotationWg.Wait() close(done) }() select { case <-done: // success: returns immediately when slots is nil/empty case <-time.After(100 * time.Millisecond): t.Error("expected goroutine to return immediately due to no marks") } } func TestRotate_OpenNewFails(t *testing.T) { badPath := "/bad/path/logfile.log" l := &Logger{ Filename: badPath, } // force an invalid path to trigger openNew failure err := l.rotate("manual") if err == nil { t.Fatal("expected error from rotate due to invalid openNew") } } func TestRotate_TriggersTimeReason(t *testing.T) { currentTime = func() time.Time { return time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC) } l := &Logger{ Filename: filepath.Join(t.TempDir(), "time-reason.log"), RotationInterval: time.Minute, lastRotationTime: time.Date(2024, 5, 1, 11, 58, 0, 0, time.UTC), } defer l.Close() err := l.Rotate() if err != nil { t.Errorf("expected successful rotate, got %v", err) } } func TestRunScheduledRotations_NoFutureTime(t *testing.T) { defer func() { recover() }() orig := currentTime defer func() { currentTime = orig }() currentTime = func() time.Time { return time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC) } tmp := t.TempDir() logFile := filepath.Join(tmp, "invalid.log") l := &Logger{Filename: logFile} l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(150 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestEnsureScheduledRotationLoopRunning_InvalidMinutes(t *testing.T) { l := &Logger{ RotateAtMinutes: []int{61, -1, 999}, // invalid minutes } l.ensureScheduledRotationLoopRunning() if len(l.processedRotateAt) != 0 { t.Errorf("expected no valid minutes, got: %v", l.processedRotateAt) } } func TestEnsureScheduledRotationLoopRunning_InvalidTimes(t *testing.T) { l := &Logger{ RotateAt: []string{"-1:00", "24:00", "00:60", "00:-1", "00", "foo:bar", "foo"}, // invalid times } l.ensureScheduledRotationLoopRunning() if len(l.processedRotateAt) != 0 { t.Errorf("expected no valid times, got: %v", l.processedRotateAt) } } func TestEnsureScheduledRotationLoopRunning_DedupMinutesAndTime(t *testing.T) { l := &Logger{ RotateAt: []string{"00:00", "12:30", "14:45"}, RotateAtMinutes: []int{0, 30}, } l.ensureScheduledRotationLoopRunning() // 2*24 for minutes + 1 for time, 00:00/12:30 deduped equals(49, len(l.processedRotateAt), t) } func TestCompressLogFile_ChownFails(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "to-compress.log") dst := src + ".gz" _ = os.WriteFile(src, []byte("dummy"), 0o644) originalChown := chown chown = func(_ string, _ os.FileInfo) error { return fmt.Errorf("mock chown failure") } defer func() { chown = originalChown }() l := &Logger{} l.resolveConfigLocked() err := l.compressLogFile(src, dst) if err != nil { t.Fatalf("compression should still succeed, got: %v", err) } if _, err := os.Stat(dst); err != nil { t.Errorf("expected compressed file to exist, got: %v", err) } } func TestOpenNew_RenameFails(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "test.log") _ = os.WriteFile(file, []byte("original"), 0o644) // Fix timestamp so backupName is predictable currentTime = func() time.Time { return time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) } originalRename := osRename osRename = func(_, _ string) error { return fmt.Errorf("mock rename failure") } defer func() { osRename = originalRename }() l := &Logger{Filename: file} err := l.openNew("size") if err == nil || !strings.Contains(err.Error(), "can't rename") { t.Fatalf("expected rename error, got: %v", err) } } func TestCompressLogFile_StatFails(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "bad.log") dst := src + ".gz" _ = os.WriteFile(src, []byte("dummy"), 0o644) _ = os.Remove(src) l := &Logger{} err := l.compressLogFile(src, dst) if err == nil || !strings.Contains(err.Error(), "failed to open source log file") { t.Errorf("expected open error, got: %v", err) } } func TestRotate_CloseFileFails(t *testing.T) { tmp := filepath.Join(t.TempDir(), "dummy.log") // Create and close a real file f, err := os.Create(tmp) if err != nil { t.Fatal(err) } _ = f.Close() // Close early to simulate Close() failure l := &Logger{ file: f, } err = l.Rotate() if err == nil { t.Fatal("expected error from closed file, got nil") } } func TestOpenNew_StatUnexpectedError(t *testing.T) { logger := &Logger{Filename: filepath.Join(t.TempDir(), "logfile.log")} originalOsStat := osStat osStat = func(name string) (os.FileInfo, error) { return nil, fmt.Errorf("mock stat failure") } defer func() { osStat = originalOsStat }() err := logger.openNew("size") if err == nil || !strings.Contains(err.Error(), "failed to stat") { t.Errorf("expected stat failure, got: %v", err) } } func TestCompressLogFile_CopyFails(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "bad.log") dst := src + ".gz" if err := os.WriteFile(src, []byte("data"), 0o200); err != nil { // write-only t.Fatalf("failed to create test file: %v", err) } defer os.Chmod(src, 0o644) originalStat := osStat osStat = func(name string) (os.FileInfo, error) { return os.Stat(src) } defer func() { osStat = originalStat }() l := &Logger{} // snapshot patched osStat l.resolveConfigLocked() err := l.compressLogFile(src, dst) if err == nil { t.Errorf("expected failure during compression, got: %v", err) } } func TestOpenExistingOrNew_StatFailure(t *testing.T) { originalStat := osStat defer func() { osStat = originalStat }() osStat = func(_ string) (os.FileInfo, error) { return nil, fmt.Errorf("mock stat failure") } logger := &Logger{Filename: "somefile.log"} logger.millCh = make(chan bool, 1) // prevent nil panic err := logger.openExistingOrNew(10) if err == nil || !strings.Contains(err.Error(), "error getting log file info") { t.Fatalf("expected stat failure, got: %v", err) } } func TestOpenNew_OpenFileFails(t *testing.T) { tmpDir := t.TempDir() // Create a file where a directory is expected fileAsDir := filepath.Join(tmpDir, "not_a_dir") err := os.WriteFile(fileAsDir, []byte("I am a file, not a dir"), 0o644) if err != nil { t.Fatalf("setup failed: %v", err) } // Attempt to use that file as a directory badPath := filepath.Join(fileAsDir, "should_fail.log") logger := &Logger{Filename: badPath} err = logger.openNew("size") if err == nil || !strings.Contains(err.Error(), "can't make directories") { t.Fatalf("expected mkdir failure, got: %v", err) } } func TestRunScheduledRotations_NoFutureSlot(t *testing.T) { orig := currentTime defer func() { currentTime = orig }() currentTime = func() time.Time { return time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC) } l := &Logger{Filename: "invalid.log"} l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(200 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestTimeFromName_MalformedFilename(t *testing.T) { logger := &Logger{Filename: "foo.log"} prefix, ext := logger.prefixAndExt() // Missing final hyphen separator, so no reason part invalid := "foo-20200101T000000000.log" _, err := logger.timeFromName(invalid, prefix, ext) if err == nil || !strings.Contains(err.Error(), "malformed backup filename") { t.Fatalf("expected malformed filename error, got: %v", err) } } func TestWrite_OpenExistingFails(t *testing.T) { // Simulate a failure in osStat that's not os.IsNotExist originalStat := osStat defer func() { osStat = originalStat }() osStat = func(name string) (os.FileInfo, error) { return nil, fmt.Errorf("mocked stat failure") } logger := &Logger{ Filename: filepath.Join(t.TempDir(), "badfile.log"), MaxSize: 10, } // prevent nil panic logger.millCh = make(chan bool, 1) _, err := logger.Write([]byte("trigger")) if err == nil || !strings.Contains(err.Error(), "error getting log file info") { t.Fatalf("expected error from Write when stat fails, got: %v", err) } } func TestWrite_IntervalRotateFails(t *testing.T) { currentTime = func() time.Time { return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) } // Patch osRename to force openNew failure (simulate rename failure inside rotate) originalRename := osRename defer func() { osRename = originalRename }() osRename = func(_, _ string) error { return fmt.Errorf("mock rename failure") } tmp := t.TempDir() logfile := filepath.Join(tmp, "fail.log") // Write some initial file content err := os.WriteFile(logfile, []byte("existing"), 0o644) if err != nil { t.Fatalf("setup failed: %v", err) } l := &Logger{ Filename: logfile, RotationInterval: time.Second, lastRotationTime: time.Date(2025, 1, 1, 11, 59, 0, 0, time.UTC), } defer l.Close() // trigger rotation path _, err = l.Write([]byte("trigger")) if err == nil || !strings.Contains(err.Error(), "interval rotation failed") { t.Fatalf("expected interval rotation error, got: %v", err) } } func TestWrite_SizeRotateFails(t *testing.T) { currentTime = func() time.Time { return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) } megabyte = 1 // tiny units for easy triggering // Patch osRename to force openNew to fail during rotate("size") originalRename := osRename defer func() { osRename = originalRename }() osRename = func(_, _ string) error { return fmt.Errorf("mock rename failure for size") } tmp := t.TempDir() logfile := filepath.Join(tmp, "sizefail.log") // Create initial file with some content (5 bytes) err := os.WriteFile(logfile, []byte("12345"), 0o644) if err != nil { t.Fatalf("setup failed: %v", err) } l := &Logger{ Filename: logfile, MaxSize: 10, // force rotation at 10 bytes } defer l.Close() // This write pushes us over the max size and triggers rotation big := []byte("123456789") // 9 bytes + 5 existing = 14 > 10 _, err = l.Write(big) if err == nil || !strings.Contains(err.Error(), "can't rename log file") { t.Fatalf("expected rename failure in size-based rotation, got: %v", err) } } func TestCompressLogFile_StatFails_1(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "test.log") dst := src + ".gz" if err := os.WriteFile(src, []byte("log content"), 0o644); err != nil { t.Fatalf("failed to create source log: %v", err) } originalStat := osStat osStat = func(_ string) (os.FileInfo, error) { return nil, fmt.Errorf("mock stat failure") } defer func() { osStat = originalStat }() l := &Logger{} // snapshot patched osStat l.resolveConfigLocked() err := l.compressLogFile(src, dst) if err == nil || !strings.Contains(err.Error(), "failed to stat source log file") { t.Fatalf("expected stat failure during compressLogFile, got: %v", err) } } func TestCompressLogFile_OpenDestFails(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "log.log") err := os.WriteFile(src, []byte("hello"), 0o644) if err != nil { t.Fatalf("failed to write source: %v", err) } fileAsDir := filepath.Join(tmp, "not_a_dir") err = os.WriteFile(fileAsDir, []byte("conflict"), 0o644) if err != nil { t.Fatalf("failed to create conflict path: %v", err) } dst := filepath.Join(fileAsDir, "dest.log.gz") l := &Logger{} l.resolveConfigLocked() err = l.compressLogFile(src, dst) if err == nil || !strings.Contains(err.Error(), "failed to open destination compressed log file") { t.Fatalf("expected failure opening dest, got: %v", err) } } type failingReader struct{} func (failingReader) Read([]byte) (int, error) { return 0, fmt.Errorf("forced read failure") } func TestCompressLogFile_CopyFails_1(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "dummy.log") dst := filepath.Join(tmp, "output.gz") // Write dummy source err := os.WriteFile(src, []byte("irrelevant"), 0o644) if err != nil { t.Fatalf("failed to create dummy file: %v", err) } defer os.Remove(src) // cleanup srcInfo, err := os.Stat(src) if err != nil { t.Fatalf("stat failed: %v", err) } dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, srcInfo.Mode()) if err != nil { t.Fatalf("failed to create dst file: %v", err) } defer func() { dstFile.Close() os.Remove(dst) }() gz := gzip.NewWriter(dstFile) // Trigger copy failure _, err = io.Copy(gz, failingReader{}) if err == nil || !strings.Contains(err.Error(), "forced read failure") { t.Fatalf("expected read failure, got: %v", err) } _ = gz.Close() // ensure no panic } func TestCompressLogFile_GzipCloseFails_Simulated(t *testing.T) { // We'll simulate a scenario where the Close() on the gzip.Writer fails // by closing the destination before it's flushed. tmp := t.TempDir() src := filepath.Join(tmp, "src.log") dst := filepath.Join(tmp, "dst.gz") // Write some dummy content err := os.WriteFile(src, []byte("data to compress"), 0o644) if err != nil { t.Fatalf("failed to write src: %v", err) } defer os.Remove(src) // Create a broken pipe that will cause Close() to fail pr, pw := io.Pipe() pw.CloseWithError(fmt.Errorf("simulated pipe failure")) // gzip.NewWriter expects a WriteCloser — so wrap `pw` gz := gzip.NewWriter(pw) // Start compression using io.Copy — should fail go func() { f, _ := os.Open(src) defer f.Close() _, _ = io.Copy(gz, f) // This flushes and triggers the Close failure _ = gz.Close() }() // Read to trigger pipe read error buf := make([]byte, 1024) _, err = pr.Read(buf) if err == nil || !strings.Contains(err.Error(), "simulated pipe failure") { t.Fatalf("expected gzip flush failure due to broken pipe, got: %v", err) } _ = os.Remove(dst) } func TestCompressLogFile_RemoveFails(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "locked.log") dst := src + ".gz" if err := os.WriteFile(src, []byte("test log"), 0o644); err != nil { t.Fatalf("failed to create source file: %v", err) } f, err := os.Open(src) if err != nil { t.Fatalf("failed to open file exclusively: %v", err) } defer f.Close() originalRemove := osRemove osRemove = func(path string) error { if path == src { return fmt.Errorf("mock remove failure") } return originalRemove(path) } defer func() { osRemove = originalRemove }() l := &Logger{} // snapshot patched osRemove l.resolveConfigLocked() err = l.compressLogFile(src, dst) if err == nil || !strings.Contains(err.Error(), "failed to remove original source log file") { t.Fatalf("expected failure from os.Remove, got: %v", err) } _ = os.Remove(dst) // cleanup } func TestEnsureScheduledRotationLoopRunning_Empty(t *testing.T) { l := &Logger{ RotateAtMinutes: nil, // empty case } l.ensureScheduledRotationLoopRunning() if len(l.processedRotateAt) != 0 { t.Errorf("expected no processed rotation minutes, got: %v", l.processedRotateAt) } } func TestEnsureScheduledRotationLoopRunning_InvalidMinutes_1(t *testing.T) { l := &Logger{ RotateAtMinutes: []int{-5, 60, 999, -1}, // all invalid } l.ensureScheduledRotationLoopRunning() if len(l.processedRotateAt) != 0 { t.Errorf("expected 0 valid minutes, got: %v", l.processedRotateAt) } if l.scheduledRotationQuitCh != nil { t.Errorf("expected scheduled rotation goroutine not to start") } } func TestRunScheduledRotations_NoFutureSlotFallback(t *testing.T) { defer func() { recover() }() orig := currentTime defer func() { currentTime = orig }() currentTime = func() time.Time { return time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC) } l := &Logger{Filename: "test-fallback.log"} l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(200 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestLoggerClose_AlreadyClosedChannel(t *testing.T) { logger := &Logger{ Filename: "test-double-close.log", scheduledRotationQuitCh: make(chan struct{}), } // Close the quit channel manually close(logger.scheduledRotationQuitCh) // Call Close — this should NOT panic or attempt to close again err := logger.Close() if err != nil { t.Fatalf("expected no error from double-close-safe Close(), got: %v", err) } } func TestMillRunOnce_NoOp(t *testing.T) { logger := &Logger{ MaxBackups: 0, MaxAge: 0, Compress: false, Filename: filepath.Join(t.TempDir(), "noop.log"), } // Should do nothing and return nil err := logger.millRunOnce() if err != nil { t.Fatalf("expected nil from noop millRunOnce, got: %v", err) } } func TestShouldTimeRotate_ZeroLastRotationTime(t *testing.T) { logger := &Logger{ RotationInterval: time.Minute, lastRotationTime: time.Time{}, // zero time } if logger.shouldTimeRotate() { t.Fatalf("expected false from shouldTimeRotate when lastRotationTime is zero") } } func TestMillRunOnce_CompressEligible(t *testing.T) { tmp := t.TempDir() logger := &Logger{ Filename: filepath.Join(tmp, "test.log"), Compress: true, MaxBackups: 1, } // Create a non-compressed log file with a valid timestamp in name backupName := "test-2025-01-01T00-00-00.000-size.log" path := filepath.Join(tmp, backupName) if err := os.WriteFile(path, []byte("log"), 0o644); err != nil { t.Fatalf("failed to create backup log: %v", err) } defer os.Remove(path) // Should find this file eligible for compression err := logger.millRunOnce() if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify compressed file was created compressed := path + ".gz" if _, err := os.Stat(compressed); err != nil { t.Errorf("expected compressed file, not found: %v", err) } _ = os.Remove(compressed) } func TestMillRunOnce_ExpiredFileSkipped(t *testing.T) { tmp := t.TempDir() base := filepath.Join(tmp, "logfile.log") logger := &Logger{ Filename: base, MaxAge: 1, // 1 day Compress: true, // trigger compression logic } // Manually choose a timestamp > 1 day old oldName := "logfile-2020-01-01T00-00-00.000-size.log" oldPath := filepath.Join(tmp, oldName) if err := os.WriteFile(oldPath, []byte("expired"), 0o644); err != nil { t.Fatalf("failed to create old log file: %v", err) } defer os.Remove(oldPath) err := logger.millRunOnce() if err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(oldPath + compressSuffix); err == nil { t.Errorf("expected no compression for expired file, but .gz file exists") } } func TestMillRun_TriggersMillRunOnce_Effect(t *testing.T) { tmp := t.TempDir() logFile := filepath.Join(tmp, "log.log") // Create a backup log file that should be compressed backup := filepath.Join(tmp, "log-2020-01-01T00-00-00.000-size.log") if err := os.WriteFile(backup, []byte("backup data"), 0o644); err != nil { t.Fatalf("failed to create backup: %v", err) } l := &Logger{ Filename: logFile, Compress: true, millCh: make(chan bool), } // Start millRun in background go l.millRun() // Trigger it l.millCh <- true time.Sleep(100 * time.Millisecond) close(l.millCh) // Wait briefly for compression to complete time.Sleep(200 * time.Millisecond) // Check if file was compressed _, err := os.Stat(backup + ".gz") if err != nil { t.Fatalf("expected compressed file not found: %v", err) } // Cleanup os.Remove(backup + ".gz") } func TestRotate_StartMillOnlyOnce_Observable(t *testing.T) { tmp := t.TempDir() base := filepath.Join(tmp, "logfile.log") logger := &Logger{ Filename: base, MaxSize: 1, Compress: true, millCh: make(chan bool, 10), // Buffered so we can trigger multiple } // Create two valid backup files to be compressed for i := 0; i < 2; i++ { name := fmt.Sprintf("logfile-2020-01-01T00-00-0%d.000-size.log", i) path := filepath.Join(tmp, name) if err := os.WriteFile(path, []byte("to compress"), 0o644); err != nil { t.Fatalf("failed to write %s: %v", path, err) } defer os.Remove(path + ".gz") } // Rotate once — triggers millRun and startMill.Do if err := logger.rotate("size"); err != nil { t.Fatalf("rotate failed: %v", err) } // Send cleanup signals — these trigger millRunOnce via millRun logger.millCh <- true logger.millCh <- true close(logger.millCh) // Wait briefly for compression to complete time.Sleep(300 * time.Millisecond) // Count only compressed versions of the test backup files count := 0 entries, _ := os.ReadDir(tmp) for _, e := range entries { if strings.HasPrefix(e.Name(), "logfile-2020") && strings.HasSuffix(e.Name(), ".gz") { count++ } } if count != 2 { t.Fatalf("expected 2 compressed files, got: %d", count) } } func TestScheduledMinuteRotationFails(t *testing.T) { tmp := t.TempDir() file := filepath.Join(tmp, "fail.log") l := &Logger{Filename: file} // force rotate to fail (invalid file handle) l.file = &os.File{} l.lastRotationTime = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(100 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestRunScheduledRotations_CannotFindNextSlot(t *testing.T) { orig := currentTime defer func() { currentTime = orig }() currentTime = func() time.Time { return time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC) } l := &Logger{Filename: "test.log"} l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(150 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestCompressLogFile_CloseDestFails(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "log.log") dst := src + ".gz" _ = os.WriteFile(src, []byte("dummy"), 0o644) // Patch osStat to return valid info originalStat := osStat osStat = func(name string) (os.FileInfo, error) { return os.Stat(src) } defer func() { osStat = originalStat }() l := &Logger{} // snapshot patched osStat l.resolveConfigLocked() err := l.compressLogFile(src, dst) // Depending on FS, close may not actually fail; just accept either nil or matching error text if err != nil && !strings.Contains(err.Error(), "failed to close destination") { t.Fatalf("expected close error (or nil), got: %v", err) } } func TestRunScheduledRotations_NoFutureSlotFound(t *testing.T) { orig := currentTime defer func() { currentTime = orig }() currentTime = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) } l := &Logger{Filename: "mock.log"} l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(200 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestScheduledRotation_TimerFiresAndRotates(t *testing.T) { orig := currentTime defer func() { currentTime = orig }() now := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) currentTime = func() time.Time { return now } tmpDir := t.TempDir() file := filepath.Join(tmpDir, "timerfire.log") l := &Logger{Filename: file} l.lastRotationTime = now.Add(-time.Hour) l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{10, 1}} // next minute after 'now' l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(1500 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestMillRunOnce_RemoveFails(t *testing.T) { tmp := t.TempDir() logFile := filepath.Join(tmp, "log-2025-01-01T00-00-00.000-size.log") _ = os.WriteFile(logFile, []byte("data"), 0o644) origRemove := osRemove osRemove = func(path string) error { return fmt.Errorf("mock remove failure") } defer func() { osRemove = origRemove }() logger := &Logger{ Filename: filepath.Join(tmp, "dummy.log"), MaxBackups: 1, Compress: false, } err := logger.millRunOnce() if err != nil { t.Errorf("unexpected error: %v", err) } } func TestCompressLogFile_CopyFails_2(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "broken.log") _ = os.WriteFile(src, []byte("data"), 0o644) // Simulate failure by removing source before compression os.Remove(src) dst := src + ".gz" l := &Logger{} err := l.compressLogFile(src, dst) if err == nil { t.Fatal("expected error due to missing source, got nil") } if !strings.Contains(err.Error(), "failed to open source") && !strings.Contains(err.Error(), "failed to open source log file") { t.Fatalf("unexpected error: %v", err) } } func TestRunScheduledRotations_FallbackRetry(t *testing.T) { orig := currentTime defer func() { currentTime = orig }() currentTime = func() time.Time { return time.Date(9999, 1, 1, 23, 59, 59, 0, time.UTC) } l := &Logger{Filename: "test.log"} l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(200 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestRunScheduledRotations_TimerFires(t *testing.T) { tmp := t.TempDir() logFile := filepath.Join(tmp, "test.log") l := &Logger{Filename: logFile} l.lastRotationTime = time.Now().Add(-time.Hour) l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 1}} // “minute 1” l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(1500 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestCompressLogFile_CopyFails_4(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "unreadable.log") _ = os.WriteFile(src, []byte("something"), 0o600) _ = os.Remove(src) // remove so Open will fail dst := filepath.Join(tmp, "unreadable.log.gz") l := &Logger{} err := l.compressLogFile(src, dst) if err == nil || !strings.Contains(err.Error(), "failed to open source") && !strings.Contains(err.Error(), "failed to open source log file") { t.Fatalf("expected source open error, got: %v", err) } } func TestWrite_SizeRotateFails_4(t *testing.T) { tmp := t.TempDir() logPath := filepath.Join(tmp, "fail-size.log") logger := &Logger{ Filename: logPath, MaxSize: 1, // 1 MB } // Write almost max-size file manually _ = os.WriteFile(logPath, bytes.Repeat([]byte("x"), int(logger.max()-1)), 0o644) // Don't preopen file — force logger to call openExistingOrNew → openNew → osRename logger.file = nil logger.size = logger.max() - 1 // Simulate rename failure origRename := osRename osRename = func(oldpath, newpath string) error { return fmt.Errorf("mock rename error") } defer func() { osRename = origRename }() _, err := logger.Write([]byte("x")) // this triggers rotation if err == nil || !strings.Contains(err.Error(), "can't rename log file") { t.Fatalf("expected rename error during rotation, got: %v", err) } } func TestWrite_IntervalRotationFails(t *testing.T) { currentTime = func() time.Time { return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) } defer func() { currentTime = time.Now }() // Patch osRename to force rotate("time") to fail origRename := osRename defer func() { osRename = origRename }() osRename = func(_, _ string) error { return fmt.Errorf("forced rename failure for interval") } tmp := t.TempDir() logfile := filepath.Join(tmp, "fail.log") // Seed file to trigger openExisting _ = os.WriteFile(logfile, []byte("seed"), 0o644) logger := &Logger{ Filename: logfile, RotationInterval: time.Minute, lastRotationTime: currentTime().Add(-2 * time.Minute), } defer logger.Close() _, err := logger.Write([]byte("trigger")) if err == nil || !strings.Contains(err.Error(), "interval rotation failed") { t.Fatalf("expected interval rotation failure, got: %v", err) } } func TestRunScheduledRotations_NoFutureSlotRetry(t *testing.T) { defer func() { recover() }() orig := currentTime defer func() { currentTime = orig }() currentTime = func() time.Time { return time.Date(9999, 1, 1, 23, 59, 59, 0, time.UTC) } l := &Logger{Filename: "noop.log"} l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(200 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestRunScheduledRotations_RotateFails(t *testing.T) { defer func() { recover() }() l := &Logger{Filename: "/invalid/should/fail.log"} l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(300 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestRotate_ManualTriggersTimeRotation(t *testing.T) { currentTime = func() time.Time { return time.Date(2025, 6, 5, 12, 0, 0, 0, time.UTC) } defer func() { currentTime = time.Now }() dir := t.TempDir() filename := filepath.Join(dir, "manual-trigger.log") // Seed file to ensure it rotates _ = os.WriteFile(filename, []byte("before"), 0o644) l := &Logger{ Filename: filename, RotationInterval: time.Minute, lastRotationTime: time.Date(2025, 6, 5, 11, 58, 0, 0, time.UTC), } defer l.Close() err := l.Rotate() if err != nil { t.Fatalf("expected successful manual rotation, got: %v", err) } // Check new empty file and rotated one with original data currentData, err := os.ReadFile(filename) if err != nil || len(currentData) != 0 { t.Errorf("expected new empty logfile after rotation, got: %q", currentData) } // The rotated file will include "-time" in the filename var found bool entries, _ := os.ReadDir(dir) for _, e := range entries { if strings.Contains(e.Name(), "-time.log") { rotatedPath := filepath.Join(dir, e.Name()) content, _ := os.ReadFile(rotatedPath) if string(content) == "before" { found = true break } } } if !found { t.Fatal("expected rotated file with -time suffix not found") } } func TestRunScheduledRotations_FallbackOnRotateFailure(t *testing.T) { defer func() { recover() }() origRename := osRename defer func() { osRename = origRename }() osRename = func(_, _ string) error { return fmt.Errorf("forced failure in scheduled rotate") } origTime := currentTime defer func() { currentTime = origTime }() currentTime = func() time.Time { return time.Date(2025, 1, 1, 0, 0, 1, 0, time.UTC) } dir := t.TempDir() logFile := filepath.Join(dir, "fallback.log") _ = os.WriteFile(logFile, []byte("seed"), 0o644) l := &Logger{Filename: logFile} // snapshot the patched globals for openNew() etc. l.resolveConfigLocked() quit := make(chan struct{}) slots := []rotateAt{{0, 0}} l.scheduledRotationWg.Add(1) go l.runScheduledRotations(quit, slots, time.UTC, currentTime) time.Sleep(300 * time.Millisecond) close(quit) l.scheduledRotationWg.Wait() } func TestLoggerClose_ClosesMillChannel(t *testing.T) { logger := &Logger{ Filename: "test-close-mill.log", millCh: make(chan bool, 1), } // Set startMill to run millRun (to simulate actual usage) logger.startMill.Do(func() { go logger.millRun() }) // Close should close millCh err := logger.Close() if err != nil { t.Fatalf("Close() returned error: %v", err) } // Wait a bit to let millRun exit time.Sleep(100 * time.Millisecond) // Test that millCh is closed select { case _, ok := <-logger.millCh: if ok { t.Fatal("millCh should be closed but is still open") } default: // if nothing is received, we assume it's closed and drained } } func TestOpenNew_SetsLogStartTimeWhenFileMissing(t *testing.T) { currentTime = func() time.Time { return time.Date(2025, 6, 5, 15, 0, 0, 0, time.UTC) } defer func() { currentTime = time.Now }() dir := t.TempDir() logfile := filepath.Join(dir, "missing.log") logger := &Logger{ Filename: logfile, } defer logger.Close() err := logger.openNew("size") if err != nil { t.Fatalf("openNew failed: %v", err) } if logger.logStartTime.IsZero() { t.Fatal("expected logStartTime to be set, but it is zero") } } func TestCountDigitsAfterDot(t *testing.T) { tests := []struct { layout string expected int }{ {"2006-01-02 15:04:05", 0}, // no dot {"2006-01-02 15:04:05.000", 3}, // exactly 3 digits {"2006-01-02 15:04:05.000000", 6}, // 6 digits {"2006-01-02 15:04:05.999999999", 9}, // 9 digits {"2006-01-02 15:04:05.12345abc", 5}, // digits then letters {"2006-01-02 15:04:05.", 0}, // dot but no digits {".1234", 4}, // string starts with dot + digits {"prefix.987suffix", 3}, // digits then letters after dot {"no_digits_after_dot.", 0}, // dot at end {"no.dot.in.string", 0}, // dot but not fractional part } for _, test := range tests { got := countDigitsAfterDot(test.layout) if got != test.expected { t.Errorf("countDigitsAfterDot(%q) = %d; want %d", test.layout, got, test.expected) } } } func TestSuffixTimeFormat(t *testing.T) { tmp := t.TempDir() logFile := filepath.Join(tmp, "invalid.log") logger := &Logger{ Filename: logFile, } err := logger.ValidateBackupTimeFormat() if err == nil { t.Fatalf("empty timestamp layout determined as valid") } // parses correctly with err == nil, but parsed time.Time won't match the supplied time.Time // invalid format invalidFormat := "2006-15-05 23:20:53" logger.BackupTimeFormat = invalidFormat err = logger.ValidateBackupTimeFormat() if err == nil { t.Fatalf("invalid timestamp layout determined as valid") } // valid formats validFormat := "20060102-15-04-05" logger.BackupTimeFormat = validFormat err = logger.ValidateBackupTimeFormat() if err != nil { t.Fatalf("valid timestamp layout determined as invalid") } validFormat = `2006-01-02-15-05-44.000` logger.BackupTimeFormat = validFormat err = logger.ValidateBackupTimeFormat() if err != nil { t.Errorf("valid timestamp layout determined as invalid") } validFormat2 := `2006-01-02-15-05-44.00000` // precision upto 5 digits after . logger.BackupTimeFormat = validFormat2 err = logger.ValidateBackupTimeFormat() if err != nil { t.Errorf("valid timestamp2 layout determined as invalid") } validFormat3 := `2006-01-02-15-05-44.0000000` // precision upto 7 digits after . logger.BackupTimeFormat = validFormat3 err = logger.ValidateBackupTimeFormat() if err != nil { t.Errorf("valid timestamp2 layout determined as invalid") } validFormat4 := `20060102-15-05` // precision upto 7 digits after . logger.BackupTimeFormat = validFormat4 err = logger.ValidateBackupTimeFormat() if err == nil { t.Errorf("timestamp4 is invalid but determined as valid") } } func TestTruncateFractional(t *testing.T) { baseTime := time.Date(2025, 5, 23, 14, 30, 45, 987654321, time.UTC) tests := []struct { n int wantNanos int wantErr bool }{ {n: 0, wantNanos: 0, wantErr: false}, // truncate to seconds {n: 1, wantNanos: 900000000, wantErr: false}, // 1 digit fractional (100ms) {n: 3, wantNanos: 987000000, wantErr: false}, // milliseconds {n: 5, wantNanos: 987650000, wantErr: false}, // upto 5 digits {n: 6, wantNanos: 987654000, wantErr: false}, // microseconds {n: 7, wantNanos: 987654300, wantErr: false}, // upto 7 digits {n: 9, wantNanos: 987654321, wantErr: false}, // nanoseconds, no truncation {n: -1, wantNanos: 0, wantErr: true}, // invalid low {n: 10, wantNanos: 0, wantErr: true}, // invalid high } for _, tt := range tests { got, err := truncateFractional(baseTime, tt.n) if (err != nil) != tt.wantErr { t.Errorf("truncateFractional(_, %d) error = %v, wantErr %v", tt.n, err, tt.wantErr) continue } if err != nil { continue // don't check time if error expected } if got.Nanosecond() != tt.wantNanos { t.Errorf("truncateFractional(_, %d) Nanosecond = %d; want %d", tt.n, got.Nanosecond(), tt.wantNanos) } // Verify that other time components are unchanged if got.Year() != baseTime.Year() || got.Month() != baseTime.Month() || got.Day() != baseTime.Day() || got.Hour() != baseTime.Hour() || got.Minute() != baseTime.Minute() || got.Second() != baseTime.Second() { t.Errorf("truncateFractional(_, %d) modified time components", tt.n) } } } func TestMillGoroutineCleanup(t *testing.T) { defer leaktest.Check(t)() // Will fail the test if goroutines leak logger := &Logger{ Filename: "test-mill.log", MaxSize: 100, // Small enough to trigger rotation/mill logic Compress: true, MaxBackups: 1, BackupTimeFormat: "2006-01-02T15-04-05.000", // consistent with timberjack defaults } _, err := logger.Write([]byte("1234567890")) if err != nil { t.Fatalf("write failed: %v", err) } // Give time for millRun to potentially start time.Sleep(100 * time.Millisecond) if err := logger.Close(); err != nil { t.Fatalf("logger close failed: %v", err) } // Wait briefly to allow goroutine shutdown time.Sleep(100 * time.Millisecond) } // TestWriteToClosedLogger verifies that a write to a closed logger succeeds // by performing a single open-write-close cycle, and that the internal // file handle remains nil. func TestWriteToClosedLogger(t *testing.T) { // 1. Setup // Use t.TempDir() to create a temporary directory that is automatically cleaned up. tempDir := t.TempDir() filename := filepath.Join(tempDir, "test-write-closed.log") logger := &Logger{ Filename: filename, } defer func() { // Even though TempDir cleans up, explicitly closing again ensures // that our test logic covers all cleanup paths. // It's safe because Close() is idempotent. logger.Close() }() initialContent := []byte("initial content\n") writeAfterCloseContent := []byte("this was written after close\n") // 2. Action: Initial write and close n, err := logger.Write(initialContent) if err != nil { t.Fatalf("Initial write failed: %v", err) } if n != len(initialContent) { t.Fatalf("Initial write: expected to write %d bytes, wrote %d", len(initialContent), n) } // Close the logger if err := logger.Close(); err != nil { t.Fatalf("Failed to close logger: %v", err) } // 3. Action: Write to the now-closed logger n, err = logger.Write(writeAfterCloseContent) // 4. Verification if err != nil { t.Fatalf("Write after close should not return an error, but got: %v", err) } if n != len(writeAfterCloseContent) { t.Fatalf("Write after close: expected to write %d bytes, wrote %d", len(writeAfterCloseContent), n) } // Verify the internal file handle is still nil if logger.file != nil { t.Fatal("logger.file should be nil after writing to a closed logger") } // Verify the complete file content fileContent, err := os.ReadFile(filename) if err != nil { t.Fatalf("Failed to read log file: %v", err) } expectedContent := bytes.Join([][]byte{initialContent, writeAfterCloseContent}, nil) if !bytes.Equal(fileContent, expectedContent) { t.Errorf("File content mismatch.\nExpected: %q\nGot: %q", expectedContent, fileContent) } } func TestOpenNewDefaultPerm(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Skipping default perm test on Windows") } // Ensure no bits get masked out. syscall.Umask(0o000) dir := makeTempDir("TestOpenNewDefaultPerm", t) defer os.RemoveAll(dir) l := &Logger{ Filename: logFile(dir), } defer l.Close() _, err := l.Write([]byte("foo")) isNil(err, t) hasPerm(logFile(dir), 0o640, t) } func TestOpenNewCustomPerm(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Skipping custom perm test on Windows") } // Ensure no bits get masked out. syscall.Umask(0o000) dir := makeTempDir("TestOpenNewCustomPerm", t) defer os.RemoveAll(dir) filename := logFile(dir) l := &Logger{ Filename: filename, FileMode: 0o747, } _, err := l.Write([]byte("foo")) isNil(err, t) hasPerm(filename, 0o747, t) l.Close() filename += ".1" l = &Logger{ Filename: filename, FileMode: 0o200, } _, err = l.Write([]byte("foo")) isNil(err, t) hasPerm(filename, 0o200, t) l.Close() filename += ".2" l = &Logger{ Filename: filename, FileMode: 0o666, } _, err = l.Write([]byte("foo")) isNil(err, t) hasPerm(filename, 0o666, t) l.Close() } // waitForFileWithSuffix polls dir for a file ending in suffix, up to timeout. func waitForFileWithSuffix(t *testing.T, dir, suffix string, timeout time.Duration) (string, error) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { ents, _ := os.ReadDir(dir) for _, e := range ents { if e.IsDir() { continue } name := e.Name() if strings.HasSuffix(name, suffix) { return filepath.Join(dir, name), nil } } time.Sleep(10 * time.Millisecond) } return "", os.ErrNotExist } func readZstdFile(t *testing.T, path string) []byte { t.Helper() f, err := os.Open(path) if err != nil { t.Fatalf("open %s: %v", path, err) } defer f.Close() rd, err := zstd.NewReader(f) if err != nil { t.Fatalf("zstd.NewReader: %v", err) } defer rd.Close() data, err := io.ReadAll(rd) if err != nil { t.Fatalf("read zstd stream: %v", err) } return data } func TestZstdCompression_SizeRotate_DefaultNaming(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "app.log") oldMB := megabyte megabyte = 1 t.Cleanup(func() { megabyte = oldMB }) l := &Logger{ Filename: logPath, MaxSize: 10, // bytes (since megabyte=1) Compression: "zstd", // enable zstd } defer l.Close() // 6 bytes per write; two writes => 12 > 10 => rotation msg := []byte("HELLO\n") if _, err := l.Write(msg); err != nil { t.Fatalf("first write: %v", err) } if _, err := l.Write(msg); err != nil { t.Fatalf("second write: %v", err) } // Wait for mill to compress the rotated file. zstFile, err := waitForFileWithSuffix(t, dir, ".log.zst", 2*time.Second) if err != nil { t.Fatalf("expected a .log.zst rotated file in %s", dir) } got := readZstdFile(t, zstFile) if !bytes.Equal(got, msg) { t.Fatalf("zstd content mismatch: got %q want %q", string(got), string(msg)) } } func TestZstdCompression_SizeRotate_AppendAfterExt(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "service.log") oldMB := megabyte megabyte = 1 t.Cleanup(func() { megabyte = oldMB }) l := &Logger{ Filename: logPath, MaxSize: 9, // two 5-byte writes => 10 > 9 => rotation Compression: "zstd", AppendTimeAfterExt: true, } defer l.Close() msg := []byte("LINE\n") // 5 bytes if _, err := l.Write(msg); err != nil { t.Fatalf("first write: %v", err) } if _, err := l.Write(msg); err != nil { t.Fatalf("second write: %v", err) } // Wait for .zst (append-after-ext form ends with just ".zst"). zstFile, err := waitForFileWithSuffix(t, dir, ".zst", 2*time.Second) if err != nil { t.Fatalf("expected a .zst rotated file in %s", dir) } base := filepath.Base(zstFile) if !strings.Contains(base, ".log-") || !strings.Contains(base, "-size") { t.Fatalf("unexpected rotated filename %q; want '.log--size.zst'", base) } got := readZstdFile(t, zstFile) if !bytes.Equal(got, msg) { t.Fatalf("zstd content mismatch: got %q want %q", string(got), string(msg)) } } func TestCompressionPrecedence_ZstdBeatsLegacyCompress(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "prec.log") oldMB := megabyte megabyte = 1 t.Cleanup(func() { megabyte = oldMB }) l := &Logger{ Filename: logPath, MaxSize: 9, // two 5-byte writes => 10 > 9 => rotation Compression: "zstd", // should win Compress: true, // legacy would have chosen gzip, but must be ignored } defer l.Close() msg := []byte("LINE\n") // 5 bytes if _, err := l.Write(msg); err != nil { t.Fatalf("first write: %v", err) } if _, err := l.Write(msg); err != nil { t.Fatalf("second write: %v", err) } // Should produce .zst, not .gz. if _, err := waitForFileWithSuffix(t, dir, ".zst", 2*time.Second); err != nil { t.Fatalf("expected .zst file; none found") } ents, _ := os.ReadDir(dir) for _, e := range ents { if strings.HasSuffix(e.Name(), ".gz") { t.Fatalf("found unexpected gzip file: %s", e.Name()) } } } func TestCompressionUnknownMeansNone(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "none.log") oldMB := megabyte megabyte = 1 t.Cleanup(func() { megabyte = oldMB }) l := &Logger{ Filename: logPath, MaxSize: 9, // two 5-byte writes => 10 > 9 => rotation Compression: "wut-is-this", // unknown -> none } defer l.Close() msg := []byte("DATA\n") // 5 bytes if _, err := l.Write(msg); err != nil { t.Fatalf("first write: %v", err) } if _, err := l.Write(msg); err != nil { t.Fatalf("second write: %v", err) } // Rotated file should be uncompressed (suffix ".log", not ".log.gz/.log.zst"). // Give the mill a moment even though no compression should happen. time.Sleep(100 * time.Millisecond) var foundUncompressed bool ents, _ := os.ReadDir(dir) for _, e := range ents { n := e.Name() if strings.HasSuffix(n, ".log") && strings.Contains(n, "-size") { foundUncompressed = true } if strings.HasSuffix(n, ".gz") || strings.HasSuffix(n, ".zst") { t.Fatalf("unexpected compressed output present: %s", n) } } if !foundUncompressed { t.Fatalf("expected an uncompressed rotated file ending with '.log', none found in %s", dir) } } // helper: create a temp dir func mktempDir(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("", "tj-") if err != nil { t.Fatalf("MkDirTemp: %v", err) } t.Cleanup(func() { _ = os.RemoveAll(dir) }) return dir } // helper: write once so the file exists and logger state is initialized func writeOnce(t *testing.T, l *Logger, data string) { t.Helper() if _, err := l.Write([]byte(data)); err != nil { t.Fatalf("write: %v", err) } } func TestRotateWithReason_CustomReason_Sanitized(t *testing.T) { dir := mktempDir(t) name := filepath.Join(dir, "app.log") l := &Logger{ Filename: name, // keep defaults: no compression, no scheduled, etc. } t.Cleanup(func() { _ = l.Close() }) // create the live file writeOnce(t, l, "hi\n") // Includes spaces, punctuation, and caps -> should sanitize to "reload-now-v2" reason := " Reload NOW!! v2 " if err := l.RotateWithReason(reason); err != nil { t.Fatalf("RotateWithReason: %v", err) } // Expect exactly one rotated file ending with "-reload-now-v2.log" entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } var matches []string for _, e := range entries { if e.IsDir() { continue } n := e.Name() // the current log stays "app.log" — rotated must not be that if n == "app.log" { continue } if strings.HasSuffix(n, "-reload-now-v2.log") { matches = append(matches, n) } } if len(matches) != 1 { t.Fatalf("expected 1 rotated file with '-reload-now-v2.log' suffix, got %d: %v", len(matches), matches) } } func TestRotateWithReason_EmptyFallsBackToTimeWhenDue(t *testing.T) { // Control time so shouldTimeRotate() returns true. oldNow := currentTime defer func() { currentTime = oldNow }() now := time.Date(2025, 5, 22, 10, 0, 0, 0, time.UTC) currentTime = func() time.Time { return now } dir := mktempDir(t) name := filepath.Join(dir, "x.log") l := &Logger{ Filename: name, RotationInterval: time.Hour, // 1h interval } t.Cleanup(func() { _ = l.Close() }) // First write initializes lastRotationTime to 'now'. writeOnce(t, l, "boot\n") // Advance time beyond the interval so an interval rotation is due. now = now.Add(2 * time.Hour) // Empty reason => legacy logic: since interval is due, we should tag "time". if err := l.RotateWithReason(""); err != nil { t.Fatalf("RotateWithReason: %v", err) } entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } found := false for _, e := range entries { if e.IsDir() { continue } n := e.Name() if n == "x.log" { continue } if strings.HasSuffix(n, "-time.log") { found = true break } } if !found { t.Fatalf("expected a rotated file with '-time.log' suffix when interval is due") } } func TestRotateWithReason_EmptyFallsBackToSizeWhenNotDue(t *testing.T) { // Control time so shouldTimeRotate() returns false. oldNow := currentTime defer func() { currentTime = oldNow }() now := time.Date(2025, 5, 22, 10, 0, 0, 0, time.UTC) currentTime = func() time.Time { return now } dir := mktempDir(t) name := filepath.Join(dir, "y.log") l := &Logger{ Filename: name, RotationInterval: time.Hour, // 1h interval, but we won't advance enough } t.Cleanup(func() { _ = l.Close() }) // First write initializes lastRotationTime to 'now'. writeOnce(t, l, "hello\n") // Advance only 10 minutes — still not due. now = now.Add(10 * time.Minute) // Empty reason => legacy logic: interval not due => "size". if err := l.RotateWithReason(""); err != nil { t.Fatalf("RotateWithReason: %v", err) } entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } found := false for _, e := range entries { if e.IsDir() { continue } n := e.Name() if n == "y.log" { continue } if strings.HasSuffix(n, "-size.log") { found = true break } } if !found { t.Fatalf("expected a rotated file with '-size.log' suffix when interval is not due") } } // TestRotate_NoDuplicateRotationOnNextWrite verifies that calling Rotate() when // an interval rotation is due does NOT cause another automatic rotation on the // very next Write() call (regression test for lastRotationTime not being updated). func TestRotate_NoDuplicateRotationOnNextWrite(t *testing.T) { oldNow := currentTime defer func() { currentTime = oldNow }() now := time.Date(2025, 5, 22, 10, 0, 0, 0, time.UTC) currentTime = func() time.Time { return now } dir := mktempDir(t) name := filepath.Join(dir, "dup.log") l := &Logger{ Filename: name, RotationInterval: time.Hour, } t.Cleanup(func() { _ = l.Close() }) // First write: initializes lastRotationTime to `now`. writeOnce(t, l, "initial\n") // Advance time beyond the interval so an interval rotation is due. now = now.Add(2 * time.Hour) // Manual rotation: should rotate once and update lastRotationTime. if err := l.Rotate(); err != nil { t.Fatalf("Rotate: %v", err) } // Write again: must NOT trigger a second automatic rotation. writeOnce(t, l, "after-manual-rotate\n") // Exactly one backup file should exist (from the manual Rotate call only). entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } var backups []string for _, e := range entries { if !e.IsDir() && e.Name() != filepath.Base(name) { backups = append(backups, e.Name()) } } if len(backups) != 1 { t.Fatalf("expected exactly 1 backup file after manual Rotate + Write, got %d: %v", len(backups), backups) } } // TestRotateWithReason_NoDuplicateRotationOnNextWrite verifies that calling // RotateWithReason() when an interval rotation is due does NOT cause another // automatic rotation on the very next Write() call. func TestRotateWithReason_NoDuplicateRotationOnNextWrite(t *testing.T) { oldNow := currentTime defer func() { currentTime = oldNow }() now := time.Date(2025, 5, 22, 10, 0, 0, 0, time.UTC) currentTime = func() time.Time { return now } dir := mktempDir(t) name := filepath.Join(dir, "dup2.log") l := &Logger{ Filename: name, RotationInterval: time.Hour, } t.Cleanup(func() { _ = l.Close() }) // First write: initializes lastRotationTime to `now`. writeOnce(t, l, "initial\n") // Advance time beyond the interval so an interval rotation is due. now = now.Add(2 * time.Hour) // Manual rotation with a custom reason: should rotate once and update lastRotationTime. if err := l.RotateWithReason("deploy"); err != nil { t.Fatalf("RotateWithReason: %v", err) } // Write again: must NOT trigger a second automatic rotation. writeOnce(t, l, "after-manual-rotate\n") // Exactly one backup file should exist (from the manual RotateWithReason call only). entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } var backups []string for _, e := range entries { if !e.IsDir() && e.Name() != filepath.Base(name) { backups = append(backups, e.Name()) } } if len(backups) != 1 { t.Fatalf("expected exactly 1 backup file after manual RotateWithReason + Write, got %d: %v", len(backups), backups) } }