Error Handling
Errors get their own first-class slot on every log entry. Attach one with WithError, or log only an error with ErrorOnly.
WithError
log.WithError(err).Error("operation failed")By default the error is serialized as {"message": err.Error()} and placed under the err field of the assembled Data map:
{
"msg": "operation failed",
"err": { "message": "connection refused" }
}WithError can be chained with WithMetadata and a level method:
log.WithMetadata(loglayer.Metadata{"host": "db1"}).
WithError(err).
Error("failed to connect")The error is associated with a single log entry; calling WithError on a builder doesn't persist to future logs.
ErrorOnly
When you want to log just an error, with no companion message:
log.ErrorOnly(err)Default level is Error. To use a different level:
log.ErrorOnly(err, loglayer.ErrorOnlyOpts{LogLevel: loglayer.LogLevelFatal})To use the error's text as the message body, set CopyMsgOnOnlyError: true on the config (or override per-call):
log.ErrorOnly(err, loglayer.ErrorOnlyOpts{CopyMsg: loglayer.CopyMsgEnabled})
log.ErrorOnly(err, loglayer.ErrorOnlyOpts{CopyMsg: loglayer.CopyMsgDisabled}) // explicit opt-outThe zero value (CopyMsgDefault) keeps the config setting; use CopyMsgEnabled or CopyMsgDisabled to override per call.
Customizing Error Serialization
The default serializer captures only err.Error(). To capture stack traces, error chains, or library-specific fields, set an ErrorSerializer. The serializer is called once per WithError / ErrorOnly invocation, only when an error is actually present.
Recommended: eris for stack traces and error chains
github.com/rotisserie/eris is purpose-built for this: its ToJSON function returns map[string]any, which is exactly the shape ErrorSerializer expects.
go get github.com/rotisserie/erisimport "github.com/rotisserie/eris"
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
ErrorSerializer: func(err error) map[string]any {
return eris.ToJSON(err, true) // true = include stack trace
},
})
err := eris.New("connection refused")
log.WithError(err).Error("db query failed"){
"msg": "db query failed",
"err": {
"root": {
"message": "connection refused",
"stack": [
"main.queryDB:/app/db.go:42",
"main.main:/app/main.go:12"
]
}
}
}eris also supports wrapping (eris.Wrap(err, "context")), which renders as a chain of root + wrap entries: handy for tracing how an error propagated.
Built-in: UnwrappingErrorSerializer
If you want chain expansion without the dependency on eris, the built-in loglayer.UnwrappingErrorSerializer walks errors.Unwrap and errors.Join and emits a causes array:
log := loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
ErrorSerializer: loglayer.UnwrappingErrorSerializer,
})
log.WithError(fmt.Errorf("op failed: %w", io.EOF)).Error("oops")
// {"err":{"message":"op failed: EOF","causes":[{"message":"EOF"}]}}
log.WithError(errors.Join(errA, errB)).Error("combined")
// {"err":{"message":"errA\nerrB","causes":[{"message":"errA"},{"message":"errB"}]}}Behavior:
- For a
%wchain, each unwrap step appends one{"message": ...}tocauses. - For
errors.Join, each member appears as one{"message": ...}incauses(the walk does not recurse into nested chains within members; if you need that, write a custom serializer). - When there's nothing below the top frame,
causesis omitted entirely so the shape matches the default serializer for unwrapped errors.
For stack traces, eris is still the right pick.
Rolling your own
If you have specific shaping needs (different field names, redaction, library-specific fields), write the serializer yourself:
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
ErrorSerializer: func(err error) map[string]any {
return map[string]any{
"message": err.Error(),
"type": fmt.Sprintf("%T", err),
"trace": stackTrace(err),
}
},
})Working with errors.Is / errors.As
The serializer receives the original error, so you can branch on its type:
ErrorSerializer: func(err error) map[string]any {
out := map[string]any{"message": err.Error()}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
out["pg_code"] = pgErr.Code
out["pg_constraint"] = pgErr.ConstraintName
}
return out
}Renaming the Error Field
Change the key the serialized error is placed under via ErrorFieldName:
loglayer.New(loglayer.Config{
Transport: structured.New(structured.Config{}),
ErrorFieldName: "error",
})
log.WithError(err).Error("failed"){
"msg": "failed",
"error": { "message": "..." }
}The default is "err".
Combining with Fields and Metadata
Errors compose with fields and metadata:
log.WithFields(loglayer.Fields{"requestId": "abc"})
log.WithMetadata(loglayer.Metadata{"retry_count": 3}).
WithError(err).
Error("retry exhausted"){
"msg": "retry exhausted",
"requestId": "abc",
"retry_count": 3,
"err": { "message": "..." }
}Fatal Exits By Default
log.WithError(err).Fatal(...) writes the entry then calls os.Exit(1). See Fatal Exits the Process for opt-out via DisableFatalExit.
