Skip to content

Commit 6bb1754

Browse files
committed
support "structured error"
A "structured error" is an error which implements error and slog.LogValuer. When encountering such an error, it gets logged normally and then another field gets added with the result of LogValue(). The name of the extra field is the original field name plus a configurable suffix, with "Details" as default. The extra details are logged as if they had been passed as a value to zapr. slog.Value.Resolve is used to protect against errors and recursion while calling LogValue, but does not protect against recursion that can occur when LogValue returns the original error: then zapIt->zapError->zapIt->... repeats until the program gets killed. A simple guard against this (not expanding error again while formatting an error) is too simplistic and would prevent nice rendering of a wrapped error that might get returned by MarshalLog. zapIt would have to track the exact error instance and detect when it gets the same value again.
1 parent 78b8af5 commit 6bb1754

File tree

8 files changed

+111
-22
lines changed

8 files changed

+111
-22
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,11 @@ The zapr `logr.LogSink` implementation also implements `logr.SlogHandler`. That
105105
enables `slogr.NewSlogHandler` to provide a `slog.Handler` which just passes
106106
parameters through to zapr. zapr handles special slog values (Group,
107107
LogValuer), regardless of which front-end API is used.
108+
109+
The value logged for errors is the string returned by `error.Error`.
110+
If the error also implements `slog.LogValuer`, then it is treated as a
111+
"structured error" and the value returned by `slog.LogValuer.LogValue` is
112+
logged *in addition* to the error string, using a key that is the original
113+
key plus a "Details" suffix. For example, `"err", err` will be logged as if
114+
`"err", err.Error(), "errDetails", err.LogValue()` has been passed. This
115+
ensures that the "err" key is always associated with a string.

slogzapr.go

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func (zl *zapLogger) Handle(_ context.Context, record slog.Record) error {
6060
// Inline all attributes.
6161
fields = append(fields, zap.Inline(zapcore.ObjectMarshalerFunc(func(enc zapcore.ObjectEncoder) error {
6262
record.Attrs(func(attr slog.Attr) bool {
63-
encodeSlog(enc, attr)
63+
zl.encodeSlog(enc, attr)
6464
return true
6565
})
6666
return nil
@@ -70,7 +70,7 @@ func (zl *zapLogger) Handle(_ context.Context, record slog.Record) error {
7070
return nil
7171
}
7272

73-
func encodeSlog(enc zapcore.ObjectEncoder, attr slog.Attr) {
73+
func (zl *zapLogger) encodeSlog(enc zapcore.ObjectEncoder, attr slog.Attr) {
7474
if attr.Equal(slog.Attr{}) {
7575
// Ignore empty attribute.
7676
return
@@ -102,8 +102,14 @@ func encodeSlog(enc zapcore.ObjectEncoder, attr slog.Attr) {
102102
case slog.KindString:
103103
enc.AddString(attr.Key, attr.Value.String())
104104
case slog.KindLogValuer:
105+
// Structured error?
106+
if err, ok := attr.Value.Any().(error); ok {
107+
zl.zapError(attr.Key, err).AddTo(enc)
108+
return
109+
}
110+
105111
// This includes klog.KObj.
106-
encodeSlog(enc, slog.Attr{
112+
zl.encodeSlog(enc, slog.Attr{
107113
Key: attr.Key,
108114
Value: attr.Value.Resolve(),
109115
})
@@ -124,32 +130,35 @@ func encodeSlog(enc zapcore.ObjectEncoder, attr slog.Attr) {
124130
if attr.Key == "" {
125131
// Inline group.
126132
for _, attr := range attrs {
127-
encodeSlog(enc, attr)
133+
zl.encodeSlog(enc, attr)
128134
}
129135
return
130136
}
131137
if len(attrs) == 0 {
132138
// Ignore empty group.
133139
return
134140
}
135-
_ = enc.AddObject(attr.Key, marshalAttrs(attrs))
141+
_ = enc.AddObject(attr.Key, marshalAttrs{zl: zl, attrs: attrs})
136142
default:
137143
// We have to go through reflection in zap.Any to get support
138144
// for e.g. fmt.Stringer.
139145
zap.Any(attr.Key, attr.Value.Any()).AddTo(enc)
140146
}
141147
}
142148

143-
type marshalAttrs []slog.Attr
149+
type marshalAttrs struct {
150+
zl *zapLogger
151+
attrs []slog.Attr
152+
}
144153

145-
func (attrs marshalAttrs) MarshalLogObject(enc zapcore.ObjectEncoder) error {
146-
for _, attr := range attrs {
147-
encodeSlog(enc, attr)
154+
func (m marshalAttrs) MarshalLogObject(enc zapcore.ObjectEncoder) error {
155+
for _, attr := range m.attrs {
156+
m.zl.encodeSlog(enc, attr)
148157
}
149158
return nil
150159
}
151160

152-
var _ zapcore.ObjectMarshaler = marshalAttrs(nil)
161+
var _ zapcore.ObjectMarshaler = marshalAttrs{}
153162

154163
func pcToCallerEntry(pc uintptr) zapcore.EntryCaller {
155164
if pc == 0 {
@@ -172,7 +181,7 @@ func pcToCallerEntry(pc uintptr) zapcore.EntryCaller {
172181

173182
func (zl *zapLogger) WithAttrs(attrs []slog.Attr) slogr.SlogSink {
174183
newLogger := *zl
175-
newLogger.l = newLogger.l.With(zap.Inline(marshalAttrs(attrs)))
184+
newLogger.l = newLogger.l.With(zap.Inline(marshalAttrs{zl: zl, attrs: attrs}))
176185
return &newLogger
177186
}
178187

zapr.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ type zapLogger struct {
8181
// Logger.Error calls.
8282
errorKey string
8383

84+
// errorKeyDetailsSuffix gets appended to the field name
85+
// when logging additional details obtained via MarshalLog.
86+
errorKeyDetailsSuffix string
87+
8488
// allowZapFields enables logging of strongly-typed Zap
8589
// fields. It is off by default because it breaks
8690
// implementation agnosticism.
@@ -136,15 +140,15 @@ func (zl *zapLogger) handleFields(lvl int, args []interface{}, additional ...zap
136140
continue
137141
}
138142
if zl.panicMessages {
139-
zl.l.WithOptions(zap.AddCallerSkip(1)).DPanic("strongly-typed Zap Field passed to logr", zapIt("zap field", args[i]))
143+
zl.l.WithOptions(zap.AddCallerSkip(1)).DPanic("strongly-typed Zap Field passed to logr", zl.zapIt("zap field", args[i]))
140144
}
141145
break
142146
}
143147

144148
// make sure this isn't a mismatched key
145149
if i == len(args)-1 {
146150
if zl.panicMessages {
147-
zl.l.WithOptions(zap.AddCallerSkip(1)).DPanic("odd number of arguments passed as key-value pairs for logging", zapIt("ignored key", args[i]))
151+
zl.l.WithOptions(zap.AddCallerSkip(1)).DPanic("odd number of arguments passed as key-value pairs for logging", zl.zapIt("ignored key", args[i]))
148152
}
149153
break
150154
}
@@ -156,12 +160,12 @@ func (zl *zapLogger) handleFields(lvl int, args []interface{}, additional ...zap
156160
if !isString {
157161
// if the key isn't a string, DPanic and stop logging
158162
if zl.panicMessages {
159-
zl.l.WithOptions(zap.AddCallerSkip(1)).DPanic("non-string key argument passed to logging, ignoring all later arguments", zapIt("invalid key", key))
163+
zl.l.WithOptions(zap.AddCallerSkip(1)).DPanic("non-string key argument passed to logging, ignoring all later arguments", zl.zapIt("invalid key", key))
160164
}
161165
break
162166
}
163167

164-
fields = append(fields, zapIt(keyStr, val))
168+
fields = append(fields, zl.zapIt(keyStr, val))
165169
i += 2
166170
}
167171

@@ -204,7 +208,7 @@ func (zl *zapLogger) Info(lvl int, msg string, keysAndVals ...interface{}) {
204208

205209
func (zl *zapLogger) Error(err error, msg string, keysAndVals ...interface{}) {
206210
if checkedEntry := zl.l.Check(zap.ErrorLevel, msg); checkedEntry != nil {
207-
checkedEntry.Write(zl.handleFields(noLevel, keysAndVals, zap.NamedError(zl.errorKey, err))...)
211+
checkedEntry.Write(zl.handleFields(noLevel, keysAndVals, zl.zapError(zl.errorKey, err))...)
208212
}
209213
}
210214

@@ -252,6 +256,7 @@ func NewLoggerWithOptions(l *zap.Logger, opts ...Option) logr.Logger {
252256
l: log,
253257
}
254258
zl.errorKey = "error"
259+
zl.errorKeyDetailsSuffix = "Details"
255260
zl.panicMessages = true
256261
for _, option := range opts {
257262
option(zl)
@@ -281,6 +286,15 @@ func ErrorKey(key string) Option {
281286
}
282287
}
283288

289+
// ErrorKeyDetailsSuffix replaces the default "Details" suffix that gets
290+
// appended to the field name for an error when logging the error details
291+
// obtained through MarshalLog.
292+
func ErrorKeyDetailsSuffix(key string) Option {
293+
return func(zl *zapLogger) {
294+
zl.errorKeyDetailsSuffix = key
295+
}
296+
}
297+
284298
// AllowZapFields controls whether strongly-typed Zap fields may
285299
// be passed instead of a key/value pair. This is disabled by
286300
// default because it breaks implementation agnosticism.

zapr_noslog.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ import (
2424
"go.uber.org/zap"
2525
)
2626

27-
func zapIt(field string, val interface{}) zap.Field {
27+
func (zl *zapLogger) zapIt(field string, val interface{}) zap.Field {
2828
// Handle types that implement logr.Marshaler: log the replacement
2929
// object instead of the original one.
3030
if marshaler, ok := val.(logr.Marshaler); ok {
3131
field, val = invokeMarshaler(field, marshaler)
3232
}
3333
return zap.Any(field, val)
3434
}
35+
36+
func (zl *zapLogger) zapError(field string, err error) zap.Field {
37+
return zap.NamedError(field, err)
38+
}

zapr_noslog_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,7 @@ func slogValuer(value interface{}) interface{} {
6161

6262
func logWithSlog(_ logr.Logger, _ string, _, _ []interface{}) {
6363
}
64+
65+
func slogStructuredError() error {
66+
return nil
67+
}

zapr_slog.go

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ import (
2727
"go.uber.org/zap/zapcore"
2828
)
2929

30-
func zapIt(field string, val interface{}) zap.Field {
30+
func (zl *zapLogger) zapIt(field string, val interface{}) zap.Field {
3131
switch valTyped := val.(type) {
32+
case error:
33+
return zl.zapError(field, valTyped)
3234
case logr.Marshaler:
3335
// Handle types that implement logr.Marshaler: log the replacement
3436
// object instead of the original one.
@@ -39,10 +41,33 @@ func zapIt(field string, val interface{}) zap.Field {
3941
val = slog.AnyValue(val).Resolve()
4042
}
4143
if slogValue, ok := val.(slog.Value); ok {
42-
return zap.Inline(zapcore.ObjectMarshalerFunc(func(enc zapcore.ObjectEncoder) error {
43-
encodeSlog(enc, slog.Attr{Key: field, Value: slogValue})
44-
return nil
45-
}))
44+
return zl.zapInline(field, slogValue)
4645
}
4746
return zap.Any(field, val)
4847
}
48+
49+
func (zl *zapLogger) zapError(field string, err error) zap.Field {
50+
if err == nil {
51+
return zap.Skip()
52+
}
53+
return zap.Inline(zapcore.ObjectMarshalerFunc(func(encoder zapcore.ObjectEncoder) error {
54+
// Always log as a normal error first.
55+
zap.NamedError(field, err).AddTo(encoder)
56+
57+
// Extra details are optional, but might be available if the error also
58+
// implements slog.LogValuer.
59+
if valuer, ok := err.(slog.LogValuer); ok {
60+
value := slog.AnyValue(valuer)
61+
value = value.Resolve()
62+
zl.zapInline(field+zl.errorKeyDetailsSuffix, value).AddTo(encoder)
63+
}
64+
return nil
65+
}))
66+
}
67+
68+
func (zl *zapLogger) zapInline(key string, value slog.Value) zap.Field {
69+
return zap.Inline(zapcore.ObjectMarshalerFunc(func(enc zapcore.ObjectEncoder) error {
70+
zl.encodeSlog(enc, slog.Attr{Key: key, Value: value})
71+
return nil
72+
}))
73+
}

zapr_slog_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,21 @@ func logWithSlog(l logr.Logger, msg string, withKeysValues, keysValues []interfa
7777
}
7878
logger.Info(msg, keysValues...)
7979
}
80+
81+
func slogStructuredError() error {
82+
return structuredError{}
83+
}
84+
85+
type structuredError struct {
86+
}
87+
88+
var _ error = structuredError{}
89+
var _ slog.LogValuer = structuredError{}
90+
91+
func (err structuredError) Error() string {
92+
return "hello world"
93+
}
94+
95+
func (err structuredError) LogValue() slog.Value {
96+
return slog.GroupValue(slog.Int("answer", 42), slog.String("thanks", "fish"))
97+
}

zapr_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,13 @@ func TestInfo(t *testing.T) {
270270
keysValues: []interface{}{slogGroup("obj", slogInt("int", 1), slogString("string", "hello"))},
271271
needSlog: true,
272272
},
273+
{
274+
msg: "structured error",
275+
formatSlog: `{"ts":%f,"caller":"zapr/zapr_test.go:%d","msg":"structured error","v":0,"myerr":"hello world","myerrDetails":{"answer":42,"thanks":"fish"}}
276+
`,
277+
keysValues: []interface{}{"myerr", slogStructuredError()},
278+
needSlog: true,
279+
},
273280
}
274281

275282
test := func(t *testing.T, logNumeric *string, enablePanics *bool, allowZapFields *bool, useSlog bool, data testCase) {

0 commit comments

Comments
 (0)