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 stays intact.
Wiring
// 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). 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:
// 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/reactwithbrowserTracingIntegration— 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 W3CtraceId+spanIdto the api (the api'ssentryPlugin({ 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 —
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:
# 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" ./distOr 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:
import { reportClientError } from '@voltro/web'
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 callspublishServerError({ error, source, name, … }), and the plugin subscribes once and captures it (tagged with the source + name; workflows also carry theirtraceIdso 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>.