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.
Values the bundle cannot know
Everything in that sentry: block is a literal frozen into the bundle. That is
right for a DSN and a release, and wrong for environment as soon as one image
serves more than one environment — one build job and several environment-bound
deploy jobs is an ordinary pipeline, and a baked environment is then true for
at most one of them. Leaving it out does not help: Sentry defaults a missing
environment to production, so a wrong tag is what you get either way, and a
wrong tag is worse than none because somebody acts on it.
There is no runtime channel to read instead — public values are baked at build time by construction — so the value has to be computed where it is known. The entry is generated anyway, so it can call something:
// app.config.ts
sentry: { dsn: '…', optionsFrom: 'src/sentryOptions.ts' }// src/sentryOptions.ts
export default () => ({
environment: location.hostname.startsWith('stage-') ? 'staging' : 'production',
})The module's default export is called by the generated entry before
initSentryBrowser, may be async, and its result is spread LAST — so it
overrides the literals above it, which is the whole reason to reach for it.
Or from the public env
Without an explicit option, the browser half reads
VOLTRO_PUBLIC_SENTRY_DSN, VOLTRO_PUBLIC_SENTRY_ENVIRONMENT and
VOLTRO_PUBLIC_SENTRY_RELEASE — the same shape the api half already has, where
sentryPlugin() takes no arguments and resolves SENTRY_* from the environment.
Declare them in your defineEnv contract and an app is finished without touching
app.config.ts.
A public value must exist when the bundle is BUILT. voltro build freezes the
declared public subset into the bundle — that is what makes it readable in a
browser — so these belong in the build (a Docker build-arg, a CI build step), not
in deployment env. Setting one as a deployment variable deploys cleanly and does
nothing; voltro start warns when it finds one, naming whether the bundle
carries that key at all.
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, run both — and both are on by default, so an app with the plugin on the api and @voltro/plugin-sentry as a dependency of the web app gets the whole trace from one SENTRY_DSN plus one VOLTRO_PUBLIC_SENTRY_DSN. Give both halves the same release.
Traces default to on, at tracesSampleRate: 1.0. That is the coherent default rather than a generous one: the browser half already sampled every page load, navigation and rpc call at 1.0, so with the api half off each of those spans was emitted, paid for, and belonged to a trace with no server side — a browser hop hanging off nothing. The rate is named on the boot line (sentry active traces=true tracesSampleRate=1) so a first boot shows what it is about to send; lower it with tracesSampleRate, or set traces: false to keep errors and breadcrumbs only.
Source maps (browser stack traces)
The web bundle is minified, so browser errors arrive in Sentry with minified stack traces unless the maps are uploaded. voltro build does it:
// app.config.ts
web: {
sourcemaps: {
mode: 'hidden',
upload: { org: 'acme', project: 'web' },
},
}# the build needs both; the deployment needs neither
SENTRY_AUTH_TOKEN=sntrys_… SENTRY_RELEASE=1.4.2 voltro buildThat emits the maps, uploads them through @sentry/cli, and then removes them from the output. Three things in that sentence are the reason it lives in the build rather than in your deploy script:
- The moment. There is no seam in a Dockerfile between "the bundle exists" and "the image is built". There is one here.
- The release. Sentry matches an artifact to an event by release, and the event's comes from
SENTRY_RELEASEviasentryPlugin. Upload under a different value and no frame resolves — silently, because an upload that matched nothing looks exactly like one that worked. A build with an upload configured and no release refuses rather than uploading under nothing. - The deletion. A
.mapleft indistis your source, downloadable by anyone. "We delete it in the deploy step" is a promise a failing build breaks, so the removal is afinally: the maps go even when the upload fails, and a configured upload that did not happen fails the build.
Turning it on selectively
app.config.ts is TypeScript, so the ordinary branch is the answer:
web: {
sourcemaps: process.env.CI
? { mode: 'hidden', upload: { org: 'acme', project: 'web' } }
: 'hidden',
}'hidden' alone emits the maps and keeps them — right for reading a stack trace locally, and a leak in an image. Omit the field entirely and nothing is emitted, which is the default.
keep defaults to "keep them only if nothing consumed them": false when upload is set, true when it is not. Set keep: true beside an upload when you want both.
The auth token is not a config field
It is read from SENTRY_AUTH_TOKEN and there is deliberately no authToken option: app.config.ts is a committed file, and a token with project:releases scope can write to every project in the org. Give it to the build (a CI secret, a Docker build secret) — not to the deployment, since the upload happens while the bundle is being built.
Install @sentry/cli yourself: pnpm add -D @sentry/cli in the web app. The framework depends on it nowhere, and that is a licence decision rather than a packaging one — it is FSL-1.1-MIT, which restricts competing commercial use, so it must not sit in the dependency graph of a package we publish. A build with an upload configured and the package missing refuses and names the command, rather than skipping the upload and leaving you to find out months later that no frame resolves.
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/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 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>.