Skip to content

Commit 7df2671

Browse files
committed
support "structured error"
A "structured error" is an error type that implements both Error and ErrorDetails. It gets logged like a normal error with "err=<Error()>", but then another "errDetails=<ErrorDetails()>" gets added to log additional information that may be stored in the error. The result of ErrorDetails is logged like any other value. Beware that rendering of structs, in particular multi-line strings in structs, is not very readable because it all gets handled by json.Marshal. This also means that unexported fields are not logged. To get nicer output of multiple values, a PseudoStruct can be returned. Those values then are formatted one-by-one by klog, which means that multi-line strings are readable.
1 parent 6121bf1 commit 7df2671

6 files changed

Lines changed: 267 additions & 0 deletions

File tree

internal/serialize/keyvalues_no_slog.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import (
2727
"github.com/go-logr/logr/funcr"
2828
)
2929

30+
type errorDetailer interface {
31+
ErrorDetails() any
32+
}
33+
3034
// KVFormat serializes one key/value pair into the provided buffer.
3135
// A space gets inserted before the pair.
3236
func (f Formatter) KVFormat(b *bytes.Buffer, k, v interface{}) string {
@@ -62,6 +66,11 @@ func (f Formatter) KVFormat(b *bytes.Buffer, k, v interface{}) string {
6266
writeStringValue(b, v)
6367
case error:
6468
writeStringValue(b, ErrorToString(v))
69+
// It might provide additional details.
70+
if v, ok := v.(errorDetailer); ok {
71+
value := v.ErrorDetails()
72+
f.FormatKVs(b, []any{key + "Details", value})
73+
}
6574
case logr.Marshaler:
6675
value := MarshalerToValue(v)
6776
// A marshaler that returns a string is useful for

internal/serialize/keyvalues_slog.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import (
2929
"github.com/go-logr/logr/funcr"
3030
)
3131

32+
type errorDetailer interface {
33+
ErrorDetails() any
34+
}
35+
3236
// KVFormat serializes one key/value pair into the provided buffer.
3337
// A space gets inserted before the pair. It returns the key.
3438
func (f Formatter) KVFormat(b *bytes.Buffer, k, v interface{}) string {
@@ -72,6 +76,11 @@ func (f Formatter) KVFormat(b *bytes.Buffer, k, v interface{}) string {
7276
writeStringValue(b, v)
7377
case error:
7478
writeStringValue(b, ErrorToString(v))
79+
// It might provide additional details.
80+
if v, ok := v.(errorDetailer); ok {
81+
value := v.ErrorDetails()
82+
f.FormatKVs(b, []any{key + "Details", value})
83+
}
7584
case logr.Marshaler:
7685
value := MarshalerToValue(v)
7786
// A marshaler that returns a string is useful for

structured_error.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
Copyright The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package klog
18+
19+
import "slices"
20+
21+
// ErrorDetailer provides additional information about an error.
22+
// When an error value implements this additional interface,
23+
// the result of ErrorDetails will be logged in a separate key/value
24+
// pair. The result of Error is logged as usual.
25+
//
26+
// In Kubernetes, text and JSON output backends (aka klog and zapr)
27+
// will support this with "<error key>Details" (typically "errDetails")
28+
// as key for the additional value.
29+
//
30+
// Other backends might not support this, so all relevant information
31+
// should be in the error string.
32+
type ErrorDetailer interface {
33+
ErrorDetails() any
34+
}
35+
36+
// ErrorWithDetails adds additional details to an error for logging.
37+
// If the base error already has such additional details, they
38+
// will be included in a list of details.
39+
//
40+
// A [PseudoStruct] can be used to log some key/value pairs as
41+
// if they were in a struct, without having to define such a struct.
42+
// The formatting may be nicer, too.
43+
func ErrorWithDetails(err error, details any) error {
44+
// This could be implemented as ErrorWithDetailsFunc(err, func() { return details }),
45+
// but having the details visible in the error instance may be more useful for
46+
// interactive debugging.
47+
return &errWithDetails{err, details}
48+
}
49+
50+
type errWithDetails struct {
51+
error
52+
details any
53+
}
54+
55+
var _ error = &errWithDetails{}
56+
var _ ErrorDetailer = &errWithDetails{}
57+
58+
func (err *errWithDetails) ErrorDetails() any {
59+
if base, ok := err.error.(ErrorDetailer); ok {
60+
baseDetails := base.ErrorDetails()
61+
if baseDetailsList, ok := baseDetails.([]any); ok {
62+
// Flatten the list.
63+
return append(slices.Clone(baseDetailsList), err.details)
64+
}
65+
// Use a pair of values in a slice which gets detected above when nesting multiple times.
66+
return []any{baseDetails, err.details}
67+
}
68+
return err.details
69+
}
70+
71+
// ErrorWithDetailsFunc adds additional details to an error for logging.
72+
// In contrast to [ErrorWithDetails], the additional details are provided
73+
// by the given function, which will be called only when needed. This
74+
// can be used to avoid building some potentially expensive data structure
75+
// that will not be needed when the error does not get logged.
76+
//
77+
// If the base error already has such additional details, they
78+
// will be included in a list of details.
79+
//
80+
// A [PseudoStruct] can be used to log some key/value pairs as
81+
// if they were in a struct, without having to define such a struct.
82+
// The formatting may be nicer, too.
83+
func ErrorWithDetailsFunc(err error, details func() any) error {
84+
return &errWithDetailsFunc{err, details}
85+
}
86+
87+
type errWithDetailsFunc struct {
88+
error
89+
details func() any
90+
}
91+
92+
var _ error = &errWithDetailsFunc{}
93+
var _ ErrorDetailer = &errWithDetailsFunc{}
94+
95+
func (err *errWithDetailsFunc) ErrorDetails() any {
96+
if base, ok := err.error.(ErrorDetailer); ok {
97+
baseDetails := base.ErrorDetails()
98+
if baseDetailsList, ok := baseDetails.([]any); ok {
99+
// Flatten the list.
100+
return append(slices.Clone(baseDetailsList), err.details())
101+
}
102+
// Use a pair of values in a slice which gets detected above when nesting multiple times.
103+
return []any{baseDetails, err.details()}
104+
}
105+
return err.details()
106+
}

structured_error_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
Copyright The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package klog
18+
19+
import (
20+
"errors"
21+
"reflect"
22+
"testing"
23+
)
24+
25+
func TestErrorDetails(t *testing.T) {
26+
base := errors.New("base")
27+
28+
for name, tc := range map[string]struct {
29+
err error
30+
expectErrorString string
31+
expectErrorDetails any
32+
}{
33+
"simple": {ErrorWithDetails(base, 42), "base", 42},
34+
"pair": {ErrorWithDetails(ErrorWithDetails(base, "hello"), "world"), "base", []any{"hello", "world"}},
35+
"nested": {ErrorWithDetails(ErrorWithDetails(ErrorWithDetails(base, "hello"), "world"), "thanks"), "base", []any{"hello", "world", "thanks"}},
36+
37+
"simple-func": {ErrorWithDetailsFunc(base, func() any { return 42 }), "base", 42},
38+
"pair-func": {ErrorWithDetailsFunc(ErrorWithDetails(base, "hello"), func() any { return "world" }), "base", []any{"hello", "world"}},
39+
"nested-func": {ErrorWithDetailsFunc(ErrorWithDetails(ErrorWithDetails(base, "hello"), "world"), func() any { return "thanks" }), "base", []any{"hello", "world", "thanks"}},
40+
} {
41+
t.Run(name, func(t *testing.T) {
42+
if actual, expect := tc.err.Error(), tc.expectErrorString; actual != expect {
43+
t.Errorf("expected error string %q, got %q", expect, actual)
44+
}
45+
if actual, expect := tc.err.(ErrorDetailer).ErrorDetails(), tc.expectErrorDetails; !reflect.DeepEqual(actual, expect) {
46+
t.Errorf("expected error details %#v, got %#v", expect, actual)
47+
}
48+
})
49+
}
50+
}

test/output.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"time"
3434

3535
"github.com/go-logr/logr"
36+
"github.com/go-logr/logr/funcr"
3637

3738
"k8s.io/klog/v2"
3839
"k8s.io/klog/v2/textlogger"
@@ -408,6 +409,36 @@ I output.go:<LINE>] "test" firstKey=1 secondKey=3
408409
text: "structs",
409410
values: []interface{}{"s", struct{ Name, Kind, hidden string }{Name: "worker", Kind: "pod", hidden: "ignore"}},
410411
expectedOutput: `I output.go:<LINE>] "structs" s={"Name":"worker","Kind":"pod"}
412+
`,
413+
},
414+
"structured error": {
415+
text: "structured error",
416+
err: structuredError{error: errors.New("fake error"), details: funcr.PseudoStruct{"x", 1, "y", "multi-line\nstring"}},
417+
expectedOutput: `E output.go:<LINE>] "structured error" err="fake error" errDetails={ x=1 y=<
418+
multi-line
419+
string
420+
> }
421+
`,
422+
},
423+
"structured error value": {
424+
text: "structured error",
425+
values: []interface{}{"someErr", structuredError{error: errors.New("fake error"), details: funcr.PseudoStruct{"x", 1, "y", "multi-line\nstring"}}},
426+
expectedOutput: `I output.go:<LINE>] "structured error" someErr="fake error" someErrDetails={ x=1 y=<
427+
multi-line
428+
string
429+
> }
430+
`,
431+
},
432+
"my structured error": {
433+
text: "my structured error",
434+
err: myStructuredError{error: errors.New("fake error"), details: myStructuredErrorDetails{1, "multi-line\nstring", 3.1}},
435+
expectedOutput: `E output.go:<LINE>] "my structured error" err="fake error" errDetails={"SomeInt":1,"SomeString":"multi-line\nstring"}
436+
`,
437+
},
438+
"my structured error value": {
439+
text: "my structured error",
440+
values: []interface{}{"someErr", myStructuredError{error: errors.New("fake error"), details: myStructuredErrorDetails{1, "multi-line\nstring", 3.1}}},
441+
expectedOutput: `I output.go:<LINE>] "my structured error" someErr="fake error" someErrDetails={"SomeInt":1,"SomeString":"multi-line\nstring"}
411442
`,
412443
},
413444
"PseudoStruct": {
@@ -1110,3 +1141,38 @@ func traceIDFromHex(str string) TraceID {
11101141
}
11111142
return result
11121143
}
1144+
1145+
// Structured error with additional values that are not part of the message
1146+
// returned by Error().
1147+
type structuredError struct {
1148+
error
1149+
details klog.PseudoStruct
1150+
}
1151+
1152+
// ErrorDetails gets called in addition to Error function to
1153+
// log additional information.
1154+
func (err structuredError) ErrorDetails() interface{} {
1155+
return err.details
1156+
}
1157+
1158+
var _ error = structuredError{}
1159+
var _ klog.ErrorDetailer = structuredError{}
1160+
1161+
// myStructuredError is a variant of structuredError where details are a struct.
1162+
type myStructuredError struct {
1163+
error
1164+
details myStructuredErrorDetails
1165+
}
1166+
1167+
func (err myStructuredError) ErrorDetails() interface{} {
1168+
return err.details
1169+
}
1170+
1171+
var _ error = myStructuredError{}
1172+
var _ klog.ErrorDetailer = myStructuredError{}
1173+
1174+
type myStructuredErrorDetails struct {
1175+
SomeInt int
1176+
SomeString string
1177+
hiddenFloat float64
1178+
}

test/zapr.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,33 @@ I output.go:<LINE>] "duplicates" trace="101112131415161718191a1b1c1d1e1f" a=1 c=
289289
def
290290
> }
291291
`: `{"caller":"test/output.go:<LINE>","msg":"keys and values","v":0,"parent":["boolsub",true,"intsub",1,"recursive",["sub","level2"],"multiLine","abc\ndef"]}
292+
`,
293+
294+
// Error values are rendered *only* with the result of MarshalLog by zapr.
295+
// This is problematic because this is not how "structured error" is defined!
296+
297+
// Errors are rendered without details by zapr.
298+
// This is okay, they were meant to be optional.
299+
`I output.go:<LINE>] "structured error" someErr="fake error" someErrDetails={ x=1 y=<
300+
multi-line
301+
string
302+
> }
303+
`: `{"caller":"test/output.go:<LINE>","msg":"structured error","v":0,"someErr":"fake error"}
304+
`,
305+
306+
`I output.go:<LINE>] "my structured error" someErr="fake error" someErrDetails={"SomeInt":1,"SomeString":"multi-line\nstring"}
307+
`: `{"caller":"test/output.go:<LINE>","msg":"my structured error","v":0,"someErr":"fake error"}
308+
`,
309+
310+
`E output.go:<LINE>] "structured error" err="fake error" errDetails={ x=1 y=<
311+
multi-line
312+
string
313+
> }
314+
`: `{"caller":"test/output.go:<LINE>","msg":"structured error","err":"fake error"}
315+
`,
316+
317+
`E output.go:<LINE>] "my structured error" err="fake error" errDetails={"SomeInt":1,"SomeString":"multi-line\nstring"}
318+
`: `{"caller":"test/output.go:<LINE>","msg":"my structured error","err":"fake error"}
292319
`,
293320
}
294321
}

0 commit comments

Comments
 (0)