Skip to content

[rlog] Updates based on integration lessons learned#39

Merged
chris-okuda merged 6 commits intomainfrom
chrisokuda/sc-37788/rlog_updates
Mar 26, 2026
Merged

[rlog] Updates based on integration lessons learned#39
chris-okuda merged 6 commits intomainfrom
chrisokuda/sc-37788/rlog_updates

Conversation

@chris-okuda
Copy link
Copy Markdown
Contributor

@chris-okuda chris-okuda commented Mar 26, 2026

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.

  • Replace per-logger SetExitFunc with process-wide SetFatalHook; os.Exit(1) when nil
  • Store default logger in 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 helper NewCapturingTestHandler
  • Rename handler.go to options.go
  • Move tests out of rlog_test.go to other files
  • Add doc.go; update README

Comments for reviewer here.

Fixes SC-37788

Type of change

  • new feature
  • bug fix
  • documentation
  • testing
  • technical debt
  • other (describe)

Author checklist

  • I have manually tested the change and/or added automation in the form of unit tests or integration tests
  • I have added new test fixtures as needed to support added tests
  • Check this box if a reviewer can merge this pull request after approval (leave it unchecked if you want to do it yourself)
  • I have moved the associated Shortcut story to "Ready for Review"

Reviewer(s) checklist

  • Any new user-facing content that has been added for this PR has been QA'ed to ensure correct grammar, spelling, and understandability.
  • Are there any TODOs in this PR that should be turned into stories?

…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 chris-okuda self-assigned this Mar 26, 2026
Copy link
Copy Markdown
Contributor Author

@chris-okuda chris-okuda left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments for reviewer.

Comment on lines +9 to +13
// 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
}
Copy link
Copy Markdown
Contributor Author

@chris-okuda chris-okuda Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting - yes, I'm happy to upgrade to 1.26 if that makes our lives easier.

Comment on lines +45 to +48
// 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to eliminate the atomic!

Comment on lines 65 to 69
func Default() *Logger {
loggerMu.RLock()
l := globalLogger
loggerMu.RUnlock()
return &l
defer loggerMu.RUnlock()
return globalLogger.Load()
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removes this copy allocation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment on lines 75 to 82
func SetDefault(logger *Logger) {
p := new(Logger)
*p = *logger
loggerMu.Lock()
defer loggerMu.Unlock()
globalLogger = *logger
globalLogger.Store(p)
slog.SetDefault(p.Logger)
}
Copy link
Copy Markdown
Contributor Author

@chris-okuda chris-okuda Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed it for a regular pointer, I guess I never tried that combination :)

Comment on lines +25 to +35
// 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
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good pattern.

@chris-okuda chris-okuda marked this pull request as ready for review March 26, 2026 01:37
cursor[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@bbengfort bbengfort left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very well thought out and designed library package!

Comment on lines +9 to +13
// 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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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].
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if logger is nil? Does it just use the default logger?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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].
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good default!

Comment on lines 65 to 69
func Default() *Logger {
loggerMu.RLock()
l := globalLogger
loggerMu.RUnlock()
return &l
defer loggerMu.RUnlock()
return globalLogger.Load()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment on lines 75 to 82
func SetDefault(logger *Logger) {
p := new(Logger)
*p = *logger
loggerMu.Lock()
defer loggerMu.Unlock()
globalLogger = *logger
globalLogger.Store(p)
slog.SetDefault(p.Logger)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +25 to +35
// 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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good pattern.

@chris-okuda chris-okuda merged commit 0596351 into main Mar 26, 2026
3 checks passed
@chris-okuda chris-okuda deleted the chrisokuda/sc-37788/rlog_updates branch March 26, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants