Proposal Details

I propose that log/slog introduces a function to create an error from a string message and zero or more slog.Attr arguments. The type, implementing the error interface, would also implement the LogValuer one as well, in order to render the error as an attribute group when logged.

Rationale

An error, that acts as an attribute group when used with slog can currently be created by a third party. There are no known problems that should prevent this from happening. The only reason this error should exist in the standard library is adoption.

As a third-party dependency, one's own code and errors can benefit from such a structured error representation. However, the errors produced by dependencies would not. They would also have to depend on such a third-party module as well.

If this functionality would be introduced into the standard library, the above scenario would not necessarily be different. However, it is more likely that more modules would embrace it as it is provided by the language itself. I believe adoption would be significantly faster because of this.

Finally, such an addition would not be unprecedented. Such a function should be as familiar as the way we currently create formatted errors, through the fmt.Errorf function.

Language change

I proposal the following code, or one that achieves similar results to be added to the log/slog package:

package slog

import (
    "strings"
)

const CauseKey = "cause"

type serror struct {
    msg   string
    err   error
    attrs []Attr
}

// Error implements error.
func (s serror) Error() string {
    var b strings.Builder

    _, _ = b.WriteString(s.msg)

    if s.err != nil {
        _ = b.WriteByte(' ')
        _, _ = b.WriteString(CauseKey + "=[" + s.err.Error() + "]")
    }

    for _, attr := range s.attrs {
        _ = b.WriteByte(' ')
        _, _ = b.WriteString(attr.String())
    }

    return b.String()
}

func (s serror) LogValue() Value {
    size := len(s.attrs) + 1
    if s.err != nil {
        size++
    }

    attrs := make([]Attr, 0, size)
    attrs = append(attrs, String(MessageKey, s.msg))

    if s.err != nil {
        attrs = append(attrs, Any(CauseKey, s.err))
    }

    attrs = append(attrs, s.attrs...)

    return GroupValue(attrs...)
}

func (e serror) Unwrap() error {
    return e.err
}

func Error(msg string, attrs ...Attr) error {
    return serror{msg: msg, attrs: attrs}
}

func WrapError(msg string, err error, attrs ...Attr) error {
    return serror{msg: msg, err: err, attrs: attrs}
}

In this example, I've introduced two functions, Error to create an initial error, and WrapError to wrap an existing one.

Alternatively, a slog.Error attribute function may be introduced instead of the WrapError, to allow wrapping one or more errors. This would be similar to fmt.Errorf, and like it, would require different types to implement the different Unwrap interfaces.

Comment From: apparentlymart

Interesting!

Is your hope then that third-party libraries would begin using this error type just in case some caller might want to pass the error directly to logging? If this were accepted, would you hope for it to become idiomatic for all errors that might possibly be logged to be LogValuer implementations?

It could be more orthogonal to introduce some means of associating attributes with an error that isn't so tightly bound to slog's idea of attributes and then teach slog to understand that more general convention, but I can't argue that it isn't far easier to just borrow the concept of attributes that package slog already defined. 🤔

Comment From: seankhliao

This doesn't fit in to the design of the standard library's error handling strategies, which are centered around matching with equality and Is / As.

I believe this falls into the category of https://go.dev/doc/faq#x_in_std