Skip to content

Latest commit

 

History

23 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

serrors

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.

Install

go get github.com/tedla-brandsema/serrors

Requires Go 1.21 (for log/slog). No dependencies.

Use

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.

Getting the context back as data

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 set

It 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

Mixed chains

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"}}

Collision policy

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.

Interop

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 layer

errors.Join is supported: joined children are rendered as a group keyed by index, and Attrs walks into them.

Examples

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.

Licence

MIT. See LICENSE.

Releases

Packages

Contributors

Languages