go / sentry

Sentry tracks errors in production services. 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. I name the package errs because it also provides the error-wrapping helpers.

API

The calls I use:

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

Functional options (WithTag, WithExtra, WithFingerprint, WithLevel) carry per-call metadata:

type Option func(*event)

func WithExtra(key string, value any) Option {
	return func(e *event) {
		if e.Extra == nil {
			e.Extra = map[string]any{}
		}
		e.Extra[key] = value
	}
}

func WithFingerprint(parts ...string) Option {
	return func(e *event) { e.Fingerprint = parts }
}

No-op without a DSN

Each process holds at most one client in an atomic pointer. A nil pointer means no DSN, and every capture is a no-op:

var active atomic.Pointer[client]

func Init(opts Options) error {
	if opts.DSN == "" {
		active.Store(nil)
		return nil
	}
	d, err := parseDSN(opts.DSN)
	if err != nil {
		return err
	}
	// ... build client, start worker
	active.Store(c)
	return nil
}

func CaptureMessage(msg string, opts ...Option) string {
	c := active.Load()
	if c == nil {
		return ""
	}
	e := c.newEvent("info", opts)
	e.Message = &messagePayload{Formatted: truncate(msg)}
	c.dispatch(e)
	return e.EventID
}

Init returns an error only for a malformed DSN. An empty DSN keeps dev runs and tests quiet.

DSN

A Sentry DSN encodes the project ID, public key, and host:

https://{public_key}@{host}/{project_id}

Init parses the DSN to build the envelope endpoint and the authorization header:

func (d dsn) envelopeEndpoint() string {
	port := ""
	if d.port != "" && !isDefaultPort(d.scheme, d.port) {
		port = ":" + d.port
	}
	return fmt.Sprintf("%s://%s%s%s/api/%s/envelope/",
		d.scheme, d.host, port, d.path, d.projectID)
}

func (d dsn) authHeader(now int64, client string) string {
	return fmt.Sprintf(
		"Sentry sentry_version=%s, sentry_timestamp=%d, sentry_key=%s, sentry_client=%s",
		protocolVersion, now, d.publicKey, client,
	)
}

Carrying a stack

A Go error carries no stack. Wrap and Errorf capture the program counters at the call site:

type stackErr struct {
	err error
	pcs []uintptr
}

func (e *stackErr) Error() string         { return e.err.Error() }
func (e *stackErr) Unwrap() error         { return e.err }
func (e *stackErr) StackTrace() []uintptr { return e.pcs }

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 use errs.Wrap(err, "...") where fmt.Errorf("...: %w", err) would appear. CaptureException walks the error chain for the first stack. If it finds none, it uses the capture-site stack:

func stackFromError(err error) []uintptr {
	for e := err; e != nil; {
		if s, ok := e.(stackCarrier); ok {
			return s.StackTrace()
		}
		switch u := e.(type) {
		case interface{ Unwrap() error }:
			e = u.Unwrap()
		case interface{ Unwrap() []error }:
			for _, c := range u.Unwrap() {
				if pcs := stackFromError(c); pcs != nil {
					return pcs
				}
			}
			return nil
		default:
			return nil
		}
	}
	return nil
}

Async dispatch

The calling goroutine must not block on a slow Sentry. The client puts events on a bounded channel, and a worker POSTs them:

func (c *client) dispatch(e *event) {
	if c.syncDispatch {
		c.send(e)
		return
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.closed.Load() {
		return
	}
	c.wg.Add(1)
	select {
	case c.queue <- e:
	default:
		// Queue is full; drop the event and undo the wg.Add so Flush
		// will not block waiting for an event that never enqueued.
		c.wg.Done()
	}
}

func (c *client) worker() {
	defer c.wg.Done()
	for e := range c.queue {
		c.send(e)
		c.wg.Done()
	}
}

The queue holds 1,000 events and drops new ones when full. Flush stops intake and waits for the queue to drain. I call it from the shutdown path in main. In tests, SyncDispatch sends on the calling goroutine.

Sending events

An envelope is three newline-separated JSON objects: envelope header, item header, and event:

func envelopeBody(e *event) ([]byte, error) {
	envelope := map[string]any{
		"event_id": e.EventID,
		"sent_at":  clock.Now().Format(time.RFC3339),
	}
	item := map[string]any{
		"type":         "event",
		"content_type": "application/json",
	}

	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	for _, v := range []any{envelope, item, e} {
		if err := enc.Encode(v); err != nil {
			return nil, fmt.Errorf("encode envelope: %w", err)
		}
	}
	return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

The worker sends the payload with the backoff helper. On a 429, a callback reads the Retry-After header to delay the retry:

res, err := httputil.Do(context.Background(), build, httputil.Config{
	Client:         c.httpClient,
	TransientCodes: transientCodes,
	RetryAfter: func(resp *http.Response) time.Duration {
		return parseRetryAfter(resp.Header.Get("Retry-After"))
	},
})

After the retries run out, the worker logs the failure and drops the event.

Stack frames

runtime.Callers returns program counters newest-first and Sentry wants them newest-last, so the client reverses the list. It marks frames inside the module in_app:

func resolveFrames(pcs []uintptr, root string) []frame {
	var out []frame
	cf := runtime.CallersFrames(pcs)
	for {
		f, more := cf.Next()
		if f.Function == "" || strings.HasPrefix(f.Function, "runtime.") {
			if !more {
				break
			}
			continue
		}
		out = append(out, frame{
			AbsPath:  f.File,
			Filename: relativeTo(f.File, root),
			Function: f.Function,
			Lineno:   f.Line,
			InApp:    inApp(f.Function),
		})
		if !more {
			break
		}
	}
	for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
		out[i], out[j] = out[j], out[i]
	}
	return out
}

func inApp(fn string) bool {
	return strings.HasPrefix(fn, "app/") || strings.HasPrefix(fn, "app.")
}

relativeTo strips the project directory prefix, so the UI shows apollo/refresh_person.go.

Trade-offs

This client has no tracing, so no Performance dashboard.

It sends no automatic breadcrumbs. The SDK's default integrations send request params, SQL, and HTTP headers with every event, which leaks PII. This client sends only what each Capture* call passes in.

Tests

Tests point the HTTP client at an httptest.NewServer that records each envelope, and set SyncDispatch:

func TestCaptureMessagePostsEnvelope(t *testing.T) {
	srv := newCaptureServer(t, 200)
	defer srv.Close()

	err := Init(Options{
		DSN:          testDSN,
		HTTPClient:   srv.Client(),
		SyncDispatch: true,
	})
	tu.OK(err == nil)

	id := CaptureMessage("hello", WithExtra("a", 1))

	tu.OK(len(id) == 32)
	ev := srv.events[0]
	msg := ev["message"].(map[string]any)
	tu.OK(msg["formatted"] == "hello")
}

Tests that do not call Init use the no-op path.

See also

cmd/sentry reads production issues from the Sentry web API.

← All articles