template/pkg/logger/logger.go
djmil 5ec8e6999a pkg/logger: human handler UX— colon format- debug mode- dedup counter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:48:51 +00:00

167 lines
5.9 KiB
Go

// Package logger wraps log/slog with a thin, ergonomic API.
//
// Design principle: call sites express what is logged, not how it reaches the
// reader. The constructor picks the right format for the execution environment;
// application code never changes.
//
// Per the Twelve-Factor App (factor XI), logs are written to stderr as an
// unbuffered stream. The execution environment (shell, systemd, Docker, k8s)
// routes and stores the stream.
//
// Two constructors write to stderr:
//
// - New(level) — JSON, 12-factor compatible; use for headless services.
// - NewCLI(level, debugFile) — auto-detects: human text on a terminal, JSON when piped.
// Providing a non-empty debugFile enables debug mode: DEBUG writes to that
// file as JSON; the screen always shows INFO and above.
//
// Typical use in a CLI application:
//
// log := logger.NewCLI("info", "").Expect("create logger")
// log.Info("server started", "port", 8080)
//
// // child logger with request-scoped fields:
// 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.
// Use for headless services; prefer NewCLI for programs invoked by a human.
// 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")
}
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
return result.Ok(&Logger{slog.New(h)})
}
// NewCLI creates a logger for programs invoked by a human operator.
//
// When stderr is a terminal it operates in one of two modes:
// - Normal (debugFile == ""): human-readable text, INFO and above on screen.
// - Debug (debugFile != ""): same screen output, plus a full JSON trace of
// DEBUG and above written to debugFile for post-run investigation.
//
// When stderr is not a terminal (piped or redirected), NewCLI behaves like
// New: JSON at the given level to stderr; debugFile is ignored.
//
// Passing a level above "info" when running in a terminal is unusual —
// NewCLI will log a warning because interactive mode always shows INFO and above.
//
// Valid levels: debug, info, warn, error.
func NewCLI(level, debugFile string) result.Expect[*Logger] {
lvl := parseLevel(level)
if lvl.Err() != nil {
return result.Errw[*Logger](lvl.Err(), "parse log level")
}
if !isTerminal(os.Stderr) {
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
return result.Ok(&Logger{slog.New(h)})
}
// debugFile activates debug mode regardless of level; level="debug" also activates it.
screenLevel := slog.LevelInfo
if debugFile != "" || lvl.Value() <= slog.LevelDebug {
screenLevel = slog.LevelDebug
}
screen := newHumanHandler(os.Stderr, screenLevel)
var h slog.Handler = screen
if debugFile != "" {
f, err := os.Create(debugFile)
if err != nil {
return result.Errw[*Logger](err, "create debug log")
}
dump := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: slog.LevelDebug})
h = multiHandler{screen, dump}
}
return result.Ok(&Logger{slog.New(h)})
}
// IsInteractive reports whether stderr is attached to a terminal.
// Use this when the application itself needs to adjust behavior based on
// whether a human operator is watching (e.g. progress bars, prompts).
func IsInteractive() bool {
return isTerminal(os.Stderr)
}
// 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")
}
h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
return result.Ok(&Logger{slog.New(h)})
}
// Close flushes any pending debug output. Call defer log.Close() at the entry
// point so a debug line that was never followed by an INFO/WARN/ERROR record
// still gets its trailing newline before the process exits.
func (l *Logger) Close() {
type flusher interface{ flush() }
if f, ok := l.Handler().(flusher); ok {
f.flush()
}
}
// 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)
}