# Sentry

> Deep Sentry integration — errors correlated to the distributed trace, breadcrumbs from the log sink, and opt-in performance traces.



---

<!-- source: en/plugins/sentry.md -->
## Sentry

_Deep Sentry integration — errors correlated to the distributed trace, breadcrumbs from the log sink, and opt-in performance traces._

`@voltro/plugin-sentry` is the deep Sentry integration: every mutation/query/action failure becomes a Sentry issue **correlated to the active distributed trace** (trace_id + span_id) and carrying the request's recent log lines as breadcrumbs. Opt into performance traces and the framework's spans flow to Sentry as transactions.

Uses the official `@sentry/node` SDK in **OpenTelemetry-consumer mode** — it consumes the framework's existing tracer (it never becomes the global provider), so the in-app [Traces dashboard](/docs/observability/distributed-tracing) stays intact.

## Wiring

```ts
// app.config.ts
import { sentryPlugin } from '@voltro/plugin-sentry'

export default {
  type: 'api' as const,
  name: 'api',
  plugins: [
    sentryPlugin({
      // dsn defaults to SENTRY_DSN; without a valid DSN the plugin is a no-op.
      environment: 'production',   // or SENTRY_ENVIRONMENT
      release: '1.4.2',            // or SENTRY_RELEASE
      traces: true,                // route the framework's spans → Sentry (default off)
      tracesSampleRate: 1.0,
      profiling: true,             // continuous CPU profiler via @sentry/profiling-node
    }),
  ],
}
```

`@sentry/*` are optional, lazy-loaded dependencies — non-users pay nothing.

## Configuration

| Option | Type | Default | Notes |
|---|---|---|---|
| `dsn` | `string` | `SENTRY_DSN` env | No valid DSN → the plugin is inert (no interceptors registered). |
| `environment` | `string` | `SENTRY_ENVIRONMENT` env | Sentry `environment` tag + `deployment.environment` resource attribute. |
| `release` | `string` | `SENTRY_RELEASE` env | Release-health + source-map matching; also `service.version`. |
| `traces` | `boolean` | `false` | Route the framework's OTel spans to Sentry as transactions. Off → errors + breadcrumbs only. |
| `tracesSampleRate` | `number` | `1.0` | Applied by the Sentry client (not a tracer sampler). Only relevant with `traces`. |
| `profiling` | `boolean` | `false` | Continuous CPU profiler via `@sentry/profiling-node` (lazy; warns if not installed). |
| `profilesSampleRate` | `number` | `1.0` | Only relevant with `profiling`. |
| `maxBreadcrumbs` | `number` | `50` | Per-trace breadcrumb cap. |
| `name` | `string` | — | Disambiguates multiple instances (`@voltro/plugin-sentry#<name>`). |

## How the deep correlation works

The framework is **Effect-native**: the active OTel span lives in Effect's fiber context, not the AsyncLocalStorage Sentry reads implicitly. So the plugin attaches trace context + breadcrumbs **explicitly** rather than relying on Sentry's ambient scope:

```
interceptMutation/Query/Action
  └─ on failure → Sentry.withScope(scope => {
       scope.setContext('trace', { trace_id, span_id })   // ← from ctx.traceId / ctx.spanId
       scope.setTags({ 'rpc.tag', 'rpc.kind', 'subject.type', 'tenant.id' })
       breadcrumbs.forEach(b => scope.addBreadcrumb(b))    // ← the trace's log lines
       Sentry.captureException(error)
     })
```

This guarantees the error links to its trace in Sentry **regardless** of the Effect/AsyncLocalStorage mismatch. Pure interruptions (client disconnects) are skipped.

### Breadcrumbs

The framework log sink feeds a bounded **per-trace** ring (capped per-trace + LRU + TTL), keyed on each log line's `traceId`. On an error the trace's breadcrumbs are attached to the event; traces that never error are reaped by the TTL. So a Sentry issue shows the exact log lines leading up to it — for that request only.

## Traces (opt-in `traces:true`)

The plugin contributes a `SentrySpanProcessor` to the framework's tracer (via the plugin observability surface — see [Observability › Routing traces to a vendor](/docs/observability/overview#routing-traces-to-a-vendor)). Every framework span becomes a Sentry transaction, sharing the same trace id the errors carry — so in Sentry you land on an error inside its full distributed trace.

We deliberately do **not** install a `SentrySampler` as the tracer's sampler: that would gate recording for the WHOLE tracer and blind the framework's own in-app Traces buffer. The framework keeps its always-on sampler; `tracesSampleRate` is applied by the Sentry client.

## Full-stack — browser → backend in ONE Sentry trace

The plugin ships a browser half at `@voltro/plugin-sentry/web` so a click in the UI and the server work it triggers appear as **one trace** in Sentry. Turn it on in the web app's `app.config.ts`:

```ts
// apps/web/app.config.ts
export default {
  type: 'web' as const,
  name: 'web',
  apis: { app: { package: '@app/api' } },
  sentry: {
    dsn: 'https://<key>@oXXXX.ingest.sentry.io/XXXX', // public — safe in the browser bundle
    environment: 'production',
    release: '1.4.2',          // MUST match the api's `release` for unified release health
    tracesSampleRate: 1.0,
  },
}
```

The framework's generated entry calls `initSentryBrowser()` before mount (no code to write). It needs `@voltro/plugin-sentry` as a dependency of the web app. Prefer the api `release` and the web `release` be identical.

What it does:
- **Inits `@sentry/react`** with `browserTracingIntegration` — page loads + client navigations become Sentry transactions, and unhandled errors / promise rejections are captured by the SDK's default global handlers.
- **Unifies the trace.** Every framework rpc call (`useMutation`/`useSubscription`/`useAction`) already wraps itself in an Effect client span and propagates that span's W3C `traceId`+`spanId` to the api (the api's `sentryPlugin({ traces:true })` turns the server spans into Sentry transactions under that same trace id). The browser half subscribes to the framework's client-trace bus and emits a browser-side Sentry span into the **same** trace, parented at the client span — so Sentry's trace view shows the browser hop and the server transactions in one tree.

> **Manual wiring** (no app.config field, e.g. an app that builds its own entry): call it yourself once at boot —
> ```ts
> import { initSentryBrowser } from '@voltro/plugin-sentry/web'
> await initSentryBrowser({ dsn: import.meta.env.VITE_SENTRY_DSN })
> ```
> It's a no-op on the server (SSR) and without a DSN.

The two halves are independent: the api plugin alone gives you server traces + errors; add the web half for the browser hop. For the FULL frontend→backend waterfall in Sentry, run **both** (`sentryPlugin({ traces:true })` on the api + `sentry: { dsn }` on the web app), with matching `release`.

## Source maps (browser stack traces)

The web bundle is minified, so browser errors arrive in Sentry with **minified** stack traces unless you upload source maps. The plugin does **not** upload them for you — it captures + correlates errors, but wiring the build to publish maps is a per-app build step (it needs your build output plus a Sentry auth token, neither of which the runtime plugin owns). Wire it once with Sentry's official tooling, keyed to the **same `release`** the plugin uses so the maps match the uploaded events:

```sh
# after the web build, from the web app dir — uploads maps for one release
pnpm add -D @sentry/cli
SENTRY_AUTH_TOKEN=… npx sentry-cli sourcemaps upload \
  --org <org> --project <project> \
  --release "$SENTRY_RELEASE" ./dist
```

Or add `@sentry/vite-plugin` to the web app's Vite config so the upload runs automatically on every production build. Either way, set `release` to the same value (e.g. the git SHA) on BOTH the api `sentryPlugin({ release })` and the web `sentry` field — the upload's `--release` must match or Sentry won't resolve the frames. Server stack traces are un-minified already (the api ships readable JS), so this is a browser-only concern.

## Catching errors — what's automatic, what's manual

Once the two halves are wired, most errors are captured **with no per-call code**:

| Error | Captured | How |
|---|---|---|
| rpc handler throw (mutation/query/action) | ✅ auto | server interceptor → Sentry, trace-correlated + breadcrumbs |
| Unhandled browser error / promise rejection | ✅ auto | `@sentry/react` global handlers (after `initSentryBrowser`) |
| **React render error** (any page, incl. catch-all `[...slug]`, `error.tsx` boundary) | ✅ auto | the framework's route ErrorBoundary publishes to a client-error bus → the Sentry web bridge captures it. React swallows boundary-caught errors before `window.onerror`, so this bridge is what makes them reach Sentry — tagged with the route + component stack. |
| **Loader failure** (page/layout `loader` reject) | ✅ auto | same route bus path |
| Manual try/catch (event handler, async effect, anywhere) | ✋ one call | `reportClientError(error, context?)` |
| **Every other server primitive** — REST route (`defineRestRoute`), aggregate, subscriber, schedule, workflow, webhook, startup | ✅ auto | each primitive's error path publishes to the framework's server-error bus → the plugin captures it, tagged `voltro.errorSource` + `voltro.name` (+ traceId for workflows) |

### Manual capture — `reportClientError`

Vendor-agnostic, ships with the framework (`@voltro/web` / re-exported from `@voltro/plugin-sentry/web`). Call it from anything the route boundary won't catch:

```tsx
import { reportClientError } from '@voltro/client'

const onPay = async () => {
  try {
    await pay.run({ amount })
  } catch (err) {
    reportClientError(err, { feature: 'checkout', amount })  // → Sentry, tagged
    toast.error('Payment failed')
  }
}
```

It's a no-op when no reporter is subscribed (Sentry not initialised), so library code can call it unconditionally. The Sentry web bridge forwards it with `voltro.errorSource: manual` + your `context`.

### Catch-all / 404 routes

A catch-all page (`[...slug].tsx`) or a scoped `not-found.tsx` that *renders* fine produces no error — a 404 isn't an exception. But if such a page (or anything under it) **throws** while rendering or in its loader, the route ErrorBoundary catches it and it's auto-captured — no `error.tsx` wiring needed (though an `error.tsx` still gets to render your fallback UI; both happen).

### Server coverage — every primitive, not just rpc

With the plugin active, errors from **every** server primitive reach Sentry — no per-handler code:

- **rpc** (mutation / query / action, and the synthesized agent `send`/`messages`) — via the interceptors (richest context: subject, span id, breadcrumbs).
- **Everything else** — REST routes (`defineRestRoute`), aggregates, subscribers, schedules, workflows, webhooks, startups — via the framework's **server-error bus**: each primitive's existing error path calls `publishServerError({ error, source, name, … })`, and the plugin subscribes once and captures it (tagged with the source + name; workflows also carry their `traceId` so the failure correlates to its trace + breadcrumbs).

The bus is vendor-agnostic (`publishServerError` / `subscribeServerErrors` from `@voltro/protocol`) — the Sentry plugin is just the subscriber. Intentional control-flow throws (a REST handler's `throw { status: 4xx }`) are **not** reported — only unexpected 5xx-class failures.

## Troubleshooting

| Symptom | Cause / fix |
|---|---|
| Boot logs `sentry inactive — no valid DSN` | `dsn` / `SENTRY_DSN` is unset or malformed. The plugin registers no interceptors until a valid DSN is present. |
| Errors arrive in Sentry but **not linked to a trace** | The `trace` context is set explicitly from `ctx.traceId` — present on every rpc call. If it's missing, the error came from outside an rpc handler (e.g. a bare `console.error`), which the interceptor doesn't see. |
| Issue has **no breadcrumbs** | Breadcrumbs come from log lines carrying `fields.traceId`. Lines logged outside the request's trace (or after the trace was reaped by TTL) aren't attached. Raise `maxBreadcrumbs` if a trace logs heavily. |
| `traces: true` but **no transactions in Sentry** | Confirm `tracesSampleRate > 0`. Spans are recorded always-on by the framework; the Sentry client applies the sample rate when building transactions. |
| In-app Traces dashboard **went empty** after enabling Sentry | You contributed a sampler that drops spans. Don't — the framework's buffer is always-on by design (this plugin deliberately omits `SentrySampler`). |
| Profiling requested but no profiles | `@sentry/profiling-node` isn't installed; boot logs a warning. `pnpm add @sentry/profiling-node`. |
| Browser + server NOT in one trace | Run BOTH halves — `sentryPlugin({ traces:true })` on the api AND `sentry:{dsn}` on the web app. Errors-only on either side won't share a trace; you need `traces` on the api. |
| Web `sentry` set but nothing in Sentry | `@voltro/plugin-sentry` must be a dependency of the WEB app (the generated entry imports `@voltro/plugin-sentry/web`). Confirm `dsn` is set — without it the browser half is a no-op. |

## Notes

- No DSN → the plugin is inert (logs a warning at boot, registers no interceptors).
- A Sentry outage is **fail-soft**: a broken transport never masks the original handler error.
- Permissions: `rpc:intercept:{mutation,query,action}` + `network:outbound:<dsn-host>`.
