Add slog.Handler implementation for zerolog#755
Conversation
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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:
- we've already had to test
attr.Value.Kind() != slog.KindGroupup above so maybe this entire block should be moved up there - could we just always concatenate the
prefix + "." + key(and slice off the bare trailing.if the `key was blank?)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
Should we also be handling KindLogValuer?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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()
|
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 |
There was a problem hiding this comment.
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) implementingslog.Handler, including attr encoding, groups, and level mapping. - Adds a new test suite covering levels, attr kinds, groups, filtering, and
LogValuerbehavior.
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) |
There was a problem hiding this comment.
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().
| 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 |
| func (h *SlogHandler) Handle(_ context.Context, record slog.Record) error { | ||
| zlevel := slogToZerologLevel(record.Level) | ||
| event := h.logger.WithLevel(zlevel) | ||
| if event == nil { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
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).
| 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) | |
| } |
| // Add timestamp | ||
| if !record.Time.IsZero() { | ||
| event.Time(TimestampFieldName, record.Time) | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| // Add timestamp | |
| if !record.Time.IsZero() { | |
| event.Time(TimestampFieldName, record.Time) | |
| } |
|
|
||
| // 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 { |
There was a problem hiding this comment.
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.
| func joinPrefix(prefix, key string) string { | |
| func joinPrefix(prefix, key string) string { | |
| if prefix == "" { | |
| return strings.Trim(key, ".") | |
| } | |
| if key == "" { | |
| return strings.Trim(prefix, ".") | |
| } |
rs
left a comment
There was a problem hiding this comment.
Could you please add documentation in the README.md about how to use it?
| @@ -0,0 +1,215 @@ | |||
| package zerolog | |||
There was a problem hiding this comment.
Does it need to be in the same package?
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 aSlogHandlerthat implementsslog.Handlerand routes everything through zerolog.What this does
zerolog.NewSlogHandler(logger)returns aslog.Handlerbacked by the givenzerolog.Logger. You can use it like:Level mapping:
slog.LevelDebug-4and below ->zerolog.TraceLevelslog.LevelDebug->zerolog.DebugLevelslog.LevelInfo->zerolog.InfoLevelslog.LevelWarn->zerolog.WarnLevelslog.LevelError->zerolog.ErrorLevelSupported features:
WithAttrsfor pre-attaching fields to child handlersWithGroupfor namespacing keys with dot-separated prefixesLogValuerresolutionWith()are preservedFiles:
slog.go- the handler implementation (~200 lines)slog_test.go- 26 tests covering levels, all attr types, groups, filtering, LogValuer, immutabilityAll existing tests continue to pass (the
RandomSamplerflake insampler_test.gois pre-existing).The
go.modalready requires Go 1.23, solog/slogis available without any changes.