We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.

This lesson's interactive features are locked, please to keep using them

Adding Attributes

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
}

Extracting the Attributes

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.

Assignment

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.