// Package logger provides a structured logging wrapper around Go's slog package. // It supports text and JSON formats, configurable log levels, and automatic source location. // Sensitive fields (API keys, passwords) are automatically redacted. package logger import ( "context" "fmt" "io" "log/slog" "os" "strings" "time" ) // Level represents the severity of a log message. type Level = slog.Level const ( LevelDebug = slog.LevelDebug LevelInfo = slog.LevelInfo LevelWarn = slog.LevelWarn LevelError = slog.LevelError ) // Logger wraps slog.Logger with redaction and context support. // It does NOT embed *slog.Logger to prevent bypassing redaction. type Logger struct { inner *slog.Logger level Level redact bool } // New creates a new Logger with the specified writer, level, and format. func New(w io.Writer, level Level, format string) *Logger { if w == nil { w = os.Stderr } opts := &slog.HandlerOptions{ Level: level, AddSource: level <= LevelDebug, } var h slog.Handler switch format { case "json": h = slog.NewJSONHandler(w, opts) default: h = slog.NewTextHandler(w, opts) } return &Logger{inner: slog.New(h), level: level, redact: true} } // NewNop creates a no-op logger that discards all output. func NewNop() *Logger { return &Logger{ inner: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), level: LevelDebug, redact: false, } } // ParseLevel converts a string to a slog Level. func ParseLevel(s string) (Level, error) { switch strings.ToLower(s) { case "debug": return LevelDebug, nil case "info": return LevelInfo, nil case "warn": return LevelWarn, nil case "error": return LevelError, nil default: return LevelInfo, fmt.Errorf("unknown log level: %q (valid: debug, info, warn, error)", s) } } // With returns a child Logger with the given attributes attached. func (l *Logger) With(args ...any) *Logger { return &Logger{ inner: l.inner.With(args...), level: l.level, redact: l.redact, } } // WithGroup returns a child Logger with the given group name. func (l *Logger) WithGroup(name string) *Logger { return &Logger{ inner: l.inner.WithGroup(name), level: l.level, redact: l.redact, } } // Level returns the current log level. func (l *Logger) Level() Level { return l.level } // ---- Core logging methods ---- func (l *Logger) Debug(msg string, args ...any) { l.log(LevelDebug, msg, args...) } func (l *Logger) Info(msg string, args ...any) { l.log(LevelInfo, msg, args...) } func (l *Logger) Warn(msg string, args ...any) { l.log(LevelWarn, msg, args...) } func (l *Logger) Error(msg string, args ...any) { l.log(LevelError, msg, args...) } // ---- Context-aware variants ---- func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) { l.logCtx(ctx, LevelDebug, msg, args...) } func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) { l.logCtx(ctx, LevelInfo, msg, args...) } func (l *Logger) WarnContext(ctx context.Context, msg string, args ...any) { l.logCtx(ctx, LevelWarn, msg, args...) } func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) { l.logCtx(ctx, LevelError, msg, args...) } // ---- Internal ---- func (l *Logger) log(level Level, msg string, args ...any) { if !l.inner.Enabled(context.Background(), level) { return } attrs := l.buildAttrs(args...) r := slog.NewRecord(time.Now(), level, msg, 0) r.AddAttrs(attrs...) _ = l.inner.Handler().Handle(context.Background(), r) } func (l *Logger) logCtx(ctx context.Context, level Level, msg string, args ...any) { if !l.inner.Enabled(ctx, level) { return } attrs := l.buildAttrs(args...) r := slog.NewRecord(time.Now(), level, msg, 0) r.AddAttrs(attrs...) _ = l.inner.Handler().Handle(ctx, r) } func (l *Logger) buildAttrs(args ...any) []slog.Attr { if len(args) == 0 { return nil } if len(args)%2 != 0 { args = append(args, "!MISSING") } attrs := make([]slog.Attr, 0, len(args)/2) for i := 0; i < len(args); i += 2 { key, ok := args[i].(string) if !ok { key = fmt.Sprintf("!BADKEY(%v)", args[i]) } val := args[i+1] if l.redact { val = redactValue(key, val) } attrs = append(attrs, slog.Any(key, val)) } return attrs } // ---- Redaction ---- var sensitiveKeys = map[string]bool{ "api_key": true, "apikey": true, "password": true, "secret": true, "token": true, "jwt_secret": true, "admin_password": true, "llm_api_key": true, "dashscope_api_key": true, "access_token": true, } func redactValue(key string, val any) any { if sensitiveKeys[strings.ToLower(key)] { if s, ok := val.(string); ok { if len(s) > 4 { return s[:2] + "****" + s[len(s)-2:] } return "****" } return "****" } return val }