Skip to content

Add slog.Handler implementation for zerolog#755

Open
veeceey wants to merge 2 commits intors:masterfrom
veeceey:feat/issue-571-slog-handler
Open

Add slog.Handler implementation for zerolog#755
veeceey wants to merge 2 commits intors:masterfrom
veeceey:feat/issue-571-slog-handler

Conversation

@veeceey
Copy link

@veeceey veeceey commented Feb 17, 2026

Closes #571

I needed slog interop in a project where zerolog handles all log output, but some dependencies use log/slog. Rather than losing zerolog's performance by switching to slog's built-in JSON handler, this adds a SlogHandler that implements slog.Handler and routes everything through zerolog.

What this does

zerolog.NewSlogHandler(logger) returns a slog.Handler backed by the given zerolog.Logger. You can use it like:

zl := zerolog.New(os.Stderr).With().Timestamp().Logger()
slog.SetDefault(slog.New(zerolog.NewSlogHandler(zl)))

slog.Info("request handled", "method", "GET", "status", 200)
// Output: {"level":"info","method":"GET","status":200,"time":...,"message":"request handled"}

Level mapping:

  • slog.LevelDebug-4 and below -> zerolog.TraceLevel
  • slog.LevelDebug -> zerolog.DebugLevel
  • slog.LevelInfo -> zerolog.InfoLevel
  • slog.LevelWarn -> zerolog.WarnLevel
  • slog.LevelError -> zerolog.ErrorLevel

Supported features:

  • All slog attribute types encoded with zerolog's typed methods (no reflection for primitives)
  • WithAttrs for pre-attaching fields to child handlers
  • WithGroup for namespacing keys with dot-separated prefixes
  • Nested groups work correctly
  • LogValuer resolution
  • Level filtering respects the zerolog Logger's configured level
  • Zerolog contextual fields from With() are preserved

Files:

  • slog.go - the handler implementation (~200 lines)
  • slog_test.go - 26 tests covering levels, all attr types, groups, filtering, LogValuer, immutability

All existing tests continue to pass (the RandomSampler flake in sampler_test.go is pre-existing).

The go.mod already requires Go 1.23, so log/slog is available without any changes.

Implement the log/slog.Handler interface to allow routing slog log
output through a zerolog.Logger backend. This enables interop between
code using the standard library's structured logging and zerolog's
high-performance JSON logger.

The handler supports:
- Level mapping (slog.LevelDebug -> zerolog.DebugLevel, etc.)
- All slog attribute types with type-specific encoding (no reflection
  for primitive types)
- WithAttrs for pre-attaching fields
- WithGroup for namespaced keys (dot-separated)
- Nested groups
- LogValuer resolution
- Zerolog contextual fields via With()
- Level filtering respecting the zerolog Logger's configured level

Closes rs#571

Signed-off-by: Varun Chawla <varun_6april@hotmail.com>
slog.go Outdated
// Resolve the attribute to handle LogValuer types
attr.Value = attr.Value.Resolve()

key := attr.Key
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is a called in a really tight loop for all the attributes, might be better to reduce the branch/potential for execution stalls. Do we know if prefix is always non-blank here? If so, we could just always do the concatenation.

Copy link
Author

Choose a reason for hiding this comment

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

Good point about the tight loop. Prefix is most commonly non-blank when we're inside nested groups, but can be empty at the top level. I think unconditional concatenation makes sense here - we can always concatenate prefix + "." + key and then just use a helper to clean up leading/trailing dots if needed. Eliminates the branch and makes the code simpler.

Copy link
Author

Choose a reason for hiding this comment

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

Pushed the changes - extracted a joinPrefix helper that just does strings.Trim(prefix+"."+key, ".") unconditionally. No more branching in the hot path.

slog.go Outdated
}
groupPrefix := prefix
if key != "" {
groupPrefix = key
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems confusing that by the time we get to this line, prefix is what we were passed in, and key has already concatenated the prefix and a dot to the key (unless key was blank)... so essentially this code is discarding the already concatenated string). I wonder two things:

  1. we've already had to test attr.Value.Kind() != slog.KindGroup up above so maybe this entire block should be moved up there
  2. could we just always concatenate the prefix + "." + key (and slice off the bare trailing . if the `key was blank?)

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, I see what you mean - we're already checking the kind above so it is a bit redundant. And you're right about the key concatenation getting discarded in the group case. I like the idea of moving the entire prefix logic up higher and just always concatenating prefix + "." + key, then stripping leading/trailing dots if needed. That would be cleaner and more efficient.

Copy link
Author

Choose a reason for hiding this comment

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

Agreed, moved the group handling up before key concatenation so it's handled first. The group case now uses joinPrefix(prefix, attr.Key) directly for its groupPrefix, and non-group attrs skip early if key is empty. Much cleaner flow now.

event = event.Time(key, cv)
case []byte:
event = event.Bytes(key, cv)
default:
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we also be handling KindLogValuer?

Copy link
Author

Choose a reason for hiding this comment

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

Good catch - actually we do handle it implicitly since we call attr.Value.Resolve() at the start which resolves LogValuer implementations. But it might be worth being more explicit about it. Let me look at whether we should add a specific case for it or if the current approach is sufficient.

Copy link
Author

Choose a reason for hiding this comment

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

Added a more explicit comment clarifying that attr.Value.Resolve() handles KindLogValuer by unwrapping it. Since Resolve() is called before the switch, we don't need a separate case for it - it'll already be resolved to its underlying kind by that point.

- Extract joinPrefix helper to reduce branching in tight loop
- Move group handling before key concatenation for clarity
- Add explicit comment about LogValuer resolution via Resolve()
@veeceey
Copy link
Author

veeceey commented Feb 23, 2026

addressed all three review points — extracted a joinPrefix helper to clean up the branching, moved group handling before key concat, and added a note about LogValuer resolution. pushed the updates, lmk if anything else needs adjusting

Copy link
Contributor

@IDisposable IDisposable left a comment

Choose a reason for hiding this comment

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

Looks good to me :shipit:

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a slog.Handler implementation that routes log/slog records through a backing zerolog.Logger, enabling slog interoperability while keeping zerolog’s encoding/perf characteristics.

Changes:

  • Introduces SlogHandler (NewSlogHandler) implementing slog.Handler, including attr encoding, groups, and level mapping.
  • Adds a new test suite covering levels, attr kinds, groups, filtering, and LogValuer behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
slog.go Implements SlogHandler to convert slog.Record + attrs into zerolog events.
slog_test.go Adds tests validating core mapping/encoding behaviors of the new handler.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


// Enabled reports whether the handler handles records at the given level.
func (h *SlogHandler) Enabled(_ context.Context, level slog.Level) bool {
return h.logger.GetLevel() <= slogToZerologLevel(level)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

SlogHandler.Enabled currently only compares against the logger’s local level. This diverges from zerolog’s actual filtering rules, which also consider the global level override (GlobalLevel()) and whether the logger has a non-nil writer. As written, Enabled can return true even when Handle will later drop the event due to GlobalLevel() (or a zero-value Logger with nil writer). Consider matching Logger.should’s level checks (without invoking sampling) by also checking h.logger.w != nil and ensuring slogToZerologLevel(level) is >= both h.logger.level and GlobalLevel().

Suggested change
return h.logger.GetLevel() <= slogToZerologLevel(level)
// Mirror zerolog.Logger.should's level and writer checks (without sampling)
if h.logger.w == nil {
return false
}
zl := slogToZerologLevel(level)
if zl < GlobalLevel() {
return false
}
if zl < h.logger.level {
return false
}
return true

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +38
func (h *SlogHandler) Handle(_ context.Context, record slog.Record) error {
zlevel := slogToZerologLevel(record.Level)
event := h.logger.WithLevel(zlevel)
if event == nil {
return nil
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

Handle ignores the context.Context passed by slog. This means zerolog hooks that rely on Event.GetCtx() (e.g., tracing / request-scoped data) cannot see the slog-provided context when logging through this handler. Consider setting the event context from the Handle ctx (ideally without clobbering an already-configured logger ctx unless that’s the intended precedence).

Suggested change
func (h *SlogHandler) Handle(_ context.Context, record slog.Record) error {
zlevel := slogToZerologLevel(record.Level)
event := h.logger.WithLevel(zlevel)
if event == nil {
return nil
}
func (h *SlogHandler) Handle(ctx context.Context, record slog.Record) error {
zlevel := slogToZerologLevel(record.Level)
event := h.logger.WithLevel(zlevel)
if event == nil {
return nil
}
// Propagate slog context to the zerolog event if it doesn't already have one.
if ctx != nil && event.GetCtx() == nil {
event = event.Ctx(ctx)
}

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +55
// Add timestamp
if !record.Time.IsZero() {
event.Time(TimestampFieldName, record.Time)
}

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

Handle unconditionally writes record.Time to TimestampFieldName. If the provided zerolog.Logger was built with .With().Timestamp() (as in the PR description example), the timestamp hook will also add the same field, producing duplicate "time" keys in the output JSON. Consider either relying on zerolog’s timestamp hook (and not emitting record.Time), or detecting and suppressing the timestamp hook when you explicitly write record.Time so the field is emitted exactly once.

Suggested change
// Add timestamp
if !record.Time.IsZero() {
event.Time(TimestampFieldName, record.Time)
}

Copilot uses AI. Check for mistakes.

// joinPrefix concatenates a prefix and key with a dot separator,
// trimming any leading or trailing dots from the result.
func joinPrefix(prefix, key string) string {
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

joinPrefix always builds prefix+"."+key and then trims dots, which forces a new string allocation even when prefix=="" or key=="". Since this helper is on the hot path for every attribute, consider handling the empty cases explicitly (return key / prefix) and only concatenating when both parts are non-empty to reduce allocations and CPU.

Suggested change
func joinPrefix(prefix, key string) string {
func joinPrefix(prefix, key string) string {
if prefix == "" {
return strings.Trim(key, ".")
}
if key == "" {
return strings.Trim(prefix, ".")
}

Copilot uses AI. Check for mistakes.
Copy link
Owner

@rs rs left a comment

Choose a reason for hiding this comment

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

Could you please add documentation in the README.md about how to use it?

@@ -0,0 +1,215 @@
package zerolog
Copy link
Owner

Choose a reason for hiding this comment

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

Does it need to be in the same package?

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.

stdlib slog handler for zerolog

4 participants