From d64c9a7138d98305e963fd437246d2ca0f0c4833 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:43:02 -0700 Subject: [PATCH] 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. --- README.md | 28 +++ slog.go | 247 +++++++++++++++++++++++ slog_test.go | 559 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 834 insertions(+) create mode 100644 slog.go create mode 100644 slog_test.go diff --git a/README.md b/README.md index f7caa1c..0989c80 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/slog.go b/slog.go new file mode 100644 index 0000000..eead9cc --- /dev/null +++ b/slog.go @@ -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) diff --git a/slog_test.go b/slog_test.go new file mode 100644 index 0000000..7994275 --- /dev/null +++ b/slog_test.go @@ -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") + } +}