Observability

Sentry

Env-gated error reporting on top of OTel: with errs-code issue grouping and full trace linking.

Once a DSN is set, sentryx turns on Sentry error reporting through slog and OpenTelemetry. That is not a technical footnote, it is the point: since the httperr handlers, the Fiber/chi panic recovery and the worker already log every 5xx and task failure through slog with a trace-carrying context, wiring in a single slog handler covers every entry point, and there is nothing Sentry-specific in your code.

Enabling it:

SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project>

That's it. Without SENTRY_DSN the integration is completely off: every hook is a no-op, and behavior is identical to a kit without Sentry.

Configuration

VariableDefaultMeaning
SENTRY_DSNemptyproject DSN; empty turns the integration fully off
SENTRY_ENVIRONMENTdev → development, else productionenvironment tag on every event
SENTRY_TRACEStrueship the kit's OTel spans to Sentry; set false when your collector forwards them instead
SENTRY_DEBUGfalsethe Sentry SDK's own debug log to stderr

Every event carries a release of <service>@<version> (from OTEL_SERVICE_NAME / OTEL_SERVICE_VERSION), and Sentry's release tracking works with no extra setup.

On top of OTel, not instead of it

sentryx brings no tracing of its own, no middleware mesh, no measurements: OTel stays the backbone, and Sentry attaches to it at three points. All three are wired by telemetry.Setup; you never call sentryx.Setup yourself:

  • Spans → Sentry. Handle.SpanExporter() is registered as an extra exporter on the otelx tracer provider (otelx.WithSpanExporter): the same spans go to your collector and to Sentry. An error thus links to the full trace waterfall: the HTTP span, every DB query with its timing, the queue tasks. Sampling stays OTel-governed (OTEL_TRACES_SAMPLER); there is no separate Sentry sample rate.
  • Errors → Sentry. Handle.SlogHandler() is one branch of the log fanout: every ERROR-level record becomes a Sentry event. The record's first error-valued attr is the captured exception; an errs error brings along the stack recorded at its point of origin, its code, public message and wrap chain (in the event's errs context). Lower-level records (DEBUG/INFO/WARN) accumulate as breadcrumbs, so the event shows the log trail that led to it, honoring LOG_LEVEL like any sink.
  • Error ↔ trace linking. The OTel integration stamps each event with the active trace/span ID, so the error lands on the correct span in the waterfall.

Breadcrumb isolation is per request: at all three entry points (a Fiber middleware, a chi middleware, a worker task middleware) the chassis calls sentryx.WithRequestHub, which puts a fresh cloned hub on the context, so one request's log lines never bleed into another request's event. On a context without such a hub (say, your own goroutine on context.Background()) breadcrumbs are dropped rather than polluting the global hub.

One error = one issue: the errs code as fingerprint

Sentry titles and groups by the outermost exception's type. In Go that would be the type name, and since every level of an errs chain carries the same *errs.Error type, all your errors would merge into a single issue named *errs.Error. So sentryx rewrites the event before sending:

  • The issue title becomes the error's errs code (or, absent a code, its outermost wrap message).
  • The code is also the grouping fingerprint: every occurrence of a defined error groups into one issue, no matter how many different code paths produced it.
  • The secondary line becomes the Public message (when set): the issue list reads as code / client-facing text, with the full internal chain one click away in the event's errs context.

On the shop's canonical error, it looks like this:

var ErrOutOfStock = errs.Define("shop_out_of_stock",
    errs.Public("One or more items are out of stock."))

// repository:
return ErrOutOfStock.New("inventory: decrement: insufficient stock")
// service:
return errs.Wrap(err, "shop: place order")

However many orders fail, however the stack shifts along the way, in Sentry it is a single issue: shop_out_of_stock, with the public message beneath it, and every event carrying the full stack, the wrap chain, the request's breadcrumbs and the trace link. And when the team decides that running out of stock is expected client behavior rather than an incident, add errs.Status(http.StatusConflict) to the definition: from then on it goes out as a 409, producing no request failed log line and no Sentry event, and the issue dies out on its own.

Sentry's OTLP ingestion accepts traces but drops OTel span events: the lifecycle view is assembled from real spans (HTTP, DB, queue, S3) plus the breadcrumb trail. Source context in the Sentry UI comes from the GitHub/SCM integration; Go binaries carry no source.

Works in dev mode too: no collector

With OTEL_DEV_MODE=true, OTel builds no exporters, but if SENTRY_DSN is set, the kit spins up a Sentry-only tracer provider, so you get the full trace and the error in Sentry locally, without any collector. This is the cheapest way to see the "follow the order" trace live: dev mode plus the DSN of a dev Sentry project.

Why a wrapper: the exception that proves the rule

The kit's principle is centralize, don't abstract, and sentryx is one of the three declared exceptions: it really does wrap sentry-go. The reason is omittability. Sentry is an optional integration: it toggles via env, and since only the telemetry facade and the chassis import sentryx, a non-chassis binary (a CLI, a migrator, anything using just otelx or dbx) never even links sentry-go. If sentry-go types flowed through the kit's API, that guarantee would be gone. In daily work you don't call it anyway: your errors reach Sentry via return err.

The API is still there when needed (it is what telemetry itself works through):

func Setup(ctx context.Context, cfg Config, serviceName, serviceVersion string, dev bool) (*Handle, error)

func (h *Handle) Enabled() bool
func (h *Handle) SpanExporter() sdktrace.SpanExporter  // nil when disabled / SENTRY_TRACES=false
func (h *Handle) SlogHandler() slog.Handler            // nil when disabled
func (h *Handle) Flush(ctx context.Context) error      // drain the event transport

func WithRequestHub(ctx context.Context) context.Context // per-request hub, called by the chassis

Flush at the very end

Sentry's event transport is asynchronous: capturing never blocks a request. Closing it down is handled by the telemetry shutdown: Flush runs after the OTel providers stop, last in the lifecycle, so errors produced during shutdown still ship before the process exits.

Patterns used

sentryx is one of the kit's three stated exceptions to hiding a dependency behind an interface; see Design patterns: Architectural principles. Its plug-in via WithSpanExporter is a concrete example of the nil-tolerant functional options variant.

Sentry's official Go documentation: docs.sentry.io/platforms/go.

Copyright © 2026