Errors that carry structured context, for log/slog.
Two layers of wrapping, one log record:
{"level":"ERROR","msg":"request failed","error":{"component":"user-service","op":"getUserProfile","cause":{"user_id":42,"op":"readUserFromDB","cause":"sql: no rows in result set"}}}user_id was known in the database layer. The decision to log was taken at the
boundary. Nothing in between formatted it into a sentence.
go get github.com/tedla-brandsema/serrors
Requires Go 1.21 (for log/slog). No dependencies.
One constructor. Wrap takes an operation name, the error underneath, and any
number of slog.Attr. Every error wraps something; where there is nothing to
wrap, name the failure with errors.New so callers can still match it.
func readUserFromDB(ctx context.Context, id int) (*User, error) {
if err := row.Scan(&u.Name); err != nil {
return nil, serrors.Wrap("readUserFromDB", err, slog.Int("user_id", id))
}
return &u, nil
}
var errMissingFile = errors.New("missing file")
func loadConfig(path string) (*Config, error) {
if !exists(path) {
return nil, serrors.Wrap("loadConfig", errMissingFile, slog.String("path", path))
}
...
}At the boundary:
logger.Error("request failed", slog.Any("error", serrors.Value(err)))Value renders any error, whatever its outermost layer is. See
Mixed chains for why that matters and what happens without it.
Error() reads "op: cause", or the operation alone when there is no cause.
Building context is free when the record is dropped: slog resolves a
LogValuer only for records the handler accepts.
Attrs returns the log record's error group, flattened, with keys joined along
the path by .:
for _, a := range serrors.Attrs(err) {
fmt.Printf("%s=%v\n", a.Key, a.Value)
}
// component=user-service
// op=getUserProfile
// cause.user_id=42
// cause.op=readUserFromDB
// cause.cause=sql: no rows in result setIt is the record, not a second traversal that resembles it, so the two cannot disagree. Joined branches are told apart by index, which is what keeps siblings carrying the same field key from being confused with one another:
form=signup
op=validateUser
cause.0.field=email cause.0.op=checkEmail cause.0.cause=not an address
cause.1.field=name cause.1.op=checkName cause.1.cause=empty
Real chains are not wrapped with one library. A layer wrapped with fmt.Errorf
and %w does not hide the structure beneath it:
inner := serrors.Wrap("readUserFromDB", sql.ErrNoRows, slog.Int("user_id", 42))
middle := fmt.Errorf("query layer: %w", inner)
outer := serrors.Wrap("getUserProfile", middle, slog.String("component", "user-service")){"error":{"component":"user-service","op":"getUserProfile","cause":{"via":"query layer","user_id":42,"op":"readUserFromDB","cause":"sql: no rows in result set"}}}user_id survives, and the text the middle layer added is kept under via.
This works for any error type that wraps opaquely, not just fmt.Errorf.
This is why the boundary above uses Value. When the outermost layer is not a
serrors error, which at a boundary it often is not, slog sees an ordinary
error and records its message, losing every field underneath:
err := fmt.Errorf("boundary: %w", inner)
logger.Error("request failed", slog.Any("error", err)) // flattened
logger.Error("request failed", slog.Any("error", serrors.Value(err))) // structured{"error":"boundary: readUserFromDB: sql: no rows in result set"}
{"error":{"via":"boundary","user_id":42,"op":"readUserFromDB","cause":"sql: no rows in result set"}}op, cause and via are reserved. A caller attribute using one of them is
not dropped and does not produce a duplicate JSON key. It is re-keyed:
serrors.Wrap("getUserProfile", sql.ErrNoRows, slog.String("op", "caller-supplied")){"error":{"field_op":"caller-supplied","op":"getUserProfile","cause":"sql: no rows in result set"}}If field_op is taken as well, the next free field_op2, field_op3 is used.
Two caller attributes with the same key collapse to the last value.
StructuredError implements Unwrap, so the standard library works unchanged:
errors.Is(err, sql.ErrNoRows) // true through any number of layers
var se *serrors.StructuredError
errors.As(err, &se) // the outermost layererrors.Join is supported: joined children are rendered as a group keyed by
index, and Attrs walks into them.
Five runnable programs are in examples/, covering layered chains,
lateral validation errors, errors.Is and errors.As interop, redacting a
field that a formatted message could not have given up, and feeding Attrs
into a span and a response body. Each one asserts its own output.
MIT. See LICENSE.