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.

← All articles