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:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user