go / sentry

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

When I'm only using Sentry 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. The package is named 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"))

Per-call metadata comes from functional options (WithTag, WithExtra, WithFingerprint, WithLevel) rather than a mutable per-request scope. Each option mutates 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

There is at most one client per process. Package functions delegate to it through an atomic pointer, so callers don't thread a handle. A nil pointer means Sentry isn't configured 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 stubbing.

DSN

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

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

Parse it once at boot. The parts build the envelope endpoint and signed auth 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 don't carry one, so Wrap and Errorf capture the program counters at the call site and defer symbolization to 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 stackCarrier and 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 shouldn't block on a slow or down Sentry. Events go onto a bounded channel; 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 (1000 events) bound drops new events when full instead of backing up callers. Flush stops accepting events and waits up to a timeout for the queue to drain; call it from main's shutdown path so a SIGTERM doesn't 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's wire format is the envelope: three newline-separated JSON lines for 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
}

POST it through the shared backoff helper, retrying transient failures. Sentry adds 429 to the transient set and asks callers to honor Retry-After, which a callback threads through:

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 are exhausted, the failure is logged and the event dropped. Capture is best-effort, and a failed Sentry must never break the caller.

Stack frames

runtime.Callers captures program counters newest-first; Sentry wants newest-last, so reverse. Frames inside the module are marked 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, "eds/") || strings.HasPrefix(fn, "eds.")
}

relativeTo trims the project root so the UI shows apollo/refresh_person.go instead of the full deploy path.

Trade-offs

What this client gives up compared to the SDK: APM tracing. No transactions, no spans, no Performance dashboard. 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 don't call Init exercise the no-op path. No HTTP traffic, no stub required.

See also

cmd/sentry reads production issues from Sentry's web API, the counterpart to this send-only client.

← All articles