From fe711ed2a58357139f286848de584777a782ff80adf25d732a28920706536719 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:02:54 +0000 Subject: [PATCH] v0.1.12 SetConsoleWriter routes console output to a host writer A host that draws on the terminal (a TUI) cannot let the logger write to stdout or stderr, yet the records are worth showing inside the host. SetErrorHandler already covers internal diagnostics; SetConsoleWriter is its sibling for console output. - SetConsoleWriter(w) sends console output of every console_target to w, split's warn-and-above records included; nil restores stdout/stderr. It holds across ApplyConfig and is safe while logging. - One stored value: ApplyConfig and SetConsoleWriter publish the console writers through storeConsole; the processor's split branch now reads the stored warn writer instead of writing os.Stderr directly. - TestLoggerConsoleTargets becomes TestConsoleWriterTakesEveryTarget, which asserts where each target's records land; a second test checks that nil restores the configured writers. - README names both hooks for terminal hosts; the API doc's callback contract covers console writers. Verified: go build ./... ok; gofmt -l on changed files clean; go vet ./... ok; go test ./... ok (all four packages, under 1 s); go test -race on the new tests ok; mutation run 6 of 6 caught. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyaMAhyEzAp3ULLWJZG4jQ --- README.md | 2 ++ doc/api.md | 2 +- logger.go | 38 ++++++++++++++++++++++++++++++-------- logger_test.go | 40 +++++++++++++++++++++++++++++++++------- processor.go | 2 +- state.go | 2 +- type.go | 6 ++++-- 7 files changed, 72 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7563769..1ec452b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ Applications enqueue records into a bounded channel. A single background process Call `ApplyConfig` (or `Build`) and then `Start` before logging. `Stop` drains accepted records and can be followed by `Start`. `Shutdown` also closes the file; after a timeout, retry it to finish cleanup. See the [API reference](doc/api.md) for ownership and concurrency rules. +Hosts that own the terminal, such as TUIs, route console output with `SetConsoleWriter` and internal diagnostics with `SetErrorHandler`. + ## Contributing Contributions and suggestions are welcome! diff --git a/doc/api.md b/doc/api.md index c1bdeaa..ca74535 100644 --- a/doc/api.md +++ b/doc/api.md @@ -390,7 +390,7 @@ Logger methods support concurrent calls. Lifecycle and configuration changes are `ApplyConfig` takes a snapshot. Emission copies the argument slice, and `LogStructured` also copies the top-level fields map. Referenced values (byte slices, nested maps/slices, pointers, Stringers, and custom marshalers) remain caller-owned: keep them immutable until a successful `Flush` or `Stop`, or pass an application-created snapshot. Do not mutate configuration while an `ApplyConfig` call is reading it. -Builders and standalone formatter/sanitizer configuration are not synchronized; finish configuration before sharing. `SetSpawn` callbacks must launch the function asynchronously and return promptly. Error handlers must return promptly and must not invoke lifecycle/configuration methods or `Flush`. +Builders and standalone formatter/sanitizer configuration are not synchronized; finish configuration before sharing. `SetSpawn` callbacks must launch the function asynchronously and return promptly. Error handlers and `SetConsoleWriter` writers must return promptly and must not invoke lifecycle/configuration methods or `Flush`. ### Usage Pattern Example diff --git a/logger.go b/logger.go index b0f8d1c..663d1e4 100644 --- a/logger.go +++ b/logger.go @@ -22,6 +22,7 @@ type Logger struct { ctxKeys atomic.Pointer[formatter.ContextKeys] spawner atomic.Pointer[func(func())] errHandler atomic.Pointer[func(string)] + consoleW io.Writer // SetConsoleWriter's writer, under initMu state State initMu sync.Mutex // serializes lifecycle and configuration changes sendMu sync.RWMutex // joins enqueues before detaching a processor @@ -185,14 +186,7 @@ func (l *Logger) applyConfig(cfg *Config) error { } } - var writer io.Writer = io.Discard - if cfg.EnableConsole { - writer = os.Stdout - if cfg.ConsoleTarget == "stderr" { - writer = os.Stderr - } - } - l.state.StdoutWriter.Store(&sink{w: writer}) + l.storeConsole(cfg) l.currentConfig.Store(cfg) l.rebuildFormatter(cfg) l.state.Flags.Store(flagsFromConfig(cfg)) @@ -382,6 +376,34 @@ func (l *Logger) SetErrorHandler(fn func(string)) { l.errHandler.Store(&fn) } +// SetConsoleWriter sends console output to w instead of stdout and stderr, +// for every console_target, split included; nil restores them. It holds across +// ApplyConfig and is safe while logging. Writes must return promptly and must +// not call lifecycle/configuration methods or Flush, as for SetErrorHandler. +func (l *Logger) SetConsoleWriter(w io.Writer) { + l.initMu.Lock() + defer l.initMu.Unlock() + l.consoleW = w + l.storeConsole(l.getConfig()) +} + +// storeConsole publishes the writers the processor uses for cfg's console output +func (l *Logger) storeConsole(cfg *Config) { + w, warn := l.consoleW, l.consoleW + switch { + case !cfg.EnableConsole: + w, warn = io.Discard, io.Discard + case w != nil: + case cfg.ConsoleTarget == "stderr": + w, warn = os.Stderr, os.Stderr + case cfg.ConsoleTarget == "split": + w, warn = os.Stdout, os.Stderr + default: + w, warn = os.Stdout, os.Stdout + } + l.state.StdoutWriter.Store(&sink{w: w, warn: warn}) +} + // SetContextKeys names the record keys for Context values; empty names are // omitted. Safe to call at any time; rebuilds the formatter. func (l *Logger) SetContextKeys(tag string, vals ...string) { diff --git a/logger_test.go b/logger_test.go index 98d1334..7bfd50b 100644 --- a/logger_test.go +++ b/logger_test.go @@ -1,6 +1,8 @@ package log import ( + "bytes" + "io" "os" "path/filepath" "strings" @@ -202,29 +204,53 @@ func TestLoggerConcurrency(t *testing.T) { } } -// TestLoggerConsoleTargets verifies console-only operation for each target. -func TestLoggerConsoleTargets(t *testing.T) { +// TestConsoleWriterTakesEveryTarget verifies each console target, split's warn +// records included, writes to a console writer set before ApplyConfig. +func TestConsoleWriterTakesEveryTarget(t *testing.T) { for _, target := range []string{"stdout", "stderr", "split"} { t.Run(target, func(t *testing.T) { + var out bytes.Buffer logger := NewLogger() + logger.SetConsoleWriter(&out) cfg := DefaultConfig() - cfg.Directory = t.TempDir() cfg.EnableConsole = true cfg.EnableFile = false cfg.ConsoleTarget = target mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig") mustNoErr(t, logger.Start(), "Start") - t.Cleanup(func() { _ = logger.Shutdown() }) - - // split routes >=WARN to stderr; exercise both branches logger.Info("console info") logger.Error("console error") - noErr(t, logger.Flush(time.Second), "Flush") + mustNoErr(t, logger.Shutdown(time.Second), "Shutdown") + contains(t, out.String(), "console info", "info record") + contains(t, out.String(), "console error", "error record") }) } } +// TestConsoleWriterNilRestoresTarget verifies nil returns a running logger's +// console output to the configured stdout and stderr. +func TestConsoleWriterNilRestoresTarget(t *testing.T) { + var out bytes.Buffer + logger := NewLogger() + cfg := DefaultConfig() + cfg.EnableFile = false + cfg.ConsoleTarget = "split" + mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig") + mustNoErr(t, logger.Start(), "Start") + t.Cleanup(func() { _ = logger.Shutdown() }) + + logger.SetConsoleWriter(&out) + logger.Info("redirected") + mustNoErr(t, logger.Flush(time.Second), "Flush") + contains(t, out.String(), "redirected", "record after SetConsoleWriter") + + logger.SetConsoleWriter(nil) + got := logger.state.StdoutWriter.Load().(*sink) + equal(t, got.w, io.Writer(os.Stdout), "below warn") + equal(t, got.warn, io.Writer(os.Stderr), "warn and above") +} + // TestLoggerWrite verifies Write emits raw bytes with no formatting, framing, or sanitization. func TestLoggerWrite(t *testing.T) { logger, tmpDir := newTestLogger(t) diff --git a/processor.go b/processor.go index d622243..5038057 100644 --- a/processor.go +++ b/processor.go @@ -146,7 +146,7 @@ func (l *Logger) processLogRecord(record logRecord) int64 { if target, _ := l.state.StdoutWriter.Load().(*sink); target != nil { writer := target.w if c.ConsoleTarget == "split" && record.Level >= LevelWarn { - writer = os.Stderr + writer = target.warn } n, err := writer.Write(formattedData) if err == nil && n != len(formattedData) { diff --git a/state.go b/state.go index 3cef21a..188f358 100644 --- a/state.go +++ b/state.go @@ -26,7 +26,7 @@ type State struct { // Outputs CurrentFile atomic.Value // stores *os.File - StdoutWriter atomic.Value // stores io.Writer (os.Stdout, os.Stderr, or io.Discard) + StdoutWriter atomic.Value // stores *sink, the console writers // File State CurrentSize atomic.Int64 // Size of the current log file diff --git a/type.go b/type.go index ce69bd8..8b8ede5 100644 --- a/type.go +++ b/type.go @@ -35,9 +35,11 @@ type TimerSet struct { diskInterval time.Duration } -// sink is a wrapper around an io.Writer, atomic value type change workaround +// sink holds the console writers in one atomic.Value; warn takes split's +// warn-and-above records type sink struct { - w io.Writer + w io.Writer + warn io.Writer } // flushRequest belongs to one processor generation; the buffered reply permits