- config: env-based loading with startup validation, 13 tests - logger: structured slog wrapper with redaction, 10 tests - errors: unified AppError model with HTTP mapping, 18 tests - 41 tests total, build + vet clean
200 lines
4.7 KiB
Go
200 lines
4.7 KiB
Go
// 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
|
|
}
|