[rlog] Updates based on integration lessons learned#39
Conversation
…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
chris-okuda
left a comment
There was a problem hiding this comment.
Comments for reviewer.
| // 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 | ||
| } |
There was a problem hiding this comment.
We were going to need this for Quarterdeck and other stuff. I also didn't realize G0 1.26 provides a slog.MultiHandler so maybe we can use that if we upgrade?
There was a problem hiding this comment.
Oh interesting - yes, I'm happy to upgrade to 1.26 if that makes our lives easier.
| // Stores the global logger that is returned by [Default] | ||
| globalLogger atomic.Pointer[Logger] | ||
| // Protects reads and writes to the globalLogger | ||
| loggerMu sync.RWMutex |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
I was able to eliminate the atomic!
| func Default() *Logger { | ||
| loggerMu.RLock() | ||
| l := globalLogger | ||
| loggerMu.RUnlock() | ||
| return &l | ||
| defer loggerMu.RUnlock() | ||
| return globalLogger.Load() | ||
| } |
There was a problem hiding this comment.
Removes this copy allocation
There was a problem hiding this comment.
So atomic is a low level concurrency primitive and notoriously difficult to use correctly. Is that why you have it wrapped in the mutex? Is there some race condition between globalLogger.Load() and globalLogger.Store?
Never mind - saw your comment below!
| func SetDefault(logger *Logger) { | ||
| p := new(Logger) | ||
| *p = *logger | ||
| loggerMu.Lock() | ||
| defer loggerMu.Unlock() | ||
| globalLogger = *logger | ||
| globalLogger.Store(p) | ||
| slog.SetDefault(p.Logger) | ||
| } |
There was a problem hiding this comment.
We still need the loggerMu to prevent race conditions where we might set/get the default logger in the wrong order. The globalLogger atomic is mainly to prevent having to copy the previous globalLogger.
There was a problem hiding this comment.
Ok that makes perfect sense - atomic as a low level concurrency primitive is notoriously difficult to use correctly. But won't simply keeping a pointer reference to the variable allow you to prevent copies without the atomic? Or is it that you're worried about the garbage collector?
There was a problem hiding this comment.
I went through a few iterations on this and there were race issues with concurrent SetDefault/Default use in that test due to both pointer issues, copies, and coordinating the default rlog logger with the default slog logger. I might be able to do something else now, but this seemed to work with this combo so I left it in the end and decided not to put more effort into figuring out if I could eliminate the atomic.
I was thinking about this last night, too, "why do I need a mutex AND an atomic value?", but I still need more experience with concurrency concepts to really grok them.
There was a problem hiding this comment.
Well if it works then it works - honestly in our code setdefault only happens once at the begining of the process so only Load/ RLock is going to be called in real operations.
There was a problem hiding this comment.
I removed it for a regular pointer, I guess I never tried that combination :)
| // 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 | ||
| } |
There was a problem hiding this comment.
I was creating this sort of thing with various features all over the place; this one can be used for all the different situations we need it for.
bbengfort
left a comment
There was a problem hiding this comment.
Very well thought out and designed library package!
| // 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 | ||
| } |
There was a problem hiding this comment.
Oh interesting - yes, I'm happy to upgrade to 1.26 if that makes our lives easier.
rlog/logger.go
Outdated
| } | ||
|
|
||
| // New returns a new [Logger] using the given [slog.Logger]. | ||
| // New returns a new [Logger] wrapping the given [slog.Logger]. |
There was a problem hiding this comment.
What happens if logger is nil? Does it just use the default logger?
There was a problem hiding this comment.
That is a good point; I guess if it's nil that causes an issue! I'll make a nil guard here, even though I really hope the user wouldn't do that.
| ) | ||
|
|
||
| // 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 Default() *Logger { | ||
| loggerMu.RLock() | ||
| l := globalLogger | ||
| loggerMu.RUnlock() | ||
| return &l | ||
| defer loggerMu.RUnlock() | ||
| return globalLogger.Load() | ||
| } |
There was a problem hiding this comment.
So atomic is a low level concurrency primitive and notoriously difficult to use correctly. Is that why you have it wrapped in the mutex? Is there some race condition between globalLogger.Load() and globalLogger.Store?
Never mind - saw your comment below!
| func SetDefault(logger *Logger) { | ||
| p := new(Logger) | ||
| *p = *logger | ||
| loggerMu.Lock() | ||
| defer loggerMu.Unlock() | ||
| globalLogger = *logger | ||
| globalLogger.Store(p) | ||
| slog.SetDefault(p.Logger) | ||
| } |
There was a problem hiding this comment.
Ok that makes perfect sense - atomic as a low level concurrency primitive is notoriously difficult to use correctly. But won't simply keeping a pointer reference to the variable allow you to prevent copies without the atomic? Or is it that you're worried about the garbage collector?
| // 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 | ||
| } |
…default logger if the user provides a nil slog logger in New
Scope of changes
Fixing some sharp edges and bugs and adding some useful features that were brought up when doing the zerolog to rlog switchover in endeavor.
Comments for reviewer here.
Fixes SC-37788
Type of change
Author checklist
Reviewer(s) checklist