From f68e227b654f7b2b906d19493b15e441695c5a85 Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Thu, 9 Apr 2026 17:25:14 +0200 Subject: [PATCH 1/4] feat: add slog handler --- bench_test.go | 85 ++++++++ example_test.go | 17 ++ slog.go | 192 ++++++++++++++++++ slog_test.go | 514 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 808 insertions(+) create mode 100644 slog.go create mode 100644 slog_test.go diff --git a/bench_test.go b/bench_test.go index 0257b16..cd2883a 100644 --- a/bench_test.go +++ b/bench_test.go @@ -1,6 +1,7 @@ package logger_test import ( + "log/slog" "os" "testing" @@ -101,6 +102,90 @@ func BenchmarkLogger_JsonCtx(b *testing.B) { }) } +func BenchmarkHandler_Logfmt(b *testing.B) { + h := logger.NewHandler(discard{}, logger.LogfmtFormat(), slog.LevelDebug) + log := slog.New(h) + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + log.Error("some message") + } + }) +} + +func BenchmarkHandler_Json(b *testing.B) { + h := logger.NewHandler(discard{}, logger.JSONFormat(), slog.LevelDebug) + log := slog.New(h) + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + log.Error("some message") + } + }) +} + +func BenchmarkHandler_LogfmtWithGroup(b *testing.B) { + h := logger.NewHandler(discard{}, logger.LogfmtFormat(), slog.LevelDebug) + log := slog.New(h.WithGroup("service")) + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + log.Error("some message") + } + }) +} + +func BenchmarkHandler_JsonWithGroup(b *testing.B) { + h := logger.NewHandler(discard{}, logger.JSONFormat(), slog.LevelDebug) + log := slog.New(h.WithGroup("service")) + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + log.Error("some message") + } + }) +} + +func BenchmarkHandler_LogfmtWithGroupAndAttrs(b *testing.B) { + h := logger.NewHandler(discard{}, logger.LogfmtFormat(), slog.LevelDebug) + log := slog.New(h.WithGroup("db").WithAttrs([]slog.Attr{ + slog.String("host", "localhost"), + slog.Int("port", 5432), + })) + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + log.Error("some message", slog.String("driver", "pgx")) + } + }) +} + +func BenchmarkHandler_JsonWithGroupAndAttrs(b *testing.B) { + h := logger.NewHandler(discard{}, logger.JSONFormat(), slog.LevelDebug) + log := slog.New(h.WithGroup("db").WithAttrs([]slog.Attr{ + slog.String("host", "localhost"), + slog.Int("port", 5432), + })) + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + log.Error("some message", slog.String("driver", "pgx")) + } + }) +} + func BenchmarkLevelLogger_Logfmt(b *testing.B) { log := logger.New(discard{}, logger.LogfmtFormat(), logger.Debug).With(ctx.Str("_n", "bench"), ctx.Int("_p", os.Getpid())) diff --git a/example_test.go b/example_test.go index cc409f2..1061d1a 100644 --- a/example_test.go +++ b/example_test.go @@ -1,6 +1,7 @@ package logger_test import ( + "log/slog" "os" "github.com/hamba/logger/v2" @@ -18,3 +19,19 @@ func ExampleSyncWriter() { log.Info("redis connection", ctx.Str("redis", "some redis name"), ctx.Int("timeout", 10)) } + +func ExampleNewHandler() { + h := logger.NewHandler(os.Stdout, logger.JSONFormat(), slog.LevelInfo) + + log := slog.New(h).With(slog.String("env", "prod")).WithGroup("db") + + log.Info("connected", slog.String("driver", "pgx")) +} + +func ExampleNewHandler_logfmt() { + h := logger.NewHandler(os.Stdout, logger.LogfmtFormat(), slog.LevelInfo) + + log := slog.New(h).With(slog.String("env", "prod")).WithGroup("db") + + log.Info("connected", slog.String("driver", "pgx")) +} diff --git a/slog.go b/slog.go new file mode 100644 index 0000000..de85f58 --- /dev/null +++ b/slog.go @@ -0,0 +1,192 @@ +package logger + +import ( + "context" + "io" + "log/slog" +) + +// Handler is a slog.Handler backed by hamba/logger. It does not synchronize writes; +// wrap the writer with NewSyncWriter for concurrent use in non-posix environments. +type Handler struct { + w io.Writer + isDiscard bool + fmtr Formatter + lvl slog.Level + + ctx []byte + prefix []byte + + groups []string +} + +// NewHandler returns a new Handler. +func NewHandler(w io.Writer, fmtr Formatter, lvl slog.Level) *Handler { + isDiscard := w == io.Discard + + return &Handler{ + w: w, + isDiscard: isDiscard, + fmtr: fmtr, + lvl: lvl, + } +} + +// Enabled returns false when lvl is below the configured minimum or the +// underlying writer is io.Discard. +func (h *Handler) Enabled(_ context.Context, lvl slog.Level) bool { + return !h.isDiscard && lvl >= h.lvl +} + +// Handle writes the record to the underlying writer. +func (h *Handler) Handle(_ context.Context, r slog.Record) error { + e := newEvent(h.fmtr) + e.prefix = append(e.prefix, h.prefix...) + + e.fmtr.AppendBeginMarker(e.buf) + e.fmtr.WriteMessage(e.buf, r.Time, mapSlogLevel(r.Level), r.Message) + e.buf.Write(h.ctx) + + r.Attrs(func(a slog.Attr) bool { + appendAttr(e, a) + return true + }) + + for range h.groups { + e.prefix = e.fmtr.AppendGroupEnd(e.buf, e.prefix) + } + + e.fmtr.AppendEndMarker(e.buf) + e.fmtr.AppendLineBreak(e.buf) + + _, err := h.w.Write(e.buf.Bytes()) + putEvent(e) + return err +} + +// WithAttrs returns a new Handler with attrs pre-serialised into the context. +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + + e := newEvent(h.fmtr) + e.buf.Write(h.ctx) + e.prefix = append(e.prefix, h.prefix...) + + for _, a := range attrs { + appendAttr(e, a) + } + + newCtx := make([]byte, e.buf.Len()) + copy(newCtx, e.buf.Bytes()) + + putEvent(e) + + return &Handler{ + fmtr: h.fmtr, + w: h.w, + isDiscard: h.isDiscard, + lvl: h.lvl, + ctx: newCtx, + prefix: h.prefix, + groups: h.groups, + } +} + +// WithGroup returns a new Handler with name appended to the group stack. +// An empty name is a no-op per the slog spec. +func (h *Handler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + + groups := make([]string, len(h.groups)+1) + copy(groups, h.groups) + groups[len(h.groups)] = name + + e := newEvent(h.fmtr) + e.buf.Write(h.ctx) + e.prefix = append(e.prefix, h.prefix...) + + e.prefix = h.fmtr.AppendGroupStart(e.buf, e.prefix, name) + + newCtx := make([]byte, e.buf.Len()) + copy(newCtx, e.buf.Bytes()) + newPrefix := make([]byte, len(e.prefix)) + copy(newPrefix, e.prefix) + + putEvent(e) + + return &Handler{ + fmtr: h.fmtr, + w: h.w, + isDiscard: h.isDiscard, + lvl: h.lvl, + ctx: newCtx, + prefix: newPrefix, + groups: groups, + } +} + +func appendAttr(e *Event, a slog.Attr) { + a.Value = a.Value.Resolve() + if a.Equal(slog.Attr{}) { + return + } + + if a.Value.Kind() == slog.KindGroup { + subs := a.Value.Group() + if len(subs) == 0 { + return + } + if a.Key == "" { + // Per the slog spec, an anonymous group is flattened into the + // enclosing scope. + for _, sub := range subs { + appendAttr(e, sub) + } + return + } + e.OpenGroup(a.Key) + for _, sub := range subs { + appendAttr(e, sub) + } + e.CloseGroup() + return + } + + switch a.Value.Kind() { + case slog.KindString: + e.AppendString(a.Key, a.Value.String()) + case slog.KindInt64: + e.AppendInt(a.Key, a.Value.Int64()) + case slog.KindUint64: + e.AppendUint(a.Key, a.Value.Uint64()) + case slog.KindFloat64: + e.AppendFloat(a.Key, a.Value.Float64()) + case slog.KindBool: + e.AppendBool(a.Key, a.Value.Bool()) + case slog.KindTime: + e.AppendTime(a.Key, a.Value.Time()) + case slog.KindDuration: + e.AppendDuration(a.Key, a.Value.Duration()) + default: + e.AppendInterface(a.Key, a.Value.Any()) + } +} + +// mapSlogLevel maps to logger.Level. Custom levels below Debug clamp to +// Debug; above Error clamp to Error. Trace and Crit are never produced. +func mapSlogLevel(lvl slog.Level) Level { + switch { + case lvl >= slog.LevelError: + return Error + case lvl >= slog.LevelWarn: + return Warn + case lvl >= slog.LevelInfo: + return Info + default: + return Debug + } +} diff --git a/slog_test.go b/slog_test.go new file mode 100644 index 0000000..4501525 --- /dev/null +++ b/slog_test.go @@ -0,0 +1,514 @@ +package logger_test + +import ( + "bytes" + "io" + "log/slog" + "testing" + "time" + + "github.com/hamba/logger/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewHandler(t *testing.T) { + t.Parallel() + + h := logger.NewHandler(io.Discard, logger.JSONFormat(), slog.LevelInfo) + + assert.IsType(t, &logger.Handler{}, h) +} + +func TestHandler_Enabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + w io.Writer + minLvl slog.Level + lvl slog.Level + want bool + }{ + { + name: "at min level", + w: io.Discard, + minLvl: slog.LevelInfo, + lvl: slog.LevelInfo, + want: false, // io.Discard short-circuits + }, + { + name: "above min level", + w: &bytes.Buffer{}, + minLvl: slog.LevelInfo, + lvl: slog.LevelWarn, + want: true, + }, + { + name: "at min level", + w: &bytes.Buffer{}, + minLvl: slog.LevelInfo, + lvl: slog.LevelInfo, + want: true, + }, + { + name: "below min level", + w: &bytes.Buffer{}, + minLvl: slog.LevelInfo, + lvl: slog.LevelDebug, + want: false, + }, + { + name: "discard writer", + w: io.Discard, + minLvl: slog.LevelDebug, + lvl: slog.LevelDebug, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + h := logger.NewHandler(test.w, logger.JSONFormat(), test.minLvl) + + got := h.Enabled(t.Context(), test.lvl) + + assert.Equal(t, test.want, got) + }) + } +} + +func TestHandler_Handle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fmtr logger.Formatter + level slog.Level + attrs []slog.Attr + want string + }{ + { + name: "json info no attrs", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + want: `{"lvl":"info","msg":"hello"}` + "\n", + }, + { + name: "json debug", + fmtr: logger.JSONFormat(), + level: slog.LevelDebug, + want: `{"lvl":"dbug","msg":"hello"}` + "\n", + }, + { + name: "json warn", + fmtr: logger.JSONFormat(), + level: slog.LevelWarn, + want: `{"lvl":"warn","msg":"hello"}` + "\n", + }, + { + name: "json error", + fmtr: logger.JSONFormat(), + level: slog.LevelError, + want: `{"lvl":"eror","msg":"hello"}` + "\n", + }, + { + name: "logfmt info no attrs", + fmtr: logger.LogfmtFormat(), + level: slog.LevelInfo, + want: "lvl=info msg=hello\n", + }, + { + name: "json with string attr", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.String("env", "prod")}, + want: `{"lvl":"info","msg":"hello","env":"prod"}` + "\n", + }, + { + name: "json with int attr", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Int("port", 5432)}, + want: `{"lvl":"info","msg":"hello","port":5432}` + "\n", + }, + { + name: "json with bool attr", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Bool("ok", true)}, + want: `{"lvl":"info","msg":"hello","ok":true}` + "\n", + }, + { + name: "json with float attr", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Float64("ratio", 1.5)}, + want: `{"lvl":"info","msg":"hello","ratio":1.5}` + "\n", + }, + { + name: "json with duration attr", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Duration("latency", 5*time.Millisecond)}, + want: `{"lvl":"info","msg":"hello","latency":"5ms"}` + "\n", + }, + { + name: "json with any attr", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Any("val", struct{ X int }{X: 1})}, + want: `{"lvl":"info","msg":"hello","val":"{X:1}"}` + "\n", + }, + { + name: "json with group attr", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Group("db", slog.String("host", "localhost"), slog.Int("port", 5432))}, + want: `{"lvl":"info","msg":"hello","db":{"host":"localhost","port":5432}}` + "\n", + }, + { + name: "logfmt with group attr", + fmtr: logger.LogfmtFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Group("db", slog.String("host", "localhost"), slog.Int("port", 5432))}, + want: "lvl=info msg=hello db.host=localhost db.port=5432\n", + }, + { + name: "json with empty group attr discarded", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Group("empty")}, + want: `{"lvl":"info","msg":"hello"}` + "\n", + }, + { + name: "json with anonymous group attr flattened", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{slog.Group("", slog.String("a", "1"), slog.String("b", "2"))}, + want: `{"lvl":"info","msg":"hello","a":"1","b":"2"}` + "\n", + }, + { + name: "json with zero attr discarded", + fmtr: logger.JSONFormat(), + level: slog.LevelInfo, + attrs: []slog.Attr{{}, slog.String("env", "prod")}, + want: `{"lvl":"info","msg":"hello","env":"prod"}` + "\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + h := logger.NewHandler(&buf, test.fmtr, slog.LevelDebug) + + r := slog.NewRecord(time.Time{}, test.level, "hello", 0) + r.AddAttrs(test.attrs...) + + err := h.Handle(t.Context(), r) + + require.NoError(t, err) + assert.Equal(t, test.want, buf.String()) + }) + } +} + +func TestHandler_WithAttrs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fmtr logger.Formatter + attrs []slog.Attr + extra []slog.Attr + want string + }{ + { + name: "json pre-serialises attrs", + fmtr: logger.JSONFormat(), + attrs: []slog.Attr{slog.String("env", "prod")}, + want: `{"lvl":"info","msg":"hello","env":"prod"}` + "\n", + }, + { + name: "logfmt pre-serialises attrs", + fmtr: logger.LogfmtFormat(), + attrs: []slog.Attr{slog.String("env", "prod")}, + want: "lvl=info msg=hello env=prod\n", + }, + { + name: "json pre-serialised plus inline attrs", + fmtr: logger.JSONFormat(), + attrs: []slog.Attr{slog.String("env", "prod")}, + extra: []slog.Attr{slog.String("req", "GET /")}, + want: `{"lvl":"info","msg":"hello","env":"prod","req":"GET /"}` + "\n", + }, + { + name: "json chained WithAttrs", + fmtr: logger.JSONFormat(), + attrs: []slog.Attr{slog.String("env", "prod")}, + extra: []slog.Attr{slog.String("svc", "api")}, + want: `{"lvl":"info","msg":"hello","env":"prod","svc":"api"}` + "\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + h := logger.NewHandler(&buf, test.fmtr, slog.LevelInfo) + h2 := h.WithAttrs(test.attrs) + + // Chain a second WithAttrs when extra is provided. + if len(test.extra) > 0 { + h2 = h2.WithAttrs(test.extra) + } + + r := slog.NewRecord(time.Time{}, slog.LevelInfo, "hello", 0) + err := h2.Handle(t.Context(), r) + + require.NoError(t, err) + assert.Equal(t, test.want, buf.String()) + }) + } +} + +func TestHandler_WithAttrs_empty(t *testing.T) { + t.Parallel() + + h := logger.NewHandler(io.Discard, logger.JSONFormat(), slog.LevelInfo) + got := h.WithAttrs(nil) + + assert.Same(t, h, got.(*logger.Handler)) +} + +func TestHandler_WithGroup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fmtr logger.Formatter + group string + withAttrs []slog.Attr + inline []slog.Attr + want string + }{ + { + name: "json nested group with inline attrs", + fmtr: logger.JSONFormat(), + group: "db", + inline: []slog.Attr{slog.String("host", "localhost")}, + want: `{"lvl":"info","msg":"hello","db":{"host":"localhost"}}` + "\n", + }, + { + name: "logfmt prefix group with inline attrs", + fmtr: logger.LogfmtFormat(), + group: "db", + inline: []slog.Attr{slog.String("host", "localhost")}, + want: "lvl=info msg=hello db.host=localhost\n", + }, + { + name: "json nested group with pre-serialised and inline attrs", + fmtr: logger.JSONFormat(), + group: "db", + withAttrs: []slog.Attr{slog.String("host", "localhost"), slog.Int("port", 5432)}, + inline: []slog.Attr{slog.String("driver", "pgx")}, + want: `{"lvl":"info","msg":"hello","db":{"host":"localhost","port":5432,"driver":"pgx"}}` + "\n", + }, + { + name: "logfmt prefix group with pre-serialised and inline attrs", + fmtr: logger.LogfmtFormat(), + group: "db", + withAttrs: []slog.Attr{slog.String("host", "localhost"), slog.Int("port", 5432)}, + inline: []slog.Attr{slog.String("driver", "pgx")}, + want: "lvl=info msg=hello db.host=localhost db.port=5432 db.driver=pgx\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + h := logger.NewHandler(&buf, test.fmtr, slog.LevelInfo) + h2 := h.WithGroup(test.group) + if len(test.withAttrs) > 0 { + h2 = h2.WithAttrs(test.withAttrs) + } + + r := slog.NewRecord(time.Time{}, slog.LevelInfo, "hello", 0) + r.AddAttrs(test.inline...) + err := h2.Handle(t.Context(), r) + + require.NoError(t, err) + assert.Equal(t, test.want, buf.String()) + }) + } +} + +func TestHandler_WithGroup_WithAttrs_WithGroup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fmtr logger.Formatter + want string + }{ + { + name: "json chained groups with attrs at each level", + fmtr: logger.JSONFormat(), + want: `{"lvl":"info","msg":"hello","a":{"x":"1","b":{"y":"2","z":"3"}}}` + "\n", + }, + { + name: "logfmt chained groups with attrs at each level", + fmtr: logger.LogfmtFormat(), + want: "lvl=info msg=hello a.x=1 a.b.y=2 a.b.z=3\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + h := logger.NewHandler(&buf, test.fmtr, slog.LevelInfo) + + // WithGroup("a") → WithAttrs([x]) → WithGroup("b") → WithAttrs([y]) + h2 := h.WithGroup("a"). + WithAttrs([]slog.Attr{slog.String("x", "1")}). + WithGroup("b"). + WithAttrs([]slog.Attr{slog.String("y", "2")}) + + r := slog.NewRecord(time.Time{}, slog.LevelInfo, "hello", 0) + r.AddAttrs(slog.String("z", "3")) + err := h2.Handle(t.Context(), r) + + require.NoError(t, err) + assert.Equal(t, test.want, buf.String()) + }) + } +} + +func TestHandler_WithGroupHandlesEmptyName(t *testing.T) { + t.Parallel() + + h := logger.NewHandler(io.Discard, logger.JSONFormat(), slog.LevelInfo) + got := h.WithGroup("") + + assert.Same(t, h, got.(*logger.Handler)) +} + +func TestHandler_WithGroup_WithAttrsUsingJSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + h := logger.NewHandler(&buf, logger.JSONFormat(), slog.LevelInfo) + + log := slog.New(h). + With(slog.String("env", "prod")). + WithGroup("db"). + With(slog.String("host", "localhost"), slog.Int("port", 5432)) + + log.Info("connected", slog.String("driver", "pgx")) + + out := buf.String() + assert.Contains(t, out, `"lvl":"info"`) + assert.Contains(t, out, `"msg":"connected"`) + assert.Contains(t, out, `"env":"prod"`) + assert.Contains(t, out, `"db":{"host":"localhost","port":5432,"driver":"pgx"}`) +} + +func TestHandler_WithGroup_WithAttrsUsingLogfmt(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + h := logger.NewHandler(&buf, logger.LogfmtFormat(), slog.LevelInfo) + + log := slog.New(h). + With(slog.String("env", "prod")). + WithGroup("db"). + With(slog.String("host", "localhost"), slog.Int("port", 5432)) + + log.Info("connected", slog.String("driver", "pgx")) + + out := buf.String() + assert.Contains(t, out, "lvl=info") + assert.Contains(t, out, "msg=connected") + assert.Contains(t, out, "env=prod") + assert.Contains(t, out, "db.host=localhost") + assert.Contains(t, out, "db.port=5432") + assert.Contains(t, out, "db.driver=pgx") +} + +func TestHandler_NestedGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fmtr logger.Formatter + want string + }{ + { + name: "json", + fmtr: logger.JSONFormat(), + want: `{"lvl":"info","msg":"hello","a":{"b":{"c":"v"}}}` + "\n", + }, + { + name: "logfmt", + fmtr: logger.LogfmtFormat(), + want: "lvl=info msg=hello a.b.c=v\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + h := logger.NewHandler(&buf, test.fmtr, slog.LevelInfo) + + r := slog.NewRecord(time.Time{}, slog.LevelInfo, "hello", 0) + r.AddAttrs(slog.Group("a", slog.Group("b", slog.String("c", "v")))) + + err := h.Handle(t.Context(), r) + + require.NoError(t, err) + assert.Equal(t, test.want, buf.String()) + }) + } +} + +func TestHandler_MapSlogLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + slogLvl slog.Level + wantLvl string + }{ + {slog.LevelDebug, "dbug"}, + {slog.LevelDebug - 4, "dbug"}, // custom level below debug + {slog.LevelInfo, "info"}, + {slog.LevelWarn, "warn"}, + {slog.LevelError, "eror"}, + {slog.LevelError + 4, "eror"}, // custom level above error + } + + for _, test := range tests { + t.Run(test.slogLvl.String(), func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + h := logger.NewHandler(&buf, logger.LogfmtFormat(), slog.LevelDebug-4) + + r := slog.NewRecord(time.Time{}, test.slogLvl, "msg", 0) + err := h.Handle(t.Context(), r) + + require.NoError(t, err) + assert.Contains(t, buf.String(), "lvl="+test.wantLvl) + }) + } +} From 525daf00288c5ebc8c57ffefe9883d5bb4d8479e Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Thu, 9 Apr 2026 18:22:29 +0200 Subject: [PATCH 2/4] fix: linter --- slog.go | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/slog.go b/slog.go index de85f58..3f9197e 100644 --- a/slog.go +++ b/slog.go @@ -135,28 +135,9 @@ func appendAttr(e *Event, a slog.Attr) { return } - if a.Value.Kind() == slog.KindGroup { - subs := a.Value.Group() - if len(subs) == 0 { - return - } - if a.Key == "" { - // Per the slog spec, an anonymous group is flattened into the - // enclosing scope. - for _, sub := range subs { - appendAttr(e, sub) - } - return - } - e.OpenGroup(a.Key) - for _, sub := range subs { - appendAttr(e, sub) - } - e.CloseGroup() - return - } - switch a.Value.Kind() { + case slog.KindGroup: + appendGroup(e, a.Key, a.Value) case slog.KindString: e.AppendString(a.Key, a.Value.String()) case slog.KindInt64: @@ -176,6 +157,28 @@ func appendAttr(e *Event, a slog.Attr) { } } +func appendGroup(e *Event, name string, val slog.Value) { + subs := val.Group() + if len(subs) == 0 { + return + } + + if name == "" { + // Per the slog spec, an anonymous group is flattened into the + // enclosing scope. + for _, a := range subs { + appendAttr(e, a) + } + return + } + + e.OpenGroup(name) + for _, a := range subs { + appendAttr(e, a) + } + e.CloseGroup() +} + // mapSlogLevel maps to logger.Level. Custom levels below Debug clamp to // Debug; above Error clamp to Error. Trace and Crit are never produced. func mapSlogLevel(lvl slog.Level) Level { From fcd88a3341c4a0b9e3555f31970702f8c10a8219 Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Thu, 9 Apr 2026 19:00:49 +0200 Subject: [PATCH 3/4] fix: ai suggestion --- slog.go | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/slog.go b/slog.go index 3f9197e..8f28b83 100644 --- a/slog.go +++ b/slog.go @@ -17,7 +17,7 @@ type Handler struct { ctx []byte prefix []byte - groups []string + openGroups int } // NewHandler returns a new Handler. @@ -52,7 +52,7 @@ func (h *Handler) Handle(_ context.Context, r slog.Record) error { return true }) - for range h.groups { + for range h.openGroups { e.prefix = e.fmtr.AppendGroupEnd(e.buf, e.prefix) } @@ -84,13 +84,13 @@ func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { putEvent(e) return &Handler{ - fmtr: h.fmtr, - w: h.w, - isDiscard: h.isDiscard, - lvl: h.lvl, - ctx: newCtx, - prefix: h.prefix, - groups: h.groups, + fmtr: h.fmtr, + w: h.w, + isDiscard: h.isDiscard, + lvl: h.lvl, + ctx: newCtx, + prefix: h.prefix, + openGroups: h.openGroups, } } @@ -101,10 +101,6 @@ func (h *Handler) WithGroup(name string) slog.Handler { return h } - groups := make([]string, len(h.groups)+1) - copy(groups, h.groups) - groups[len(h.groups)] = name - e := newEvent(h.fmtr) e.buf.Write(h.ctx) e.prefix = append(e.prefix, h.prefix...) @@ -119,13 +115,13 @@ func (h *Handler) WithGroup(name string) slog.Handler { putEvent(e) return &Handler{ - fmtr: h.fmtr, - w: h.w, - isDiscard: h.isDiscard, - lvl: h.lvl, - ctx: newCtx, - prefix: newPrefix, - groups: groups, + fmtr: h.fmtr, + w: h.w, + isDiscard: h.isDiscard, + lvl: h.lvl, + ctx: newCtx, + prefix: newPrefix, + openGroups: h.openGroups + 1, } } From 274430433fee3deea2e8e83fdd5673c3ffbee570 Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Thu, 9 Apr 2026 19:34:55 +0200 Subject: [PATCH 4/4] fix: cr suggestion Co-authored-by: Brendan Le Glaunec --- slog.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/slog.go b/slog.go index 8f28b83..022c59c 100644 --- a/slog.go +++ b/slog.go @@ -22,11 +22,9 @@ type Handler struct { // NewHandler returns a new Handler. func NewHandler(w io.Writer, fmtr Formatter, lvl slog.Level) *Handler { - isDiscard := w == io.Discard - return &Handler{ w: w, - isDiscard: isDiscard, + isDiscard: w == io.Discard, fmtr: fmtr, lvl: lvl, }