Skip to content

Log an error

Errors implement slog.LogValuer, so passing one to a handler emits a group of attributes rather than a flattened string.

logger.Error("release lookup failed", "err", err)
level=ERROR msg="release lookup failed" \
  err.msg="looking up codeberg: no provider registered" \
  err.kind=forge.provider_not_found \
  err.hint="[Register one with forge.Register(), or check the spelling.]" \
  err.source_type=codeberg

Nothing is needed to switch this on. It happens because the error implements the interface slog already looks for.

What is in the group

Key From
msg err.Error()
kind the error's identity — see below
hint Hints(err), when there are any
detail Details(err), when there are any
anything else whatever you attached with WithAttrs

Empty groups are omitted, so a plain error logs as err.msg and err.kind and nothing more.

kind is the identity, not the outermost wrapper

errors.KindOf skips this package's own annotation layers — hint, detail, attributes, message, stack — and reports the first kind that says what the error is:

err := errors.WithAttrs(
    errors.WithHint(errors.WithStack(ErrProviderNotFound), "…"),
    slog.String("source_type", "codeberg"),
)

errors.KindOf(err)   // "forge.provider_not_found", not "errors.attrs"

Reporting the outermost kind would name whichever annotation happened to be applied last, which describes the plumbing rather than the failure. This is also what go/observability will report as OpenTelemetry's exception.type.

Attach the things you will search on

import "log/slog"

return errors.WithAttrs(err,
    slog.String("source_type", sourceType),
    slog.String("host", host),
    slog.Int("attempt", attempt),
)

They arrive as first-class attributes — err.host=codeberg.org — not inside a message you have to pattern-match later.

slog.Attr is used rather than a type of our own because both destinations want exactly that shape: a log record and a trace span attribute are both key/value. Choosing anything else would mean translating at each end.

The stack is not in the group

Deliberately. It is large, it is rarely what a log line is for, and including it by default makes every error log unreadable.

A handler that wants it asks:

if stack := errors.StackOf(err); stack != nil {
    logger.Debug("failure stack", "stack", stack.String())
}

StackOf returns the outermost stack — the one closest to where the error surfaced. That is also the route go/observability uses to fill OTEL's exception.stacktrace.

Verbose output for a human

For a CLI printing to a terminal rather than a log pipeline, %+v renders everything in one block, most actionable first:

fmt.Fprintf(os.Stderr, "%+v\n", err)
looking up codeberg: no provider registered
HINT: Register one with forge.Register(), or check the spelling.
source_type=codeberg
forge.Lookup
    /home/you/go/forge/registry.go:128
...