-
Notifications
You must be signed in to change notification settings - Fork 1.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
WrapError: wrap an error with fields to be logged by zap.Error #1271
base: master
Are you sure you want to change the base?
Conversation
eb82ae4
to
ec5af52
Compare
fields_error.go
Outdated
return e.err.Error() | ||
} | ||
|
||
func (e errorWithFields) MarshalLogObject(oe zapcore.ObjectEncoder) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using the same interface as ObjectMarshaler
makes it easy to integrate in an efficient way. I'm open to using a separate interface, but then adding it as a nested object will require an additional allocation to wrap the custom interface into an ObjectMarshaler
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's fine to use implement ObjectMarshaler
directly, yeah.
That said, I think it might be nicer to do
func WrappedError(err error, fields ...Field) Field {
return zap.Error(&errorWithFields{
err: err,
fields: fields,
})
}
primarily for ergonomics, because (1) that's what you're going to be doing with it anyway and (2) it probably makes more sense as a Field
func rather than as something you pass to a Field
func (since the only practical candidate is Error
).
(pedantry: I chose WrappedError
because none of the other Field
func names are/have verbs)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we want the return to be an error
to match the general usage of this -- the method that is adding fields currently returns an error, and the signature shouldn't change, this just adds some fields to be logged as part of the error log.
I've added an example to show this that should showcase it a little more clearly.
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## master #1271 +/- ##
==========================================
+ Coverage 98.08% 98.11% +0.02%
==========================================
Files 50 51 +1
Lines 3240 3287 +47
==========================================
+ Hits 3178 3225 +47
Misses 53 53
Partials 9 9 ☔ View full report in Codecov by Sentry. |
fields_error.go
Outdated
@@ -0,0 +1,54 @@ | |||
// Copyright (c) 2023 Uber Technologies, Inc. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice assignment.
is the receiver able to unpack the args? |
Related to uber-go/guide#179 Callsites that receive an error should either log, or return an error. However, if the callsite has additioanl context, the simplest option is to add it to the error, but it's then flattened into a string, losing the benefit of structured logging. This often results in callsites logging with additional fields, and returning an error that is likely to be logged again. `WrapError` provides a way for callsites to return an error that includes fields to be logged, which will be added to an `errorFields` key.
I didn't have use-cases that required unpacking, but open to it if there's a strong use-case for it. It's also something that can be added in the future if required, so I'd rather start without. |
These are frequently nested. Irrespective of whether it's supported or not, perhaps it's worth calling out in the documentation and adding a test? I also wondered if a syntax like:
wouldn't be more natural since you can both wrap and extend the error message? I do typically extend the error message in each layer. |
29186ef
to
8ce30df
Compare
Good call on nesting, I've added nesting support -- supporting multiple
We end up with 2 varargs (args to the format string, and fields). Having a single varargs where a subset is format args, and others are field hides the expectations from the type system which doesn't seem worth it for saving a few characters. |
fields_error_test.go
Outdated
}, enc.Fields) | ||
|
||
wrap3 := fmt.Errorf("wrap3: %w", wrap2) | ||
wrap4 := WrapError(wrap3, Bool("wrap4", true)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what about a test case for when names overlap? It seems worthwhile capturing the behavior explicitly.
you probably considered creating a new namespace for each error, but dismissed it as too slow/complicated?
(and then, naturally, I started thinking if configuring the behavior is worth it, but it increases the verbosity a lot. I guess we can always add WrappErrorNamespaced
if people really want it?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, added a test where names overlap. In the JSON encoder, this will have duplicate keys (same as logger.With
, but the map encoder has last-write-wins. What's interesting though is that we add the fields in unwrap order, so the most recently added field is overwritten.
Re: nesting, yeah I don't like the verbosity or performance.
Oh fair, I imagined we'd only let people do a single string, without the "error vararg". I guess Two more comments (I apparently cannot comment on non-touched lines in github?). Wanna:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! LGTM modulo the actual function signature.
fields_error.go
Outdated
return e.err.Error() | ||
} | ||
|
||
func (e errorWithFields) MarshalLogObject(oe zapcore.ObjectEncoder) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's fine to use implement ObjectMarshaler
directly, yeah.
That said, I think it might be nicer to do
func WrappedError(err error, fields ...Field) Field {
return zap.Error(&errorWithFields{
err: err,
fields: fields,
})
}
primarily for ergonomics, because (1) that's what you're going to be doing with it anyway and (2) it probably makes more sense as a Field
func rather than as something you pass to a Field
func (since the only practical candidate is Error
).
(pedantry: I chose WrappedError
because none of the other Field
func names are/have verbs)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated, thanks for the reviews!
fields_error_test.go
Outdated
}, enc.Fields) | ||
|
||
wrap3 := fmt.Errorf("wrap3: %w", wrap2) | ||
wrap4 := WrapError(wrap3, Bool("wrap4", true)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, added a test where names overlap. In the JSON encoder, this will have duplicate keys (same as logger.With
, but the map encoder has last-write-wins. What's interesting though is that we add the fields in unwrap order, so the most recently added field is overwritten.
Re: nesting, yeah I don't like the verbosity or performance.
fields_error.go
Outdated
return e.err.Error() | ||
} | ||
|
||
func (e errorWithFields) MarshalLogObject(oe zapcore.ObjectEncoder) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we want the return to be an error
to match the general usage of this -- the method that is adding fields currently returns an error, and the signature shouldn't change, this just adds some fields to be logged as part of the error log.
I've added an example to show this that should showcase it a little more clearly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// fields are added in Unwrap order, and last added field wins in the map encoder | ||
// which is the first field added. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just to do the sanity-check once again - are we sure this is okay? it feels weird.
one possibly crazy idea - I guess we cannot detect a conflict somehow make it visible? Hm.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The map encoder overwrites, the JSON encoder adds the duplicate fields. This is the behaviour of logger.With
as well, see #622
This is one of the tradeoffs for performance, though there's some ideas in the issue for how we could detect this, which should apply to all cases of duplicated fields.
|
||
// Output: | ||
// {"level":"error","msg":"parse empty","error":"line not in expected format"} | ||
// {"level":"error","msg":"parse invalid duration","error":"parse writeTimeout: time: missing unit in duration \"45\"","errorFields":{"writeTimeout":"45"}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LG:
{
"level": "error",
"msg": "parse invalid duration",
"error": "parse writeTimeout: time: missing unit in duration \"45\"",
"errorFields": {
"writeTimeout": "45"
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. Mostly LGTM with minor comments, however I have one last minute thought about the API:
Under the current design, WrapError will not be able to include additional context to the error object itself. So users will either do the following, and add no context:
return WrapError(err, zap.String("user", user))
Or the following, where they always do a fmt.Errorf:
return WrapError(fmt.Errorf("load: %w", err), zap.String("user", user))
They may also accidentally duplicate information because the fmt.Errorf is already there:
return WrapError(fmt.Errorf("load user %q: %w", user, err), zap.String("user", user))
What do you think about modifying -- or in a future change, adding a variant that takes a positional static msg string?
func WrapError(desc string, err error, fields ...zap.Field) error
return WrapError("load user", err, zap.String("user", user))
// equivalent to fmt.Errorf with just one wrapped error:
return WrapError(fmt.Errorf("load user: %w", err), zap.String("user", user))
I'm happy to defer this to a later change, but I have a feeling that adding context to the error is going to be a more frequent use case than not.
(Apologies for not bringing this up sooner.)
func (e errorWithFields) Unwrap() error { | ||
return e.err | ||
} | ||
|
||
func (e errorWithFields) Error() string { | ||
return e.err.Error() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason to implement these receivers on the value instead of pointer, given that WrapError returns a pointer?
return timeouts{}, zap.WrapError(fmt.Errorf("parse readTimeout: %w", err), | ||
zap.String("readTimeout", parts[0])) | ||
} | ||
|
||
write, err := time.ParseDuration(parts[1]) | ||
if err != nil { | ||
return timeouts{}, zap.WrapError(fmt.Errorf("parse writeTimeout: %w", err), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because the example will be used as reference, maybe we should reduce redundancy in the message here. The log message already says we're parsing a timeout, so maybe just the fields will be enough here.
return timeouts{}, zap.WrapError(fmt.Errorf("parse readTimeout: %w", err), | |
zap.String("readTimeout", parts[0])) | |
} | |
write, err := time.ParseDuration(parts[1]) | |
if err != nil { | |
return timeouts{}, zap.WrapError(fmt.Errorf("parse writeTimeout: %w", err), | |
return timeouts{}, zap.WrapError(err, zap.String("readTimeout", parts[0])) | |
} | |
write, err := time.ParseDuration(parts[1]) | |
if err != nil { | |
return timeouts{}, zap.WrapError(err, |
Theoretically, if there was a caller to this function before the log, it would add the parseTimeouts:
prefix, and that would also make it to the log.
// WrapError returns an error that will log the provided fields if the error | ||
// is logged using `Error`. | ||
func WrapError(err error, fields ...Field) error { | ||
return &errorWithFields{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// WrapError returns an error that will log the provided fields if the error | |
// is logged using `Error`. | |
func WrapError(err error, fields ...Field) error { | |
return &errorWithFields{ | |
// WrapError returns an error that, when logged with [Error] or [NamedError], | |
// will add the specified fields to the log entry. | |
// | |
// For example, given: | |
// | |
// func NewUser(uid string) (*User, error) { | |
// if !isValid(uid) { | |
// return nil, zap.WrapError(ErrInvalidUserID, zap.String("uid", uid)) | |
// } | |
// // ... | |
// } | |
// | |
// The following will log the error message, as well as the 'uid' field. | |
// | |
// u, err := NewUser(uid) | |
// if err != nil { | |
// log.Warn("Unable to create user", zap.Error(err)) | |
// } | |
func WrapError(err error, fields ...Field) error { | |
return &errorWithFields{ |
(I wanted to clarify that 'Error' refers to the field constructor, not the Logger.Error method and given that it's a new thing, more details on what it does and how to use it.)
assert.True(t, errors.Is(wrap2, rootErr), "errors.Is") | ||
assert.True(t, errors.Is(wrap2, wrap1), "errors.Is") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
optional: assert.ErrorIs
assert.True(t, errors.Is(wrap2, rootErr), "errors.Is") | |
assert.True(t, errors.Is(wrap2, wrap1), "errors.Is") | |
assert.ErrorIs(t, wrap2, rootErr, "errors.Is") | |
assert.ErrorIs(t, wrap2, wrap1, "errors.Is") |
|
||
// WrapError returns an error that will log the provided fields if the error | ||
// is logged using `Error`. | ||
func WrapError(err error, fields ...Field) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
optional: do we want to return nil if error is nil?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think we should return nil
in that case.
LGTM modulo returning nil if the input error is also nil. I'm mixed on this change because I think it can:
but I can appreciate the value of enabling errors to be annotated without requiring users to define discrete error types IFF all they need to do with error metadata is log it. The above concerns are why I had suggested producing a My only practical concern right now is if this becomes "a common way to return errors from a function", that it might get expensive. Can you paste benchmark output (unless I missed it somewhere)? |
Related to uber-go/guide#179
Callsites that receive an error should either log, or return an error.
However, if the callsite has additioanl context, the simplest option is to add it to the error, but it's then flattened into a string, losing the benefit of structured logging. This often results in callsites logging with additional fields, and returning an error that is likely to be logged again.
WrapError
provides a way for callsites to return an error that includes fields to be logged, which will be added to anerrorFields
key.