template/pkg/logger/logger.go

107 lines
3.5 KiB
Go

// Package logger wraps log/slog with a thin, ergonomic API.
//
// Per the Twelve-Factor App (factor XI), the application writes structured log
// events to stderr and never manages log files or routing itself. The execution
// environment (shell, systemd, Docker) is responsible for capturing and storing
// the stream. Human-readable output belongs on stdout via fmt.Print*.
//
// Typical use:
//
// log := logger.New("info").Expect("create logger")
// log.Info("server started", "port", 8080)
//
// // child logger for request-scoped fields that repeat across many lines:
// req := log.WithField("request_id", rid)
// req.Info("start")
// req.Info("end")
//
// In tests that need to assert on log content, use NewWriter with a buffer:
//
// var buf bytes.Buffer
// log := logger.NewWriter(&buf, "debug").Expect("create logger")
// // ... exercise code ...
// // assert on buf.String()
package logger
import (
"io"
"log/slog"
"os"
"gitea.djmil.dev/go/template/pkg/result"
)
// Logger is a thin wrapper around *slog.Logger.
// All slog methods (Info, Error, Debug, Warn, …) are available via embedding.
type Logger struct {
*slog.Logger
}
// New creates a JSON logger writing to stderr for the given level string.
// Valid levels: debug, info, warn, error.
func New(level string) result.Expect[*Logger] {
lvl := parseLevel(level)
if lvl.Err() != nil {
return result.Errw[*Logger](lvl.Err(), "parse log level")
}
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
logger := &Logger{slog.New(handler)}
return result.Ok(logger)
}
// NewDevelopment creates a human-friendly text logger writing to stderr at debug level.
// Use this in local dev; prefer New() in any deployed environment.
func NewDevelopment() *Logger {
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
return &Logger{slog.New(h)}
}
// NewWriter creates a JSON logger writing to w. Intended for tests that need to
// assert on log content — pass a *bytes.Buffer and inspect it after the fact.
func NewWriter(w io.Writer, level string) result.Expect[*Logger] {
lvl := parseLevel(level)
if lvl.Err() != nil {
return result.Errw[*Logger](lvl.Err(), "parse log level")
}
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
return result.Ok(&Logger{slog.New(handler)})
}
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
func NewNop() *Logger {
return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))}
}
// WithField returns a child logger that always includes key=value in every log line.
func (l *Logger) WithField(key string, value any) *Logger {
return &Logger{l.Logger.With(key, value)}
}
// WithFields returns a child logger enriched with every key/value in fields.
// Prefer WithField for one or two fields; use WithFields for structured context
// objects (e.g. attaching a request span).
func (l *Logger) WithFields(fields map[string]any) *Logger {
args := make([]any, 0, len(fields)*2)
for k, v := range fields {
args = append(args, k, v)
}
return &Logger{l.Logger.With(args...)}
}
// ── helpers ───────────────────────────────────────────────────────────────────
func parseLevel(level string) result.Expect[slog.Level] {
var lvl slog.Level
if err := lvl.UnmarshalText([]byte(level)); err != nil {
return result.Errw[slog.Level](err, "unknown level (use debug|info|warn|error)")
}
return result.Ok(lvl)
}