diff --git a/go.mod b/go.mod index be576413..21c7dd6b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect lukechampine.com/uint128 v1.1.1 // indirect modernc.org/cc/v3 v3.36.0 // indirect modernc.org/ccgo/v3 v3.16.8 // indirect diff --git a/go.sum b/go.sum index 39780c60..39eac3b6 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.36.0 h1:0kmRkTmqNidmu3c7BNDSdVHCxXCkWLmWmCIVX4LUboo= diff --git a/internal/log/log.go b/internal/log/log.go index 5733e252..bc4f4a46 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -9,10 +9,35 @@ import ( "time" "log/slog" + "gopkg.in/natefinch/lumberjack.v2" ) var logger *slog.Logger +// Config holds logging configuration +type Config struct { + Level string + Output string // "stdout", "stderr", or file path + Rotation bool // enable file rotation + MaxSize int // MB + MaxBackups int + MaxAge int // days + Compress bool +} + +// DefaultConfig returns default logging configuration +func DefaultConfig() Config { + return Config{ + Level: "INFO", + Output: "stdout", + Rotation: false, + MaxSize: 100, + MaxBackups: 3, + MaxAge: 7, + Compress: true, + } +} + // sensitiveFields contains field names that should be sanitized in logs var sensitiveFields = []string{ "token", @@ -35,14 +60,47 @@ func Init() { // InitWithLevel initializes the logger with specified level func InitWithLevel(level string) { - levelVar := parseLevel(level) + cfg := DefaultConfig() + cfg.Level = level + InitWithConfig(cfg) +} + +// InitWithConfig initializes the logger with full configuration +func InitWithConfig(cfg Config) { + levelVar := parseLevel(cfg.Level) opts := &slog.HandlerOptions{ Level: levelVar, ReplaceAttr: sanitizeAttrs, } - handler := slog.NewJSONHandler(os.Stdout, opts) + var handler slog.Handler + + switch cfg.Output { + case "stdout": + handler = slog.NewJSONHandler(os.Stdout, opts) + case "stderr": + handler = slog.NewJSONHandler(os.Stderr, opts) + default: + // File output with optional rotation + if cfg.Rotation { + lumberjackLogger := &lumberjack.Logger{ + Filename: cfg.Output, + MaxSize: cfg.MaxSize, + MaxBackups: cfg.MaxBackups, + MaxAge: cfg.MaxAge, + Compress: cfg.Compress, + } + handler = slog.NewJSONHandler(lumberjackLogger, opts) + } else { + file, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + panic(err) + } + handler = slog.NewJSONHandler(file, opts) + } + } + logger = slog.New(handler) slog.SetDefault(logger) } diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 1661e397..fcb21bb8 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -2,6 +2,7 @@ package log import ( "log/slog" + "os" "testing" ) @@ -130,3 +131,59 @@ func TestRequestLogger(t *testing.T) { t.Error("RequestLogger should not return nil") } } + +func TestInitWithConfig(t *testing.T) { + // Test with stdout output + cfg := DefaultConfig() + cfg.Output = "stdout" + cfg.Level = "DEBUG" + InitWithConfig(cfg) + + if logger == nil { + t.Error("logger should not be nil after InitWithConfig") + } +} + +func TestInitWithConfigFileOutput(t *testing.T) { + // Test with file output (no rotation) + tmpFile := t.TempDir() + "/test.log" + cfg := DefaultConfig() + cfg.Output = tmpFile + cfg.Rotation = false + InitWithConfig(cfg) + + Info("test message for file") + + // Verify file was created + if _, err := os.Stat(tmpFile); os.IsNotExist(err) { + t.Errorf("log file %s should exist", tmpFile) + } +} + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Level != "INFO" { + t.Errorf("default Level = %s, want INFO", cfg.Level) + } + + if cfg.Output != "stdout" { + t.Errorf("default Output = %s, want stdout", cfg.Output) + } + + if cfg.MaxSize != 100 { + t.Errorf("default MaxSize = %d, want 100", cfg.MaxSize) + } + + if cfg.MaxBackups != 3 { + t.Errorf("default MaxBackups = %d, want 3", cfg.MaxBackups) + } + + if cfg.MaxAge != 7 { + t.Errorf("default MaxAge = %d, want 7", cfg.MaxAge) + } + + if !cfg.Compress { + t.Error("default Compress should be true") + } +}