Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/xmidt-org/wrpkafka v0.1.2
go.uber.org/fx v1.24.0
gopkg.in/dealancer/validate.v2 v2.1.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/dealancer/validate.v2 v2.1.0 h1:XY95SZhVH1rBe8uwtnQEsOO79rv8GPwK+P3VWhQfJbA=
gopkg.in/dealancer/validate.v2 v2.1.0/go.mod h1:EipWMj8hVO2/dPXVlYRe9yKcgVd5OttpQDiM1/wZ0DE=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
7 changes: 7 additions & 0 deletions internal/app/default-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,10 @@ logger:

# Time encoding (iso8601, millis, nanos, rfc3339)
encode_time: "iso8601"

# rotation: # Log rotation settings (for file outputs)
# maxsize: 50 # Max size in MB before rotation
# maxage: 2 # Days to keep old files
# maxbackups: 10 # Number of old files to retain
# compress: true # Compress rotated files
# localtime: false # Use local time for backup filenames
48 changes: 39 additions & 9 deletions internal/app/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"

"go.uber.org/fx/fxevent"
"gopkg.in/natefinch/lumberjack.v2"
)

// LogConfig defines the configuration for structured logging using slog.
Expand All @@ -34,6 +35,27 @@ type LogConfig struct {

// EncodeTime defines how to encode the time (iso8601, millis, nanos, rfc3339)
EncodeTime string `default:"iso8601"`

// Rotation configures log rotation for file outputs
Rotation RotationConfig
}

// RotationConfig defines log rotation settings
type RotationConfig struct {
// MaxSize is the maximum size in megabytes of the log file before it gets rotated
MaxSize int `yaml:"maxsize" default:"50"`

// MaxAge is the maximum number of days to retain old log files
MaxAge int `yaml:"maxage" default:"2"`

// MaxBackups is the maximum number of old log files to retain
MaxBackups int `yaml:"maxbackups" default:"10"`

// Compress determines if rotated files should be compressed using gzip
Compress bool `yaml:"compress" default:"true"`

// LocalTime determines if the time used for formatting the timestamps in backup files is the computer's local time
LocalTime bool `yaml:"localtime" default:"false"`
}

// newLogger creates a new structured logger based on the configuration.
Expand All @@ -50,11 +72,15 @@ func newLogger(cfg LogConfig) (*slog.Logger, error) {
case "stderr":
out = os.Stderr
default:
file, err := os.OpenFile(cfg.OutputPaths[0], os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
// Use rotating logger for file outputs
out = &lumberjack.Logger{
Filename: cfg.OutputPaths[0],
MaxSize: cfg.Rotation.MaxSize,
MaxAge: cfg.Rotation.MaxAge,
MaxBackups: cfg.Rotation.MaxBackups,
Compress: cfg.Rotation.Compress,
LocalTime: cfg.Rotation.LocalTime,
}
out = file
}
default:
// For multiple output paths, write to all
Expand All @@ -66,11 +92,15 @@ func newLogger(cfg LogConfig) (*slog.Logger, error) {
case "stderr":
writers = append(writers, os.Stderr)
default:
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
writers = append(writers, file)
// Use rotating logger for file outputs
writers = append(writers, &lumberjack.Logger{
Filename: path,
MaxSize: cfg.Rotation.MaxSize,
MaxAge: cfg.Rotation.MaxAge,
MaxBackups: cfg.Rotation.MaxBackups,
Compress: cfg.Rotation.Compress,
LocalTime: cfg.Rotation.LocalTime,
})
}
}
out = io.MultiWriter(writers...)
Expand Down
143 changes: 143 additions & 0 deletions internal/app/logger_rotation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// SPDX-FileCopyrightText: 2026 Comcast Cable Communications Management, LLC
// SPDX-License-Identifier: Apache-2.0

package app

import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewLogger_WithRotation(t *testing.T) {
// Create a temporary directory for test logs
tmpDir, err := os.MkdirTemp("", "splitter-log-rotation-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

logFile := filepath.Join(tmpDir, "test.log")

cfg := LogConfig{
Level: "INFO",
Encoding: "json",
OutputPaths: []string{logFile},
Rotation: RotationConfig{
MaxSize: 1, // 1 MB for quick testing
MaxAge: 1, // 1 day
MaxBackups: 2, // Keep 2 backups
Compress: true,
LocalTime: true,
},
}

logger, err := newLogger(cfg)
require.NoError(t, err)
require.NotNil(t, logger)

// Log some messages
logger.Info("test message 1", "key", "value1")
logger.Info("test message 2", "key", "value2")
logger.Error("test error", "error", "something went wrong")

// Give it a moment to write
time.Sleep(100 * time.Millisecond)

// Verify log file was created
assert.FileExists(t, logFile)

// Read and verify content
content, err := os.ReadFile(logFile)
require.NoError(t, err)
assert.Contains(t, string(content), "test message 1")
assert.Contains(t, string(content), "test message 2")
assert.Contains(t, string(content), "test error")
}

func TestNewLogger_MultipleOutputsWithRotation(t *testing.T) {
// Create a temporary directory for test logs
tmpDir, err := os.MkdirTemp("", "splitter-log-multi-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

logFile1 := filepath.Join(tmpDir, "test1.log")
logFile2 := filepath.Join(tmpDir, "test2.log")

cfg := LogConfig{
Level: "DEBUG",
Encoding: "console",
OutputPaths: []string{"stdout", logFile1, logFile2},
Rotation: RotationConfig{
MaxSize: 5,
MaxAge: 7,
MaxBackups: 3,
Compress: false,
LocalTime: false,
},
}

logger, err := newLogger(cfg)
require.NoError(t, err)
require.NotNil(t, logger)

// Log a test message
logger.Debug("debug message for multiple outputs")

// Give it a moment to write
time.Sleep(100 * time.Millisecond)

// Both log files should exist and contain the message
assert.FileExists(t, logFile1)
assert.FileExists(t, logFile2)

content1, err := os.ReadFile(logFile1)
require.NoError(t, err)
assert.Contains(t, string(content1), "debug message for multiple outputs")

content2, err := os.ReadFile(logFile2)
require.NoError(t, err)
assert.Contains(t, string(content2), "debug message for multiple outputs")
}

func TestNewLogger_RotationConfigDefaults(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "splitter-log-defaults-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

logFile := filepath.Join(tmpDir, "defaults.log")

cfg := LogConfig{
Level: "WARN",
OutputPaths: []string{logFile},
// Rotation config not specified - should use defaults
}

logger, err := newLogger(cfg)
require.NoError(t, err)
require.NotNil(t, logger)

logger.Warn("warning with default rotation config")

// Give it a moment to write
time.Sleep(100 * time.Millisecond)

assert.FileExists(t, logFile)
content, err := os.ReadFile(logFile)
require.NoError(t, err)
assert.Contains(t, string(content), "warning with default rotation config")
}

func TestRotationConfig_DefaultValues(t *testing.T) {
// Test that rotation config struct has sensible zero values
var cfg RotationConfig

// These should be the zero values, which are handled by lumberjack defaults
assert.Equal(t, 0, cfg.MaxSize)
assert.Equal(t, 0, cfg.MaxAge)
assert.Equal(t, 0, cfg.MaxBackups)
assert.False(t, cfg.Compress)
assert.False(t, cfg.LocalTime)
}
25 changes: 21 additions & 4 deletions internal/app/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ func (s *LoggerTestSuite) TestNewLogger_FileOutput() {
s.NoError(err)
s.NotNil(logger)

// Log a message to trigger file creation
logger.Info("test message")

// Verify file was created
s.FileExists(logFile)
}
Expand All @@ -189,9 +192,13 @@ func (s *LoggerTestSuite) TestNewLogger_InvalidFilePath() {
OutputPaths: []string{invalidPath},
}

// Logger creation succeeds with lumberjack, but writing will fail
logger, err := newLogger(cfg)
s.Error(err)
s.Nil(logger)
s.NoError(err)
s.NotNil(logger)

// The error will occur when we try to write, not during logger creation
// This is expected behavior with lumberjack
}

// Test newLogger with multiple outputs
Expand All @@ -209,6 +216,9 @@ func (s *LoggerTestSuite) TestNewLogger_MultipleOutputs() {
s.NoError(err)
s.NotNil(logger)

// Log a message to trigger file creation
logger.Info("test message")

// Verify files were created
s.FileExists(logFile1)
s.FileExists(logFile2)
Expand All @@ -225,9 +235,16 @@ func (s *LoggerTestSuite) TestNewLogger_MultipleOutputs_WithInvalidPath() {
OutputPaths: []string{"stdout", logFile, invalidPath},
}

// Logger creation will succeed with lumberjack even with invalid paths
logger, err := newLogger(cfg)
s.Error(err)
s.Nil(logger)
s.NoError(err)
s.NotNil(logger)

// Log a message - some outputs may fail silently but valid ones should work
logger.Info("test message")

// At least the valid log file should be created
s.FileExists(logFile)
}

// Test newLogger logs message to file
Expand Down
Loading