Skip to content

Commit 561de0c

Browse files
authored
Merge pull request #111 from thockin/master
funcr: Add options for hooks during rendering
2 parents e39a01e + f77f1b9 commit 561de0c

File tree

3 files changed

+229
-22
lines changed

3 files changed

+229
-22
lines changed

funcr/example_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,57 @@ func ExampleUnderlier() {
5656
}
5757
// Output: hello world
5858
}
59+
60+
func ExampleOptions() {
61+
var log logr.Logger = funcr.NewJSON(
62+
func(obj string) { fmt.Println(obj) },
63+
funcr.Options{
64+
LogCaller: funcr.All,
65+
Verbosity: 1, // V(2) and higher is ignored.
66+
})
67+
log.V(0).Info("V(0) message", "key", "value")
68+
log.V(1).Info("V(1) message", "key", "value")
69+
log.V(2).Info("V(2) message", "key", "value")
70+
// Output:
71+
// {"logger":"","caller":{"file":"example_test.go","line":67},"level":0,"msg":"V(0) message","key":"value"}
72+
// {"logger":"","caller":{"file":"example_test.go","line":68},"level":1,"msg":"V(1) message","key":"value"}
73+
}
74+
75+
func ExampleOptions_renderHooks() {
76+
// prefix all builtin keys with "log:"
77+
prefixSpecialKeys := func(kvList []interface{}) []interface{} {
78+
for i := 0; i < len(kvList); i += 2 {
79+
k, _ := kvList[i].(string)
80+
kvList[i] = "log:" + k
81+
}
82+
return kvList
83+
}
84+
85+
// present saved values as a single JSON object
86+
valuesAsObject := func(kvList []interface{}) []interface{} {
87+
return []interface{}{"labels", funcr.PseudoStruct(kvList)}
88+
}
89+
90+
var log logr.Logger = funcr.NewJSON(
91+
func(obj string) { fmt.Println(obj) },
92+
funcr.Options{
93+
RenderBuiltinsHook: prefixSpecialKeys,
94+
RenderValuesHook: valuesAsObject,
95+
})
96+
log = log.WithName("MyLogger")
97+
log = log.WithValues("savedKey", "savedValue")
98+
log.Info("the message", "key", "value")
99+
// Output: {"log:logger":"MyLogger","log:level":0,"log:msg":"the message","labels":{"savedKey":"savedValue"},"key":"value"}
100+
}
101+
102+
func ExamplePseudoStruct() {
103+
var log logr.Logger = funcr.NewJSON(
104+
func(obj string) { fmt.Println(obj) },
105+
funcr.Options{})
106+
kv := []interface{}{
107+
"field1", 12345,
108+
"field2", true,
109+
}
110+
log.Info("the message", "key", funcr.PseudoStruct(kv))
111+
// Output: {"logger":"","level":0,"msg":"the message","key":{"field1":12345,"field2":true}}
112+
}

funcr/funcr.go

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,29 @@ type Options struct {
9797
// more logs. Info logs at or below this level will be written, while logs
9898
// above this level will be discarded.
9999
Verbosity int
100+
101+
// RenderBuiltinsHook allows users to mutate the list of key-value pairs
102+
// while a log line is being rendered. The kvList argument follows logr
103+
// conventions - each pair of slice elements is comprised of a string key
104+
// and an arbitrary value (verified and sanitized before calling this
105+
// hook). The value returned must follow the same conventions. This hook
106+
// can be used to audit or modify logged data. For example, you might want
107+
// to prefix all of funcr's built-in keys with some string. This hook is
108+
// only called for built-in (provided by funcr itself) key-value pairs.
109+
// Equivalent hooks are offered for key-value pairs saved via
110+
// logr.Logger.WithValues or Formatter.AddValues (see RenderValuesHook) and
111+
// for user-provided pairs (see RenderArgsHook).
112+
RenderBuiltinsHook func(kvList []interface{}) []interface{}
113+
114+
// RenderValuesHook is the same as RenderBuiltinsHook, except that it is
115+
// only called for key-value pairs saved via logr.Logger.WithValues. See
116+
// RenderBuiltinsHook for more details.
117+
RenderValuesHook func(kvList []interface{}) []interface{}
118+
119+
// RenderArgsHook is the same as RenderBuiltinsHook, except that it is only
120+
// called for key-value pairs passed directly to Info and Error. See
121+
// RenderBuiltinsHook for more details.
122+
RenderArgsHook func(kvList []interface{}) []interface{}
100123
}
101124

102125
// MessageClass indicates which category or categories of messages to consider.
@@ -199,14 +222,21 @@ const (
199222
outputJSON
200223
)
201224

202-
// render produces a log-line, ready to use.
225+
// PseudoStruct is a list of key-value pairs that gets logged as a struct.
226+
type PseudoStruct []interface{}
227+
228+
// render produces a log line, ready to use.
203229
func (f Formatter) render(builtins, args []interface{}) string {
204230
// Empirically bytes.Buffer is faster than strings.Builder for this.
205231
buf := bytes.NewBuffer(make([]byte, 0, 1024))
206232
if f.outputFormat == outputJSON {
207233
buf.WriteByte('{')
208234
}
209-
f.flatten(buf, builtins, false)
235+
vals := builtins
236+
if hook := f.opts.RenderBuiltinsHook; hook != nil {
237+
vals = hook(f.sanitize(vals))
238+
}
239+
f.flatten(buf, vals, false)
210240
continuing := len(builtins) > 0
211241
if len(f.valuesStr) > 0 {
212242
if continuing {
@@ -219,7 +249,11 @@ func (f Formatter) render(builtins, args []interface{}) string {
219249
continuing = true
220250
buf.WriteString(f.valuesStr)
221251
}
222-
f.flatten(buf, args, continuing)
252+
vals = args
253+
if hook := f.opts.RenderArgsHook; hook != nil {
254+
vals = hook(f.sanitize(vals))
255+
}
256+
f.flatten(buf, vals, continuing)
223257
if f.outputFormat == outputJSON {
224258
buf.WriteByte('}')
225259
}
@@ -233,17 +267,15 @@ func (f Formatter) render(builtins, args []interface{}) string {
233267
// ensures that there is a value for every key (adding a value if needed) and
234268
// that each key is a string (substituting a key if needed).
235269
func (f Formatter) flatten(buf *bytes.Buffer, kvList []interface{}, continuing bool) []interface{} {
270+
// This logic overlaps with sanitize() but saves one type-cast per key,
271+
// which can be measurable.
236272
if len(kvList)%2 != 0 {
237-
kvList = append(kvList, "<no-value>")
273+
kvList = append(kvList, noValue)
238274
}
239275
for i := 0; i < len(kvList); i += 2 {
240276
k, ok := kvList[i].(string)
241277
if !ok {
242-
snippet := f.pretty(kvList[i])
243-
if len(snippet) > 16 {
244-
snippet = snippet[:16]
245-
}
246-
k = fmt.Sprintf("<non-string-key: %s>", snippet)
278+
k = f.nonStringKey(kvList[i])
247279
kvList[i] = k
248280
}
249281
v := kvList[i+1]
@@ -253,7 +285,7 @@ func (f Formatter) flatten(buf *bytes.Buffer, kvList []interface{}, continuing b
253285
buf.WriteByte(',')
254286
} else {
255287
// In theory the format could be something we don't understand. In
256-
// practice, we control it, so it won't
288+
// practice, we control it, so it won't be.
257289
buf.WriteByte(' ')
258290
}
259291
}
@@ -337,6 +369,26 @@ func (f Formatter) prettyWithFlags(value interface{}, flags uint32) string {
337369
return `"` + strconv.FormatComplex(complex128(v), 'f', -1, 64) + `"`
338370
case complex128:
339371
return `"` + strconv.FormatComplex(v, 'f', -1, 128) + `"`
372+
case PseudoStruct:
373+
buf := bytes.NewBuffer(make([]byte, 0, 1024))
374+
v = f.sanitize(v)
375+
if flags&flagRawStruct == 0 {
376+
buf.WriteByte('{')
377+
}
378+
for i := 0; i < len(v); i += 2 {
379+
if i > 0 {
380+
buf.WriteByte(',')
381+
}
382+
buf.WriteByte('"')
383+
buf.WriteString(v[i].(string))
384+
buf.WriteByte('"')
385+
buf.WriteByte(':')
386+
buf.WriteString(f.pretty(v[i+1]))
387+
}
388+
if flags&flagRawStruct == 0 {
389+
buf.WriteByte('}')
390+
}
391+
return buf.String()
340392
}
341393

342394
buf := bytes.NewBuffer(make([]byte, 0, 256))
@@ -480,17 +532,27 @@ func isEmpty(v reflect.Value) bool {
480532
return false
481533
}
482534

483-
type callerID struct {
535+
// Caller represents the original call site for a log line, after considering
536+
// logr.Logger.WithCallDepth and logr.Logger.WithCallStackHelper. The File and
537+
// Line fields will always be provided, while the Func field is optional.
538+
// Users can set the render hook fields in Options to examine logged key-value
539+
// pairs, one of which will be {"caller", Caller} if the Options.LogCaller
540+
// field is enabled for the given MessageClass.
541+
type Caller struct {
542+
// File is the basename of the file for this call site.
484543
File string `json:"file"`
485-
Line int `json:"line"`
544+
// Line is the line number in the file for this call site.
545+
Line int `json:"line"`
546+
// Func is the function name for this call site, or empty if
547+
// Options.LogCallerFunc is not enabled.
486548
Func string `json:"function,omitempty"`
487549
}
488550

489-
func (f Formatter) caller() callerID {
551+
func (f Formatter) caller() Caller {
490552
// +1 for this frame, +1 for Info/Error.
491553
pc, file, line, ok := runtime.Caller(f.depth + 2)
492554
if !ok {
493-
return callerID{"<unknown>", 0, ""}
555+
return Caller{"<unknown>", 0, ""}
494556
}
495557
fn := ""
496558
if f.opts.LogCallerFunc {
@@ -499,7 +561,40 @@ func (f Formatter) caller() callerID {
499561
}
500562
}
501563

502-
return callerID{filepath.Base(file), line, fn}
564+
return Caller{filepath.Base(file), line, fn}
565+
}
566+
567+
const noValue = "<no-value>"
568+
569+
func (f Formatter) nonStringKey(v interface{}) string {
570+
return fmt.Sprintf("<non-string-key: %s>", f.snippet(v))
571+
}
572+
573+
// snippet produces a short snippet string of an arbitrary value.
574+
func (f Formatter) snippet(v interface{}) string {
575+
const snipLen = 16
576+
577+
snip := f.pretty(v)
578+
if len(snip) > snipLen {
579+
snip = snip[:snipLen]
580+
}
581+
return snip
582+
}
583+
584+
// sanitize ensures that a list of key-value pairs has a value for every key
585+
// (adding a value if needed) and that each key is a string (substituting a key
586+
// if needed).
587+
func (f Formatter) sanitize(kvList []interface{}) []interface{} {
588+
if len(kvList)%2 != 0 {
589+
kvList = append(kvList, noValue)
590+
}
591+
for i := 0; i < len(kvList); i += 2 {
592+
_, ok := kvList[i].(string)
593+
if !ok {
594+
kvList[i] = f.nonStringKey(kvList[i])
595+
}
596+
}
597+
return kvList
503598
}
504599

505600
// Init configures this Formatter from runtime info, such as the call depth
@@ -520,8 +615,8 @@ func (f Formatter) GetDepth() int {
520615
return f.depth
521616
}
522617

523-
// FormatInfo flattens an Info log message into strings.
524-
// The prefix will be empty when no names were set, or when the output is
618+
// FormatInfo renders an Info log message into strings. The prefix will be
619+
// empty when no names were set (via AddNames), or when the output is
525620
// configured for JSON.
526621
func (f Formatter) FormatInfo(level int, msg string, kvList []interface{}) (prefix, argsStr string) {
527622
args := make([]interface{}, 0, 64) // using a constant here impacts perf
@@ -540,8 +635,8 @@ func (f Formatter) FormatInfo(level int, msg string, kvList []interface{}) (pref
540635
return prefix, f.render(args, kvList)
541636
}
542637

543-
// FormatError flattens an Error log message into strings.
544-
// The prefix will be empty when no names were set, or when the output is
638+
// FormatError renders an Error log message into strings. The prefix will be
639+
// empty when no names were set (via AddNames), or when the output is
545640
// configured for JSON.
546641
func (f Formatter) FormatError(err error, msg string, kvList []interface{}) (prefix, argsStr string) {
547642
args := make([]interface{}, 0, 64) // using a constant here impacts perf
@@ -578,11 +673,16 @@ func (f *Formatter) AddName(name string) {
578673
// AddValues adds key-value pairs to the set of saved values to be logged with
579674
// each log line.
580675
func (f *Formatter) AddValues(kvList []interface{}) {
581-
// Pre-render values, so we don't have to do it on each Info/Error call.
582-
buf := bytes.NewBuffer(make([]byte, 0, 1024))
583676
// Three slice args forces a copy.
584677
n := len(f.values)
585-
f.values = f.flatten(buf, append(f.values[:n:n], kvList...), false)
678+
vals := append(f.values[:n:n], kvList...)
679+
if hook := f.opts.RenderValuesHook; hook != nil {
680+
vals = hook(f.sanitize(vals))
681+
}
682+
683+
// Pre-render values, so we don't have to do it on each Info/Error call.
684+
buf := bytes.NewBuffer(make([]byte, 0, 1024))
685+
f.values = f.flatten(buf, vals, false)
586686
f.valuesStr = buf.String()
587687
}
588688

funcr/funcr_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"encoding/json"
2121
"fmt"
2222
"path/filepath"
23+
"reflect"
2324
"runtime"
2425
"testing"
2526

@@ -433,6 +434,10 @@ func TestPretty(t *testing.T) {
433434
exp: `{"Tinnerint":0,"Tinnermap":{},"Tinnerslice":[]}`,
434435
},
435436
{val: Tembedjsontags{}},
437+
{
438+
val: PseudoStruct(makeKV("f1", 1, "f2", true, "f3", []int{})),
439+
exp: `{"f1":1,"f2":true,"f3":[]}`,
440+
},
436441
}
437442

438443
f := NewFormatter(Options{})
@@ -484,6 +489,13 @@ func TestRender(t *testing.T) {
484489
args: makeKV("bool1", true, "bool2", false),
485490
expectKV: `"int1"=1 "int2"=2 "str1"="ABC" "str2"="DEF" "bool1"=true "bool2"=false`,
486491
expectJSON: `{"int1":1,"int2":2,"str1":"ABC","str2":"DEF","bool1":true,"bool2":false}`,
492+
}, {
493+
name: "pseudo structs",
494+
builtins: makeKV("int", PseudoStruct(makeKV("intsub", 1))),
495+
values: makeKV("str", PseudoStruct(makeKV("strsub", "2"))),
496+
args: makeKV("bool", PseudoStruct(makeKV("boolsub", true))),
497+
expectKV: `"int"={"intsub":1} "str"={"strsub":"2"} "bool"={"boolsub":true}`,
498+
expectJSON: `{"int":{"intsub":1},"str":{"strsub":"2"},"bool":{"boolsub":true}}`,
487499
}, {
488500
name: "missing value",
489501
builtins: makeKV("builtin"),
@@ -535,6 +547,47 @@ func TestRender(t *testing.T) {
535547
}
536548
}
537549

550+
func TestSanitize(t *testing.T) {
551+
testCases := []struct {
552+
name string
553+
kv []interface{}
554+
expect []interface{}
555+
}{{
556+
name: "empty",
557+
kv: []interface{}{},
558+
expect: []interface{}{},
559+
}, {
560+
name: "already sane",
561+
kv: makeKV("int", 1, "str", "ABC", "bool", true),
562+
expect: makeKV("int", 1, "str", "ABC", "bool", true),
563+
}, {
564+
name: "missing value",
565+
kv: makeKV("key"),
566+
expect: makeKV("key", "<no-value>"),
567+
}, {
568+
name: "non-string key int",
569+
kv: makeKV(123, "val"),
570+
expect: makeKV("<non-string-key: 123>", "val"),
571+
}, {
572+
name: "non-string key struct",
573+
kv: makeKV(struct {
574+
F1 string
575+
F2 int
576+
}{"f1", 8675309}, "val"),
577+
expect: makeKV(`<non-string-key: {"F1":"f1","F2":>`, "val"),
578+
}}
579+
580+
f := NewFormatterJSON(Options{})
581+
for _, tc := range testCases {
582+
t.Run(tc.name, func(t *testing.T) {
583+
r := f.sanitize(tc.kv)
584+
if !reflect.DeepEqual(r, tc.expect) {
585+
t.Errorf("wrong output:\nexpected %q\n got %q", tc.expect, r)
586+
}
587+
})
588+
}
589+
}
590+
538591
func TestEnabled(t *testing.T) {
539592
t.Run("default V", func(t *testing.T) {
540593
log := newSink(func(prefix, args string) {}, NewFormatter(Options{}))

0 commit comments

Comments
 (0)