1
0
mirror of https://github.com/rs/zerolog synced 2026-06-08 17:13:30 +00:00
Files
rs-zerolog/slog_test.go
T
Varun Chawla d64c9a7138 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.
2026-03-17 00:43:02 +00:00

560 lines
14 KiB
Go

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")
}
}