1
0
mirror of https://github.com/rs/zerolog synced 2026-06-08 17:13:30 +00:00

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.
This commit is contained in:
Varun Chawla
2026-03-16 17:43:02 -07:00
committed by GitHub
parent a0d61dc2c7
commit d64c9a7138
3 changed files with 834 additions and 0 deletions
+28
View File
@@ -29,6 +29,7 @@ Find out [who uses zerolog](https://github.com/rs/zerolog/wiki/Who-uses-zerolog)
- [JSON and CBOR encoding formats](#binary-encoding)
- [Pretty logging for development](#pretty-logging)
- [Error Logging (with optional Stacktrace)](#error-logging)
- [`log/slog` integration](#integration-with-logslog)
## Installation
@@ -715,6 +716,33 @@ go build -tags binary_log .
To decode binary encoded log files you can use any CBOR decoder. One has been tested to work
with zerolog library is [CSD](https://github.com/toravir/csd/).
## Integration with `log/slog`
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:
```go
package main
import (
"log/slog"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func main() {
zl := log.Logger
handler := zerolog.NewSlogHandler(zl)
logger := slog.New(handler)
logger.Info("user logged in", "user", "alice", "role", "admin")
}
// Output: {"level":"info","user":"alice","role":"admin","time":"...","message":"user logged in"}
```
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`).
## Related Projects
- [grpc-zerolog](https://github.com/cheapRoc/grpc-zerolog): Implementation of `grpclog.LoggerV2` interface using `zerolog`
+247
View File
@@ -0,0 +1,247 @@
package zerolog
import (
"context"
"log/slog"
"time"
)
// SlogHandler implements the slog.Handler interface using a zerolog.Logger
// as the underlying log backend. This allows code that uses the standard
// library's slog package to route log output through zerolog.
type SlogHandler struct {
logger Logger
prefix string // group prefix for nested groups
attrs []slog.Attr
}
// NewSlogHandler creates a new slog.Handler that writes log records to the
// given zerolog.Logger. The handler maps slog levels to zerolog levels and
// converts slog attributes to zerolog fields.
func NewSlogHandler(logger Logger) *SlogHandler {
return &SlogHandler{logger: logger}
}
// Enabled reports whether the handler handles records at the given level.
// It mirrors Logger.should's level and writer checks (without sampling).
func (h *SlogHandler) Enabled(_ context.Context, level slog.Level) bool {
if h.logger.w == nil {
return false
}
zl := slogToZerologLevel(level)
if zl < GlobalLevel() {
return false
}
return zl >= h.logger.level
}
// Handle handles the Record. It converts the slog.Record into a zerolog event
// and writes it using the underlying zerolog.Logger.
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 so that hooks
// relying on Event.GetCtx() (e.g. tracing) can access it.
if ctx != nil {
event = event.Ctx(ctx)
}
// Add pre-attached attrs from WithAttrs
for _, a := range h.attrs {
event = appendSlogAttr(event, a, h.prefix)
}
// Add attrs from the record itself
record.Attrs(func(a slog.Attr) bool {
event = appendSlogAttr(event, a, h.prefix)
return true
})
// Add timestamp from the slog record, but only if the logger doesn't
// already have a timestampHook (added via .With().Timestamp()) to
// avoid duplicate timestamp keys in the output.
if !record.Time.IsZero() && !h.hasTimestampHook() {
event.Time(TimestampFieldName, record.Time)
}
event.Msg(record.Message)
return nil
}
// hasTimestampHook reports whether the logger has a timestampHook installed,
// which would cause duplicate timestamp fields if we also emit record.Time.
func (h *SlogHandler) hasTimestampHook() bool {
for _, hook := range h.logger.hooks {
if _, ok := hook.(timestampHook); ok {
return true
}
}
return false
}
// WithAttrs returns a new Handler with the given attributes pre-attached.
// These attributes will be included in every subsequent log record.
func (h *SlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
h2 := h.clone()
h2.attrs = append(h2.attrs, attrs...)
return h2
}
// WithGroup returns a new Handler with the given group name. All subsequent
// attributes will be nested under this group name in the output.
func (h *SlogHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := h.clone()
if h2.prefix != "" {
h2.prefix = h2.prefix + "." + name
} else {
h2.prefix = name
}
return h2
}
func (h *SlogHandler) clone() *SlogHandler {
h2 := &SlogHandler{
logger: h.logger,
prefix: h.prefix,
}
if len(h.attrs) > 0 {
h2.attrs = make([]slog.Attr, len(h.attrs))
copy(h2.attrs, h.attrs)
}
return h2
}
// slogToZerologLevel maps slog levels to zerolog levels.
//
// slog levels: Debug=-4, Info=0, Warn=4, Error=8
// zerolog levels: Trace=-1, Debug=0, Info=1, Warn=2, Error=3, Fatal=4, Panic=5
func slogToZerologLevel(level slog.Level) Level {
switch {
case level < slog.LevelDebug:
return TraceLevel
case level < slog.LevelInfo:
return DebugLevel
case level < slog.LevelWarn:
return InfoLevel
case level < slog.LevelError:
return WarnLevel
default:
return ErrorLevel
}
}
// zerologToSlogLevel maps zerolog levels to slog levels.
func zerologToSlogLevel(level Level) slog.Level {
switch level {
case TraceLevel:
return slog.LevelDebug - 4
case DebugLevel:
return slog.LevelDebug
case InfoLevel:
return slog.LevelInfo
case WarnLevel:
return slog.LevelWarn
case ErrorLevel:
return slog.LevelError
case FatalLevel:
return slog.LevelError + 4
case PanicLevel:
return slog.LevelError + 8
default:
return slog.LevelInfo
}
}
// joinPrefix concatenates a prefix and key with a dot separator.
// It avoids allocations when either prefix or key is empty.
func joinPrefix(prefix, key string) string {
if prefix == "" {
return key
}
if key == "" {
return prefix
}
return prefix + "." + key
}
// appendSlogAttr appends a single slog.Attr to the zerolog event, handling
// type-specific encoding to avoid reflection where possible.
func appendSlogAttr(event *Event, attr slog.Attr, prefix string) *Event {
if event == nil {
return event
}
// Resolve the attribute to handle LogValuer types.
// This handles slog.KindLogValuer implicitly by unwrapping
// any values that implement slog.LogValuer to their resolved form.
attr.Value = attr.Value.Resolve()
// For group kinds, handle grouping before key concatenation
if attr.Value.Kind() == slog.KindGroup {
attrs := attr.Value.Group()
if len(attrs) == 0 {
return event
}
groupPrefix := joinPrefix(prefix, attr.Key)
for _, ga := range attrs {
event = appendSlogAttr(event, ga, groupPrefix)
}
return event
}
// Skip empty keys for non-group attributes
if attr.Key == "" {
return event
}
key := joinPrefix(prefix, attr.Key)
val := attr.Value
switch val.Kind() {
case slog.KindString:
event = event.Str(key, val.String())
case slog.KindInt64:
event = event.Int64(key, val.Int64())
case slog.KindUint64:
event = event.Uint64(key, val.Uint64())
case slog.KindFloat64:
event = event.Float64(key, val.Float64())
case slog.KindBool:
event = event.Bool(key, val.Bool())
case slog.KindDuration:
event = event.Dur(key, val.Duration())
case slog.KindTime:
event = event.Time(key, val.Time())
case slog.KindAny:
v := val.Any()
switch cv := v.(type) {
case error:
event = event.AnErr(key, cv)
case time.Duration:
event = event.Dur(key, cv)
case time.Time:
event = event.Time(key, cv)
case []byte:
event = event.Bytes(key, cv)
default:
event = event.Interface(key, v)
}
default:
event = event.Interface(key, val.Any())
}
return event
}
// Verify at compile time that SlogHandler satisfies the slog.Handler interface.
var _ slog.Handler = (*SlogHandler)(nil)
+559
View File
@@ -0,0 +1,559 @@
package zerolog_test
import (
"context"
"bytes"
"encoding/json"
"errors"
"log/slog"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/internal/cbor"
)
func newSlogLogger(buf *bytes.Buffer) *slog.Logger {
zl := zerolog.New(buf)
return slog.New(zerolog.NewSlogHandler(zl))
}
// decodeOutput converts the buffer contents to a JSON string,
// handling CBOR-encoded output when built with the binary_log tag.
func decodeOutput(buf *bytes.Buffer) string {
p := buf.Bytes()
if len(p) == 0 || p[0] < 0x7F {
return buf.String()
}
return cbor.DecodeObjectToStr(p) + "\n"
}
func decodeJSON(t *testing.T, buf *bytes.Buffer) map[string]interface{} {
t.Helper()
var m map[string]interface{}
s := decodeOutput(buf)
if err := json.Unmarshal([]byte(s), &m); err != nil {
t.Fatalf("failed to decode JSON %q: %v", s, err)
}
return m
}
func TestSlogHandler_BasicInfo(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("hello world")
m := decodeJSON(t, &buf)
if m["level"] != "info" {
t.Errorf("expected level info, got %v", m["level"])
}
if m["message"] != "hello world" {
t.Errorf("expected message 'hello world', got %v", m["message"])
}
}
func TestSlogHandler_Debug(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf).Level(zerolog.DebugLevel)
logger := slog.New(zerolog.NewSlogHandler(zl))
logger.Debug("debug msg")
m := decodeJSON(t, &buf)
if m["level"] != "debug" {
t.Errorf("expected level debug, got %v", m["level"])
}
if m["message"] != "debug msg" {
t.Errorf("expected message 'debug msg', got %v", m["message"])
}
}
func TestSlogHandler_Warn(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Warn("warn msg")
m := decodeJSON(t, &buf)
if m["level"] != "warn" {
t.Errorf("expected level warn, got %v", m["level"])
}
}
func TestSlogHandler_Error(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Error("error msg")
m := decodeJSON(t, &buf)
if m["level"] != "error" {
t.Errorf("expected level error, got %v", m["level"])
}
}
func TestSlogHandler_WithStringAttr(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("test", "key", "value")
m := decodeJSON(t, &buf)
if m["key"] != "value" {
t.Errorf("expected key=value, got %v", m["key"])
}
}
func TestSlogHandler_WithIntAttr(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("test", slog.Int("count", 42))
m := decodeJSON(t, &buf)
if m["count"] != float64(42) {
t.Errorf("expected count=42, got %v", m["count"])
}
}
func TestSlogHandler_WithBoolAttr(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("test", slog.Bool("flag", true))
m := decodeJSON(t, &buf)
if m["flag"] != true {
t.Errorf("expected flag=true, got %v", m["flag"])
}
}
func TestSlogHandler_WithFloat64Attr(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("test", slog.Float64("pi", 3.14))
m := decodeJSON(t, &buf)
if m["pi"] != 3.14 {
t.Errorf("expected pi=3.14, got %v", m["pi"])
}
}
func TestSlogHandler_WithTimeAttr(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
ts := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
logger.Info("test", slog.Time("created", ts))
m := decodeJSON(t, &buf)
if m["created"] == nil {
t.Error("expected created field to be present")
}
}
func TestSlogHandler_WithDurationAttr(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("test", slog.Duration("elapsed", 5*time.Second))
m := decodeJSON(t, &buf)
if m["elapsed"] == nil {
t.Error("expected elapsed field to be present")
}
}
func TestSlogHandler_WithErrorAttr(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("test", slog.Any("err", errors.New("something failed")))
m := decodeJSON(t, &buf)
if m["err"] != "something failed" {
t.Errorf("expected err='something failed', got %v", m["err"])
}
}
func TestSlogHandler_WithAttrs(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf)
handler := zerolog.NewSlogHandler(zl)
child := handler.WithAttrs([]slog.Attr{
slog.String("component", "auth"),
slog.Int("version", 2),
})
logger := slog.New(child)
logger.Info("request handled")
m := decodeJSON(t, &buf)
if m["component"] != "auth" {
t.Errorf("expected component=auth, got %v", m["component"])
}
if m["version"] != float64(2) {
t.Errorf("expected version=2, got %v", m["version"])
}
if m["message"] != "request handled" {
t.Errorf("expected message 'request handled', got %v", m["message"])
}
}
func TestSlogHandler_WithAttrsEmpty(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf)
handler := zerolog.NewSlogHandler(zl)
// WithAttrs with empty slice should return same handler
child := handler.WithAttrs(nil)
if child != handler {
t.Error("expected WithAttrs(nil) to return same handler")
}
}
func TestSlogHandler_WithGroup(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf)
handler := zerolog.NewSlogHandler(zl)
child := handler.WithGroup("request")
logger := slog.New(child)
logger.Info("handled", "method", "GET", "status", 200)
m := decodeJSON(t, &buf)
if m["request.method"] != "GET" {
t.Errorf("expected request.method=GET, got %v", m["request.method"])
}
if m["request.status"] != float64(200) {
t.Errorf("expected request.status=200, got %v", m["request.status"])
}
}
func TestSlogHandler_WithGroupEmpty(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf)
handler := zerolog.NewSlogHandler(zl)
// WithGroup with empty name should return same handler
child := handler.WithGroup("")
if child != handler {
t.Error("expected WithGroup('') to return same handler")
}
}
func TestSlogHandler_WithNestedGroups(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf)
handler := zerolog.NewSlogHandler(zl)
child := handler.WithGroup("http").WithGroup("request")
logger := slog.New(child)
logger.Info("handled", "method", "POST")
m := decodeJSON(t, &buf)
if m["http.request.method"] != "POST" {
t.Errorf("expected http.request.method=POST, got %v", m["http.request.method"])
}
}
func TestSlogHandler_WithGroupAndAttrs(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf)
handler := zerolog.NewSlogHandler(zl)
child := handler.WithGroup("server").WithAttrs([]slog.Attr{
slog.String("host", "localhost"),
})
logger := slog.New(child)
logger.Info("started", "port", 8080)
m := decodeJSON(t, &buf)
if m["server.host"] != "localhost" {
t.Errorf("expected server.host=localhost, got %v", m["server.host"])
}
if m["server.port"] != float64(8080) {
t.Errorf("expected server.port=8080, got %v", m["server.port"])
}
}
func TestSlogHandler_GroupAttrInRecord(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("test", slog.Group("user",
slog.String("name", "alice"),
slog.Int("age", 30),
))
m := decodeJSON(t, &buf)
if m["user.name"] != "alice" {
t.Errorf("expected user.name=alice, got %v", m["user.name"])
}
if m["user.age"] != float64(30) {
t.Errorf("expected user.age=30, got %v", m["user.age"])
}
}
func TestSlogHandler_LevelFiltering(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf).Level(zerolog.WarnLevel)
handler := zerolog.NewSlogHandler(zl)
// Debug should be filtered
if handler.Enabled(nil, slog.LevelDebug) {
t.Error("expected debug to be filtered at warn level")
}
// Info should be filtered
if handler.Enabled(nil, slog.LevelInfo) {
t.Error("expected info to be filtered at warn level")
}
// Warn should pass
if !handler.Enabled(nil, slog.LevelWarn) {
t.Error("expected warn to be enabled at warn level")
}
// Error should pass
if !handler.Enabled(nil, slog.LevelError) {
t.Error("expected error to be enabled at warn level")
}
}
func TestSlogHandler_FilteredMessageNotWritten(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf).Level(zerolog.ErrorLevel)
logger := slog.New(zerolog.NewSlogHandler(zl))
logger.Info("should not appear")
if buf.Len() != 0 {
t.Errorf("expected no output for filtered message, got %q", buf.String())
}
}
func TestSlogHandler_MultipleAttrs(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("multi",
slog.String("a", "1"),
slog.Int("b", 2),
slog.Bool("c", true),
slog.Float64("d", 3.5),
)
m := decodeJSON(t, &buf)
if m["a"] != "1" {
t.Errorf("expected a=1, got %v", m["a"])
}
if m["b"] != float64(2) {
t.Errorf("expected b=2, got %v", m["b"])
}
if m["c"] != true {
t.Errorf("expected c=true, got %v", m["c"])
}
if m["d"] != 3.5 {
t.Errorf("expected d=3.5, got %v", m["d"])
}
}
func TestSlogHandler_LogValuer(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("test", "addr", testLogValuer{host: "example.com", port: 443})
m := decodeJSON(t, &buf)
// LogValuer resolves to a group
if m["addr.host"] != "example.com" {
t.Errorf("expected addr.host=example.com, got %v", m["addr.host"])
}
if m["addr.port"] != float64(443) {
t.Errorf("expected addr.port=443, got %v", m["addr.port"])
}
}
type testLogValuer struct {
host string
port int
}
func (v testLogValuer) LogValue() slog.Value {
return slog.GroupValue(
slog.String("host", v.host),
slog.Int("port", v.port),
)
}
func TestSlogHandler_WithAttrsImmutability(t *testing.T) {
var buf1, buf2 bytes.Buffer
zl1 := zerolog.New(&buf1)
zl2 := zerolog.New(&buf2)
handler := zerolog.NewSlogHandler(zl1)
child1 := handler.WithAttrs([]slog.Attr{slog.String("from", "child1")})
_ = zerolog.NewSlogHandler(zl2).WithAttrs([]slog.Attr{slog.String("from", "child2")})
slog.New(child1).Info("test")
m := decodeJSON(t, &buf1)
if m["from"] != "child1" {
t.Errorf("expected from=child1, got %v", m["from"])
}
}
func TestSlogHandler_LevelMapping(t *testing.T) {
tests := []struct {
slogLevel slog.Level
wantLevel string
}{
{slog.LevelDebug - 4, "trace"},
{slog.LevelDebug, "debug"},
{slog.LevelInfo, "info"},
{slog.LevelWarn, "warn"},
{slog.LevelError, "error"},
}
for _, tt := range tests {
var buf bytes.Buffer
zl := zerolog.New(&buf).Level(zerolog.TraceLevel)
logger := slog.New(zerolog.NewSlogHandler(zl))
logger.Log(nil, tt.slogLevel, "test")
m := decodeJSON(t, &buf)
if m["level"] != tt.wantLevel {
t.Errorf("slog level %d: expected zerolog level %q, got %q",
tt.slogLevel, tt.wantLevel, m["level"])
}
buf.Reset()
}
}
func TestSlogHandler_EmptyMessage(t *testing.T) {
var buf bytes.Buffer
logger := newSlogLogger(&buf)
logger.Info("", "key", "val")
m := decodeJSON(t, &buf)
if m["key"] != "val" {
t.Errorf("expected key=val, got %v", m["key"])
}
}
func TestSlogHandler_WithContext(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf).With().Str("service", "api").Logger()
logger := slog.New(zerolog.NewSlogHandler(zl))
logger.Info("request")
m := decodeJSON(t, &buf)
if m["service"] != "api" {
t.Errorf("expected service=api, got %v", m["service"])
}
if m["message"] != "request" {
t.Errorf("expected message 'request', got %v", m["message"])
}
}
func TestSlogHandler_EnabledRespectsGlobalLevel(t *testing.T) {
var buf bytes.Buffer
zl := zerolog.New(&buf).Level(zerolog.DebugLevel)
handler := zerolog.NewSlogHandler(zl)
// Logger level is debug, so info should be enabled
if !handler.Enabled(nil, slog.LevelInfo) {
t.Fatal("expected info to be enabled before setting global level")
}
// Set global level to error
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
defer zerolog.SetGlobalLevel(zerolog.TraceLevel)
// Now info should be disabled even though logger level allows it
if handler.Enabled(nil, slog.LevelInfo) {
t.Error("expected info to be disabled when GlobalLevel is error")
}
// Error should still be enabled
if !handler.Enabled(nil, slog.LevelError) {
t.Error("expected error to be enabled when GlobalLevel is error")
}
}
func TestSlogHandler_EnabledNilWriter(t *testing.T) {
zl := zerolog.Nop()
handler := zerolog.NewSlogHandler(zl)
if handler.Enabled(nil, slog.LevelError) {
t.Error("expected disabled for nop logger")
}
}
func TestSlogHandler_HandlePropagatesContext(t *testing.T) {
var buf bytes.Buffer
type ctxKey struct{}
ctx := context.WithValue(context.Background(), ctxKey{}, "test-value")
var gotCtx context.Context
hook := zerolog.HookFunc(func(e *zerolog.Event, level zerolog.Level, msg string) {
gotCtx = e.GetCtx()
})
zl := zerolog.New(&buf).Hook(hook)
handler := zerolog.NewSlogHandler(zl)
record := slog.NewRecord(time.Now(), slog.LevelInfo, "test", 0)
_ = handler.Handle(ctx, record)
if gotCtx == nil {
t.Fatal("expected context to be propagated to event")
}
if gotCtx.Value(ctxKey{}) != "test-value" {
t.Error("expected context value to be preserved")
}
}
func TestSlogHandler_NoDuplicateTimestamp(t *testing.T) {
var buf bytes.Buffer
// Create logger with Timestamp() hook - this adds "time" automatically
zl := zerolog.New(&buf).With().Timestamp().Logger()
handler := zerolog.NewSlogHandler(zl)
record := slog.NewRecord(time.Now(), slog.LevelInfo, "test", 0)
_ = handler.Handle(context.Background(), record)
output := decodeOutput(&buf)
// Count occurrences of the timestamp field name - should appear exactly once
count := 0
for i := 0; i < len(output); i++ {
if i+4 <= len(output) && output[i:i+4] == "time" {
count++
}
}
if count > 1 {
t.Errorf("expected at most 1 timestamp field, got %d in output: %s", count, output)
}
}
func TestSlogHandler_TimestampWithoutHook(t *testing.T) {
var buf bytes.Buffer
// Logger without Timestamp() hook - Handle should add the timestamp
zl := zerolog.New(&buf)
handler := zerolog.NewSlogHandler(zl)
ts := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
record := slog.NewRecord(ts, slog.LevelInfo, "test", 0)
_ = handler.Handle(context.Background(), record)
m := decodeJSON(t, &buf)
if m[zerolog.TimestampFieldName] == nil {
t.Error("expected timestamp field when logger has no timestamp hook")
}
}