Skip to content

Commit 35ded09

Browse files
arukiidouabhinav
andauthored
zapslog: fix all with slogtest, support inline group, ignore empty group. (#1408)
This change adds a test based on testing/slogtest that verifies compliance with the slog handler contract (a draft of this was available in #1335), and fixes all resulting issues. The two remaining issues were: - `Group("", attrs)` should inline the new fields instead of creating a group with an empty name. This was fixed with the use of `zap.Inline`. - Groups without any attributes should not be created. That is, `logger.WithGroup("foo").Info("bar")` should not create an empty "foo" namespace (`"foo": {}`). This was fixed by keeping track of unapplied groups and applying them the first time a field is serialized. Following this change, slogtest passes as expected. Refs #1333 Resolves #1334, #1401, #1402 Supersedes #1263, #1335 ### TESTS - passed. arukiidou#1 - This also works in Go 1.22 --------- Signed-off-by: junya koyama <[email protected]> Co-authored-by: Abhinav Gupta <[email protected]>
1 parent 27b96e3 commit 35ded09

File tree

2 files changed

+281
-11
lines changed

2 files changed

+281
-11
lines changed

Diff for: exp/zapslog/handler.go

+52-10
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ type Handler struct {
3939
addCaller bool
4040
addStackAt slog.Level
4141
callerSkip int
42+
43+
// List of unapplied groups.
44+
//
45+
// These are applied only if we encounter a real field
46+
// to avoid creating empty namespaces -- which is disallowed by slog's
47+
// usage contract.
48+
groups []string
4249
}
4350

4451
// NewHandler builds a [Handler] that writes to the supplied [zapcore.Core]
@@ -88,6 +95,10 @@ func convertAttrToField(attr slog.Attr) zapcore.Field {
8895
case slog.KindUint64:
8996
return zap.Uint64(attr.Key, attr.Value.Uint64())
9097
case slog.KindGroup:
98+
if attr.Key == "" {
99+
// Inlines recursively.
100+
return zap.Inline(groupObject(attr.Value.Group()))
101+
}
91102
return zap.Object(attr.Key, groupObject(attr.Value.Group()))
92103
case slog.KindLogValuer:
93104
return convertAttrToField(slog.Attr{
@@ -157,34 +168,65 @@ func (h *Handler) Handle(ctx context.Context, record slog.Record) error {
157168
ce.Stack = stacktrace.Take(3 + h.callerSkip)
158169
}
159170

160-
fields := make([]zapcore.Field, 0, record.NumAttrs())
171+
fields := make([]zapcore.Field, 0, record.NumAttrs()+len(h.groups))
172+
173+
var addedNamespace bool
161174
record.Attrs(func(attr slog.Attr) bool {
162-
fields = append(fields, convertAttrToField(attr))
175+
f := convertAttrToField(attr)
176+
if !addedNamespace && len(h.groups) > 0 && f != zap.Skip() {
177+
// Namespaces are added only if at least one field is present
178+
// to avoid creating empty groups.
179+
fields = h.appendGroups(fields)
180+
addedNamespace = true
181+
}
182+
fields = append(fields, f)
163183
return true
164184
})
185+
165186
ce.Write(fields...)
166187
return nil
167188
}
168189

190+
func (h *Handler) appendGroups(fields []zapcore.Field) []zapcore.Field {
191+
for _, g := range h.groups {
192+
fields = append(fields, zap.Namespace(g))
193+
}
194+
return fields
195+
}
196+
169197
// WithAttrs returns a new Handler whose attributes consist of
170198
// both the receiver's attributes and the arguments.
171199
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
172-
fields := make([]zapcore.Field, 0, len(attrs))
200+
fields := make([]zapcore.Field, 0, len(attrs)+len(h.groups))
201+
var addedNamespace bool
173202
for _, attr := range attrs {
174-
fields = append(fields, convertAttrToField(attr))
203+
f := convertAttrToField(attr)
204+
if !addedNamespace && len(h.groups) > 0 && f != zap.Skip() {
205+
// Namespaces are added only if at least one field is present
206+
// to avoid creating empty groups.
207+
fields = h.appendGroups(fields)
208+
addedNamespace = true
209+
}
210+
fields = append(fields, f)
211+
}
212+
213+
cloned := *h
214+
cloned.core = h.core.With(fields)
215+
if addedNamespace {
216+
// These groups have been applied so we can clear them.
217+
cloned.groups = nil
175218
}
176-
return h.withFields(fields...)
219+
return &cloned
177220
}
178221

179222
// WithGroup returns a new Handler with the given group appended to
180223
// the receiver's existing groups.
181224
func (h *Handler) WithGroup(group string) slog.Handler {
182-
return h.withFields(zap.Namespace(group))
183-
}
225+
newGroups := make([]string, len(h.groups)+1)
226+
copy(newGroups, h.groups)
227+
newGroups[len(h.groups)] = group
184228

185-
// withFields returns a cloned Handler with the given fields.
186-
func (h *Handler) withFields(fields ...zapcore.Field) *Handler {
187229
cloned := *h
188-
cloned.core = h.core.With(fields)
230+
cloned.groups = newGroups
189231
return &cloned
190232
}

Diff for: exp/zapslog/handler_test.go

+229-1
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,18 @@
2323
package zapslog
2424

2525
import (
26+
"bytes"
27+
"encoding/json"
2628
"log/slog"
29+
"sync"
2730
"testing"
31+
"testing/slogtest"
2832
"time"
2933

3034
"github.com/stretchr/testify/assert"
3135
"github.com/stretchr/testify/require"
3236
"go.uber.org/zap/zapcore"
37+
"go.uber.org/zap/zaptest"
3338
"go.uber.org/zap/zaptest/observer"
3439
)
3540

@@ -128,8 +133,8 @@ func TestEmptyAttr(t *testing.T) {
128133
}
129134

130135
func TestWithName(t *testing.T) {
131-
t.Parallel()
132136
fac, observedLogs := observer.New(zapcore.DebugLevel)
137+
133138
t.Run("default", func(t *testing.T) {
134139
sl := slog.New(NewHandler(fac))
135140
sl.Info("msg")
@@ -150,6 +155,191 @@ func TestWithName(t *testing.T) {
150155
})
151156
}
152157

158+
func TestInlineGroup(t *testing.T) {
159+
fac, observedLogs := observer.New(zapcore.DebugLevel)
160+
161+
t.Run("simple", func(t *testing.T) {
162+
sl := slog.New(NewHandler(fac))
163+
sl.Info("msg", "a", "b", slog.Group("", slog.String("c", "d")), "e", "f")
164+
165+
logs := observedLogs.TakeAll()
166+
require.Len(t, logs, 1, "Expected exactly one entry to be logged")
167+
assert.Equal(t, map[string]any{
168+
"a": "b",
169+
"c": "d",
170+
"e": "f",
171+
}, logs[0].ContextMap(), "Unexpected context")
172+
})
173+
174+
t.Run("recursive", func(t *testing.T) {
175+
sl := slog.New(NewHandler(fac))
176+
sl.Info("msg", "a", "b", slog.Group("", slog.Group("", slog.Group("", slog.String("c", "d"))), slog.Group("", "e", "f")))
177+
178+
logs := observedLogs.TakeAll()
179+
require.Len(t, logs, 1, "Expected exactly one entry to be logged")
180+
assert.Equal(t, map[string]any{
181+
"a": "b",
182+
"c": "d",
183+
"e": "f",
184+
}, logs[0].ContextMap(), "Unexpected context")
185+
})
186+
}
187+
188+
func TestWithGroup(t *testing.T) {
189+
fac, observedLogs := observer.New(zapcore.DebugLevel)
190+
191+
// Groups can be nested inside each other.
192+
t.Run("nested", func(t *testing.T) {
193+
sl := slog.New(NewHandler(fac))
194+
sl.With("a", "b").WithGroup("G").WithGroup("in").Info("msg", "c", "d")
195+
196+
logs := observedLogs.TakeAll()
197+
require.Len(t, logs, 1, "Expected exactly one entry to be logged")
198+
assert.Equal(t, map[string]any{
199+
"G": map[string]any{
200+
"in": map[string]any{
201+
"c": "d",
202+
},
203+
},
204+
"a": "b",
205+
}, logs[0].ContextMap(), "Unexpected context")
206+
})
207+
208+
t.Run("nested empty", func(t *testing.T) {
209+
sl := slog.New(NewHandler(fac))
210+
sl.With("a", "b").WithGroup("G").WithGroup("in").Info("msg")
211+
212+
logs := observedLogs.TakeAll()
213+
require.Len(t, logs, 1, "Expected exactly one entry to be logged")
214+
assert.Equal(t, map[string]any{
215+
"a": "b",
216+
}, logs[0].ContextMap(), "Unexpected context")
217+
})
218+
219+
t.Run("empty group", func(t *testing.T) {
220+
sl := slog.New(NewHandler(fac))
221+
sl.With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").Info("msg")
222+
223+
logs := observedLogs.TakeAll()
224+
require.Len(t, logs, 1, "Expected exactly one entry to be logged")
225+
assert.Equal(t, map[string]any{
226+
"G": map[string]any{
227+
"c": "d",
228+
},
229+
"a": "b",
230+
}, logs[0].ContextMap(), "Unexpected context")
231+
})
232+
233+
t.Run("skipped field", func(t *testing.T) {
234+
sl := slog.New(NewHandler(fac))
235+
sl.WithGroup("H").With(slog.Attr{}).Info("msg")
236+
237+
logs := observedLogs.TakeAll()
238+
require.Len(t, logs, 1, "Expected exactly one entry to be logged")
239+
assert.Equal(t, map[string]any{}, logs[0].ContextMap(), "Unexpected context")
240+
})
241+
242+
t.Run("reuse", func(t *testing.T) {
243+
sl := slog.New(NewHandler(fac)).WithGroup("G")
244+
245+
sl.With("a", "b").Info("msg1", "c", "d")
246+
sl.With("e", "f").Info("msg2", "g", "h")
247+
248+
logs := observedLogs.TakeAll()
249+
require.Len(t, logs, 2, "Expected exactly two entries to be logged")
250+
251+
assert.Equal(t, map[string]any{
252+
"G": map[string]any{
253+
"a": "b",
254+
"c": "d",
255+
},
256+
}, logs[0].ContextMap(), "Unexpected context")
257+
assert.Equal(t, "msg1", logs[0].Message, "Unexpected message")
258+
259+
assert.Equal(t, map[string]any{
260+
"G": map[string]any{
261+
"e": "f",
262+
"g": "h",
263+
},
264+
}, logs[1].ContextMap(), "Unexpected context")
265+
assert.Equal(t, "msg2", logs[1].Message, "Unexpected message")
266+
})
267+
}
268+
269+
// Run a few different loggers with concurrent logs
270+
// in an attempt to trip up 'go test -race' and discover any data races.
271+
func TestConcurrentLogs(t *testing.T) {
272+
t.Parallel()
273+
274+
const (
275+
NumWorkers = 10
276+
NumLogs = 100
277+
)
278+
279+
tests := []struct {
280+
name string
281+
buildHandler func(zapcore.Core) slog.Handler
282+
}{
283+
{
284+
name: "default",
285+
buildHandler: func(core zapcore.Core) slog.Handler {
286+
return NewHandler(core)
287+
},
288+
},
289+
{
290+
name: "grouped",
291+
buildHandler: func(core zapcore.Core) slog.Handler {
292+
return NewHandler(core).WithGroup("G")
293+
},
294+
},
295+
{
296+
name: "named",
297+
buildHandler: func(core zapcore.Core) slog.Handler {
298+
return NewHandler(core, WithName("test-name"))
299+
},
300+
},
301+
}
302+
303+
for _, tt := range tests {
304+
tt := tt
305+
t.Run(tt.name, func(t *testing.T) {
306+
t.Parallel()
307+
308+
fac, observedLogs := observer.New(zapcore.DebugLevel)
309+
sl := slog.New(tt.buildHandler(fac))
310+
311+
// Use two wait groups to coordinate the workers:
312+
//
313+
// - ready: indicates when all workers should start logging.
314+
// - done: indicates when all workers have finished logging.
315+
var ready, done sync.WaitGroup
316+
ready.Add(NumWorkers)
317+
done.Add(NumWorkers)
318+
319+
for i := 0; i < NumWorkers; i++ {
320+
i := i
321+
go func() {
322+
defer done.Done()
323+
324+
ready.Done() // I'm ready.
325+
ready.Wait() // Are others?
326+
327+
for j := 0; j < NumLogs; j++ {
328+
sl.Info("msg", "worker", i, "log", j)
329+
}
330+
}()
331+
}
332+
333+
done.Wait()
334+
335+
// Ensure that all logs were recorded.
336+
logs := observedLogs.TakeAll()
337+
assert.Len(t, logs, NumWorkers*NumLogs,
338+
"Wrong number of logs recorded")
339+
})
340+
}
341+
}
342+
153343
type Token string
154344

155345
func (Token) LogValue() slog.Value {
@@ -189,3 +379,41 @@ func TestAttrKinds(t *testing.T) {
189379
},
190380
entry.ContextMap())
191381
}
382+
383+
func TestSlogtest(t *testing.T) {
384+
var buff bytes.Buffer
385+
core := zapcore.NewCore(
386+
zapcore.NewJSONEncoder(zapcore.EncoderConfig{
387+
TimeKey: slog.TimeKey,
388+
MessageKey: slog.MessageKey,
389+
LevelKey: slog.LevelKey,
390+
EncodeLevel: zapcore.CapitalLevelEncoder,
391+
EncodeTime: zapcore.RFC3339TimeEncoder,
392+
}),
393+
zapcore.AddSync(&buff),
394+
zapcore.DebugLevel,
395+
)
396+
397+
// zaptest doesn't expose the underlying core,
398+
// so we'll extract it from the logger.
399+
testCore := zaptest.NewLogger(t).Core()
400+
401+
handler := NewHandler(zapcore.NewTee(core, testCore))
402+
err := slogtest.TestHandler(
403+
handler,
404+
func() []map[string]any {
405+
// Parse the newline-delimted JSON in buff.
406+
var entries []map[string]any
407+
408+
dec := json.NewDecoder(bytes.NewReader(buff.Bytes()))
409+
for dec.More() {
410+
var ent map[string]any
411+
require.NoError(t, dec.Decode(&ent), "Error decoding log message")
412+
entries = append(entries, ent)
413+
}
414+
415+
return entries
416+
},
417+
)
418+
require.NoError(t, err, "Unexpected error from slogtest.TestHandler")
419+
}

0 commit comments

Comments
 (0)