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.