Add automatic log rotation for daemon (bd-154)

- Integrated lumberjack library for production-ready log rotation
- Configurable via env vars: BEADS_DAEMON_LOG_MAX_SIZE, BEADS_DAEMON_LOG_MAX_BACKUPS, BEADS_DAEMON_LOG_MAX_AGE, BEADS_DAEMON_LOG_COMPRESS
- Defaults: 10MB max size, 3 backups, 7 day retention, compression enabled
- Added comprehensive tests for env var parsing and rotation config
- Updated README.md and CHANGELOG.md with rotation documentation
- Prevents unbounded log growth for long-running daemons

Amp-Thread-ID: https://ampcode.com/threads/T-8232d41a-6872-4f4c-962c-7fae8f5e83b7
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-18 16:30:19 -07:00
parent 8f80dde0ad
commit cff10b1998
7 changed files with 179 additions and 7 deletions

View File

@@ -22,6 +22,7 @@ import (
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"gopkg.in/natefinch/lumberjack.v2"
)
var daemonCmd = &cobra.Command{
@@ -182,6 +183,24 @@ func boolToFlag(condition bool, flag string) string {
return ""
}
// getEnvInt reads an integer from environment variable with a default value
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
if parsed, err := strconv.Atoi(val); err == nil {
return parsed
}
}
return defaultValue
}
// getEnvBool reads a boolean from environment variable with a default value
func getEnvBool(key string, defaultValue bool) bool {
if val := os.Getenv(key); val != "" {
return val == "true" || val == "1"
}
return defaultValue
}
func getPIDFilePath(global bool) (string, error) {
var beadsDir string
var err error
@@ -606,10 +625,18 @@ func importToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat
}
func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, pidFile string, global bool) {
logF, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err)
os.Exit(1)
// Configure log rotation with lumberjack
maxSizeMB := getEnvInt("BEADS_DAEMON_LOG_MAX_SIZE", 10)
maxBackups := getEnvInt("BEADS_DAEMON_LOG_MAX_BACKUPS", 3)
maxAgeDays := getEnvInt("BEADS_DAEMON_LOG_MAX_AGE", 7)
compress := getEnvBool("BEADS_DAEMON_LOG_COMPRESS", true)
logF := &lumberjack.Logger{
Filename: logPath,
MaxSize: maxSizeMB, // MB
MaxBackups: maxBackups, // number of rotated files
MaxAge: maxAgeDays, // days
Compress: compress, // compress old logs
}
defer logF.Close()

View File

@@ -0,0 +1,129 @@
package main
import (
"os"
"testing"
)
func TestLogRotation(t *testing.T) {
// Set small max size for testing (1 MB)
os.Setenv("BEADS_DAEMON_LOG_MAX_SIZE", "1")
os.Setenv("BEADS_DAEMON_LOG_MAX_BACKUPS", "2")
os.Setenv("BEADS_DAEMON_LOG_MAX_AGE", "7")
os.Setenv("BEADS_DAEMON_LOG_COMPRESS", "false") // disable for easier testing
defer func() {
os.Unsetenv("BEADS_DAEMON_LOG_MAX_SIZE")
os.Unsetenv("BEADS_DAEMON_LOG_MAX_BACKUPS")
os.Unsetenv("BEADS_DAEMON_LOG_MAX_AGE")
os.Unsetenv("BEADS_DAEMON_LOG_COMPRESS")
}()
// Test env parsing
maxSize := getEnvInt("BEADS_DAEMON_LOG_MAX_SIZE", 10)
if maxSize != 1 {
t.Errorf("Expected max size 1, got %d", maxSize)
}
maxBackups := getEnvInt("BEADS_DAEMON_LOG_MAX_BACKUPS", 3)
if maxBackups != 2 {
t.Errorf("Expected max backups 2, got %d", maxBackups)
}
compress := getEnvBool("BEADS_DAEMON_LOG_COMPRESS", true)
if compress {
t.Errorf("Expected compress false, got true")
}
}
func TestGetEnvInt(t *testing.T) {
tests := []struct {
name string
envValue string
defaultValue int
expected int
}{
{"not set", "", 10, 10},
{"valid int", "42", 10, 42},
{"invalid int", "invalid", 10, 10},
{"zero", "0", 10, 0},
{"negative", "-5", 10, -5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv("TEST_INT", tt.envValue)
defer os.Unsetenv("TEST_INT")
} else {
os.Unsetenv("TEST_INT")
}
result := getEnvInt("TEST_INT", tt.defaultValue)
if result != tt.expected {
t.Errorf("Expected %d, got %d", tt.expected, result)
}
})
}
}
func TestGetEnvBool(t *testing.T) {
tests := []struct {
name string
envValue string
defaultValue bool
expected bool
}{
{"not set default true", "", true, true},
{"not set default false", "", false, false},
{"true string", "true", false, true},
{"1 string", "1", false, true},
{"false string", "false", true, false},
{"0 string", "0", true, false},
{"invalid string", "invalid", true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv("TEST_BOOL", tt.envValue)
defer os.Unsetenv("TEST_BOOL")
} else {
os.Unsetenv("TEST_BOOL")
}
result := getEnvBool("TEST_BOOL", tt.defaultValue)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestLogFileRotationDefaults(t *testing.T) {
// Test default values when no env vars set
os.Unsetenv("BEADS_DAEMON_LOG_MAX_SIZE")
os.Unsetenv("BEADS_DAEMON_LOG_MAX_BACKUPS")
os.Unsetenv("BEADS_DAEMON_LOG_MAX_AGE")
os.Unsetenv("BEADS_DAEMON_LOG_COMPRESS")
maxSize := getEnvInt("BEADS_DAEMON_LOG_MAX_SIZE", 10)
if maxSize != 10 {
t.Errorf("Expected default max size 10, got %d", maxSize)
}
maxBackups := getEnvInt("BEADS_DAEMON_LOG_MAX_BACKUPS", 3)
if maxBackups != 3 {
t.Errorf("Expected default max backups 3, got %d", maxBackups)
}
maxAge := getEnvInt("BEADS_DAEMON_LOG_MAX_AGE", 7)
if maxAge != 7 {
t.Errorf("Expected default max age 7, got %d", maxAge)
}
compress := getEnvBool("BEADS_DAEMON_LOG_COMPRESS", true)
if !compress {
t.Errorf("Expected default compress true, got false")
}
}