// Package log provides structured logging using slog. // It supports JSON output, configurable log levels, and sensitive field sanitization. package log import ( "context" "os" "strings" "time" "gopkg.in/natefinch/lumberjack.v2" "log/slog" ) 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", "password", "secret", "key", "credential", "auth", "api_key", "api_secret", "private_key", "access_token", "refresh_token", } // Init initializes the logger with JSON handler and INFO level func Init() { InitWithLevel("INFO") } // InitWithLevel initializes the logger with specified level func InitWithLevel(level string) { 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, } 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) } // parseLevel parses string level to slog.Level func parseLevel(level string) slog.Level { switch strings.ToUpper(level) { case "DEBUG": return slog.LevelDebug case "INFO": return slog.LevelInfo case "WARN", "WARNING": return slog.LevelWarn case "ERROR": return slog.LevelError default: return slog.LevelInfo } } // sanitizeAttrs sanitizes sensitive fields in log attributes func sanitizeAttrs(groups []string, a slog.Attr) slog.Attr { // Check if attribute key contains sensitive field name keyLower := strings.ToLower(a.Key) for _, field := range sensitiveFields { if strings.Contains(keyLower, field) { return slog.String(a.Key, "[REDACTED]") } } return a } // IsSensitive checks if a field name is sensitive func IsSensitive(field string) bool { fieldLower := strings.ToLower(field) for _, f := range sensitiveFields { if strings.Contains(fieldLower, f) { return true } } return false } // Info logs an info level message func Info(msg string, args ...any) { slog.Info(msg, args...) } // Error logs an error level message func Error(msg string, args ...any) { slog.Error(msg, args...) } // Debug logs a debug level message func Debug(msg string, args ...any) { slog.Debug(msg, args...) } // Warn logs a warning level message func Warn(msg string, args ...any) { slog.Warn(msg, args...) } // Logger returns the underlying logger func Logger() *slog.Logger { return logger } // WithContext returns a logger with context func WithContext(ctx context.Context) *slog.Logger { // Extract trace_id from context if present if traceID, ok := ctx.Value("trace_id").(string); ok { return logger.With("trace_id", traceID) } return logger } // RequestLogger returns a logger with request fields func RequestLogger(method, path, clientIP string) *slog.Logger { return logger.With( "method", method, "path", path, "client_ip", clientIP, "time", time.Now().UTC(), ) } // Fatal logs an error message and exits with code 1 func Fatal(msg string, args ...any) { slog.Error(msg, args...) os.Exit(1) }