fix(logger): deduplicate keys across WithFields chains (gost#895)

slog.Logger.With does not deduplicate keys, so chained WithFields calls
(e.g. service -> listener/handler, each setting "kind") emitted duplicate
JSON keys. slogLogger now rebuilds from a pristine base logger with
accumulated attrs deduplicated by key, last write wins.
This commit is contained in:
ginuerzh
2026-08-18 21:49:11 +08:00
parent 18df26e9bc
commit cc02cff00c
2 changed files with 57 additions and 7 deletions
+37 -7
View File
@@ -62,8 +62,17 @@ func LevelOption(level logger.LogLevel) Option {
// slogLogger implements logger.Logger backed by log/slog.
type slogLogger struct {
// base is the pristine slog.Logger before any WithFields attrs are
// applied. It is used to rebuild logger after each WithFields call.
base *slog.Logger
// logger is the effective slog.Logger used for emitting entries.
logger *slog.Logger
level *slog.LevelVar
// args holds the accumulated WithFields key-value pairs in insertion
// order, deduplicated by key (last write wins). slog.Logger.With does not
// deduplicate, so chaining WithFields with a repeated key would otherwise
// emit duplicate JSON keys.
args []any
level *slog.LevelVar
}
// NewLogger creates a new logger.Logger backed by log/slog.
@@ -90,6 +99,7 @@ func NewLogger(opts ...Option) logger.Logger {
}
return &slogLogger{
base: sl,
logger: sl,
level: levelVar,
}
@@ -186,18 +196,38 @@ func revertLevel(lvl slog.Level) logger.LogLevel {
}
// WithFields returns a new Logger with the given fields attached to every
// subsequent log entry.
// subsequent log entry. A field whose key was already set by an earlier
// WithFields call is overwritten (last write wins) rather than duplicated.
func (l *slogLogger) WithFields(fields map[string]any) logger.Logger {
attrs := make([]any, 0, len(fields)*2)
for k, v := range fields {
attrs = append(attrs, k, v)
}
args := mergeArgs(l.args, fields)
return &slogLogger{
logger: l.logger.With(attrs...),
base: l.base,
logger: l.base.With(args...),
args: args,
level: l.level,
}
}
// mergeArgs merges fields into args (a flat [key, value, ...] slice),
// overwriting any existing key in place to preserve insertion order.
func mergeArgs(args []any, fields map[string]any) []any {
index := make(map[string]int, len(args)/2+len(fields))
out := make([]any, 0, len(args)+len(fields)*2)
for i := 0; i < len(args); i += 2 {
index[args[i].(string)] = len(out)
out = append(out, args[i], args[i+1])
}
for k, v := range fields {
if i, ok := index[k]; ok {
out[i+1] = v
} else {
index[k] = len(out)
out = append(out, k, v)
}
}
return out
}
// Trace logs at Trace level.
func (l *slogLogger) Trace(args ...any) {
l.log(levelTrace, args...)
+20
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"log/slog"
"regexp"
"strings"
"testing"
"time"
@@ -184,6 +185,25 @@ func TestWithFields(t *testing.T) {
}
}
func TestWithFieldsDeduplicatesKeys(t *testing.T) {
var buf bytes.Buffer
l := NewLogger(
OutputOption(&buf),
LevelOption(logger.InfoLevel),
FormatOption(logger.JSONFormat),
)
l = l.WithFields(map[string]any{"kind": "service", "service": "s"}).WithFields(map[string]any{"kind": "handler"})
l.Info("hello")
out := buf.String()
// "kind" must appear exactly once, with the last value winning.
if strings.Count(out, `"kind"`) != 1 {
t.Errorf("expected one %q key, got %q", "kind", out)
}
if !strings.Contains(out, `"kind":"handler"`) {
t.Errorf("expected kind=handler, got %q", out)
}
}
func TestIsLevelEnabled(t *testing.T) {
l := NewLogger(LevelOption(logger.InfoLevel))
if !l.IsLevelEnabled(logger.WarnLevel) {