SlogHandler violates several parts of the slog.Handler contract
SlogHandler violates several parts of the slog.Handler contract
References
Issues
1. Nested groups are flattened into dotted keys instead of nested objects:
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger()))
logger.WithGroup("g").Info("msg", "k", "v")Output: {"level":"info","g.k":"v","time":"2026-08-12T00:46:25+05:30","message":"msg"}
Expected: {"level":"info","g":{"k":"v"},"time":"2026-08-12T00:59:55+05:30","message":"msg"}
2. Keys collapse onto the most-recently-opened group, regardless of where they were actually added. This can also lead to duplicate keys, as shown in the example below:
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger()))
logger.With("l", 1).WithGroup("g").With("l", 2).WithGroup("G").Info("msg", "l", 3)Output: {"level":"info","g.G.l":1,"g.G.l":2,"g.G.l":3,"time":"2026-08-12T00:55:38+05:30","message":"msg"}
Expected: {"level":"info","l":1,"g":{"l":2,"G":{"l":3}},"time":"2026-08-12T00:55:56+05:30","message":"msg"}
Expected (even with a flattened-key design): {"level":"info","l":1,"g.l":2,"g.G.l":3,"time":"2026-08-12T00:55:38+05:30","message":"msg"}
3. Doesn't respect slog.Record.Time:
In practice, when slog.Record.Time is left at its default (time.Now()), it and the Timestamp() hook's own capture differ by mere nanoseconds — invisible in normal use. But the contract still requires sourcing from record.Time, which becomes obvious once it's set explicitly, as below:
h := zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger())
h.Handle(context.Background(), slog.NewRecord(time.Date(2001, time.April, 1, 0, 0, 0, 0, time.UTC), slog.LevelInfo, "msg", 0))Output: {"level":"info","time":"2026-08-12T01:07:28+05:30","message":"msg"}
Expected: {"level":"info","time":"2001-04-01T00:00:00Z","message":"msg"}
4. The slog.Handler contract states that a zero record.Time should be dropped; this handler doesn't:
h := zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger())
h.Handle(context.Background(), slog.NewRecord(time.Time{}, slog.LevelInfo, "msg", 0))Output: {"level":"info","time":"2026-08-12T01:17:48+05:30","message":"msg"}
Expected: {"level":"info","message":"msg"}
5. Logs slog.Record.Time when Timestamp() hook is not present:
This produces inconsistent logs and can also lead to a duplicate time field if a custom timestamp hook is used.
hook := zerolog.HookFunc(func(e *zerolog.Event, level zerolog.Level, message string) {
e.Time(zerolog.TimestampFieldName, time.Now())
})
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).Hook(hook)))
logger.Info("msg")Output: {"level":"info","time":"2026-08-12T15:40:07+05:30","time":"2026-08-12T15:40:07+05:30","message":"msg"}
Expected: {"level":"info","time":"2026-08-12T15:40:07+05:30","message":"msg"}
6. Doesn't respect slog.Record.PC:
As a result, the caller field doesn't resolve to the correct call site.
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Caller().Logger()))
logger.Info("msg")Output: {"level":"info","time":"2026-08-12T01:25:31+05:30","caller":"github.com/rs/[email protected]/slog.go:71","message":"msg"}
Expected: {"level":"info","time":"2026-08-12T01:26:03+05:30","caller":"/path/to/project/main.go:12","message":"msg"}
7. The slog.Handler contract states that a zero record.PC should be dropped; this handler doesn't:
h := zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Caller().Logger())
h.Handle(context.Background(), slog.NewRecord(time.Now(), slog.LevelInfo, "msg", 0))Output: {"level":"info","time":"2026-08-12T01:22:06+05:30","caller":"github.com/rs/[email protected]/slog.go:71","message":"msg"}
Expected: {"level":"info","time":"2026-08-12T01:22:09+05:30","message":"msg"}
8. Values with an empty key are dropped instead of being logged:
Per the slog.Handler contract, only an attr whose key and value are both zero should be dropped — checked via attr.Equal(slog.Attr{}), not a bare key check. zerolog.Logger logs empty keys too.
logger := slog.New(zerolog.NewSlogHandler(zerolog.New(os.Stdout).With().Timestamp().Logger()))
logger.Info("msg", "", "v")Output: {"level":"info","time":"2026-08-12T01:34:17+05:30","message":"msg"}
Expected: {"level":"info","":"v","time":"2026-08-12T01:34:43+05:30","message":"msg"}
9. Doesn't pre-format WithAttrs/WithGroup output (via zerolog.Context), as the handler guide recommends for performance:
WithAttrs accumulates attrs across multiple calls, then re-encodes all of them from scratch — along with the record's own attrs — on every single Handle() call, instead of writing accumulated attrs into the underlying zerolog.Context once, at WithAttrs/WithGroup time.
10. The test suite doesn't run the official testing/slogtest conformance suite. If it did, it would fail because of the issues above.
11. Incorrect tests
TestSlogHandler_EmptyMessageis written to test that an empty message is omitted from the log output, but it never actually asserts that.TestSlogHandler_WithAttrsImmutabilityis meant to test attrs immutability, but it uses two differentzerolog.Loggerinstances with different buffers:child1is derived fromzl1, while the other child is derived from an unrelatedzl2. As written, it never proves that callingWithAttrstwice on the same handler leaves the first call's result unmutated.TestSlogHandler_EnabledNilWriteruseszerolog.Nop(), which creates a logger viazerolog.New(nil).Level(Disabled). SinceNewsubstitutesio.Discardwhenever the writer isnil, the resulting logger's writer is never actuallynil— defeating the purpose of the test, which should isolate thew == nilbranch specifically, distinct from theDisabled-level branch.
Other issues (non-functional)
1. slog_test.go uses package zerolog_test
This is inconsistent with the rest of the project, which uses
package zerologfor test files (except files with examples). This makes internal testing helpers likedecodeIfBinaryToBytes/decodeIfBinaryToStringunavailable.slog_test.gouses its owndecodeJSONanddecodeOutputhelpers instead, which is inconsistent with the rest of the project.
2. slog_test.go uses JSON decoding for assertions
The rest of the project expects log output to be byte-for-byte identical to the expected string. slog_test.go deviates from this pattern by using json.Unmarshal for its assertions instead.
Source: rs/zerolog