log: add size-based log file rotation (#2682)

Signed-off-by: EisenbergD <dominik.eisenberg@beiersdorf.com>
Co-authored-by: EisenbergD <dominik.eisenberg@beiersdorf.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dominik Eisenberg
2026-10-15 16:40:05 +00:00
committed by GitHub
co-authored by EisenbergD Claude Opus 5.5
parent 6e242d7af2
commit 5a7f8b5bd3
11 changed files with 1299 additions and 15 deletions
+3
View File
@@ -97,6 +97,9 @@ windows_exporter accepts flags to configure certain behaviours. The ones configu
| `--web.config.file` | A [web config][web_config] for setting up TLS and Auth | None |
| `--config.file` | [Using a config file](#using-a-configuration-file) from path | None |
| `--log.file` | Output file of log messages. One of [stdout, stderr, eventlog, \<path to log file>]<br>**NOTE:** The MSI installer will add a default argument to the installed service setting this to eventlog | stderr |
| `--log.rotation.max-size` | Size in megabytes at which the log file is rotated. Requires `--log.file` to be a path. If another process holds the file open without delete sharing, the exporter keeps writing to it and retries the rotation every minute. A moved or deleted log file is recreated within a minute. | `0` (off) |
| `--log.rotation.max-backups` | Number of rotated log files to keep. `0` keeps all. | `0` |
| `--log.rotation.compress` | Compress rotated log files with gzip. | `false` |
## Installation
+7 -1
View File
@@ -175,13 +175,19 @@ func run(ctx context.Context, args []string) int {
_ = logFile.Set("stderr")
}
logger, err := log.New(logConfig)
logger, logCloser, err := log.New(logConfig)
if err != nil {
logStartupError(ctx, "failed to create logger", err)
return 1
}
if logCloser != nil {
defer func() {
_ = logCloser.Close()
}()
}
logger.LogAttrs(ctx, slog.LevelDebug, "logging has Started")
if configFile != nil && *configFile != "" {
+8 -3
View File
@@ -41,9 +41,14 @@ type configFile struct {
} `yaml:"collectors"`
Collector collector.Config `yaml:"collector"`
Log struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
File string `yaml:"file"`
Level string `yaml:"level"`
Format string `yaml:"format"`
File string `yaml:"file"`
Rotation struct {
MaxSize string `yaml:"max-size"`
MaxBackups string `yaml:"max-backups"`
Compress bool `yaml:"compress"`
} `yaml:"rotation"`
} `yaml:"log"`
Process struct {
Priority string `yaml:"priority"`
+13
View File
@@ -41,4 +41,17 @@ func AddFlags(a *kingpin.Application, config *log.Config) {
}
a.Flag(FileFlagName, FileFlagHelp).Default(config.File.String()).SetValue(config.File)
a.Flag(
"log.rotation.max-size",
"Size in megabytes at which the log file is rotated. 0 disables rotation. Requires log.file to be a file path.",
).Default("0").IntVar(&config.Rotation.MaxSize)
a.Flag(
"log.rotation.max-backups",
"Number of rotated log files to keep. 0 keeps all.",
).Default("0").IntVar(&config.Rotation.MaxBackups)
a.Flag(
"log.rotation.compress",
"Compress rotated log files with gzip.",
).Default("false").BoolVar(&config.Rotation.Compress)
}
+80 -11
View File
@@ -22,7 +22,9 @@ import (
"fmt"
"io"
"log/slog"
"math"
"os"
"time"
"github.com/prometheus-community/windows_exporter/internal/log/eventlog"
"github.com/prometheus/common/promslog"
@@ -30,6 +32,8 @@ import (
)
// AllowedFile is a settable identifier for the output file that the logger can have.
// A path is only stored by Set; New opens it, since the rotation settings are
// parsed after the file flag.
type AllowedFile struct {
s string
w io.Writer
@@ -60,31 +64,96 @@ func (f *AllowedFile) Set(s string) error {
f.w = eventlog.NewEventLogWriter(eventLog)
default:
file, err := os.OpenFile(s, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o200)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
f.w = file
f.w = nil
}
return nil
}
// isPath reports whether the output is a log file path rather than a stream.
func (f *AllowedFile) isPath() bool {
return f.s != "" && f.w == nil
}
// Rotation configures the size-based rotation of a log file. Rotation is
// disabled unless MaxSize is set.
type Rotation struct {
// MaxSize is the size in megabytes at which the log file is rotated. 0 disables rotation.
MaxSize int
// MaxBackups is the number of rotated files to keep. 0 keeps all.
MaxBackups int
// Compress compresses rotated files with gzip.
Compress bool
}
func (r Rotation) configured() bool {
return r != Rotation{}
}
func (r Rotation) validate() error {
var errs []error
if r.MaxSize < 0 {
errs = append(errs, errors.New("log.rotation.max-size must not be negative"))
}
if r.MaxBackups < 0 {
errs = append(errs, errors.New("log.rotation.max-backups must not be negative"))
}
return errors.Join(errs...)
}
// Config is a struct containing configurable settings for the logger.
type Config struct {
*promslog.Config
File *AllowedFile
File *AllowedFile
Rotation Rotation
}
func New(config *Config) (*slog.Logger, error) {
// New creates the logger. The returned io.Closer releases the log file; it is
// nil for stdout, stderr and the event log.
func New(config *Config) (*slog.Logger, io.Closer, error) {
if config.File == nil {
return nil, errors.New("log file undefined")
return nil, nil, errors.New("log file undefined")
}
config.Writer = config.File.w
if err := config.Rotation.validate(); err != nil {
return nil, nil, err
}
writer := config.File.w
var closer io.Closer
switch {
case !config.File.isPath():
if config.Rotation.configured() {
return nil, nil, fmt.Errorf("log rotation requires log.file to be a file path, not %s", config.File.s)
}
case config.Rotation.MaxSize > 0:
file, err := newRotatingFile(config.File.s, int64(config.Rotation.MaxSize)<<20, config.Rotation.MaxBackups, config.Rotation.Compress, time.Now)
if err != nil {
return nil, nil, fmt.Errorf("failed to open log file: %w", err)
}
writer, closer = file, file
case config.Rotation.configured():
return nil, nil, errors.New("log rotation requires log.rotation.max-size")
default:
// Without a size limit the file is never rotated, but a moved or
// deleted file is still replaced by a new one at the path.
file, err := newRotatingFile(config.File.s, math.MaxInt64, 0, false, time.Now)
if err != nil {
return nil, nil, fmt.Errorf("failed to open log file: %w", err)
}
writer, closer = file, file
}
config.Writer = writer
config.Style = promslog.SlogStyle
return promslog.New(config.Config), nil
return promslog.New(config.Config), closer, nil
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package log
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
)
func newConfig(t *testing.T, file string, rotation Rotation) *Config {
t.Helper()
allowedFile := &AllowedFile{}
require.NoError(t, allowedFile.Set(file))
return &Config{Config: &promslog.Config{}, File: allowedFile, Rotation: rotation}
}
func TestNewFileWithoutRotation(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "windows_exporter.log")
logger, closer, err := New(newConfig(t, path, Rotation{}))
require.NoError(t, err)
require.NotNil(t, closer)
logger.Info("hello")
require.NoError(t, closer.Close())
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "hello")
}
func writeMegabytes(logger *slog.Logger, megabytes int) {
message := strings.Repeat("x", 1024)
for range megabytes * 1100 {
logger.Info(message)
}
}
func TestNewFileWithSizeRotation(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "windows_exporter.log")
logger, closer, err := New(newConfig(t, path, Rotation{MaxSize: 1}))
require.NoError(t, err)
require.NotNil(t, closer)
writeMegabytes(logger, 2)
require.NoError(t, closer.Close())
entries, err := os.ReadDir(dir)
require.NoError(t, err)
require.GreaterOrEqual(t, len(entries), 2, "expected the active log file and at least one backup")
info, err := os.Stat(path)
require.NoError(t, err)
require.LessOrEqual(t, info.Size(), int64(1024*1024))
}
func TestNewFileMaxBackups(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "windows_exporter.log")
logger, closer, err := New(newConfig(t, path, Rotation{MaxSize: 1, MaxBackups: 2}))
require.NoError(t, err)
// Rotates at least four times; only the two newest backups are kept.
writeMegabytes(logger, 5)
require.NoError(t, closer.Close())
entries, err := os.ReadDir(dir)
require.NoError(t, err)
require.Len(t, entries, 3, "expected the active log file and two backups")
}
func TestNewFileUnwritable(t *testing.T) {
t.Parallel()
// The parent of the log file is a file, so the log file can't be created.
parent := filepath.Join(t.TempDir(), "not-a-directory")
require.NoError(t, os.WriteFile(parent, nil, 0o600))
path := filepath.Join(parent, "windows_exporter.log")
for name, rotation := range map[string]Rotation{
"without rotation": {},
"with rotation": {MaxSize: 1},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, _, err := New(newConfig(t, path, rotation))
require.ErrorContains(t, err, "failed to open log file")
})
}
}
func TestNewRotationValidation(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "windows_exporter.log")
for name, tc := range map[string]struct {
file string
rotation Rotation
err string
}{
"stream": {"stdout", Rotation{MaxSize: 10}, "requires log.file to be a file path"},
"no max size": {path, Rotation{MaxBackups: 3}, "requires log.rotation.max-size"},
"negative size": {path, Rotation{MaxSize: -1}, "max-size must not be negative"},
"negative backups": {path, Rotation{MaxSize: 1, MaxBackups: -1}, "max-backups must not be negative"},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, _, err := New(newConfig(t, tc.file, tc.rotation))
require.ErrorContains(t, err, tc.err)
})
}
}
func TestNewStreamHasNoCloser(t *testing.T) {
t.Parallel()
_, closer, err := New(newConfig(t, "stderr", Rotation{}))
require.NoError(t, err)
require.Nil(t, closer)
}
+393
View File
@@ -0,0 +1,393 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package log
import (
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
)
const (
// rotationRetryInterval is how long a log file whose rotation failed is
// appended to before the rotation is tried again.
rotationRetryInterval = time.Minute
backupTimeFormat = "2006-01-02T15-04-05.000"
gzipSuffix = ".gz"
)
// rotatingFile appends to a log file and rotates it before a write would make
// it larger than maxSize. A rotation that fails, for example because another
// process holds the file open without delete sharing, never drops a line: the
// file is appended to and the rotation is retried after rotationRetryInterval.
type rotatingFile struct {
path string
maxSize int64
maxBackups int
compress bool
now func() time.Time
rename func(oldPath, newPath string) error
retryInterval time.Duration
mu sync.Mutex
file *os.File
size int64
retryAfter time.Time
// checkAfter is when the next write checks that the path still points
// to the open file; an admin may have moved or deleted it.
checkAfter time.Time
// cleanup compresses and prunes rotated files in the background; Close
// waits for it. cleanupMu runs one cleanup at a time.
cleanup sync.WaitGroup
cleanupMu sync.Mutex
}
func newRotatingFile(path string, maxSize int64, maxBackups int, compress bool, now func() time.Time) (*rotatingFile, error) {
return newRotatingFileWithRename(path, maxSize, maxBackups, compress, now, os.Rename)
}
func newRotatingFileWithRename(
path string,
maxSize int64,
maxBackups int,
compress bool,
now func() time.Time,
rename func(oldPath, newPath string) error,
) (*rotatingFile, error) {
r := &rotatingFile{
path: path,
maxSize: maxSize,
maxBackups: maxBackups,
compress: compress,
now: now,
rename: rename,
retryInterval: rotationRetryInterval,
}
file, size, err := openLogFileWithSize(path)
if err != nil {
return nil, err
}
r.file, r.size = file, size
r.checkAfter = now().Add(r.retryInterval)
// A file that is already too large is rotated right away. If that fails,
// the logger still starts and the rotation is retried on a later write.
if r.size >= r.maxSize {
r.mu.Lock()
_ = r.rotate()
r.mu.Unlock()
}
return r, nil
}
func openLogFileWithSize(path string) (*os.File, int64, error) {
file, err := openLogFile(path)
if err != nil {
return nil, 0, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, 0, fmt.Errorf("failed to read log file size: %w", err)
}
return file, info.Size(), nil
}
// Write appends p to the log file. p is never split across files.
func (r *rotatingFile) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.file == nil {
return 0, os.ErrClosed
}
if now := r.now(); !now.Before(r.checkAfter) {
r.checkAfter = now.Add(r.retryInterval)
// The file was moved, deleted or replaced: start a new one at the
// path. If that fails, keep writing to the current file.
if same, err := sameFile(r.file, r.path); err != nil || !same {
_ = r.reopen()
}
}
if r.size > 0 && int64(len(p)) > r.maxSize-r.size && !r.now().Before(r.retryAfter) {
// On failure, keep appending to the current file.
_ = r.rotate()
}
n, err := r.file.Write(p)
r.size += int64(n)
return n, err
}
// Close closes the log file and waits for compression and pruning of rotated files.
func (r *rotatingFile) Close() error {
r.mu.Lock()
var err error
if r.file != nil {
err = r.file.Close()
r.file = nil
}
r.mu.Unlock()
r.cleanup.Wait()
return err
}
// rotate renames the log file to a backup and opens a new one. r.mu must be held.
func (r *rotatingFile) rotate() error {
backup := r.backupName()
if err := r.rename(r.path, backup); err != nil {
// The file was moved or deleted: there is nothing to rename, so
// start a new file at the path.
if errors.Is(err, os.ErrNotExist) {
err = r.reopen()
if err == nil {
return nil
}
}
r.retryAfter = r.now().Add(r.retryInterval)
return fmt.Errorf("failed to rename log file: %w", err)
}
file, size, err := openLogFileWithSize(r.path)
if err != nil {
// Keep the current file under its name and keep writing to it.
_ = r.rename(backup, r.path)
r.retryAfter = r.now().Add(r.retryInterval)
return fmt.Errorf("failed to open new log file: %w", err)
}
_ = r.file.Close()
r.file, r.size = file, size
r.retryAfter = time.Time{}
r.cleanup.Go(func() {
r.cleanupMu.Lock()
defer r.cleanupMu.Unlock()
r.cleanupBackups(backup)
})
return nil
}
// reopen opens the file at the path and writes to it from now on. r.mu must be held.
func (r *rotatingFile) reopen() error {
file, size, err := openLogFileWithSize(r.path)
if err != nil {
return err
}
_ = r.file.Close()
r.file, r.size = file, size
r.retryAfter = time.Time{}
return nil
}
// backupName returns <name>-<UTC time><ext> next to the log file, with a
// counter before the extension if that name, or its compressed version, exists.
func (r *rotatingFile) backupName() string {
dir, prefix, ext := r.nameParts()
stamp := r.now().UTC().Format(backupTimeFormat)
for i := 0; ; i++ {
name := prefix + stamp + ext
if i > 0 {
name = prefix + stamp + "-" + strconv.Itoa(i) + ext
}
path := filepath.Join(dir, name)
if !exists(path) && !exists(path+gzipSuffix) {
return path
}
}
}
// nameParts returns the directory of the log file, the prefix of its backups
// (name without extension and "-") and its extension.
func (r *rotatingFile) nameParts() (string, string, string) {
base := filepath.Base(r.path)
ext := filepath.Ext(base)
return filepath.Dir(r.path), strings.TrimSuffix(base, ext) + "-", ext
}
func exists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
// cleanupBackups compresses the new backup and removes the oldest backups
// beyond maxBackups. Errors only leave files behind, so they are ignored.
func (r *rotatingFile) cleanupBackups(backup string) {
if r.compress {
_ = compressFile(backup)
}
if r.maxBackups <= 0 {
return
}
backups := r.backups()
if len(backups) <= r.maxBackups {
return
}
for _, old := range backups[:len(backups)-r.maxBackups] {
_ = os.Remove(old.path)
}
}
type backupFile struct {
path string
time time.Time
counter int
}
// backups returns the rotated files of the log file, oldest first. Files that
// don't match the backup name exactly are ignored.
func (r *rotatingFile) backups() []backupFile {
dir, prefix, ext := r.nameParts()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var backups []backupFile
for _, entry := range entries {
name, ok := strings.CutPrefix(entry.Name(), prefix)
if !ok || entry.IsDir() {
continue
}
name = strings.TrimSuffix(name, gzipSuffix)
name, ok = strings.CutSuffix(name, ext)
if !ok || len(name) < len(backupTimeFormat) {
continue
}
stamp, counter := name[:len(backupTimeFormat)], 0
if rest := name[len(backupTimeFormat):]; rest != "" {
number, ok := strings.CutPrefix(rest, "-")
if !ok {
continue
}
if counter, err = strconv.Atoi(number); err != nil {
continue
}
}
t, err := time.Parse(backupTimeFormat, stamp)
if err != nil {
continue
}
backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), time: t, counter: counter})
}
slices.SortFunc(backups, func(a, b backupFile) int {
if c := a.time.Compare(b.time); c != 0 {
return c
}
return a.counter - b.counter
})
return backups
}
// compressFile replaces path with path.gz.
func compressFile(path string) (err error) {
src, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = src.Close() }()
tmp := path + gzipSuffix + ".tmp"
dst, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = dst.Close()
_ = os.Remove(tmp)
}
}()
writer := gzip.NewWriter(dst)
if _, err = io.Copy(writer, src); err != nil {
return err
}
if err = writer.Close(); err != nil {
return err
}
if err = dst.Close(); err != nil {
return err
}
_ = src.Close()
if err = os.Rename(tmp, path+gzipSuffix); err != nil {
return err
}
return os.Remove(path)
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows
package log
import "os"
// openLogFile opens path for appending. The exporter only runs on Windows;
// this keeps the rotation testable on other platforms.
func openLogFile(path string) (*os.File, error) {
return os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o640)
}
// sameFile reports whether path still refers to file.
func sameFile(file *os.File, path string) (bool, error) {
fileInfo, err := file.Stat()
if err != nil {
return false, err
}
pathInfo, err := os.Stat(path)
if err != nil {
return false, err
}
return os.SameFile(fileInfo, pathInfo), nil
}
+339
View File
@@ -0,0 +1,339 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package log
import (
"compress/gzip"
"errors"
"io"
"math"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// testRotatingFile returns a rotating file with a small size limit and a
// clock that only moves when the test moves it.
func testRotatingFile(t *testing.T, maxSize int64, maxBackups int, compress bool) (*rotatingFile, string, *time.Time) {
t.Helper()
path := filepath.Join(t.TempDir(), "windows_exporter.log")
now := time.Date(2026, 10, 15, 14, 3, 5, 0, time.UTC)
file, err := newRotatingFile(path, maxSize, maxBackups, compress, func() time.Time { return now })
require.NoError(t, err)
t.Cleanup(func() { _ = file.Close() })
return file, path, &now
}
func write(t *testing.T, w io.Writer, line string) {
t.Helper()
n, err := w.Write([]byte(line))
require.NoError(t, err)
require.Equal(t, len(line), n)
}
func dirEntries(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
slices.Sort(names)
return names
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
return string(data)
}
func TestRotatingFileRotatesBeforeExceedingMaxSize(t *testing.T) {
t.Parallel()
file, path, now := testRotatingFile(t, 10, 0, false)
write(t, file, "12345\n")
write(t, file, "1234\n") // 11 bytes would exceed 10: rotate first
*now = now.Add(time.Second)
write(t, file, "abcdefghijklmn\n") // longer than max-size on its own: rotate, then write it whole
require.NoError(t, file.Close())
require.Equal(t, []string{
"windows_exporter-2026-10-15T14-03-05.000.log",
"windows_exporter-2026-10-15T14-03-06.000.log",
"windows_exporter.log",
}, dirEntries(t, filepath.Dir(path)))
require.Equal(t, "12345\n", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-05.000.log")))
require.Equal(t, "1234\n", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-06.000.log")))
require.Equal(t, "abcdefghijklmn\n", readFile(t, path))
}
func TestRotatingFileKeepsWritingWhenRenameFails(t *testing.T) {
t.Parallel()
file, path, now := testRotatingFile(t, 10, 0, false)
errBlocked := errors.New("file is in use")
renames := 0
file.rename = func(oldPath, newPath string) error {
renames++
if renames == 1 {
return errBlocked
}
return os.Rename(oldPath, newPath)
}
write(t, file, "1234567890")
write(t, file, "after a failed rotation\n") // the rename fails: the line goes to the current file
require.Equal(t, 1, renames)
write(t, file, "still no retry\n") // within the retry interval: no new attempt
require.Equal(t, 1, renames)
*now = now.Add(file.retryInterval)
write(t, file, "rotated\n") // the retry succeeds
require.Equal(t, 2, renames)
require.NoError(t, file.Close())
backup := filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-04-05.000.log")
require.Equal(t, "1234567890after a failed rotation\nstill no retry\n", readFile(t, backup), "no line may be lost")
require.Equal(t, "rotated\n", readFile(t, path))
}
func TestRotatingFileStartsWhenInitialRotationFails(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "windows_exporter.log")
require.NoError(t, os.WriteFile(path, []byte("an old log that is already too large\n"), 0o600))
now := time.Date(2026, 10, 15, 14, 3, 5, 0, time.UTC)
// Rotating the oversized file on startup works.
file, err := newRotatingFile(path, 10, 0, false, func() time.Time { return now })
require.NoError(t, err)
write(t, file, "new\n")
require.NoError(t, file.Close())
require.Equal(t, "new\n", readFile(t, path))
// If it fails, the logger still starts and appends.
require.NoError(t, os.WriteFile(path, []byte("an old log that is already too large\n"), 0o600))
file, err = newRotatingFileWithRename(path, 10, 0, false, func() time.Time { return now }, func(string, string) error {
return errors.New("file is in use")
})
require.NoError(t, err)
write(t, file, "appended\n")
require.NoError(t, file.Close())
require.Equal(t, "an old log that is already too large\nappended\n", readFile(t, path))
}
func TestRotatingFileMaxBackups(t *testing.T) {
t.Parallel()
file, path, now := testRotatingFile(t, 4, 2, false)
// A file that only looks similar is never removed.
other := filepath.Join(filepath.Dir(path), "windows_exporter-notes.log")
require.NoError(t, os.WriteFile(other, []byte("keep"), 0o600))
for i := range 5 {
*now = now.Add(time.Second)
write(t, file, strings.Repeat(string(rune('a'+i)), 4))
}
require.NoError(t, file.Close())
require.Equal(t, []string{
"windows_exporter-2026-10-15T14-03-09.000.log",
"windows_exporter-2026-10-15T14-03-10.000.log",
"windows_exporter-notes.log",
"windows_exporter.log",
}, dirEntries(t, filepath.Dir(path)))
}
func TestRotatingFileCompress(t *testing.T) {
t.Parallel()
file, path, now := testRotatingFile(t, 4, 0, true)
write(t, file, "abcd")
*now = now.Add(time.Second)
write(t, file, "efgh")
require.NoError(t, file.Close())
require.Equal(t, []string{
"windows_exporter-2026-10-15T14-03-06.000.log.gz",
"windows_exporter.log",
}, dirEntries(t, filepath.Dir(path)))
compressed, err := os.Open(filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-06.000.log.gz"))
require.NoError(t, err)
defer compressed.Close()
reader, err := gzip.NewReader(compressed)
require.NoError(t, err)
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, "abcd", string(data))
}
func TestRotatingFileBackupNamesAreUnique(t *testing.T) {
t.Parallel()
// The clock doesn't move, so every rotation has the same timestamp.
file, path, _ := testRotatingFile(t, 4, 0, false)
write(t, file, "aaaa")
write(t, file, "bbbb")
write(t, file, "cccc")
require.NoError(t, file.Close())
require.Equal(t, []string{
"windows_exporter-2026-10-15T14-03-05.000-1.log",
"windows_exporter-2026-10-15T14-03-05.000.log",
"windows_exporter.log",
}, dirEntries(t, filepath.Dir(path)))
require.Equal(t, "aaaa", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-05.000.log")))
require.Equal(t, "bbbb", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-05.000-1.log")))
}
func TestRotatingFileWriteAfterClose(t *testing.T) {
t.Parallel()
file, _, _ := testRotatingFile(t, 10, 0, false)
require.NoError(t, file.Close())
_, err := file.Write([]byte("late\n"))
require.ErrorIs(t, err, os.ErrClosed)
require.NoError(t, file.Close(), "a second Close is a no-op")
}
func TestRotatingFileRecreatesMovedFileOnRotation(t *testing.T) {
t.Parallel()
file, path, _ := testRotatingFile(t, 100, 0, false)
write(t, file, strings.Repeat("a", 59)+"\n")
moved := filepath.Join(filepath.Dir(path), "moved.log")
require.NoError(t, os.Rename(path, moved))
// The rotation finds no file to rename and starts a new one at the path.
write(t, file, strings.Repeat("b", 59)+"\n")
require.NoError(t, file.Close())
require.Equal(t, strings.Repeat("a", 59)+"\n", readFile(t, moved))
require.Equal(t, strings.Repeat("b", 59)+"\n", readFile(t, path))
}
func TestRotatingFileRecreatesMovedOrDeletedFile(t *testing.T) {
t.Parallel()
for name, remove := range map[string]func(path string) error{
"moved": func(path string) error { return os.Rename(path, path+".moved") },
"deleted": os.Remove,
"replaced": func(path string) error {
return errors.Join(os.Rename(path, path+".old"), os.WriteFile(path, []byte("other\n"), 0o600))
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
// Far below max-size: no rotation is involved.
file, path, now := testRotatingFile(t, 1<<20, 0, false)
write(t, file, "before\n")
require.NoError(t, remove(path))
// Until the next check, lines go to the old file.
write(t, file, "until the check\n")
*now = now.Add(file.retryInterval)
write(t, file, "after\n")
require.NoError(t, file.Close())
require.True(t, strings.HasSuffix(readFile(t, path), "after\n"), "the path has a new log file")
require.NotContains(t, readFile(t, path), "before", "the new file doesn't continue the old one")
})
}
}
func TestRotatingFileKeepsFileAtPath(t *testing.T) {
t.Parallel()
file, path, now := testRotatingFile(t, 1<<20, 0, false)
write(t, file, "one\n")
*now = now.Add(file.retryInterval)
// The check finds the same file: nothing is reopened.
write(t, file, "two\n")
require.NoError(t, file.Close())
require.Equal(t, "one\ntwo\n", readFile(t, path))
require.Equal(t, []string{"windows_exporter.log"}, dirEntries(t, filepath.Dir(path)))
}
func TestRotatingFileWithoutSizeLimit(t *testing.T) {
t.Parallel()
// A plain log file: no size limit, never rotated.
file, path, now := testRotatingFile(t, math.MaxInt64, 0, false)
write(t, file, strings.Repeat("a", 1000))
*now = now.Add(file.retryInterval)
write(t, file, strings.Repeat("b", 1000))
require.NoError(t, file.Close())
require.Equal(t, []string{"windows_exporter.log"}, dirEntries(t, filepath.Dir(path)))
require.Len(t, readFile(t, path), 2000)
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package log
import (
"os"
"golang.org/x/sys/windows"
)
// openLogFile opens path for appending. Unlike os.OpenFile, it allows other
// processes to read, write, rename and delete the file while it is open
// (FILE_SHARE_DELETE), so the file can be rotated without closing it first and
// admins can move or delete it while the service runs.
func openLogFile(path string) (*os.File, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, &os.PathError{Op: "open", Path: path, Err: err}
}
handle, err := windows.CreateFile(
pathPtr,
windows.FILE_APPEND_DATA|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_ALWAYS,
windows.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
return nil, &os.PathError{Op: "open", Path: path, Err: err}
}
return os.NewFile(uintptr(handle), path), nil
}
// sameFile reports whether path still refers to file, by volume serial number
// and file index.
func sameFile(file *os.File, path string) (bool, error) {
var fileInfo windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &fileInfo); err != nil {
return false, err
}
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return false, err
}
// No access rights are needed to read the file index; backup semantics
// would also open a directory, which then doesn't match.
handle, err := windows.CreateFile(
pathPtr,
0,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return false, err
}
defer func() { _ = windows.CloseHandle(handle) }()
var pathInfo windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &pathInfo); err != nil {
return false, err
}
return fileInfo.VolumeSerialNumber == pathInfo.VolumeSerialNumber &&
fileInfo.FileIndexHigh == pathInfo.FileIndexHigh &&
fileInfo.FileIndexLow == pathInfo.FileIndexLow, nil
}
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package log
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// openWithoutDeleteSharing opens path like a log shipper that doesn't allow
// delete sharing, which blocks renaming the file.
func openWithoutDeleteSharing(t *testing.T, path string) windows.Handle {
t.Helper()
pathPtr, err := windows.UTF16PtrFromString(path)
require.NoError(t, err)
handle, err := windows.CreateFile(pathPtr, windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
require.NoError(t, err)
return handle
}
func TestRotatingFileBlockedByOtherProcess(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "windows_exporter.log")
now := time.Date(2026, 10, 15, 14, 3, 5, 0, time.UTC)
file, err := newRotatingFile(path, 10, 0, false, func() time.Time { return now })
require.NoError(t, err)
t.Cleanup(func() { _ = file.Close() })
write(t, file, "1234567890")
blocker := openWithoutDeleteSharing(t, path)
// The rename fails while the file is held: the line is appended instead.
write(t, file, "while blocked\n")
require.Equal(t, []string{"windows_exporter.log"}, dirEntries(t, filepath.Dir(path)))
require.NoError(t, windows.CloseHandle(blocker))
now = now.Add(file.retryInterval)
write(t, file, "after release\n")
require.NoError(t, file.Close())
require.Equal(t, "1234567890while blocked\n", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-04-05.000.log")))
require.Equal(t, "after release\n", readFile(t, path))
}
func TestOpenLogFileAllowsRenameAndDelete(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "windows_exporter.log")
file, err := openLogFile(path)
require.NoError(t, err)
t.Cleanup(func() { _ = file.Close() })
_, err = file.WriteString("first\n")
require.NoError(t, err)
// An admin can move the file while the exporter writes to it.
moved := filepath.Join(dir, "moved.log")
require.NoError(t, os.Rename(path, moved))
_, err = file.WriteString("second\n")
require.NoError(t, err)
require.Equal(t, "first\nsecond\n", readFile(t, moved))
// And delete it.
require.NoError(t, os.Remove(moved))
}
func TestOpenLogFileAppends(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "windows_exporter.log")
require.NoError(t, os.WriteFile(path, []byte("existing\n"), 0o600))
file, err := openLogFile(path)
require.NoError(t, err)
_, err = file.WriteString("appended\n")
require.NoError(t, err)
require.NoError(t, file.Close())
require.Equal(t, "existing\nappended\n", readFile(t, path))
}
func TestRotatingFileRecoversWhenMovedOrDeleted(t *testing.T) {
t.Parallel()
for name, remove := range map[string]func(path string) error{
"moved": func(path string) error { return os.Rename(path, path+".moved") },
"deleted": os.Remove,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "windows_exporter.log")
now := time.Date(2026, 10, 15, 14, 3, 5, 0, time.UTC)
file, err := newRotatingFile(path, 1<<20, 0, false, func() time.Time { return now })
require.NoError(t, err)
t.Cleanup(func() { _ = file.Close() })
write(t, file, "before\n")
// Share-delete lets an admin move or delete the file while it is open.
require.NoError(t, remove(path))
now = now.Add(file.retryInterval)
write(t, file, "after\n")
require.NoError(t, file.Close())
require.Equal(t, "after\n", readFile(t, path))
})
}
}
func TestSameFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "windows_exporter.log")
file, err := openLogFile(path)
require.NoError(t, err)
t.Cleanup(func() { _ = file.Close() })
same, err := sameFile(file, path)
require.NoError(t, err)
require.True(t, same)
require.NoError(t, os.Rename(path, filepath.Join(dir, "moved.log")))
require.NoError(t, os.WriteFile(path, []byte("other"), 0o600))
same, err = sameFile(file, path)
require.NoError(t, err)
require.False(t, same, "a new file at the path is not the open file")
}