Skip to content

Commit a44214d

Browse files
authored
Merge pull request #274 from thockin/master
funcr: Handle nested empty groups properly
2 parents 7083935 + 376cba9 commit a44214d

File tree

3 files changed

+180
-95
lines changed

3 files changed

+180
-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

slogr_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func TestRunSlogTestsOnSlogHandlerLogSink(t *testing.T) {
127127
"a Handler should handle the WithGroup method",
128128
"a Handler should handle multiple WithGroup and WithAttr calls",
129129
"a Handler should not output groups for an empty Record",
130+
"a Handler should not output groups if there are no attributes",
130131
"a Handler should call Resolve on attribute values in groups",
131132
"a Handler should call Resolve on attribute values in groups from WithAttrs",
132133
}

0 commit comments

Comments
 (0)