From b30cd9625bfadbcd4d6f5a8cfadb7bfdf7911a5e Mon Sep 17 00:00:00 2001 From: Chris Okuda Date: Wed, 25 Mar 2026 15:25:16 -1000 Subject: [PATCH 1/6] rlog: global fatal hook, slog default sync, With/WithGroup, fan-out, test helpers - Replace per-logger SetExitFunc with process-wide SetFatalHook; os.Exit(1) when nil - Store default logger in atomic.Pointer; SetDefault copies logger and calls slog.SetDefault; init wires slog default - Add Logger.With / WithGroup and package With / WithGroup - Add FanOut slog.Handler (NewFanOut, clone record per child) - Add testing helpers (e.g. NewCapturingTestHandler, ParseJSONLine) - Rename handler.go to options.go; move tests to logger_test.go - Add doc.go; update README --- rlog/README.md | 97 +++++------ rlog/doc.go | 68 ++++++++ rlog/fanout.go | 77 +++++++++ rlog/fanout_test.go | 80 ++++++++++ rlog/logger.go | 152 ++++++++++-------- rlog/{rlog_test.go => logger_test.go} | 221 ++++++++++---------------- rlog/{handler.go => options.go} | 15 +- rlog/options_test.go | 72 +++++++++ rlog/testing.go | 207 ++++++++++++++++++++++++ rlog/testing_test.go | 104 ++++++++++++ 10 files changed, 838 insertions(+), 255 deletions(-) create mode 100644 rlog/doc.go create mode 100644 rlog/fanout.go create mode 100644 rlog/fanout_test.go rename rlog/{rlog_test.go => logger_test.go} (61%) rename rlog/{handler.go => options.go} (76%) create mode 100644 rlog/options_test.go create mode 100644 rlog/testing.go create mode 100644 rlog/testing_test.go diff --git a/rlog/README.md b/rlog/README.md index 0bb73b0..73f7420 100644 --- a/rlog/README.md +++ b/rlog/README.md @@ -1,24 +1,23 @@ # rlog -The `r` is for “Rotational”! +The `r` is for “Rotational”! This package extends [`log/slog`](https://pkg.go.dev/log/slog) with **Trace**, **Fatal**, and **Panic**, and helpers in the usual shapes (key/value, `*Context`, `*Attrs`). See [`go doc`](https://pkg.go.dev/go.rtnl.ai/x/rlog) for the full API. -## Features Overview +- **Fatal** runs [`SetFatalHook`](https://pkg.go.dev/go.rtnl.ai/x/rlog#SetFatalHook) after logging if set; otherwise [`os.Exit(1)`](https://pkg.go.dev/os#Exit). **Panic** logs then panics with the message. +- [`MergeWithCustomLevels`](https://pkg.go.dev/go.rtnl.ai/x/rlog#MergeWithCustomLevels) maps custom levels to names `TRACE`, `FATAL`, `PANIC` in JSON/text output. [`ReplaceLevelKey`](https://pkg.go.dev/go.rtnl.ai/x/rlog#ReplaceLevelKey) does only the level mapping if you compose `ReplaceAttr` yourself. [`WithGlobalLevel`](https://pkg.go.dev/go.rtnl.ai/x/rlog#WithGlobalLevel) helps keep the global logging level synced. +- [`Logger.With`](https://pkg.go.dev/go.rtnl.ai/x/rlog#Logger.With) / [`WithGroup`](https://pkg.go.dev/go.rtnl.ai/x/rlog#Logger.WithGroup) keep a `*rlog.Logger` (same args as [`slog.Logger.With`](https://pkg.go.dev/log/slog#Logger.With)). Package-level [`With`](https://pkg.go.dev/go.rtnl.ai/x/rlog#With), [`WithGroup`](https://pkg.go.dev/go.rtnl.ai/x/rlog#WithGroup), [`Log`](https://pkg.go.dev/go.rtnl.ai/x/rlog#Log), and [`LogAttrs`](https://pkg.go.dev/go.rtnl.ai/x/rlog#LogAttrs) use the default logger. +- [`NewFanOut`](https://pkg.go.dev/go.rtnl.ai/x/rlog#NewFanOut) returns a [`slog.Handler`](https://pkg.go.dev/log/slog#Handler) that forwards each record to every child handler (multi-sink logging). +- [`NewCapturingTestHandler`](https://pkg.go.dev/go.rtnl.ai/x/rlog#NewCapturingTestHandler) helps tests capture records and JSON lines; pass `nil` or a [`testing.TB`](https://pkg.go.dev/testing#TB) to print test logs to the console. Helpers include [`ParseJSONLine`](https://pkg.go.dev/go.rtnl.ai/x/rlog#ParseJSONLine), [`ResultMaps`](https://pkg.go.dev/go.rtnl.ai/x/rlog#CapturingTestHandler.ResultMaps), and [`RecordsAndLines`](https://pkg.go.dev/go.rtnl.ai/x/rlog#CapturingTestHandler.RecordsAndLines) which returns a mutex-locked snapshot of current logs. +- [`LevelDecoder`](https://pkg.go.dev/go.rtnl.ai/x/rlog#LevelDecoder) parses level strings, including the added `TRACE`, `FATAL`, and `PANIC`. -- Wraps [`log/slog`](https://pkg.go.dev/log/slog) with extra severities **Trace** (more verbose than Debug), **Fatal**, and **Panic**, plus helpers in the usual slog shapes: key/value, `*Context`, and `*Attrs` ([`slog.LogAttrs`](https://pkg.go.dev/log/slog#LogAttrs)). -- **Fatal** logs, then runs an exit hook (default `os.Exit(1)`); **Panic** logs, then `panic`s with the message. -- Use `rlog.MergeWithCustomLevels` on handler options so JSON/text output uses level names `TRACE`, `FATAL`, and `PANIC` instead of numeric offsets. +## Default logger and Custom Handlers -## Global logger - -- The default global logger will log JSON to stdout at level **Info** -- `SetDefault` replaces the global logger; `Default` returns the current one. Package-level helpers (`rlog.Info`, `rlog.Trace`, `rlog.DebugContext`, …) delegate to the global logger. -- `SetLevel`, `Level`, and `LevelString` read or update the shared [`slog.LevelVar`](https://pkg.go.dev/log/slog#LevelVar) used by the default install. -- `WithGlobalLevel` sets `HandlerOptions.Level` to that same `LevelVar`. Use `MergeWithCustomLevels(WithGlobalLevel(opts))` when creating handlers so `SetLevel` still controls verbosity after `SetDefault`. A fixed `HandlerOptions{Level: …}` does not follow `SetLevel`. +- The package default logs JSON to stdout at **Info**. +- [`SetDefault`](https://pkg.go.dev/go.rtnl.ai/x/rlog#SetDefault) replaces rlog’s global logger and updates [`slog.Default`](https://pkg.go.dev/log/slog#Default) via [`slog.SetDefault`](https://pkg.go.dev/log/slog#SetDefault). +- [`SetLevel`](https://pkg.go.dev/go.rtnl.ai/x/rlog#SetLevel), [`Level`](https://pkg.go.dev/go.rtnl.ai/x/rlog#Level), and [`LevelString`](https://pkg.go.dev/go.rtnl.ai/x/rlog#LevelString) read or set the shared [`slog.LevelVar`](https://pkg.go.dev/log/slog#LevelVar) wired into the default install. +- Handlers you build yourself should use `MergeWithCustomLevels(WithGlobalLevel(opts))` so they still honor that threshold after you call [`SetDefault`](https://pkg.go.dev/go.rtnl.ai/x/rlog#SetDefault); see [`WithGlobalLevel`](https://pkg.go.dev/go.rtnl.ai/x/rlog#WithGlobalLevel). ## Example -Below is an example program that exercises the main features of rlog. - ```go package main @@ -31,51 +30,53 @@ import ( ) func main() { - // --- Global default (before SetDefault): shared LevelVar + package-level helpers --- + // Global default logger (JSON stdout): allow Trace. rlog.SetLevel(rlog.LevelTrace) - rlog.Info("installed JSON default at init") - // Output: {"time":"…","level":"INFO","msg":"installed JSON default at init"} - rlog.Trace("package-level Trace") - // Output: {"time":"…","level":"TRACE","msg":"package-level Trace"} + rlog.Info("hello") + // Output example: {"time":"2026-03-25T12:00:00.000-00:00","level":"INFO","msg":"hello"} + rlog.Trace("verbose") + // Output example: {"time":"…","level":"TRACE","msg":"verbose"} - // --- Build a logger and use *Logger methods (handler uses global LevelVar via WithGlobalLevel) --- + // Custom logger: MergeWithCustomLevels names TRACE/FATAL/PANIC; WithGlobalLevel ties + // level to rlog.SetLevel so this handler follows the same threshold as the default. opts := rlog.MergeWithCustomLevels(rlog.WithGlobalLevel(nil)) - h := slog.NewJSONHandler(os.Stdout, opts) - log := rlog.New(slog.New(h)) - log.SetExitFunc(func() {}) // omit in production; avoids exit in this demo - - ctx := context.Background() + log := rlog.New(slog.New(slog.NewJSONHandler(os.Stdout, opts))) - // Rlog-added level: key/value (Trace, Fatal, Panic have *Context and *Attrs too) + // rlog-only level + key/value fields (Trace also has TraceContext, TraceAttrs, …). log.Trace("trace-msg", "k", "v") - // Output: {"time":"…","level":"TRACE","msg":"trace-msg","k":"v"} + // Output example: {"time":"…","level":"TRACE","msg":"trace-msg","k":"v"} - // Standard slog level + context (same idea for InfoContext, WarnContext, …) - log.DebugContext(ctx, "debug-msg", "k", "v") - // Output: {"time":"…","level":"DEBUG","msg":"debug-msg","k":"v"} - - // Standard slog level + slog.Attr (LogAttrs-style; same for WarnAttrs, ErrorAttrs, …) - log.InfoAttrs(ctx, "info-msg", slog.String("k", "v")) - // Output: {"time":"…","level":"INFO","msg":"info-msg","k":"v"} - - log.Fatal("fatal-msg") // would os.Exit(1) without SetExitFunc - // Output: {"time":"…","level":"FATAL","msg":"fatal-msg"} + // Without a hook, Fatal would os.Exit(1) and the rest of main would not run. + rlog.SetFatalHook(func() {}) // demo only — replace with test hook or omit in production + defer rlog.SetFatalHook(nil) // setting the fatal hook to nil causes future Fatal calls to use os.Exit(1) + log.Fatal("fatal-msg") + // Output example: {"time":"…","level":"FATAL","msg":"fatal-msg"} — then runs the fatal hook (no exit here) +// Panic levels call panic("panic-msg") after logging func() { defer func() { recover() }() - log.Panic("panic-msg") // logs then panic(message) - // Output: {"time":"…","level":"PANIC","msg":"panic-msg"} + log.Panic("panic-msg") + // Output example: {"time":"…","level":"PANIC","msg":"panic-msg"} — then panic("panic-msg") }() - // --- Point package-level API at this logger --- - rlog.SetDefault(log) - rlog.Default().Warn("same logger as SetDefault") - // Output: {"time":"…","level":"WARN","msg":"same logger as SetDefault"} - rlog.DebugContext(ctx, "same logger via package-level DebugContext") - // Output: {"time":"…","level":"DEBUG","msg":"same logger via package-level DebugContext"} - rlog.InfoAttrs(ctx, "same logger via package-level InfoAttrs", slog.String("k", "v")) - // Output: {"time":"…","level":"INFO","msg":"same logger via package-level InfoAttrs","k":"v"} + // WithGroup/With return *rlog.Logger and work the same as for a slog.Logger. + sub := log.WithGroup("svc").With("name", "api") + sub.Info("scoped") + // Output example: {"time":"…","level":"INFO","msg":"scoped","svc":{"name":"api"}} + + // Stdlib-style context and slog.Attr APIs on *rlog.Logger. + log.DebugContext(context.Background(), "debug-msg", "k", "v") + // Output example: {"time":"…","level":"DEBUG","msg":"debug-msg","k":"v"} + log.InfoAttrs(context.Background(), "info-msg", slog.String("k", "v")) + // Output example: {"time":"…","level":"INFO","msg":"info-msg","k":"v"} + + + // Package-level helpers and slog.Default now use this logger. + new := log.With(slog.String("new", "logger")) + rlog.SetDefault(new) + rlog.Warn("via package after SetDefault") + // Output example: {"time":"…","level":"WARN","msg":"via package after SetDefault","new":"logger"} + rlog.FatalAttrs("fatal-msg", slog.String("fatal", "attr")) + // Output example: {"time":"…","level":"FATAL","msg":"fatal-msg","new":"logger","fatal":"attr"} — then runs the fatal hook (no exit here) } ``` - -JSON output uses the default JSON handler’s `time` field; levels show as `TRACE`, `DEBUG`, `INFO`, etc. diff --git a/rlog/doc.go b/rlog/doc.go new file mode 100644 index 0000000..8b6c0c8 --- /dev/null +++ b/rlog/doc.go @@ -0,0 +1,68 @@ +// Package rlog wraps [log/slog] with extra levels (Trace, Fatal, Panic), test +// capture helpers, and small slog utilities (handler options, global level sync, +// multi-sink fan-out). See [README.md in the repo], or [rlog on pkg.go.dev], for +// API summary and usage notes. +// +// package main +// +// import ( +// "context" +// "log/slog" +// "os" +// +// "go.rtnl.ai/x/rlog" +// ) +// +// func main() { +// // Global default logger (JSON stdout): allow Trace. +// rlog.SetLevel(rlog.LevelTrace) +// rlog.Info("hello") +// // Output example: {"time":"2026-03-25T12:00:00.000-00:00","level":"INFO","msg":"hello"} +// rlog.Trace("verbose") +// // Output example: {"time":"…","level":"TRACE","msg":"verbose"} +// +// // Custom logger: MergeWithCustomLevels names TRACE/FATAL/PANIC; WithGlobalLevel ties +// // level to rlog.SetLevel so this handler follows the same threshold as the default. +// opts := rlog.MergeWithCustomLevels(rlog.WithGlobalLevel(nil)) +// log := rlog.New(slog.New(slog.NewJSONHandler(os.Stdout, opts))) +// +// // rlog-only level + key/value fields (Trace also has TraceContext, TraceAttrs, …). +// log.Trace("trace-msg", "k", "v") +// // Output example: {"time":"…","level":"TRACE","msg":"trace-msg","k":"v"} +// +// // Without a hook, Fatal would os.Exit(1) and the rest of main would not run. +// rlog.SetFatalHook(func() {}) // demo only — replace with test hook or omit in production +// defer rlog.SetFatalHook(nil) // setting the fatal hook to nil causes future Fatal calls to use os.Exit(1) +// log.Fatal("fatal-msg") +// // Output example: {"time":"…","level":"FATAL","msg":"fatal-msg"} — then runs the fatal hook (no exit here) +// +// // Panic levels call panic("panic-msg") after logging +// func() { +// defer func() { recover() }() +// log.Panic("panic-msg") +// // Output example: {"time":"…","level":"PANIC","msg":"panic-msg"} — then panic("panic-msg") +// }() +// +// // WithGroup/With return *rlog.Logger and work the same as for a slog.Logger. +// sub := log.WithGroup("svc").With("name", "api") +// sub.Info("scoped") +// // Output example: {"time":"…","level":"INFO","msg":"scoped","svc":{"name":"api"}} +// +// // Stdlib-style context and slog.Attr APIs on *rlog.Logger. +// log.DebugContext(context.Background(), "debug-msg", "k", "v") +// // Output example: {"time":"…","level":"DEBUG","msg":"debug-msg","k":"v"} +// log.InfoAttrs(context.Background(), "info-msg", slog.String("k", "v")) +// // Output example: {"time":"…","level":"INFO","msg":"info-msg","k":"v"} +// +// // Package-level helpers and slog.Default now use this logger. +// new := log.With(slog.String("new", "logger")) +// rlog.SetDefault(new) +// rlog.Warn("via package after SetDefault") +// // Output example: {"time":"…","level":"WARN","msg":"via package after SetDefault","new":"logger"} +// rlog.FatalAttrs("fatal-msg", slog.String("fatal", "attr")) +// // Output example: {"time":"…","level":"FATAL","msg":"fatal-msg","new":"logger","fatal":"attr"} — then runs the fatal hook (no exit here) +// } +// +// [README.md in the repo]: https://github.com/rotationalio/x/blob/main/rlog/README.md +// [rlog on pkg.go.dev]: https://pkg.go.dev/go.rtnl.ai/x/rlog +package rlog diff --git a/rlog/fanout.go b/rlog/fanout.go new file mode 100644 index 0000000..39f46f8 --- /dev/null +++ b/rlog/fanout.go @@ -0,0 +1,77 @@ +package rlog + +import ( + "context" + "errors" + "log/slog" +) + +// FanOut is a [slog.Handler] that forwards each record to every child handler, +// using a fresh [slog.Record.Clone] per child. +type FanOut struct { + handlers []slog.Handler +} + +// NewFanOut returns a new [FanOut] that forwards each record to every child +// handler. +func NewFanOut(handlers ...slog.Handler) *FanOut { + hs := append([]slog.Handler(nil), handlers...) + return &FanOut{handlers: hs} +} + +// Enabled reports whether any child handler accepts the given level. +func (f *FanOut) Enabled(ctx context.Context, level slog.Level) bool { + for _, h := range f.handlers { + if h.Enabled(ctx, level) { + return true + } + } + return false +} + +// Handle forwards a clone of r to each child and joins any non-nil errors. +func (f *FanOut) Handle(ctx context.Context, r slog.Record) error { + if len(f.handlers) == 0 { + return nil + } + + var errs []error + for _, h := range f.handlers { + r2 := r.Clone() + if err := h.Handle(ctx, r2); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// WithAttrs returns a fan-out whose children are wrapped with the same attrs. +func (f *FanOut) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return f + } + + n := len(f.handlers) + next := make([]slog.Handler, n) + for i, h := range f.handlers { + next[i] = h.WithAttrs(attrs) + } + + return &FanOut{handlers: next} +} + +// WithGroup returns a fan-out whose children are wrapped with the same group. +func (f *FanOut) WithGroup(name string) slog.Handler { + if name == "" { + return f + } + + n := len(f.handlers) + next := make([]slog.Handler, n) + for i, h := range f.handlers { + next[i] = h.WithGroup(name) + } + + return &FanOut{handlers: next} +} diff --git a/rlog/fanout_test.go b/rlog/fanout_test.go new file mode 100644 index 0000000..4840c2e --- /dev/null +++ b/rlog/fanout_test.go @@ -0,0 +1,80 @@ +package rlog_test + +import ( + "bytes" + "context" + "log/slog" + "strings" + "testing" + "testing/slogtest" + "time" + + "go.rtnl.ai/x/assert" + "go.rtnl.ai/x/rlog" +) + +// Each sink receives the same logical record when levels allow. +func TestFanOut_Handle_clonesToEachSink(t *testing.T) { + var a, b bytes.Buffer + ha := slog.NewJSONHandler(&a, rlog.MergeWithCustomLevels(&slog.HandlerOptions{Level: slog.LevelInfo})) + hb := slog.NewJSONHandler(&b, rlog.MergeWithCustomLevels(&slog.HandlerOptions{Level: slog.LevelInfo})) + f := rlog.NewFanOut(ha, hb) + + ctx := context.Background() + r := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0) + assert.Ok(t, f.Handle(ctx, r)) + + assert.Contains(t, a.String(), "hello") + assert.Contains(t, b.String(), "hello") +} + +// Enabled is true if any child is enabled for the level. +func TestFanOut_Enabled_OR(t *testing.T) { + var quiet, loud bytes.Buffer + hQuiet := slog.NewJSONHandler(&quiet, &slog.HandlerOptions{Level: slog.LevelError}) + hLoud := slog.NewJSONHandler(&loud, &slog.HandlerOptions{Level: slog.LevelDebug}) + f := rlog.NewFanOut(hQuiet, hLoud) + ctx := context.Background() + + assert.True(t, f.Enabled(ctx, slog.LevelInfo), "loud child accepts Info") + assert.False(t, f.Enabled(ctx, rlog.LevelTrace), "neither accepts Trace by default opts") +} + +// WithGroup on the fan-out applies to every child. +func TestFanOut_WithGroup_propagates(t *testing.T) { + var a, b bytes.Buffer + ha := slog.NewJSONHandler(&a, rlog.MergeWithCustomLevels(&slog.HandlerOptions{Level: slog.LevelInfo})) + hb := slog.NewJSONHandler(&b, rlog.MergeWithCustomLevels(&slog.HandlerOptions{Level: slog.LevelInfo})) + f := rlog.NewFanOut(ha, hb).WithGroup("outer").(*rlog.FanOut) + + ctx := context.Background() + r := slog.NewRecord(time.Now(), slog.LevelInfo, "msg", 0) + r.AddAttrs(slog.String("k", "v")) + assert.Ok(t, f.Handle(ctx, r)) + + for _, out := range []string{a.String(), b.String()} { + assert.Contains(t, out, `"outer":`) + assert.Contains(t, out, `"k":"v"`) + } +} + +// Single-child fan-out should satisfy slog's handler test suite. +func TestFanOut_slogtest_singleChild(t *testing.T) { + var buf bytes.Buffer + h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + fan := rlog.NewFanOut(h) + + err := slogtest.TestHandler(fan, func() []map[string]any { + var maps []map[string]any + for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") { + if line == "" { + continue + } + m, e := rlog.ParseJSONLine(line) + assert.Ok(t, e) + maps = append(maps, m) + } + return maps + }) + assert.Ok(t, err) +} diff --git a/rlog/logger.go b/rlog/logger.go index 57b83a7..aad7108 100644 --- a/rlog/logger.go +++ b/rlog/logger.go @@ -5,59 +5,80 @@ import ( "log/slog" "os" "sync" + "sync/atomic" ) //============================================================================= // Logger Type //============================================================================= -// Logger is a wrapper around the standard library's slog.Logger that adds custom -// severity levels with a custom exit function for logging Fatal messages. +// Logger is a wrapper around the standard library's [slog.Logger] that adds custom +// severity levels (Trace, Fatal, Panic). After Fatal logging, see [SetFatalHook]. +// The wrapped [slog.Logger] is stored in the [Logger] struct and can be accessed +// using the [Logger.Logger] field, though it is not recommended to mutate the +// wrapped [slog.Logger] directly. type Logger struct { *slog.Logger - exitFunc func() } -// New returns a new [Logger] using the given [slog.Logger]. +// New returns a new [Logger] wrapping the given [slog.Logger]. func New(logger *slog.Logger) *Logger { return &Logger{Logger: logger} } +// With returns a derived [Logger] with the given context attributes (key/value +// pairs and/or [slog.Attr] values, same rules as [slog.Logger.With]). +func (l *Logger) With(args ...any) *Logger { + return &Logger{Logger: l.Logger.With(args...)} +} + +// WithGroup returns a derived [Logger] with the given group. +func (l *Logger) WithGroup(name string) *Logger { + return &Logger{Logger: l.Logger.WithGroup(name)} +} + //============================================================================= // Global Logger Init and Management //============================================================================= -// Global variables for the default (global) [Logger]. var ( - globalLogger Logger - loggerMu sync.RWMutex // protects reads and writes to the globalLogger - globalLevel *slog.LevelVar = &slog.LevelVar{} + // Stores the global logger that is returned by [Default] + globalLogger atomic.Pointer[Logger] + // Protects reads and writes to the globalLogger + loggerMu sync.RWMutex + // The zero value is [slog.LevelInfo] + globalLevel *slog.LevelVar = &slog.LevelVar{} + // Stores the function to call when a fatal log is written, or nil if no hook is set + fatalHookAtomic atomic.Pointer[struct{ fn func() }] ) -// Initializes the global logger and level once. Is a no-op if already initialized. +// Initializes the global logger to be a console JSON logger with level [slog.LevelInfo]. func init() { - globalLevel.Set(slog.LevelInfo) - globalLogger = *newDefaultGlobalLogger() + l := New(slog.New(slog.NewJSONHandler(os.Stdout, MergeWithCustomLevels(&slog.HandlerOptions{Level: globalLevel})))) + globalLogger.Store(l) + slog.SetDefault(l.Logger) } -// Default returns the default (global) [Logger], creating it if it doesn't -// exist. Use [SetDefault] to set the default [Logger]. If no default [Logger] -// is set, a new JSON logger to stdout is created with the default -// [slog.HandlerOptions] and level [slog.LevelInfo]. Safe to use concurrently. +// Default returns the default (global) [Logger]. Use [SetDefault] to set the +// default [Logger]. The returned pointer is shared until the next [SetDefault]; +// do not mutate the [Logger] value it points to. func Default() *Logger { loggerMu.RLock() - l := globalLogger - loggerMu.RUnlock() - return &l + defer loggerMu.RUnlock() + return globalLogger.Load() } -// SetDefault sets the default (global) [Logger]. To use the global logger level -// and custom levels, the handler must be constructed using [MergeWithCustomLevels] -// and [WithGlobalLevel]. Safe to use concurrently. +// SetDefault sets the default (global) [Logger] and [slog.Default] to the same +// underlying [*slog.Logger]. To use the global logger level and custom levels, +// the handler must be constructed using [MergeWithCustomLevels] and +// [WithGlobalLevel]. Safe to use concurrently. func SetDefault(logger *Logger) { + p := new(Logger) + *p = *logger loggerMu.Lock() defer loggerMu.Unlock() - globalLogger = *logger + globalLogger.Store(p) + slog.SetDefault(p.Logger) } // Level returns the global log level. Safe to use concurrently. @@ -76,10 +97,24 @@ func SetLevel(level slog.Level) { globalLevel.Set(level) } -// newDefaultGlobalLogger creates a new default global [Logger] with a JSON stdout -// handler with the global level. -func newDefaultGlobalLogger() *Logger { - return New(slog.New(slog.NewJSONHandler(os.Stdout, MergeWithCustomLevels(&slog.HandlerOptions{Level: globalLevel})))) +// SetFatalHook sets the function invoked after Fatal log output. If fn is nil, +// the hook is cleared and Fatal calls [os.Exit](1). The hook is process-wide +// for all [*Logger] values. Safe to call concurrently with logging. +func SetFatalHook(fn func()) { + if fn == nil { + fatalHookAtomic.Store(nil) + return + } + fatalHookAtomic.Store(&struct{ fn func() }{fn: fn}) +} + +// exitFatal runs the hook from [SetFatalHook] if installed, else [os.Exit](1). +func exitFatal() { + slot := fatalHookAtomic.Load() + if slot == nil { + os.Exit(1) + } + slot.fn() } //============================================================================= @@ -87,6 +122,16 @@ func newDefaultGlobalLogger() *Logger { // NOTE: These functions are aliases for the [Logger] methods with the default [Logger]. //============================================================================= +// With returns a derived [Logger] with the given attributes. +func With(args ...any) *Logger { + return Default().With(args...) +} + +// WithGroup returns a derived [Logger] with the given group. +func WithGroup(name string) *Logger { + return Default().WithGroup(name) +} + // Log emits a log record with the given level and message using the [Default] logger. // Arguments are handled like [slog.Logger.Log]. func Log(ctx context.Context, level slog.Level, msg string, args ...any) { @@ -128,9 +173,9 @@ func Error(msg string, args ...any) { Default().Error(msg, args...) } -// Fatal logs at [LevelFatal] using the [Default] logger, then calls the exit hook if set, -// otherwise [os.Exit] with code 1. It does not return. Arguments are handled like -// [slog.Logger.Log]; you can pass any number of key/value pairs or [slog.Attr] objects. +// Fatal logs at [LevelFatal] using the [Default] logger, then runs the global fatal hook +// from [SetFatalHook] if set, otherwise [os.Exit](1). It does not return. Arguments are +// handled like [slog.Logger.Log]; you can pass any number of key/value pairs or [slog.Attr] objects. func Fatal(msg string, args ...any) { Default().Fatal(msg, args...) } @@ -176,9 +221,9 @@ func ErrorContext(ctx context.Context, msg string, args ...any) { Default().ErrorContext(ctx, msg, args...) } -// FatalContext logs at [LevelFatal] with ctx using the [Default] logger, then calls the exit -// hook if set, otherwise [os.Exit] with code 1. It does not return. Arguments are handled like -// [slog.Logger.Log]; you can pass any number of key/value pairs or [slog.Attr] objects. +// FatalContext logs at [LevelFatal] with ctx using the [Default] logger, then runs the global +// fatal hook from [SetFatalHook] if set, otherwise [os.Exit](1). It does not return. Arguments +// are handled like [slog.Logger.Log]; you can pass any number of key/value pairs or [slog.Attr] objects. func FatalContext(ctx context.Context, msg string, args ...any) { Default().FatalContext(ctx, msg, args...) } @@ -220,9 +265,9 @@ func ErrorAttrs(ctx context.Context, msg string, attrs ...slog.Attr) { Default().ErrorAttrs(ctx, msg, attrs...) } -// FatalAttrs logs at [LevelFatal] with attrs using the [Default] logger, then calls the exit -// hook if set, otherwise [os.Exit] with code 1. It does not return. Uses [slog.LogAttrs] for -// efficiency. +// FatalAttrs logs at [LevelFatal] with attrs using the [Default] logger, then runs the global +// fatal hook from [SetFatalHook] if set, otherwise [os.Exit](1). It does not return. Uses +// [slog.LogAttrs] for efficiency. func FatalAttrs(ctx context.Context, msg string, attrs ...slog.Attr) { Default().FatalAttrs(ctx, msg, attrs...) } @@ -284,28 +329,28 @@ func (l *Logger) TraceAttrs(ctx context.Context, msg string, attrs ...slog.Attr) // Fatal Functions //============================================================================= -// Fatal logs at [LevelFatal] then calls the exit hook if set, otherwise [os.Exit] -// with code 1. It does not return. Arguments are handled like [slog.Logger.Log]; +// Fatal logs at [LevelFatal] then runs the global fatal hook from [SetFatalHook] if set, +// otherwise [os.Exit](1). It does not return. Arguments are handled like [slog.Logger.Log]; // you can pass any number of key/value pairs or [slog.Attr] objects. func (l *Logger) Fatal(msg string, args ...any) { l.FatalContext(context.Background(), msg, args...) } -// FatalContext logs at [LevelFatal] with ctx then calls the exit hook if set, -// otherwise [os.Exit] with code 1. It does not return. Arguments are handled like +// FatalContext logs at [LevelFatal] with ctx then runs the global fatal hook from [SetFatalHook] +// if set, otherwise [os.Exit](1). It does not return. Arguments are handled like // [slog.Logger.Log]; you can pass any number of key/value pairs or [slog.Attr] // objects. func (l *Logger) FatalContext(ctx context.Context, msg string, args ...any) { l.Logger.Log(ctx, LevelFatal, msg, args...) - l.exit() + exitFatal() } -// FatalAttrs logs at [LevelFatal] with attrs then calls the exit hook if set, -// otherwise [os.Exit] with code 1. It does not return. Uses [slog.LogAttrs] for +// FatalAttrs logs at [LevelFatal] with attrs then runs the global fatal hook from [SetFatalHook] +// if set, otherwise [os.Exit](1). It does not return. Uses [slog.LogAttrs] for // efficiency. func (l *Logger) FatalAttrs(ctx context.Context, msg string, attrs ...slog.Attr) { l.Logger.LogAttrs(ctx, LevelFatal, msg, attrs...) - l.exit() + exitFatal() } //============================================================================= @@ -333,24 +378,3 @@ func (l *Logger) PanicAttrs(ctx context.Context, msg string, attrs ...slog.Attr) l.Logger.LogAttrs(ctx, LevelPanic, msg, attrs...) panic(msg) } - -//============================================================================= -// Exit Function -//============================================================================= - -var defaultExitFunc func() = func() { os.Exit(1) } - -// SetExitFunc sets the function called after Fatal logging. If unset, Fatal -// calls [os.Exit] with code 1. Useful for testing. -func (l *Logger) SetExitFunc(fn func()) { - l.exitFunc = fn -} - -// exit runs the configured exit hook or [os.Exit] with code 1. -func (l *Logger) exit() { - if l.exitFunc == nil { - defaultExitFunc() - return // unreachable after os.Exit; satisfies nil-check analysis - } - l.exitFunc() -} diff --git a/rlog/rlog_test.go b/rlog/logger_test.go similarity index 61% rename from rlog/rlog_test.go rename to rlog/logger_test.go index b059d3b..4ea536c 100644 --- a/rlog/rlog_test.go +++ b/rlog/logger_test.go @@ -3,10 +3,8 @@ package rlog_test import ( "bytes" "context" - "encoding/json" "io" "log/slog" - "os" "sync" "sync/atomic" "testing" @@ -15,57 +13,7 @@ import ( "go.rtnl.ai/x/rlog" ) -// JSON output uses "TRACE", "FATAL", and "PANIC" for the custom levels and the -// usual default slog levels plus an undefined custom level. -func TestMergeHandlerOptions_levelStrings(t *testing.T) { - var buf bytes.Buffer - logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: rlog.LevelTrace}) - - logger.Log(context.Background(), rlog.LevelTrace, "trace-msg") - logger.Log(context.Background(), slog.LevelDebug, "debug-msg") - logger.Log(context.Background(), slog.LevelInfo, "info-msg") - logger.Log(context.Background(), slog.LevelWarn, "warn-msg") - logger.Log(context.Background(), slog.LevelError, "error-msg") - logger.Log(context.Background(), rlog.LevelFatal, "fatal-msg") - logger.Log(context.Background(), rlog.LevelPanic, "panic-msg") - logger.Log(context.Background(), rlog.LevelPanic+4, "panic-plus-4-msg") - - out := buf.String() - for _, want := range []string{`"level":"TRACE"`, `"level":"DEBUG"`, `"level":"INFO"`, `"level":"WARN"`, `"level":"ERROR"`, `"level":"FATAL"`, `"level":"PANIC"`, `"level":"ERROR+12"`} { - assert.Contains(t, out, want, "output missing level substring: %s", want) - } -} - -// User ReplaceAttr still runs after the level-name replacer (drop attr + trace -// level preserved). -func TestMergeHandlerOptions_chainsUserReplaceAttr(t *testing.T) { - var buf bytes.Buffer - opts := &slog.HandlerOptions{ - Level: rlog.LevelTrace, - ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { - if a.Key == "drop" { - return slog.Attr{} - } - return a - }, - } - logger := newTestLogger(t, &buf, opts) - logger.Log(context.Background(), rlog.LevelTrace, "x", "drop", true, "keep", "y") - - out := buf.String() - assert.NotContains(t, out, "drop", "user ReplaceAttr should drop key") - assert.Contains(t, out, `"level":"TRACE"`) - assert.Contains(t, out, "keep") -} - -// Min level Error: Trace is below threshold, Panic is not. -func TestEnabled_ordering(t *testing.T) { - var buf bytes.Buffer - logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: slog.LevelError}) - assert.False(t, logger.Enabled(context.Background(), rlog.LevelTrace), "Trace should be disabled when min is Error") - assert.True(t, logger.Enabled(context.Background(), rlog.LevelPanic), "Panic should be enabled when min is Error") -} - +// Logger *Attrs helpers emit JSON with expected level and structured fields. func TestDefaultAttrs(t *testing.T) { t.Run("DebugAttrs", func(t *testing.T) { var buf bytes.Buffer @@ -108,6 +56,7 @@ func TestDefaultAttrs(t *testing.T) { }) } +// Trace, TraceContext, and TraceAttrs write at LevelTrace when enabled. func TestTrace(t *testing.T) { t.Run("Trace", func(t *testing.T) { var buf bytes.Buffer @@ -140,13 +89,16 @@ func TestTrace(t *testing.T) { }) } +// Fatal, FatalContext, and FatalAttrs write at LevelFatal when enabled and run the fatal hook. func TestFatal(t *testing.T) { t.Run("Fatal", func(t *testing.T) { var buf bytes.Buffer var exited bool logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: rlog.LevelFatal}) - logger.SetExitFunc(func() { exited = true }) - defer logger.SetExitFunc(func() { os.Exit(1) }) + + rlog.SetFatalHook(func() { exited = true }) + t.Cleanup(func() { rlog.SetFatalHook(nil) }) + logger.Fatal("bye") assert.True(t, exited) out := buf.String() @@ -158,8 +110,10 @@ func TestFatal(t *testing.T) { var buf bytes.Buffer var exited bool logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: rlog.LevelFatal}) - logger.SetExitFunc(func() { exited = true }) - defer logger.SetExitFunc(func() { os.Exit(1) }) + + rlog.SetFatalHook(func() { exited = true }) + t.Cleanup(func() { rlog.SetFatalHook(nil) }) + logger.FatalContext(context.Background(), "bye") assert.True(t, exited) out := buf.String() @@ -171,8 +125,10 @@ func TestFatal(t *testing.T) { var buf bytes.Buffer var exited bool logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: rlog.LevelFatal}) - logger.SetExitFunc(func() { exited = true }) - defer logger.SetExitFunc(func() { os.Exit(1) }) + + rlog.SetFatalHook(func() { exited = true }) + t.Cleanup(func() { rlog.SetFatalHook(nil) }) + logger.FatalAttrs(context.Background(), "bye", slog.String("k", "v")) assert.True(t, exited) out := buf.String() @@ -182,6 +138,7 @@ func TestFatal(t *testing.T) { }) } +// Panic, PanicContext, and PanicAttrs write at LevelPanic when enabled and panic with the message. func TestPanic(t *testing.T) { t.Run("Panic", func(t *testing.T) { var buf bytes.Buffer @@ -218,23 +175,9 @@ func TestPanic(t *testing.T) { }) } -// Decoded JSON has string level "TRACE" (not a numeric slog offset). -func TestJSON_shape(t *testing.T) { - var buf bytes.Buffer - logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: rlog.LevelTrace}) - logger.Log(context.Background(), rlog.LevelTrace, "x") - - var m map[string]any - assert.Ok(t, json.Unmarshal(buf.Bytes(), &m)) - assert.Equal(t, "TRACE", m["level"]) -} - -// Default and SetDefault must remain safe under heavy concurrent use (run with -race). -// The race detector flags unsafe access to the package-global logger; atomics verify -// that every goroutine ran its full loop and that logging paths executed as expected. +// Concurrent SetDefault, Default, and package-level logging (run with -race). func TestDefaultSetDefault_concurrent(t *testing.T) { - // Enough goroutines and iterations to interleave real workloads; poolSize > 1 so - // SetDefault swaps among different logger instances rather than one repeated value. + // Pool of loggers to rotate under SetDefault. const ( goroutines = 128 iterations = 400 @@ -250,11 +193,10 @@ func TestDefaultSetDefault_concurrent(t *testing.T) { pool[i] = rlog.New(slog.New(h)) } - // Each inner-loop iteration runs exactly one switch arm; (id+n)%4 cycles all four - // equally over iterations, so per goroutine each arm runs iterations/4 times. + // Expected counts: each goroutine hits each switch arm equally; only three arms log. perG := iterations / 4 wantIterations := int64(goroutines * iterations) - wantLog := int64(goroutines * perG * 3) // only arms 1–3 log; each logs once per hit + wantLog := int64(goroutines * perG * 3) var wg sync.WaitGroup var iterationsDone, logOps, nilishLoggers atomic.Int64 @@ -263,27 +205,22 @@ func TestDefaultSetDefault_concurrent(t *testing.T) { go func(id int) { defer wg.Done() for n := range iterations { - // Four interleavings: writer-only, read+log on *Logger, package-level - // Info (which calls Default again internally), and set-then-read-then-log. switch (id + n) % 4 { - case 0: + case 0: // swap global only rlog.SetDefault(pool[(id+n)%poolSize]) - case 1: + case 1: // read default, method log l := rlog.Default() - noteNilishLogger(l, &nilishLoggers) if l != nil && l.Logger != nil { l.Info("concurrent") logOps.Add(1) } - case 2: - l := rlog.Default() - noteNilishLogger(l, &nilishLoggers) + case 2: // read default, package-level log + _ = rlog.Default() rlog.Info("concurrent-global") logOps.Add(1) - case 3: + case 3: // swap then read and log rlog.SetDefault(pool[(id*3+n)%poolSize]) l := rlog.Default() - noteNilishLogger(l, &nilishLoggers) if l != nil && l.Logger != nil { l.Info("after-set") logOps.Add(1) @@ -295,29 +232,30 @@ func TestDefaultSetDefault_concurrent(t *testing.T) { } wg.Wait() - // nilishLoggers: regression sentinel for a broken or torn default logger. - // iterationsDone: every inner-loop body completed (no panic, no stuck goroutine). - // logOps: arms that should emit a log did so the expected number of times. + // No nil default; full iteration count; logging arms ran expected times. assert.Equal(t, int64(0), nilishLoggers.Load(), "nil *Logger or nil embedded slog.Logger under concurrency") assert.Equal(t, wantIterations, iterationsDone.Load()) assert.Equal(t, wantLog, logOps.Load()) - // Smoke check: global default is still usable after concurrent churn. rlog.Default().Info("post-stress") } -// Global package functions must log through [rlog.SetDefault], not the library default. +// Package-level rlog.* functions delegate to SetDefault's logger. func TestGlobalFunctionsUseSetDefaultLogger(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{Level: rlog.LevelTrace} inner := slog.New(slog.NewTextHandler(&buf, rlog.MergeWithCustomLevels(opts))) lg := rlog.New(inner) - var exited bool - lg.SetExitFunc(func() { exited = true }) + + rlog.SetFatalHook(func() {}) + t.Cleanup(func() { rlog.SetFatalHook(nil) }) prev := rlog.Default() t.Cleanup(func() { rlog.SetDefault(prev) }) + + // Set the default logger to the one we created and verify that it matches [slog.Default]. rlog.SetDefault(lg) + assert.Equal(t, rlog.Default().Logger, slog.Default(), "slog.Default must match rlog's embedded logger after SetDefault") ctx := context.Background() @@ -325,48 +263,46 @@ func TestGlobalFunctionsUseSetDefaultLogger(t *testing.T) { name string run func() want []string - fatal bool panic bool } cases := []gCase{ - {"Log", func() { rlog.Log(ctx, slog.LevelInfo, "log-msg") }, []string{"INFO", "log-msg"}, false, false}, + {"Log", func() { rlog.Log(ctx, slog.LevelInfo, "log-msg") }, []string{"INFO", "log-msg"}, false}, {"LogAttrs", func() { rlog.LogAttrs(ctx, slog.LevelWarn, "la-msg", slog.String("k", "v")) - }, []string{"WARN", "la-msg", "k=v"}, false, false}, - - {"Trace", func() { rlog.Trace("trace-msg") }, []string{"TRACE", "trace-msg"}, false, false}, - {"Debug", func() { rlog.Debug("debug-msg") }, []string{"DEBUG", "debug-msg"}, false, false}, - {"Info", func() { rlog.Info("info-msg") }, []string{"INFO", "info-msg"}, false, false}, - {"Warn", func() { rlog.Warn("warn-msg") }, []string{"WARN", "warn-msg"}, false, false}, - {"Error", func() { rlog.Error("error-msg") }, []string{"ERROR", "error-msg"}, false, false}, - {"Fatal", func() { rlog.Fatal("fatal-msg") }, []string{"FATAL", "fatal-msg"}, true, false}, - {"Panic", func() { rlog.Panic("panic-msg") }, []string{"PANIC", "panic-msg"}, false, true}, - - {"TraceContext", func() { rlog.TraceContext(ctx, "tc") }, []string{"TRACE", "tc"}, false, false}, - {"DebugContext", func() { rlog.DebugContext(ctx, "dc") }, []string{"DEBUG", "dc"}, false, false}, - {"InfoContext", func() { rlog.InfoContext(ctx, "ic") }, []string{"INFO", "ic"}, false, false}, - {"WarnContext", func() { rlog.WarnContext(ctx, "wc") }, []string{"WARN", "wc"}, false, false}, - {"ErrorContext", func() { rlog.ErrorContext(ctx, "ec") }, []string{"ERROR", "ec"}, false, false}, - {"FatalContext", func() { rlog.FatalContext(ctx, "fc") }, []string{"FATAL", "fc"}, true, false}, - {"PanicContext", func() { rlog.PanicContext(ctx, "pc") }, []string{"PANIC", "pc"}, false, true}, - - {"TraceAttrs", func() { rlog.TraceAttrs(ctx, "ta", slog.String("a", "1")) }, []string{"TRACE", "ta", "a=1"}, false, false}, - {"DebugAttrs", func() { rlog.DebugAttrs(ctx, "da", slog.String("a", "1")) }, []string{"DEBUG", "da", "a=1"}, false, false}, - {"InfoAttrs", func() { rlog.InfoAttrs(ctx, "ia", slog.String("a", "1")) }, []string{"INFO", "ia", "a=1"}, false, false}, - {"WarnAttrs", func() { rlog.WarnAttrs(ctx, "wa", slog.String("a", "1")) }, []string{"WARN", "wa", "a=1"}, false, false}, - {"ErrorAttrs", func() { rlog.ErrorAttrs(ctx, "ea", slog.String("a", "1")) }, []string{"ERROR", "ea", "a=1"}, false, false}, - {"FatalAttrs", func() { rlog.FatalAttrs(ctx, "fa", slog.String("a", "1")) }, []string{"FATAL", "fa", "a=1"}, true, false}, - {"PanicAttrs", func() { rlog.PanicAttrs(ctx, "pa", slog.String("a", "1")) }, []string{"PANIC", "pa", "a=1"}, false, true}, + }, []string{"WARN", "la-msg", "k=v"}, false}, + + {"Trace", func() { rlog.Trace("trace-msg") }, []string{"TRACE", "trace-msg"}, false}, + {"Debug", func() { rlog.Debug("debug-msg") }, []string{"DEBUG", "debug-msg"}, false}, + {"Info", func() { rlog.Info("info-msg") }, []string{"INFO", "info-msg"}, false}, + {"Warn", func() { rlog.Warn("warn-msg") }, []string{"WARN", "warn-msg"}, false}, + {"Error", func() { rlog.Error("error-msg") }, []string{"ERROR", "error-msg"}, false}, + {"Fatal", func() { rlog.Fatal("fatal-msg") }, []string{"FATAL", "fatal-msg"}, false}, + {"Panic", func() { rlog.Panic("panic-msg") }, []string{"PANIC", "panic-msg"}, true}, + + {"TraceContext", func() { rlog.TraceContext(ctx, "tc") }, []string{"TRACE", "tc"}, false}, + {"DebugContext", func() { rlog.DebugContext(ctx, "dc") }, []string{"DEBUG", "dc"}, false}, + {"InfoContext", func() { rlog.InfoContext(ctx, "ic") }, []string{"INFO", "ic"}, false}, + {"WarnContext", func() { rlog.WarnContext(ctx, "wc") }, []string{"WARN", "wc"}, false}, + {"ErrorContext", func() { rlog.ErrorContext(ctx, "ec") }, []string{"ERROR", "ec"}, false}, + {"FatalContext", func() { rlog.FatalContext(ctx, "fc") }, []string{"FATAL", "fc"}, false}, + {"PanicContext", func() { rlog.PanicContext(ctx, "pc") }, []string{"PANIC", "pc"}, true}, + + {"TraceAttrs", func() { rlog.TraceAttrs(ctx, "ta", slog.String("a", "1")) }, []string{"TRACE", "ta", "a=1"}, false}, + {"DebugAttrs", func() { rlog.DebugAttrs(ctx, "da", slog.String("a", "1")) }, []string{"DEBUG", "da", "a=1"}, false}, + {"InfoAttrs", func() { rlog.InfoAttrs(ctx, "ia", slog.String("a", "1")) }, []string{"INFO", "ia", "a=1"}, false}, + {"WarnAttrs", func() { rlog.WarnAttrs(ctx, "wa", slog.String("a", "1")) }, []string{"WARN", "wa", "a=1"}, false}, + {"ErrorAttrs", func() { rlog.ErrorAttrs(ctx, "ea", slog.String("a", "1")) }, []string{"ERROR", "ea", "a=1"}, false}, + {"FatalAttrs", func() { rlog.FatalAttrs(ctx, "fa", slog.String("a", "1")) }, []string{"FATAL", "fa", "a=1"}, false}, + {"PanicAttrs", func() { rlog.PanicAttrs(ctx, "pa", slog.String("a", "1")) }, []string{"PANIC", "pa", "a=1"}, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { buf.Reset() - exited = false - switch { - case tc.panic: + // If the test case expects a panic, verify that a panic was actually raised. + if tc.panic { var sawPanic bool func() { defer func() { @@ -377,13 +313,11 @@ func TestGlobalFunctionsUseSetDefaultLogger(t *testing.T) { tc.run() }() assert.True(t, sawPanic, "expected panic from %s", tc.name) - case tc.fatal: - tc.run() - assert.True(t, exited, "expected exit hook from %s", tc.name) - default: + } else { tc.run() } + // Verify the output contains the expected substrings. out := buf.String() for _, w := range tc.want { assert.Contains(t, out, w) @@ -392,23 +326,28 @@ func TestGlobalFunctionsUseSetDefaultLogger(t *testing.T) { } } +// rlog.SetDefault updates slog.Default to the same underlying logger. +func TestSlogSetDefault_alignedWithRlog(t *testing.T) { + var buf bytes.Buffer + opts := rlog.MergeWithCustomLevels(&slog.HandlerOptions{Level: slog.LevelInfo}) + lg := rlog.New(slog.New(slog.NewJSONHandler(&buf, opts))) + prev := rlog.Default() + t.Cleanup(func() { rlog.SetDefault(prev) }) + + rlog.SetDefault(lg) + assert.Equal(t, rlog.Default().Logger, slog.Default(), "slog.Default must match rlog's embedded logger after SetDefault") + + slog.Info("via-slog") + assert.Contains(t, buf.String(), "via-slog") +} + //============================================================================= -// Helpers +// Helper Functions //============================================================================= +// newTestLogger wraps JSONHandler with MergeWithCustomLevels so TRACE/FATAL/PANIC levels work. func newTestLogger(t *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) *rlog.Logger { t.Helper() inner := slog.New(slog.NewJSONHandler(buf, rlog.MergeWithCustomLevels(opts))) return rlog.New(inner) } - -// noteNilishLogger increments cnt if l or its embedded [*slog.Logger] is nil. -func noteNilishLogger(l *rlog.Logger, cnt *atomic.Int64) { - if l == nil { - cnt.Add(1) - return - } - if l.Logger == nil { - cnt.Add(1) - } -} diff --git a/rlog/handler.go b/rlog/options.go similarity index 76% rename from rlog/handler.go rename to rlog/options.go index 71f0219..19f37a3 100644 --- a/rlog/handler.go +++ b/rlog/options.go @@ -31,7 +31,7 @@ func MergeWithCustomLevels(opts *slog.HandlerOptions) *slog.HandlerOptions { next := merged.ReplaceAttr merged.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr { - a = replaceLevelKey(a) + a = ReplaceLevelKey(groups, a) if next != nil { return next(groups, a) @@ -43,7 +43,18 @@ func MergeWithCustomLevels(opts *slog.HandlerOptions) *slog.HandlerOptions { return &merged } -func replaceLevelKey(a slog.Attr) slog.Attr { +// ReplaceLevelKey replaces the level key with the custom level key. Use this +// as a ReplaceAttr function for a [slog.HandlerOptions]. If you wish to merge +// this with other ReplaceAttr functions, you can use [MergeWithCustomLevels]. +// +// Example: +// +// opts := &slog.HandlerOptions{ +// ReplaceAttr: rlog.ReplaceLevelKey, +// } +// logger := slog.New(slog.NewJSONHandler(w, opts)) +// logger.Log(context.Background(), rlog.LevelTrace, "message", "hello", "world") +func ReplaceLevelKey(_ []string, a slog.Attr) slog.Attr { if a.Key != slog.LevelKey { return a } diff --git a/rlog/options_test.go b/rlog/options_test.go new file mode 100644 index 0000000..ef5fb7f --- /dev/null +++ b/rlog/options_test.go @@ -0,0 +1,72 @@ +package rlog_test + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" + + "go.rtnl.ai/x/assert" + "go.rtnl.ai/x/rlog" +) + +// Verifies MergeWithCustomLevels maps custom levels to TRACE/FATAL/PANIC strings and leaves other levels as slog defaults. +func TestMergeHandlerOptions_levelStrings(t *testing.T) { + var buf bytes.Buffer + logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: rlog.LevelTrace}) + + logger.Log(context.Background(), rlog.LevelTrace, "trace-msg") + logger.Log(context.Background(), slog.LevelDebug, "debug-msg") + logger.Log(context.Background(), slog.LevelInfo, "info-msg") + logger.Log(context.Background(), slog.LevelWarn, "warn-msg") + logger.Log(context.Background(), slog.LevelError, "error-msg") + logger.Log(context.Background(), rlog.LevelFatal, "fatal-msg") + logger.Log(context.Background(), rlog.LevelPanic, "panic-msg") + logger.Log(context.Background(), rlog.LevelPanic+4, "panic-plus-4-msg") + + out := buf.String() + for _, want := range []string{`"level":"TRACE"`, `"level":"DEBUG"`, `"level":"INFO"`, `"level":"WARN"`, `"level":"ERROR"`, `"level":"FATAL"`, `"level":"PANIC"`, `"level":"ERROR+12"`} { + assert.Contains(t, out, want, "output missing level substring: %s", want) + } +} + +// User ReplaceAttr still runs after the level-name replacer (drop attr + trace level preserved). +func TestMergeHandlerOptions_chainsUserReplaceAttr(t *testing.T) { + var buf bytes.Buffer + opts := &slog.HandlerOptions{ + Level: rlog.LevelTrace, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == "drop" { + return slog.Attr{} + } + return a + }, + } + logger := newTestLogger(t, &buf, opts) + logger.Log(context.Background(), rlog.LevelTrace, "x", "drop", true, "keep", "y") + + out := buf.String() + assert.NotContains(t, out, "drop", "user ReplaceAttr should drop key") + assert.Contains(t, out, `"level":"TRACE"`) + assert.Contains(t, out, "keep") +} + +// Min level Error: Trace is below threshold, Panic is not. +func TestEnabled_ordering(t *testing.T) { + var buf bytes.Buffer + logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: slog.LevelError}) + assert.False(t, logger.Enabled(context.Background(), rlog.LevelTrace), "Trace should be disabled when min is Error") + assert.True(t, logger.Enabled(context.Background(), rlog.LevelPanic), "Panic should be enabled when min is Error") +} + +// Decoded JSON has string level "TRACE" (not a numeric slog offset). +func TestJSON_shape(t *testing.T) { + var buf bytes.Buffer + logger := newTestLogger(t, &buf, &slog.HandlerOptions{Level: rlog.LevelTrace}) + logger.Log(context.Background(), rlog.LevelTrace, "x") + + var m map[string]any + assert.Ok(t, json.Unmarshal(buf.Bytes(), &m)) + assert.Equal(t, "TRACE", m["level"]) +} diff --git a/rlog/testing.go b/rlog/testing.go new file mode 100644 index 0000000..e41eda1 --- /dev/null +++ b/rlog/testing.go @@ -0,0 +1,207 @@ +package rlog + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "slices" + "strings" + "sync" + "testing" +) + +// capturingJSONOpts is reused by [CapturingTestHandler.Handle] so every log line +// does not rebuild [MergeWithCustomLevels](nil) (closure + [slog.HandlerOptions] escape). +var capturingJSONOpts = MergeWithCustomLevels(nil) + +// captureState holds lines and records shared by all derived handlers from the same root. +type captureState struct { + mu sync.Mutex + records []slog.Record + lines []string +} + +// CapturingTestHandler is a [slog.Handler] that records each [slog.Record] and +// each rendered JSON line (one line per Handle call). All derived handlers +// (via WithAttrs/WithGroup) share the same capture buffers. Construct with +// [NewCapturingTestHandler]; pass a non-nil [testing.TB] to also log each line +// via [testing.TB.Log]. +type CapturingTestHandler struct { + state *captureState + topAttrs []slog.Attr + segments []captureGroupSegment + tb testing.TB +} + +// captureGroupSegment is one WithGroup plus attrs added before the next WithGroup. +type captureGroupSegment struct { + name string + attrs []slog.Attr +} + +// NewCapturingTestHandler returns an empty [*CapturingTestHandler] with JSON line +// output. If tb is not nil, each line is also sent to [testing.TB.Log] on the +// associated [testing.TB] (e.g. for live test output). +func NewCapturingTestHandler(tb testing.TB) *CapturingTestHandler { + if tb != nil { + tb.Helper() + } + return &CapturingTestHandler{state: &captureState{}, tb: tb} +} + +// Enabled always returns true so tests see all levels. +func (h *CapturingTestHandler) Enabled(context.Context, slog.Level) bool { + return true +} + +// Handle renders one JSON line (with top attrs and groups), then appends a copy of r and +// that line to shared state under a single critical section. Nothing is stored if JSON +// rendering fails, so [CapturingTestHandler.Records] and [CapturingTestHandler.Lines] stay +// aligned by index. Concurrent Handle calls may reorder entries relative to start time, +// but each index still pairs one record with one line. +func (h *CapturingTestHandler) Handle(ctx context.Context, r slog.Record) error { + rec := r.Clone() + + var buf bytes.Buffer + var jh slog.Handler = slog.NewJSONHandler(&buf, capturingJSONOpts) + + if len(h.topAttrs) > 0 { + jh = jh.WithAttrs(h.topAttrs) + } + + for _, seg := range h.segments { + jh = jh.WithGroup(seg.name) + if len(seg.attrs) > 0 { + jh = jh.WithAttrs(seg.attrs) + } + } + + if err := jh.Handle(ctx, r); err != nil { + return err + } + + line := strings.TrimSuffix(buf.String(), "\n") + + h.state.mu.Lock() + h.state.records = append(h.state.records, rec) + h.state.lines = append(h.state.lines, line) + h.state.mu.Unlock() + + if h.tb != nil { + h.tb.Log(line) + } + + return nil +} + +// WithAttrs adds attrs at the top level until a group is opened; then they attach to the innermost group. +func (h *CapturingTestHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + + // If no groups are open, add the attrs to the top level. + if len(h.segments) == 0 { + top := h.topAttrs + if len(top) > 0 { + top = append(slices.Clone(top), attrs...) + } else { + top = attrs + } + return &CapturingTestHandler{state: h.state, topAttrs: top, segments: h.segments, tb: h.tb} + } + + // If a group is open, add the attrs to the innermost group. + segs := slices.Clone(h.segments) + last := len(segs) - 1 + if len(segs[last].attrs) > 0 { + segs[last].attrs = append(slices.Clone(segs[last].attrs), attrs...) + } else { + segs[last].attrs = attrs + } + return &CapturingTestHandler{state: h.state, topAttrs: h.topAttrs, segments: segs, tb: h.tb} +} + +// WithGroup opens a new nested group for subsequent WithAttrs. +func (h *CapturingTestHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + segs := append(slices.Clone(h.segments), captureGroupSegment{name: name}) + return &CapturingTestHandler{state: h.state, topAttrs: h.topAttrs, segments: segs, tb: h.tb} +} + +// Records returns a snapshot of captured records (newest appended last). +func (h *CapturingTestHandler) Records() []slog.Record { + h.state.mu.Lock() + defer h.state.mu.Unlock() + out := make([]slog.Record, len(h.state.records)) + copy(out, h.state.records) + return out +} + +// Lines returns a snapshot of captured JSON lines (one per Handle). +func (h *CapturingTestHandler) Lines() []string { + h.state.mu.Lock() + defer h.state.mu.Unlock() + out := make([]string, len(h.state.lines)) + copy(out, h.state.lines) + return out +} + +// RecordsAndLines returns snapshots of records and lines taken under one lock. Prefer this +// over separate [CapturingTestHandler.Records] and [CapturingTestHandler.Lines] calls when +// logs may be written concurrently, so lengths match and index i is always the same event. +func (h *CapturingTestHandler) RecordsAndLines() (records []slog.Record, lines []string) { + h.state.mu.Lock() + defer h.state.mu.Unlock() + records = make([]slog.Record, len(h.state.records)) + copy(records, h.state.records) + lines = make([]string, len(h.state.lines)) + copy(lines, h.state.lines) + return records, lines +} + +// Reset clears captured records and lines. +func (h *CapturingTestHandler) Reset() { + h.state.mu.Lock() + defer h.state.mu.Unlock() + h.state.records = h.state.records[:0] + h.state.lines = h.state.lines[:0] +} + +// ResultMaps parses each captured line as JSON into a map. +// This is useful for use with [testing/slogtest.TestHandler]. +func (h *CapturingTestHandler) ResultMaps() ([]map[string]any, error) { + lines := h.Lines() + out := make([]map[string]any, 0, len(lines)) + + for _, line := range lines { + m, err := ParseJSONLine(line) + if err != nil { + return nil, err + } + out = append(out, m) + } + + return out, nil +} + +// ParseJSONLine unmarshals one JSON log line into a map. +func ParseJSONLine(s string) (map[string]any, error) { + var m map[string]any + if err := json.Unmarshal([]byte(s), &m); err != nil { + return nil, err + } + return m, nil +} + +// MustParseJSONLine is like [ParseJSONLine] but panics on error; intended for tests. +func MustParseJSONLine(s string) map[string]any { + m, err := ParseJSONLine(s) + if err != nil { + panic(err) + } + return m +} diff --git a/rlog/testing_test.go b/rlog/testing_test.go new file mode 100644 index 0000000..64bdf87 --- /dev/null +++ b/rlog/testing_test.go @@ -0,0 +1,104 @@ +package rlog_test + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "testing" + "testing/slogtest" + "time" + + "go.rtnl.ai/x/assert" + "go.rtnl.ai/x/rlog" +) + +// CapturingTestHandler should pass the standard slog handler tests. +func TestCapturingTestHandler_slogtest(t *testing.T) { + cap := rlog.NewCapturingTestHandler(nil) + err := slogtest.TestHandler(cap, func() []map[string]any { + maps, err := cap.ResultMaps() + assert.Ok(t, err) + return maps + }) + assert.Ok(t, err) +} + +// Groups nest attrs in JSON output and ResultMaps sees nested maps. +func TestCapturingTestHandler_groupNesting(t *testing.T) { + h := rlog.NewCapturingTestHandler(nil).WithGroup("g").(*rlog.CapturingTestHandler) + h = h.WithAttrs([]slog.Attr{slog.String("inside", "yes")}).(*rlog.CapturingTestHandler) + + ctx := context.Background() + assert.Ok(t, h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, "hi", 0))) + + lines := h.Lines() + assert.Equal(t, 1, len(lines)) + m := rlog.MustParseJSONLine(lines[0]) + g, ok := m["g"].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "yes", g["inside"]) +} + +// ParseJSONLine and MustParseJSONLine round-trip a simple object. +func TestParseJSONLine_helpers(t *testing.T) { + const line = `{"level":"INFO","msg":"x","k":1}` + m, err := rlog.ParseJSONLine(line) + assert.Ok(t, err) + assert.Equal(t, "INFO", m["level"]) + assert.Equal(t, float64(1), m["k"]) + assert.Equal(t, m, rlog.MustParseJSONLine(line)) +} + +// NewCapturingTestHandler links to [testing.TB] (only *testing.T / B / F satisfy TB outside stdlib). +// Derived handlers still share capture state; slogtest exercises WithAttrs/WithGroup with tb set. +func TestCapturingTestHandler_slogtest_withTB(t *testing.T) { + cap := rlog.NewCapturingTestHandler(t) + err := slogtest.TestHandler(cap, func() []map[string]any { + maps, err := cap.ResultMaps() + assert.Ok(t, err) + return maps + }) + assert.Ok(t, err) +} + +// Concurrent Handle calls must keep the same index aligned in records and lines. +func TestCapturingTestHandler_concurrentHandle_recordsMatchLines(t *testing.T) { + h := rlog.NewCapturingTestHandler(nil) + ctx := context.Background() + const n = 64 + var wg sync.WaitGroup + for i := range n { + wg.Add(1) + go func(i int) { + defer wg.Done() + msg := fmt.Sprintf("msg-%d", i) + assert.Ok(t, h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, msg, 0))) + }(i) + } + wg.Wait() + + recs, lines := h.RecordsAndLines() + assert.Equal(t, len(recs), len(lines)) + assert.Equal(t, n, len(recs)) + for i := range recs { + assert.Contains(t, lines[i], recs[i].Message) + } +} + +// Derived handlers share the same capture buffers. +func TestCapturingTestHandler_derivedSharesLines(t *testing.T) { + h := rlog.NewCapturingTestHandler(nil) + ctx := context.Background() + assert.Ok(t, h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, "one", 0))) + + h2 := h.WithAttrs([]slog.Attr{slog.String("k", "v")}).(*rlog.CapturingTestHandler) + assert.Ok(t, h2.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelWarn, "two", 0))) + + lines := h.Lines() + assert.Equal(t, 2, len(lines)) + assert.True(t, strings.Contains(lines[0], `"msg":"one"`)) + assert.True(t, strings.Contains(lines[1], `"k":"v"`)) + assert.True(t, strings.Contains(lines[1], `"msg":"two"`)) +} From 97980e74633d6588ae85d32dff87d6310b3caff5 Mon Sep 17 00:00:00 2001 From: Chris Okuda Date: Wed, 25 Mar 2026 15:48:27 -1000 Subject: [PATCH 2/6] remove old test stuff --- rlog/logger_test.go | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/rlog/logger_test.go b/rlog/logger_test.go index 4ea536c..8232f24 100644 --- a/rlog/logger_test.go +++ b/rlog/logger_test.go @@ -199,7 +199,7 @@ func TestDefaultSetDefault_concurrent(t *testing.T) { wantLog := int64(goroutines * perG * 3) var wg sync.WaitGroup - var iterationsDone, logOps, nilishLoggers atomic.Int64 + var iterationsDone, logOps atomic.Int64 wg.Add(goroutines) for g := range goroutines { go func(id int) { @@ -209,22 +209,16 @@ func TestDefaultSetDefault_concurrent(t *testing.T) { case 0: // swap global only rlog.SetDefault(pool[(id+n)%poolSize]) case 1: // read default, method log - l := rlog.Default() - if l != nil && l.Logger != nil { - l.Info("concurrent") - logOps.Add(1) - } + rlog.Default().Info("concurrent") + logOps.Add(1) case 2: // read default, package-level log _ = rlog.Default() rlog.Info("concurrent-global") logOps.Add(1) case 3: // swap then read and log rlog.SetDefault(pool[(id*3+n)%poolSize]) - l := rlog.Default() - if l != nil && l.Logger != nil { - l.Info("after-set") - logOps.Add(1) - } + rlog.Default().Info("after-set") + logOps.Add(1) } iterationsDone.Add(1) } @@ -232,8 +226,6 @@ func TestDefaultSetDefault_concurrent(t *testing.T) { } wg.Wait() - // No nil default; full iteration count; logging arms ran expected times. - assert.Equal(t, int64(0), nilishLoggers.Load(), "nil *Logger or nil embedded slog.Logger under concurrency") assert.Equal(t, wantIterations, iterationsDone.Load()) assert.Equal(t, wantLog, logOps.Load()) From 5534eda96ac12c99072b16120234c1a6566e3d1d Mon Sep 17 00:00:00 2001 From: Chris Okuda Date: Wed, 25 Mar 2026 15:51:57 -1000 Subject: [PATCH 3/6] fix bug in fanout handler --- rlog/fanout.go | 12 ++++++++---- rlog/fanout_test.go | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/rlog/fanout.go b/rlog/fanout.go index 39f46f8..7e5e036 100644 --- a/rlog/fanout.go +++ b/rlog/fanout.go @@ -29,17 +29,21 @@ func (f *FanOut) Enabled(ctx context.Context, level slog.Level) bool { return false } -// Handle forwards a clone of r to each child and joins any non-nil errors. +// Handle forwards a clone of r to each child that [slog.Handler.Enabled] accepts +// for r's level, and joins any non-nil errors. func (f *FanOut) Handle(ctx context.Context, r slog.Record) error { if len(f.handlers) == 0 { return nil } + level := r.Level var errs []error for _, h := range f.handlers { - r2 := r.Clone() - if err := h.Handle(ctx, r2); err != nil { - errs = append(errs, err) + if h.Enabled(ctx, level) { + r2 := r.Clone() + if err := h.Handle(ctx, r2); err != nil { + errs = append(errs, err) + } } } diff --git a/rlog/fanout_test.go b/rlog/fanout_test.go index 4840c2e..1c7d8ec 100644 --- a/rlog/fanout_test.go +++ b/rlog/fanout_test.go @@ -40,6 +40,21 @@ func TestFanOut_Enabled_OR(t *testing.T) { assert.False(t, f.Enabled(ctx, rlog.LevelTrace), "neither accepts Trace by default opts") } +// Handle skips children whose minimum level is above the record (like slog.MultiHandler). +func TestFanOut_Handle_skipsDisabledChildren(t *testing.T) { + var quiet, loud bytes.Buffer + hQuiet := slog.NewJSONHandler(&quiet, &slog.HandlerOptions{Level: slog.LevelError}) + hLoud := slog.NewJSONHandler(&loud, &slog.HandlerOptions{Level: slog.LevelDebug}) + f := rlog.NewFanOut(hQuiet, hLoud) + + ctx := context.Background() + r := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0) + assert.Ok(t, f.Handle(ctx, r)) + + assert.Equal(t, "", strings.TrimSpace(quiet.String()), "Error-only sink must not receive Info") + assert.Contains(t, loud.String(), "hello") +} + // WithGroup on the fan-out applies to every child. func TestFanOut_WithGroup_propagates(t *testing.T) { var a, b bytes.Buffer From 449351e231992a08b1fea20deff86008e85f5ee0 Mon Sep 17 00:00:00 2001 From: Chris Okuda Date: Thu, 26 Mar 2026 08:09:31 -1000 Subject: [PATCH 4/6] note to use the slog multihandler in go 1.26 or later and return the default logger if the user provides a nil slog logger in New --- rlog/fanout.go | 11 +++++++---- rlog/logger.go | 6 +++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/rlog/fanout.go b/rlog/fanout.go index 7e5e036..9c5ec4c 100644 --- a/rlog/fanout.go +++ b/rlog/fanout.go @@ -7,13 +7,16 @@ import ( ) // FanOut is a [slog.Handler] that forwards each record to every child handler, -// using a fresh [slog.Record.Clone] per child. +// using a fresh [slog.Record.Clone] per child. For Go 1.26 or later, use the +// standard library's [slog.MultiHandler] instead; this is a clone of the +// functionality for Go 1.25 and earlier. type FanOut struct { handlers []slog.Handler } // NewFanOut returns a new [FanOut] that forwards each record to every child -// handler. +// handler. For Go 1.26 or later, use [slog.MultiHandler] instead; this is a +// clone of the functionality for Go 1.25 and earlier. func NewFanOut(handlers ...slog.Handler) *FanOut { hs := append([]slog.Handler(nil), handlers...) return &FanOut{handlers: hs} @@ -50,7 +53,7 @@ func (f *FanOut) Handle(ctx context.Context, r slog.Record) error { return errors.Join(errs...) } -// WithAttrs returns a fan-out whose children are wrapped with the same attrs. +// WithAttrs returns a [FanOut] whose children are wrapped with the same attrs. func (f *FanOut) WithAttrs(attrs []slog.Attr) slog.Handler { if len(attrs) == 0 { return f @@ -65,7 +68,7 @@ func (f *FanOut) WithAttrs(attrs []slog.Attr) slog.Handler { return &FanOut{handlers: next} } -// WithGroup returns a fan-out whose children are wrapped with the same group. +// WithGroup returns a [FanOut] whose children are wrapped with the same group. func (f *FanOut) WithGroup(name string) slog.Handler { if name == "" { return f diff --git a/rlog/logger.go b/rlog/logger.go index aad7108..5cfcdca 100644 --- a/rlog/logger.go +++ b/rlog/logger.go @@ -21,8 +21,12 @@ type Logger struct { *slog.Logger } -// New returns a new [Logger] wrapping the given [slog.Logger]. +// New returns a new [Logger] wrapping the given [slog.Logger]. If logger is nil, +// returns the [Default] [Logger]. func New(logger *slog.Logger) *Logger { + if logger == nil { + return Default() + } return &Logger{Logger: logger} } From e8ac89d110a8bb19f03ddddd10074f1e3334a262 Mon Sep 17 00:00:00 2001 From: Chris Okuda Date: Thu, 26 Mar 2026 08:23:00 -1000 Subject: [PATCH 5/6] we can get rid of the atomic if I do things correctly with a pointer --- rlog/logger.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/rlog/logger.go b/rlog/logger.go index 5cfcdca..28f2a85 100644 --- a/rlog/logger.go +++ b/rlog/logger.go @@ -47,7 +47,7 @@ func (l *Logger) WithGroup(name string) *Logger { var ( // Stores the global logger that is returned by [Default] - globalLogger atomic.Pointer[Logger] + globalLogger *Logger // Protects reads and writes to the globalLogger loggerMu sync.RWMutex // The zero value is [slog.LevelInfo] @@ -58,9 +58,7 @@ var ( // Initializes the global logger to be a console JSON logger with level [slog.LevelInfo]. func init() { - l := New(slog.New(slog.NewJSONHandler(os.Stdout, MergeWithCustomLevels(&slog.HandlerOptions{Level: globalLevel})))) - globalLogger.Store(l) - slog.SetDefault(l.Logger) + SetDefault(New(slog.New(slog.NewJSONHandler(os.Stdout, MergeWithCustomLevels(&slog.HandlerOptions{Level: globalLevel}))))) } // Default returns the default (global) [Logger]. Use [SetDefault] to set the @@ -69,7 +67,7 @@ func init() { func Default() *Logger { loggerMu.RLock() defer loggerMu.RUnlock() - return globalLogger.Load() + return globalLogger } // SetDefault sets the default (global) [Logger] and [slog.Default] to the same @@ -81,7 +79,7 @@ func SetDefault(logger *Logger) { *p = *logger loggerMu.Lock() defer loggerMu.Unlock() - globalLogger.Store(p) + globalLogger = p slog.SetDefault(p.Logger) } From 6f362b02d18840fe697dfd6ea0300c6487cbc246 Mon Sep 17 00:00:00 2001 From: Chris Okuda Date: Thu, 26 Mar 2026 08:23:12 -1000 Subject: [PATCH 6/6] add with/withgroup test --- rlog/logger_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/rlog/logger_test.go b/rlog/logger_test.go index 8232f24..452282a 100644 --- a/rlog/logger_test.go +++ b/rlog/logger_test.go @@ -333,6 +333,65 @@ func TestSlogSetDefault_alignedWithRlog(t *testing.T) { assert.Contains(t, buf.String(), "via-slog") } +// With and WithGroup on *rlog.Logger, and package-level rlog.With / rlog.WithGroup after SetDefault, +// merge fixed attrs and nested groups into JSON in the same shape as slog. +func TestWithAndWithGroupLoggerAndPackageLevel(t *testing.T) { + // Create a new test root logger with a buffer and options + var buf bytes.Buffer + root := newTestLogger(t, &buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + + // Test Logger.With + withLogger := root.With(slog.String("k1", "v1")) + + withLogger.Info("hello1", slog.String("k2", "v2")) + out := buf.String() + + assert.Contains(t, out, `"msg":"hello1"`, "Logger.With: expected message hello1 in output") + assert.Contains(t, out, `"k1":"v1"`, "Logger.With: expected fixed attr k1 from With") + assert.Contains(t, out, `"k2":"v2"`, "Logger.With: expected call-site attr k2") + + buf.Reset() + + // Test Logger.WithGroup + groupLogger := root.WithGroup("G") + + groupLogger.Info("hello2", slog.String("k3", "v3")) + out = buf.String() + + assert.Contains(t, out, `"msg":"hello2"`, "Logger.WithGroup: expected message hello2 in output") + assert.Contains(t, out, `"G":{"k3":"v3"}`, "Logger.WithGroup: expected attrs nested under group G") + + buf.Reset() + + // Test Logger.With + WithGroup combo + mixedLogger := root.With(slog.String("outer", "val")).WithGroup("g") + + mixedLogger.Info("hello3", slog.String("inner", "val2")) + out = buf.String() + + assert.Contains(t, out, `"msg":"hello3"`, "With then WithGroup: expected message hello3 in output") + assert.Contains(t, out, `"outer":"val"`, "With then WithGroup: expected fixed attr outer before group") + assert.Contains(t, out, `"g":{"inner":"val2"}`, "With then WithGroup: expected inner attrs under group g") + + buf.Reset() + + // Package-level rlog.With chained with rlog.WithGroup + orig := rlog.Default() + t.Cleanup(func() { rlog.SetDefault(orig) }) + rlog.SetDefault(root) + + rlog.With(slog.String("pkgk", "pkgv")).WithGroup("pg").Info( + "from_pkg_chain", + slog.String("other", "x"), + slog.String("deepk", "deepv"), + ) + out = buf.String() + + assert.Contains(t, out, `"msg":"from_pkg_chain"`, "package With+WithGroup: expected message in output") + assert.Contains(t, out, `"pkgk":"pkgv"`, "package With+WithGroup: expected fixed attr from With") + assert.Contains(t, out, `"pg":{"other":"x","deepk":"deepv"}`, "package With+WithGroup: expected call-site attrs under group pg") +} + //============================================================================= // Helper Functions //=============================================================================