template/pkg/logger/logger.go
djmil 13f6a6812a result.Wrap - propagate a failed Expect into a new type U
- public API streamline
- Failf[T]("msg") -	originate a failure from a message; embed a cause with %w
- Err[T](err) - sets .err verbatim (the return zero, err / sentinel case)
2026-06-14 11:43:23 +00:00

160 lines
5.7 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, debugOut) — auto-detects: human text on a terminal, JSON when piped.
// Passing a non-nil debugOut enables debug mode: screen shows debug messages
// and the writer receives a full JSON trace. Any io.Writer is accepted —
// a file, a bytes.Buffer, os.Stdout, a network connection. The caller owns
// the writer and is responsible for closing it.
//
// Typical CLI use:
//
// // Normal mode:
// log := logger.NewCLI("info", nil).Expect("create logger")
//
// // Debug mode — full trace to a file:
// f, err := os.Create(path) // caller controls the file; #nosec G304 if path is a CLI flag
// if err != nil { ... }
// defer f.Close()
// log := logger.NewCLI("info", f).Expect("create logger")
//
// 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")
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.Wrap[*Logger](lvl, "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 (debugOut == nil): human-readable text, INFO and above on screen.
// - Debug (debugOut != nil): same screen output, plus full JSON trace written
// to debugOut. level="debug" also activates debug mode without a writer.
//
// When stderr is not a terminal (piped or redirected), NewCLI behaves like
// New: JSON at the given level to stderr; debugOut is ignored.
//
// The caller owns debugOut and is responsible for closing it when done.
// Valid levels: debug, info, warn, error.
func NewCLI(level string, debugOut io.Writer) result.Expect[*Logger] {
lvl := parseLevel(level)
if lvl.Err() != nil {
return result.Wrap[*Logger](lvl, "parse log level")
}
if !isTerminal(os.Stderr) {
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
return result.Ok(&Logger{slog.New(h)})
}
// debugOut or level="debug" activates debug mode.
screenLevel := slog.LevelInfo
if debugOut != nil || lvl.Value() <= slog.LevelDebug {
screenLevel = slog.LevelDebug
}
screen := newHumanHandler(os.Stderr, screenLevel)
var h slog.Handler = screen
if debugOut != nil {
dump := slog.NewJSONHandler(debugOut, &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.Wrap[*Logger](lvl, "parse log level")
}
h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
return result.Ok(&Logger{slog.New(h)})
}
// Flush writes any pending debug output. Only needed if a debug loop is the
// very last operation before exit with no subsequent INFO/WARN/ERROR record.
// Most programs never need this — an INFO at the end commits the line naturally.
func (l *Logger) Flush() {
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
err := lvl.UnmarshalText([]byte(level))
return result.Of(lvl, err)
}