

0 / 2 embers
0 / 3000 xp
click for more info
Complete a lesson to start your streak
click for more info
Difficulty: 10
click for more info
Not enough gems
Cost: 6 gems
1: Logging Errors
incomplete
2: Stack Traces
incomplete
3: Slog Groups
incomplete
4: Handle Errors Once
incomplete
5: Adding Attributes
incomplete
6: Multiple Errors
incomplete
Back
ctrl+,
Next
ctrl+.
This lesson's interactive features are locked, please to keep using them
Remember this example code that (properly) handles the error only once?
func order(purchase Purchase) {
if err := validatePurchase(purchase); err != nil {
slog.Error("Failed to validate purchase", "error", err)
return
}
// happy path...
}
func validatePurchase(purchase Purchase) error {
for i, item := range purchase.Items {
if err := validateItem(item); err != nil {
return fmt.Errorf("failed to validate item %d (%v): %w", i, item, err)
}
}
return nil
}
The problem is that it came at a cost. We're no longer taking advantage of structured logging for item number and item details... let's fix that.
I'm a fan of building a custom error type that uses the same pattern as WithStack from github.com/pkg/errors. It lets us store extra attributes on an error and extract them later for logging. Something like this:
type errWithAttrs struct {
error
attrs []slog.Attr
}
func WithAttrs(err error, args ...any) error {
return &errWithAttrs{
error: err,
attrs: argsToAttr(args),
}
}
// argsToAttr turns a list of typed or untyped values into a slice of [slog.Attr].
// args[i] is treated as a key if it is a string or an [slog.Attr]; otherwise, it
// is treated as a value with key "!BADKEY".
func argsToAttr(args []any) []slog.Attr {
attrs := make([]slog.Attr, 0, len(args))
for i := 0; i < len(args); {
switch key := args[i].(type) {
case slog.Attr:
attrs = append(attrs, key)
i++
case string:
if i+1 >= len(args) {
attrs = append(attrs, slog.String("!BADKEY", key))
i++
} else {
attrs = append(attrs, slog.Any(key, args[i+1]))
i += 2
}
default:
attrs = append(attrs, slog.Any("!BADKEY", args[i]))
i++
}
}
return attrs
}
Now our previous example can be rewritten to use WithAttrs so we keep the structured fields!
func validatePurchase(purchase Purchase) error {
for i, item := range purchase.Items {
if err := validateItem(item); err != nil {
return WithAttrs(
fmt.Errorf("failed to validate item: %w", err),
"item_no", i,
"item", item,
)
}
}
return nil
}
The errWithAttrs type we created has an Attrs() method, and we could simply call it, but that introduces a problem: if there are multiple layers of wrapped errors, we'll only extract attributes from the outermost error. To solve that, let's add a helper that extracts all attributes from an error chain:
func (e *errWithAttrs) Unwrap() error {
return e.error
}
func (e *errWithAttrs) Attrs() []slog.Attr {
return e.attrs
}
type attrError interface {
Attrs() []slog.Attr
}
// Attrs recursively extracts all logging attributes from an error chain. In the
// case of duplicate keys, the outermost value takes precedence.
func Attrs(err error) []slog.Attr {
var attrs []slog.Attr
for err != nil {
if ae, ok := err.(attrError); ok {
attrs = append(attrs, ae.Attrs()...)
}
err = errors.Unwrap(err)
}
return attrs
}
Once you get really disciplined about structured logging and error handling, you may find yourself writing a number of log-related helpers like these. You (or your company) may want to keep them in a shared package to avoid code duplication.
Keep structured error context without reintroducing redundant logs.
Restart your server with LINKO_LOG_FILE=linko.access.log set:
LINKO_LOG_FILE=linko.access.log go run .
Run and submit the CLI tests from the root of the Linko repo.