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.

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/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 —

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" ./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:

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 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>.