Skip to content

Commit d64c9a7

Browse files
authored
Add slog.Handler implementation for zerolog (#755)
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: ```go 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.
1 parent a0d61dc commit d64c9a7

3 files changed

Lines changed: 834 additions & 0 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Find out [who uses zerolog](https://github.com/rs/zerolog/wiki/Who-uses-zerolog)
2929
- [JSON and CBOR encoding formats](#binary-encoding)
3030
- [Pretty logging for development](#pretty-logging)
3131
- [Error Logging (with optional Stacktrace)](#error-logging)
32+
- [`log/slog` integration](#integration-with-logslog)
3233

3334
## Installation
3435

@@ -715,6 +716,33 @@ go build -tags binary_log .
715716
To decode binary encoded log files you can use any CBOR decoder. One has been tested to work
716717
with zerolog library is [CSD](https://github.com/toravir/csd/).
717718

719+
## Integration with `log/slog`
720+
721+
zerolog provides a `slog.Handler` implementation that routes `log/slog` records through a zerolog logger. This lets you use the standard library's `slog` API while keeping zerolog's performance and encoding:
722+
723+
```go
724+
package main
725+
726+
import (
727+
"log/slog"
728+
729+
"github.com/rs/zerolog"
730+
"github.com/rs/zerolog/log"
731+
)
732+
733+
func main() {
734+
zl := log.Logger
735+
handler := zerolog.NewSlogHandler(zl)
736+
logger := slog.New(handler)
737+
738+
logger.Info("user logged in", "user", "alice", "role", "admin")
739+
}
740+
741+
// Output: {"level":"info","user":"alice","role":"admin","time":"...","message":"user logged in"}
742+
```
743+
744+
The handler supports all `slog` features including `WithAttrs`, `WithGroup`, nested groups, and `LogValuer` resolution. slog levels are mapped to zerolog levels (e.g. `slog.LevelDebug` to `zerolog.DebugLevel`).
745+
718746
## Related Projects
719747

720748
- [grpc-zerolog](https://github.com/cheapRoc/grpc-zerolog): Implementation of `grpclog.LoggerV2` interface using `zerolog`

slog.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package zerolog
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"time"
7+
)
8+
9+
// SlogHandler implements the slog.Handler interface using a zerolog.Logger
10+
// as the underlying log backend. This allows code that uses the standard
11+
// library's slog package to route log output through zerolog.
12+
type SlogHandler struct {
13+
logger Logger
14+
prefix string // group prefix for nested groups
15+
attrs []slog.Attr
16+
}
17+
18+
// NewSlogHandler creates a new slog.Handler that writes log records to the
19+
// given zerolog.Logger. The handler maps slog levels to zerolog levels and
20+
// converts slog attributes to zerolog fields.
21+
func NewSlogHandler(logger Logger) *SlogHandler {
22+
return &SlogHandler{logger: logger}
23+
}
24+
25+
// Enabled reports whether the handler handles records at the given level.
26+
// It mirrors Logger.should's level and writer checks (without sampling).
27+
func (h *SlogHandler) Enabled(_ context.Context, level slog.Level) bool {
28+
if h.logger.w == nil {
29+
return false
30+
}
31+
zl := slogToZerologLevel(level)
32+
if zl < GlobalLevel() {
33+
return false
34+
}
35+
return zl >= h.logger.level
36+
}
37+
38+
// Handle handles the Record. It converts the slog.Record into a zerolog event
39+
// and writes it using the underlying zerolog.Logger.
40+
func (h *SlogHandler) Handle(ctx context.Context, record slog.Record) error {
41+
zlevel := slogToZerologLevel(record.Level)
42+
event := h.logger.WithLevel(zlevel)
43+
if event == nil {
44+
return nil
45+
}
46+
47+
// Propagate slog context to the zerolog event so that hooks
48+
// relying on Event.GetCtx() (e.g. tracing) can access it.
49+
if ctx != nil {
50+
event = event.Ctx(ctx)
51+
}
52+
53+
// Add pre-attached attrs from WithAttrs
54+
for _, a := range h.attrs {
55+
event = appendSlogAttr(event, a, h.prefix)
56+
}
57+
58+
// Add attrs from the record itself
59+
record.Attrs(func(a slog.Attr) bool {
60+
event = appendSlogAttr(event, a, h.prefix)
61+
return true
62+
})
63+
64+
// Add timestamp from the slog record, but only if the logger doesn't
65+
// already have a timestampHook (added via .With().Timestamp()) to
66+
// avoid duplicate timestamp keys in the output.
67+
if !record.Time.IsZero() && !h.hasTimestampHook() {
68+
event.Time(TimestampFieldName, record.Time)
69+
}
70+
71+
event.Msg(record.Message)
72+
return nil
73+
}
74+
75+
// hasTimestampHook reports whether the logger has a timestampHook installed,
76+
// which would cause duplicate timestamp fields if we also emit record.Time.
77+
func (h *SlogHandler) hasTimestampHook() bool {
78+
for _, hook := range h.logger.hooks {
79+
if _, ok := hook.(timestampHook); ok {
80+
return true
81+
}
82+
}
83+
return false
84+
}
85+
86+
// WithAttrs returns a new Handler with the given attributes pre-attached.
87+
// These attributes will be included in every subsequent log record.
88+
func (h *SlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
89+
if len(attrs) == 0 {
90+
return h
91+
}
92+
h2 := h.clone()
93+
h2.attrs = append(h2.attrs, attrs...)
94+
return h2
95+
}
96+
97+
// WithGroup returns a new Handler with the given group name. All subsequent
98+
// attributes will be nested under this group name in the output.
99+
func (h *SlogHandler) WithGroup(name string) slog.Handler {
100+
if name == "" {
101+
return h
102+
}
103+
h2 := h.clone()
104+
if h2.prefix != "" {
105+
h2.prefix = h2.prefix + "." + name
106+
} else {
107+
h2.prefix = name
108+
}
109+
return h2
110+
}
111+
112+
func (h *SlogHandler) clone() *SlogHandler {
113+
h2 := &SlogHandler{
114+
logger: h.logger,
115+
prefix: h.prefix,
116+
}
117+
if len(h.attrs) > 0 {
118+
h2.attrs = make([]slog.Attr, len(h.attrs))
119+
copy(h2.attrs, h.attrs)
120+
}
121+
return h2
122+
}
123+
124+
// slogToZerologLevel maps slog levels to zerolog levels.
125+
//
126+
// slog levels: Debug=-4, Info=0, Warn=4, Error=8
127+
// zerolog levels: Trace=-1, Debug=0, Info=1, Warn=2, Error=3, Fatal=4, Panic=5
128+
func slogToZerologLevel(level slog.Level) Level {
129+
switch {
130+
case level < slog.LevelDebug:
131+
return TraceLevel
132+
case level < slog.LevelInfo:
133+
return DebugLevel
134+
case level < slog.LevelWarn:
135+
return InfoLevel
136+
case level < slog.LevelError:
137+
return WarnLevel
138+
default:
139+
return ErrorLevel
140+
}
141+
}
142+
143+
// zerologToSlogLevel maps zerolog levels to slog levels.
144+
func zerologToSlogLevel(level Level) slog.Level {
145+
switch level {
146+
case TraceLevel:
147+
return slog.LevelDebug - 4
148+
case DebugLevel:
149+
return slog.LevelDebug
150+
case InfoLevel:
151+
return slog.LevelInfo
152+
case WarnLevel:
153+
return slog.LevelWarn
154+
case ErrorLevel:
155+
return slog.LevelError
156+
case FatalLevel:
157+
return slog.LevelError + 4
158+
case PanicLevel:
159+
return slog.LevelError + 8
160+
default:
161+
return slog.LevelInfo
162+
}
163+
}
164+
165+
// joinPrefix concatenates a prefix and key with a dot separator.
166+
// It avoids allocations when either prefix or key is empty.
167+
func joinPrefix(prefix, key string) string {
168+
if prefix == "" {
169+
return key
170+
}
171+
if key == "" {
172+
return prefix
173+
}
174+
return prefix + "." + key
175+
}
176+
177+
// appendSlogAttr appends a single slog.Attr to the zerolog event, handling
178+
// type-specific encoding to avoid reflection where possible.
179+
func appendSlogAttr(event *Event, attr slog.Attr, prefix string) *Event {
180+
if event == nil {
181+
return event
182+
}
183+
184+
// Resolve the attribute to handle LogValuer types.
185+
// This handles slog.KindLogValuer implicitly by unwrapping
186+
// any values that implement slog.LogValuer to their resolved form.
187+
attr.Value = attr.Value.Resolve()
188+
189+
// For group kinds, handle grouping before key concatenation
190+
if attr.Value.Kind() == slog.KindGroup {
191+
attrs := attr.Value.Group()
192+
if len(attrs) == 0 {
193+
return event
194+
}
195+
groupPrefix := joinPrefix(prefix, attr.Key)
196+
for _, ga := range attrs {
197+
event = appendSlogAttr(event, ga, groupPrefix)
198+
}
199+
return event
200+
}
201+
202+
// Skip empty keys for non-group attributes
203+
if attr.Key == "" {
204+
return event
205+
}
206+
207+
key := joinPrefix(prefix, attr.Key)
208+
val := attr.Value
209+
210+
switch val.Kind() {
211+
case slog.KindString:
212+
event = event.Str(key, val.String())
213+
case slog.KindInt64:
214+
event = event.Int64(key, val.Int64())
215+
case slog.KindUint64:
216+
event = event.Uint64(key, val.Uint64())
217+
case slog.KindFloat64:
218+
event = event.Float64(key, val.Float64())
219+
case slog.KindBool:
220+
event = event.Bool(key, val.Bool())
221+
case slog.KindDuration:
222+
event = event.Dur(key, val.Duration())
223+
case slog.KindTime:
224+
event = event.Time(key, val.Time())
225+
case slog.KindAny:
226+
v := val.Any()
227+
switch cv := v.(type) {
228+
case error:
229+
event = event.AnErr(key, cv)
230+
case time.Duration:
231+
event = event.Dur(key, cv)
232+
case time.Time:
233+
event = event.Time(key, cv)
234+
case []byte:
235+
event = event.Bytes(key, cv)
236+
default:
237+
event = event.Interface(key, v)
238+
}
239+
default:
240+
event = event.Interface(key, val.Any())
241+
}
242+
243+
return event
244+
}
245+
246+
// Verify at compile time that SlogHandler satisfies the slog.Handler interface.
247+
var _ slog.Handler = (*SlogHandler)(nil)

0 commit comments

Comments
 (0)