Sentry

Deep Sentry integration — errors correlated to the distributed trace, breadcrumbs from the log sink, and controlled 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. The framework's spans flow to Sentry as transactions by default.

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 on)
      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 true 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.

Server traces

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.

Correlating the browser page with server traces

The plugin ships a browser half at @voltro/plugin-sentry/web. It records one page-load or navigation transaction with rpc child spans, while each rpc child links to the Effect trace continued by the server. This avoids a transaction and network envelope per rpc call without losing the connection between browser and server work. 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,
    browserTracing: true,
    rpcSpans: true,
  },
}

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.
  • Keeps rpc spans page-owned. 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 browser half adds a child to the currently active page/navigation transaction and attaches the Effect/server trace as a span link. If no browser transaction is active, it emits no rpc root transaction.
  • Honours sampling. tracesSampleRate decides whether the page/navigation root is recorded; its rpc children inherit that decision. The bridge does not inject a sampled parent decision.

Browser trace controls:

Option Default Effect
tracesSampleRate 1.0 Samples page-load/navigation transactions and therefore their rpc children. Error reporting is independent.
browserTracing true Installs page-load/navigation instrumentation. false also defaults rpcSpans to off.
rpcSpans same as browserTracing Adds rpc children to an active browser transaction. false keeps browser errors and page traces but removes the rpc bridge. Set it to true with browserTracing:false only if your app creates active Sentry transactions itself.

At browser boot the plugin prints the effective values, including the mode:

[@voltro/plugin-sentry] browser active { tracesSampleRate: 1, browserTracing: true, rpcSpans: true, rpcSpanMode: 'page-child-with-server-link' }

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 page performance, browser errors, rpc markers, and links back to the server traces. Both tracing halves 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 server transactions plus one sampled browser transaction per page/navigation 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 explicit, not a production recommendation. Lower the rate independently on api and web to fit the trace budget. Set api traces:false to keep server errors and breadcrumbs only; set web browserTracing:false to keep browser error capture without automatic performance transactions; or use web rpcSpans:false to keep page performance while omitting rpc child spans.

Source maps (every bundle, browser and server)

Every bundle voltro build emits is minified, so an error arrives in Sentry with a minified stack trace unless the maps are uploaded. voltro build does it:

// app.config.ts — top level, for a web app AND an api app
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 build

That 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_RELEASE via sentryPlugin. 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 .map left in dist is your source, downloadable by anyone. "We delete it in the deploy step" is a promise a failing build breaks, so the removal is a finally: 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:

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 app being built. 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.

It covers the server too — and that is what it was missing

An earlier version of this page said server traces were un-minified already and that source maps were a browser-only concern. That was wrong about the built artefact, and the correction is the reason the option moved to the top level.

voltro build minifies four bundles, not one: the browser bundle, the SSR bundle, the voltro start bundle, and — for an api app — the precompiled entry plus the serve bundle that voltro serve actually runs. keepNames rescues the handful of identifiers that are read as strings (Effect tags, error names) and nothing else, so a frame out of the serve bundle reads:

at l  (file:///…/serveBundle/serveEntry.js:79:51)
at Qo (file:///…/serveBundle/serveEntry.js:236:76405)

The server half is also where it hurts most. A schedule, a workflow step, a subscriber or a REST route has no browser caller whose frame could stand in for it — a workflow that dies at 03:30 in a cron is only what its stack trace says.

So sourcemaps is one field at the top level of app.config.ts and it covers everything the build minifies. It replaces web.sourcemaps, which reached only the browser bundle; the build refuses the old spelling rather than ignoring it, and voltro update moves it for you.

Two details worth knowing before the first upload:

  • Each output directory is uploaded under the prefix that matches how its files are named. dist/assets is fetched by a browser under ~/assets; the SSR, start and serve bundles are files a node process loaded and go up under ~/. Set upload.urlPrefix only if you know your project needs one value for both.
  • node:*-slim images have no CA certificates. /etc/ssl/certs is empty and the ca-certificates package is not installed. Node carries its own roots, so nothing in JS notices — but sentry-cli is a Rust binary that uses the system store, and it fails with SSL peer certificate … unable to get local issuer certificate. Add ca-certificates to the build stage.

Debug ids are what make a server frame resolve, and they need their own step. sentry-cli sourcemaps upload does not write them — inject is a separate subcommand, and upload only uses ids that are already there, falling back to matching on the artifact NAME when they are not.

That fallback cannot work for a server bundle: the artifact is named from --url-prefix (~/chunk-ABC.js) while the frame carries the absolute path the node process loaded (/workspace/…/serveBundle/chunk-ABC.js), and nothing rewrites either side. Measured downstream before this was fixed: 4300 artifacts uploaded, the release finalised, every frame still minified — nothing red, which is the failure this page's own warning describes.

voltro build runs inject first now, so the ids are in both the shipped JS and the map before anything is uploaded. The URL prefix stays as the fallback for the browser bundle, whose frames really are URLs under ~/assets.

An uploaded artifact count does not prove that one real event resolves. Check the exact event after a deploy in Sentry's in-product source-map debugger (open the event, then the stack frame's "source map" indicator): it reports the event's release, debug id and artifact match rather than merely confirming that an upload command returned successfully. sentry-cli 3 removed its sourcemaps explain command in favour of that view, which is why the framework no longer wraps it.

The web server is a SECOND process — and it needs the DSN too

A framework app runs two server processes. voltro serve is the api, where sentryPlugin() initialises the SDK through the plugin lifecycle. voltro start is the web server — SSR, loaders, ISR, the revalidation legs — and it has no plugin lifecycle at all.

It now initialises Sentry itself when SENTRY_DSN is set:

SENTRY_DSN=… SENTRY_ENVIRONMENT=production SENTRY_RELEASE=1.4.2 voltro start
{"level":"info","msg":"sentry active","environment":"production","release":"1.4.2","traces":false,"profiling":false,"process":"web"}

Same line and same field names as the api half, from the same function — one vocabulary across both pods, plus process so a collector can tell them apart.

Why this was a hole rather than a missing log line. An SSR shell throw is caught, logged, and answered with a 500. No browser ever renders it, so the client-side ErrorBoundary bridge below cannot see it either: the error existed only in the pod's stdout. Both ends of the integration worked and the middle was dark.

Three things to know:

  • @voltro/plugin-sentry must be installed in the WEB app, not only the api — the web server resolves the shared init from it. With a DSN set and the package missing, the boot says so and names the command; it does not fail.
  • traces is false here, deliberately. The api half contributes a span processor to the framework's tracer. The web server has no tracer, so initialising with tracing on would put traces: true in a boot line while nothing produces a span.
  • No DSN is silent. On the api side sentryPlugin() in your config is a declaration, so an inert one is worth a warning. Here there is no declaration to contradict, and a line about an integration you did not ask for is noise.

Re-run voltro build after upgrading. Production voltro start loads the app's precompiled start bundle, so the web half picks this up when that bundle is rebuilt — not from the framework version alone.

What reaches Sentry — a declared failure does not

A procedure's error: union is a contract. Somebody wrote the outcome down, the client gets it typed and branches on it, and it describes a state of the world: AccessDeniedError on a team you are not a member of is the system working. A defect is the opposite — nobody foresaw it. Sentry is built for the second.

So a cause that carries only declared failures is not reported:

sentryPlugin()                              // captureFailures: false — the default
sentryPlugin({ captureFailures: true })     // report them again
sentryPlugin({ captureFailures: 'infrastructure' })   // declared, but the STORE / a transport / the cipher broke
sentryPlugin({ captureFailures: (e) => (e as { _tag?: string })._tag === 'PaymentDeclined' })
  • 'infrastructure' is the preset between the two booleans. A handler may declare StoreOperationFailed in its error: so a client gets it typed — and a declared database outage is still a database outage. The preset reports a failure whose tag says infrastructure broke (StoreOperationFailed, SqlError, ResultLengthMismatch, FieldDecryptionError, RequestError, ResponseError, CacheError, WorkflowAuthorityUnavailable) and keeps every other declared failure out. Without it, migrating handlers to EffectStore would have removed every store outage from Sentry.

  • Defects are always reported, including a defect that arrived beside a failure — the rule is failures-only, so one cannot hide the other.

  • Both halves take the option. A rejected call is an rpc error on the client too, so initSentryBrowser({ captureFailures }) applies the same rule to rpc.* events that carry a _tag. Set them together, or the api goes quiet while the browser keeps filing the same rejection.

  • Route render failures and reportClientError are never filtered. Nobody declared those, which is the whole distinction.

  • The boot line names it — sentry active … captureFailures=false — because a default the framework picks for you is one nobody finds again.

Why the default is off: an issue list that fills with the expected is one nobody reads. Every invalid form value, every click on a resource without access and every call on a deleted row was a level: error issue. If Sentry is where you see errors at all, captureFailures: true is the one-line answer and it is a reasonable choice.

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, in the BROWSER (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.
React render error, during SSR — six sites: ssr-shell, spa-layout-shell, render (incl. a loader throw), not-found-render, ppr-holes, swr-refresh ✅ auto, from the WEB server a different process and a different path: the render never reaches a browser, so the row above cannot cover it. Captured by the web server's own Sentry (see the section above), tagged voltro.process=web and voltro.stage. The last three are warn-level — the request still serves something — and are reported anyway: quieter is not less real, and nothing else can see them. Needs SENTRY_DSN on the web deployment.
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 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.

An encrypted workflow's error text stays in your process. A workflow declared encrypted: true publishes its failures with contentSensitive: true, and the plugin then sends a withheld copy: the error's class (so issues still group), its stack frames and the run's identity (runId, executionId, trace), with the message replaced by a pointer to the run and without its cause or a typed failure's fields. The event is tagged voltro.contentWithheld: true. The full error is readable where the run is shown, by whoever may see the run. A reporter of your own subscribes to the same bus and can call withheldServerError(event) from @voltro/protocol — see encrypted workflows.

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