pkg/logger: io.Writer injection, Flush(), -log-dump flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
djmil 2026-06-03 19:31:20 +00:00
parent 5ec8e6999a
commit 15ff92c64a
5 changed files with 52 additions and 43 deletions

View File

@ -19,8 +19,8 @@ type AppConfig struct {
// LoggerConfig controls logging behavior. // LoggerConfig controls logging behavior.
type LoggerConfig struct { type LoggerConfig struct {
Level string // debug | info | warn | error Level string // debug | info | warn | error
DebugFile string // non-empty enables debug mode: writes full JSON trace to this path LogDump string // non-empty enables debug mode: writes full JSON trace to this path
} }
// Greeter config for internal/greeter/Service. // Greeter config for internal/greeter/Service.
@ -42,7 +42,7 @@ func parseArgs() *Config {
port := flag.Int("port", 8080, "listen port") port := flag.Int("port", 8080, "listen port")
env := flag.String("env", "dev", "environment: dev | staging | prod") env := flag.String("env", "dev", "environment: dev | staging | prod")
level := flag.String("log-level", "info", "log level: debug | info | warn | error") level := flag.String("log-level", "info", "log level: debug | info | warn | error")
debugLog := flag.String("debug-log", "", "write full debug trace to file (enables debug mode)") debugLog := flag.String("log-dump", "", "write full debug trace to file (enables debug mode)")
flag.Parse() flag.Parse()
@ -52,8 +52,8 @@ func parseArgs() *Config {
Env: *env, Env: *env,
}, },
Logger: LoggerConfig{ Logger: LoggerConfig{
Level: *level, Level: *level,
DebugFile: *debugLog, LogDump: *debugLog,
}, },
Greeter: GreeterConfig{ Greeter: GreeterConfig{
Name: *name, Name: *name,

View File

@ -6,6 +6,7 @@ package main
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
@ -22,9 +23,13 @@ type app struct {
} }
func newApp(cfg *Config) *app { func newApp(cfg *Config) *app {
// NewCLI auto-detects: human text on a terminal, JSON when piped. // Open the debug writer if requested. The OS closes it on exit.
// Pass -debug-log FILE to enable debug mode (writes full trace to file). var debugOut io.Writer
log := logger.NewCLI(cfg.Logger.Level, cfg.Logger.DebugFile).Expect("create logger") if cfg.Logger.LogDump != "" {
debugOut = result.Of(os.Create(cfg.Logger.LogDump)).Expect("enable logs dump") // #nosec G304 — CLI flag
}
log := logger.NewCLI(cfg.Logger.Level, debugOut).Expect("create logger")
log.Debug("config", "port", cfg.App.Port, "level", cfg.Logger.Level, "env", cfg.App.Env) log.Debug("config", "port", cfg.App.Port, "level", cfg.Logger.Level, "env", cfg.App.Env)
if cfg.App.Env == "dev" { if cfg.App.Env == "dev" {
log.Warn("dev mode — not for production") log.Warn("dev mode — not for production")
@ -55,8 +60,6 @@ func main() {
} }
func (a *app) run() { func (a *app) run() {
defer a.log.Close()
// Warm up the greeter with a few names before the real call. // Warm up the greeter with a few names before the real call.
// In debug mode this produces repeated identical debug lines from the greeter, // In debug mode this produces repeated identical debug lines from the greeter,
// demonstrating how the deduplication counter collapses them in-place. // demonstrating how the deduplication counter collapses them in-place.

View File

@ -11,26 +11,27 @@
// Two constructors write to stderr: // Two constructors write to stderr:
// //
// - New(level) — JSON, 12-factor compatible; use for headless services. // - New(level) — JSON, 12-factor compatible; use for headless services.
// - NewCLI(level, debugFile) — auto-detects: human text on a terminal, JSON when piped. // - NewCLI(level, debugOut) — auto-detects: human text on a terminal, JSON when piped.
// Providing a non-empty debugFile enables debug mode: DEBUG writes to that // Passing a non-nil debugOut enables debug mode: screen shows debug messages
// file as JSON; the screen always shows INFO and above. // 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 use in a CLI application: // Typical CLI use:
// //
// log := logger.NewCLI("info", "").Expect("create logger") // // Normal mode:
// log.Info("server started", "port", 8080) // log := logger.NewCLI("info", nil).Expect("create logger")
// //
// // child logger with request-scoped fields: // // Debug mode — full trace to a file:
// req := log.WithField("request_id", rid) // f, err := os.Create(path) // caller controls the file; #nosec G304 if path is a CLI flag
// req.Info("start") // if err != nil { ... }
// req.Info("end") // 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: // In tests that need to assert on log content, use NewWriter with a buffer:
// //
// var buf bytes.Buffer // var buf bytes.Buffer
// log := logger.NewWriter(&buf, "debug").Expect("create logger") // log := logger.NewWriter(&buf, "debug").Expect("create logger")
// // ... exercise code ...
// // assert on buf.String()
package logger package logger
import ( import (
@ -63,18 +64,16 @@ func New(level string) result.Expect[*Logger] {
// NewCLI creates a logger for programs invoked by a human operator. // NewCLI creates a logger for programs invoked by a human operator.
// //
// When stderr is a terminal it operates in one of two modes: // When stderr is a terminal it operates in one of two modes:
// - Normal (debugFile == ""): human-readable text, INFO and above on screen. // - Normal (debugOut == nil): human-readable text, INFO and above on screen.
// - Debug (debugFile != ""): same screen output, plus a full JSON trace of // - Debug (debugOut != nil): same screen output, plus full JSON trace written
// DEBUG and above written to debugFile for post-run investigation. // to debugOut. level="debug" also activates debug mode without a writer.
// //
// When stderr is not a terminal (piped or redirected), NewCLI behaves like // When stderr is not a terminal (piped or redirected), NewCLI behaves like
// New: JSON at the given level to stderr; debugFile is ignored. // New: JSON at the given level to stderr; debugOut 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.
// //
// The caller owns debugOut and is responsible for closing it when done.
// Valid levels: debug, info, warn, error. // Valid levels: debug, info, warn, error.
func NewCLI(level, debugFile string) result.Expect[*Logger] { func NewCLI(level string, debugOut io.Writer) result.Expect[*Logger] {
lvl := parseLevel(level) lvl := parseLevel(level)
if lvl.Err() != nil { if lvl.Err() != nil {
return result.Errw[*Logger](lvl.Err(), "parse log level") return result.Errw[*Logger](lvl.Err(), "parse log level")
@ -85,20 +84,16 @@ func NewCLI(level, debugFile string) result.Expect[*Logger] {
return result.Ok(&Logger{slog.New(h)}) return result.Ok(&Logger{slog.New(h)})
} }
// debugFile activates debug mode regardless of level; level="debug" also activates it. // debugOut or level="debug" activates debug mode.
screenLevel := slog.LevelInfo screenLevel := slog.LevelInfo
if debugFile != "" || lvl.Value() <= slog.LevelDebug { if debugOut != nil || lvl.Value() <= slog.LevelDebug {
screenLevel = slog.LevelDebug screenLevel = slog.LevelDebug
} }
screen := newHumanHandler(os.Stderr, screenLevel) screen := newHumanHandler(os.Stderr, screenLevel)
var h slog.Handler = screen var h slog.Handler = screen
if debugFile != "" { if debugOut != nil {
f, err := os.Create(debugFile) dump := slog.NewJSONHandler(debugOut, &slog.HandlerOptions{Level: slog.LevelDebug})
if err != nil {
return result.Errw[*Logger](err, "create debug log")
}
dump := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: slog.LevelDebug})
h = multiHandler{screen, dump} h = multiHandler{screen, dump}
} }
@ -124,10 +119,10 @@ func NewWriter(w io.Writer, level string) result.Expect[*Logger] {
return result.Ok(&Logger{slog.New(h)}) return result.Ok(&Logger{slog.New(h)})
} }
// Close flushes any pending debug output. Call defer log.Close() at the entry // Flush writes any pending debug output. Only needed if a debug loop is the
// point so a debug line that was never followed by an INFO/WARN/ERROR record // very last operation before exit with no subsequent INFO/WARN/ERROR record.
// still gets its trailing newline before the process exits. // Most programs never need this — an INFO at the end commits the line naturally.
func (l *Logger) Close() { func (l *Logger) Flush() {
type flusher interface{ flush() } type flusher interface{ flush() }
if f, ok := l.Handler().(flusher); ok { if f, ok := l.Handler().(flusher); ok {
f.flush() f.flush()

View File

@ -22,7 +22,7 @@ func TestNewCLI(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.level, func(t *testing.T) { t.Run(tc.level, func(t *testing.T) {
// In tests stderr is not a terminal — NewCLI uses JSON path. // In tests stderr is not a terminal — NewCLI uses JSON path.
r := logger.NewCLI(tc.level, "") r := logger.NewCLI(tc.level, nil)
if tc.wantErr { if tc.wantErr {
testutil.ResultErr(t, r) testutil.ResultErr(t, r)
return return

View File

@ -119,7 +119,18 @@ func (r Expect[T]) Unwrap() (T, error) {
// [Run] call as a normal Go error. A stack trace is captured at this call site // [Run] call as a normal Go error. A stack trace is captured at this call site
// when [CaptureStack] is true. // when [CaptureStack] is true.
// //
// msg should express intent — what the code was trying to accomplish — not
// the mechanism. This produces error messages that read as "intent: cause"
// rather than "operation: cause", keeping failure context meaningful to the
// reader without leaking implementation details.
//
// // Good — expresses intent:
// data := Parse(raw).Expect("parse user input") // data := Parse(raw).Expect("parse user input")
// log := logger.NewCLI(level, out).Expect("create logger")
//
// // Avoid — describes the mechanism, not the goal:
// data := Parse(raw).Expect("call Parse()")
// log := logger.NewCLI(level, out).Expect("call NewCLI")
func (r Expect[T]) Expect(msg string) T { func (r Expect[T]) Expect(msg string) T {
if r.err != nil { if r.err != nil {
exitGoroutine(&stackError{ exitGoroutine(&stackError{