go / errs
errs is my error client for Go services. It uses the standard library only. Today it sends errors to Sentry.
Most of the official sentry-go SDK is tracing, profiling,
breadcrumbs, and framework integrations. I use Sentry only for error
tracking, so a few hundred lines cover it.
API
errs.Init(errs.Options{DSN: os.Getenv("SENTRY_DSN")})
defer errs.Flush(2 * time.Second)
errs.CaptureMessage("did something weird", errs.WithExtra("k", v))
errs.CaptureException(err, errs.WithFingerprint("apollo", "429"))
WithTag, WithExtra, WithFingerprint, and WithLevel add
metadata to one event.
An empty DSN makes every capture a no-op, so dev runs and tests stay
quiet. Init returns an error only for a malformed DSN.
Attaching a stack
A Go error has no stack trace. Wrap and Errorf capture the program
counters where they run:
func Wrap(err error, msg string) error {
if err == nil {
return nil
}
return &stackErr{
err: fmt.Errorf("%s: %w", msg, err),
pcs: callers(2),
}
}
I write errs.Wrap(err, "...") where fmt.Errorf("...: %w", err)
would go. CaptureException walks the error chain, through
errors.Join too, and sends the first stack it finds. With none, it
sends the stack where it was called. In a deferred recover,
PanicError keeps the frames of the line that panicked.
Titles
errors.New and fmt.Errorf give every error one type,
*errors.errorString, and Sentry would title most issues with it. So
errs titles a plain error by the phrase before its first ": ".
save note reminder: no recipient is titled save note reminder.
A phrase with a digit in it makes no title, because Sentry groups by
title. company 123: not found would open one issue for each company.
Never block the caller
A capture puts the event on a channel of 1,000 and returns. When the
channel is full, the capture drops the event. One worker sends each
event, retries 429 and other transient codes, and obeys Retry-After.
After the last retry, it logs the failure and drops the event.
Flush in main sends what is left at shutdown. Tests set
SyncDispatch, so each capture sends before the test reads it.
A frame is in_app when its function is in the main module. errs
reads the module path from runtime/debug.ReadBuildInfo, so a new
app needs no setting.
One policy for a 500
Every handler sends a failure to one function:
func WriteServerError(w http.ResponseWriter, err error) {
if !errors.Is(err, context.Canceled) {
errs.CaptureException(err)
}
log.Printf("WriteServerError: %s", sanitizeLogValue(err.Error()))
http.Error(w, http.StatusText(500), 500)
}
The caller wraps the cause, so Sentry gets the whole chain, and the user sees a plain 500. A request that the browser cancels writes a log line and opens no issue. A test fails on a handler that writes a 5xx any other way.
Slow requests
The request middleware sends an event for a request over 750 ms. It
carries duration_ms, db_ms, db_queries, and acquire_ms, which
say where the time went (see go / postgres).
The request log line carries the same fields for every request, so
sockeye can chart them.
Trade-offs
errs has no tracing and sends no automatic breadcrumbs. The
default integrations of the official SDK send request parameters, SQL,
and headers with every event, which leaks personal data. errs sends
only what each call passes in.
cmd / sentry reads the issues back in the terminal.