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