// 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) }