go / sentry

Sentry tracks errors across production services. The official sentry-go SDK is a large multi-feature package, and error capture is the smallest piece of it. Most of its weight goes to APM tracing (transactions and spans), profiling, breadcrumbs, and framework integrations.

When I use Sentry only for error tracking (capturing exceptions and messages, attaching tags and extras), a small in-repo client covers the use case in a few hundred lines. I name the package errs because it also provides the error-wrapping helpers that feed it.

API

The surface I rely on:

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

Functions accept per-call metadata through functional options (WithTag, WithExtra, WithFingerprint, WithLevel) instead of a mutable per-request scope. Each option modifies the outgoing event:

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. Package functions reach it through an atomic pointer, so callers do not thread a handle. A nil pointer means Sentry is unconfigured, and every capture is a silent 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 when a non-empty DSN is malformed, so misconfiguration surfaces at startup. An empty DSN keeps dev runs and tests quiet without a stub.

DSN

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

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

The client parses the DSN during initialization 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

Ruby exceptions arrive with a backtrace. Go errors do not carry one, so the Wrap and Errorf functions capture the program counters at the call site and resolve function names at send time:

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),
	}
}

Use errs.Wrap(err, "...") anywhere fmt.Errorf("...: %w", err) would appear. CaptureException walks the error chain for the first attached stack trace. If it finds none, it falls back to the capture-site stack, so an event is never frameless:

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

Sentry capture runs on the error path. The calling goroutine should not block on a slow or down Sentry. The client puts events onto a bounded channel, and a background 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 queueLimit bound holds 1,000 events, and a full queue drops new events instead of backing up callers. Flush stops accepting events and waits up to a timeout for the queue to drain. Call it from the shutdown path in main, so a SIGTERM does not strand the last few events. In tests, SyncDispatch sends on the calling goroutine, so each capture is observable before the test exits.

Sending events

Sentry uses the envelope format, which consists of three newline-separated JSON objects: the envelope header, item header, and event payload:

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 to retry transient errors. Sentry returns HTTP 429 when rate-limited, and a callback reads the Retry-After header to delay retries:

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

Retries happen inside the worker, not the caller. After the delays run out, the worker logs the failure and drops the event. Capture is best-effort, and a failed Sentry must never break the caller.

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 as in_app, so the UI surfaces them and folds third-party frames away by default:

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 displays relative file paths, such as apollo/refresh_person.go.

Trade-offs

What this client gives up compared to the SDK: APM tracing. No transactions, no spans, no Performance dashboard. That is acceptable when only the error feed is in use.

A quieter benefit: no automatic breadcrumbs. The SDK's default integrations capture request params, SQL, and HTTP headers as breadcrumbs and ship them with every event, a steady leak of PII into a third-party service. This client carries only the data each Capture* call passes in, which makes reviewing what leaves the process trivial.

Tests

Point the client's HTTP transport at an httptest.NewServer that records each envelope POST, and use SyncDispatch so the send completes inside the test:

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, which avoids network traffic and test mocks.

See also

cmd/sentry reads production issues from the Sentry web API as the companion to this reporting client.

← All articles