Two modes for interactive CLI use — driven by debugFile presence: - Normal (debugFile=""): human text on screen, INFO and above. - Debug (debugFile set): same screen + full JSON trace to file. Auto-detects TTY; falls back to 12-factor JSON when piped/redirected. IsInteractive() exposes TTY detection for call sites that need it. Terminal format: INFO has no prefix (program's normal voice); WARN prints "warning: …"; ERROR prints "error: …"; DEBUG "debug: …". Breaking: NewDevelopment removed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
157 lines
5.5 KiB
Go
157 lines
5.5 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)})
|
|
}
|
|
|
|
screen := newHumanHandler(os.Stderr, slog.LevelInfo)
|
|
|
|
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}
|
|
}
|
|
|
|
log := &Logger{slog.New(h)}
|
|
if lvl.Value() > slog.LevelInfo {
|
|
log.Warn("log level set above INFO — interactive mode always shows INFO and above",
|
|
"requested", level)
|
|
}
|
|
return result.Ok(log)
|
|
}
|
|
|
|
// 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)})
|
|
}
|
|
|
|
// 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)
|
|
}
|