Skip to content

Commit 376cba9

Browse files
committed
funcr: Handle nested empty groups properly
This ruins the whole idea of pre-rendering. We simply don't know if we have to render a group until we know the whole log record. Not having values at the time that `WithGroup` is called is not sufficient information.
1 parent a6645b7 commit 376cba9

File tree

2 files changed

+179
-95
lines changed

2 files changed

+179
-95
lines changed

funcr/funcr.go

+86-83
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,14 @@ func newFormatter(opts Options, outfmt outputFormat) Formatter {
236236
// implementation. It should be constructed with NewFormatter. Some of
237237
// its methods directly implement logr.LogSink.
238238
type Formatter struct {
239-
outputFormat outputFormat
240-
prefix string
241-
values []any
242-
valuesStr string
243-
parentValuesStr string
244-
depth int
245-
opts *Options
246-
group string // for slog groups
247-
groupDepth int
239+
outputFormat outputFormat
240+
prefix string
241+
values []any
242+
valuesStr string
243+
depth int
244+
opts *Options
245+
groupName string // for slog groups
246+
groups []groupDef
248247
}
249248

250249
// outputFormat indicates which outputFormat to use.
@@ -257,83 +256,116 @@ const (
257256
outputJSON
258257
)
259258

259+
// groupDef represents a saved group. The values may be empty, but we don't
260+
// know if we need to render the group until the final record is rendered.
261+
type groupDef struct {
262+
name string
263+
values string
264+
}
265+
260266
// PseudoStruct is a list of key-value pairs that gets logged as a struct.
261267
type PseudoStruct []any
262268

263269
// render produces a log line, ready to use.
264270
func (f Formatter) render(builtins, args []any) string {
265271
// Empirically bytes.Buffer is faster than strings.Builder for this.
266272
buf := bytes.NewBuffer(make([]byte, 0, 1024))
273+
267274
if f.outputFormat == outputJSON {
268-
buf.WriteByte('{') // for the whole line
275+
buf.WriteByte('{') // for the whole record
269276
}
270277

278+
// Render builtins
271279
vals := builtins
272280
if hook := f.opts.RenderBuiltinsHook; hook != nil {
273281
vals = hook(f.sanitize(vals))
274282
}
275-
f.flatten(buf, vals, false, false) // keys are ours, no need to escape
283+
f.flatten(buf, vals, false) // keys are ours, no need to escape
276284
continuing := len(builtins) > 0
277285

278-
if f.parentValuesStr != "" {
279-
if continuing {
280-
buf.WriteByte(f.comma())
286+
// Turn the inner-most group into a string
287+
argsStr := func() string {
288+
buf := bytes.NewBuffer(make([]byte, 0, 1024))
289+
290+
vals = args
291+
if hook := f.opts.RenderArgsHook; hook != nil {
292+
vals = hook(f.sanitize(vals))
281293
}
282-
buf.WriteString(f.parentValuesStr)
283-
continuing = true
284-
}
294+
f.flatten(buf, vals, true) // escape user-provided keys
285295

286-
groupDepth := f.groupDepth
287-
if f.group != "" {
288-
if f.valuesStr != "" || len(args) != 0 {
289-
if continuing {
290-
buf.WriteByte(f.comma())
291-
}
292-
buf.WriteString(f.quoted(f.group, true)) // escape user-provided keys
293-
buf.WriteByte(f.colon())
294-
buf.WriteByte('{') // for the group
295-
continuing = false
296-
} else {
297-
// The group was empty
298-
groupDepth--
296+
return buf.String()
297+
}()
298+
299+
// Render the stack of groups from the inside out.
300+
bodyStr := f.renderGroup(f.groupName, f.valuesStr, argsStr)
301+
for i := len(f.groups) - 1; i >= 0; i-- {
302+
grp := &f.groups[i]
303+
if grp.values == "" && bodyStr == "" {
304+
// no contents, so we must elide the whole group
305+
continue
299306
}
307+
bodyStr = f.renderGroup(grp.name, grp.values, bodyStr)
300308
}
301309

302-
if f.valuesStr != "" {
310+
if bodyStr != "" {
303311
if continuing {
304312
buf.WriteByte(f.comma())
305313
}
306-
buf.WriteString(f.valuesStr)
307-
continuing = true
314+
buf.WriteString(bodyStr)
308315
}
309316

310-
vals = args
311-
if hook := f.opts.RenderArgsHook; hook != nil {
312-
vals = hook(f.sanitize(vals))
317+
if f.outputFormat == outputJSON {
318+
buf.WriteByte('}') // for the whole record
313319
}
314-
f.flatten(buf, vals, continuing, true) // escape user-provided keys
315320

316-
for i := 0; i < groupDepth; i++ {
317-
buf.WriteByte('}') // for the groups
321+
return buf.String()
322+
}
323+
324+
// renderGroup returns a string representation of the named group with rendered
325+
// values and args. If the name is empty, this will return the values and args,
326+
// joined. If the name is not empty, this will return a single key-value pair,
327+
// where the value is a grouping of the values and args. If the values and
328+
// args are both empty, this will return an empty string, even if the name was
329+
// specified.
330+
func (f Formatter) renderGroup(name string, values string, args string) string {
331+
buf := bytes.NewBuffer(make([]byte, 0, 1024))
332+
333+
needClosingBrace := false
334+
if name != "" && (values != "" || args != "") {
335+
buf.WriteString(f.quoted(name, true)) // escape user-provided keys
336+
buf.WriteByte(f.colon())
337+
buf.WriteByte('{')
338+
needClosingBrace = true
318339
}
319340

320-
if f.outputFormat == outputJSON {
321-
buf.WriteByte('}') // for the whole line
341+
continuing := false
342+
if values != "" {
343+
buf.WriteString(values)
344+
continuing = true
345+
}
346+
347+
if args != "" {
348+
if continuing {
349+
buf.WriteByte(f.comma())
350+
}
351+
buf.WriteString(args)
352+
}
353+
354+
if needClosingBrace {
355+
buf.WriteByte('}')
322356
}
323357

324358
return buf.String()
325359
}
326360

327-
// flatten renders a list of key-value pairs into a buffer. If continuing is
328-
// true, it assumes that the buffer has previous values and will emit a
329-
// separator (which depends on the output format) before the first pair it
330-
// writes. If escapeKeys is true, the keys are assumed to have
331-
// non-JSON-compatible characters in them and must be evaluated for escapes.
361+
// flatten renders a list of key-value pairs into a buffer. If escapeKeys is
362+
// true, the keys are assumed to have non-JSON-compatible characters in them
363+
// and must be evaluated for escapes.
332364
//
333365
// This function returns a potentially modified version of kvList, which
334366
// ensures that there is a value for every key (adding a value if needed) and
335367
// that each key is a string (substituting a key if needed).
336-
func (f Formatter) flatten(buf *bytes.Buffer, kvList []any, continuing bool, escapeKeys bool) []any {
368+
func (f Formatter) flatten(buf *bytes.Buffer, kvList []any, escapeKeys bool) []any {
337369
// This logic overlaps with sanitize() but saves one type-cast per key,
338370
// which can be measurable.
339371
if len(kvList)%2 != 0 {
@@ -354,7 +386,7 @@ func (f Formatter) flatten(buf *bytes.Buffer, kvList []any, continuing bool, esc
354386
}
355387
v := kvList[i+1]
356388

357-
if i > 0 || continuing {
389+
if i > 0 {
358390
if f.outputFormat == outputJSON {
359391
buf.WriteByte(f.comma())
360392
} else {
@@ -766,46 +798,17 @@ func (f Formatter) sanitize(kvList []any) []any {
766798
// startGroup opens a new group scope (basically a sub-struct), which locks all
767799
// the current saved values and starts them anew. This is needed to satisfy
768800
// slog.
769-
func (f *Formatter) startGroup(group string) {
801+
func (f *Formatter) startGroup(name string) {
770802
// Unnamed groups are just inlined.
771-
if group == "" {
803+
if name == "" {
772804
return
773805
}
774806

775-
// Any saved values can no longer be changed.
776-
buf := bytes.NewBuffer(make([]byte, 0, 1024))
777-
continuing := false
778-
779-
if f.parentValuesStr != "" {
780-
buf.WriteString(f.parentValuesStr)
781-
continuing = true
782-
}
783-
784-
if f.group != "" && f.valuesStr != "" {
785-
if continuing {
786-
buf.WriteByte(f.comma())
787-
}
788-
buf.WriteString(f.quoted(f.group, true)) // escape user-provided keys
789-
buf.WriteByte(f.colon())
790-
buf.WriteByte('{') // for the group
791-
continuing = false
792-
}
793-
794-
if f.valuesStr != "" {
795-
if continuing {
796-
buf.WriteByte(f.comma())
797-
}
798-
buf.WriteString(f.valuesStr)
799-
}
800-
801-
// NOTE: We don't close the scope here - that's done later, when a log line
802-
// is actually rendered (because we have N scopes to close).
803-
804-
f.parentValuesStr = buf.String()
807+
n := len(f.groups)
808+
f.groups = append(f.groups[:n:n], groupDef{f.groupName, f.valuesStr})
805809

806810
// Start collecting new values.
807-
f.group = group
808-
f.groupDepth++
811+
f.groupName = name
809812
f.valuesStr = ""
810813
f.values = nil
811814
}
@@ -900,7 +903,7 @@ func (f *Formatter) AddValues(kvList []any) {
900903

901904
// Pre-render values, so we don't have to do it on each Info/Error call.
902905
buf := bytes.NewBuffer(make([]byte, 0, 1024))
903-
f.flatten(buf, vals, false, true) // escape user-provided keys
906+
f.flatten(buf, vals, true) // escape user-provided keys
904907
f.valuesStr = buf.String()
905908
}
906909

funcr/slogsink_test.go

+93-12
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,100 @@ func TestSlogSink(t *testing.T) {
8383
}
8484
}
8585

86-
func TestSlogSinkNestedGroups(t *testing.T) {
87-
capt := &capture{}
88-
logger := logr.New(newSink(capt.Func, NewFormatterJSON(Options{})))
89-
slogger := slog.New(logr.ToSlogHandler(logger))
90-
slogger = slogger.With("out", 0)
91-
slogger = slogger.WithGroup("g1").With("mid1", 1)
92-
slogger = slogger.WithGroup("g2").With("mid2", 2)
93-
slogger = slogger.WithGroup("g3").With("in", 3)
94-
slogger.Info("msg", "k", "v")
86+
func TestSlogSinkGroups(t *testing.T) {
87+
testCases := []struct {
88+
name string
89+
fn func(slogger *slog.Logger)
90+
expect string
91+
}{{
92+
name: "no group",
93+
fn: func(slogger *slog.Logger) {
94+
slogger.
95+
Info("msg", "k", "v")
96+
},
97+
expect: `{"logger":"","level":0,"msg":"msg","k":"v"}`,
98+
}, {
99+
name: "1 group with leaf args",
100+
fn: func(slogger *slog.Logger) {
101+
slogger.
102+
WithGroup("g1").
103+
Info("msg", "k", "v")
104+
},
105+
expect: `{"logger":"","level":0,"msg":"msg","g1":{"k":"v"}}`,
106+
}, {
107+
name: "1 group without leaf args",
108+
fn: func(slogger *slog.Logger) {
109+
slogger.
110+
WithGroup("g1").
111+
Info("msg")
112+
},
113+
expect: `{"logger":"","level":0,"msg":"msg"}`,
114+
}, {
115+
name: "1 group with value without leaf args",
116+
fn: func(slogger *slog.Logger) {
117+
slogger.
118+
WithGroup("g1").With("k1", 1).
119+
Info("msg")
120+
},
121+
expect: `{"logger":"","level":0,"msg":"msg","g1":{"k1":1}}`,
122+
}, {
123+
name: "2 groups with values no leaf args",
124+
fn: func(slogger *slog.Logger) {
125+
slogger.
126+
WithGroup("g1").With("k1", 1).
127+
WithGroup("g2").With("k2", 2).
128+
Info("msg")
129+
},
130+
expect: `{"logger":"","level":0,"msg":"msg","g1":{"k1":1,"g2":{"k2":2}}}`,
131+
}, {
132+
name: "3 empty groups with no values or leaf args",
133+
fn: func(slogger *slog.Logger) {
134+
slogger.
135+
WithGroup("g1").
136+
WithGroup("g2").
137+
WithGroup("g3").
138+
Info("msg")
139+
},
140+
expect: `{"logger":"","level":0,"msg":"msg"}`,
141+
}, {
142+
name: "3 empty groups with no values but with leaf args",
143+
fn: func(slogger *slog.Logger) {
144+
slogger.
145+
WithGroup("g1").
146+
WithGroup("g2").
147+
WithGroup("g3").
148+
Info("msg", "k", "v")
149+
},
150+
expect: `{"logger":"","level":0,"msg":"msg","g1":{"g2":{"g3":{"k":"v"}}}}`,
151+
}, {
152+
name: "multiple groups with and without values",
153+
fn: func(slogger *slog.Logger) {
154+
slogger.
155+
With("k0", 0).
156+
WithGroup("g1").
157+
WithGroup("g2").
158+
WithGroup("g3").With("k3", 3).
159+
WithGroup("g4").
160+
WithGroup("g5").
161+
WithGroup("g6").With("k6", 6).
162+
WithGroup("g7").
163+
WithGroup("g8").
164+
WithGroup("g9").
165+
Info("msg")
166+
},
167+
expect: `{"logger":"","level":0,"msg":"msg","k0":0,"g1":{"g2":{"g3":{"k3":3,"g4":{"g5":{"g6":{"k6":6}}}}}}}`,
168+
}}
95169

96-
expect := `{"logger":"","level":0,"msg":"msg","out":0,"g1":{"mid1":1,"g2":{"mid2":2,"g3":{"in":3,"k":"v"}}}}`
97-
if capt.log != expect {
98-
t.Errorf("\nexpected %q\n got %q", expect, capt.log)
170+
for _, tc := range testCases {
171+
t.Run(tc.name, func(t *testing.T) {
172+
capt := &capture{}
173+
logger := logr.New(newSink(capt.Func, NewFormatterJSON(Options{})))
174+
slogger := slog.New(logr.ToSlogHandler(logger))
175+
tc.fn(slogger)
176+
if capt.log != tc.expect {
177+
t.Errorf("\nexpected: `%s`\n got: `%s`", tc.expect, capt.log)
178+
}
179+
})
99180
}
100181
}
101182

0 commit comments

Comments
 (0)