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.