Observability (Sentry-compatible error tracking)
Observability (Sentry-compatible error tracking)
Error tracking via the Sentry SDK, sending to any Sentry-compatible backend. Optional: omit VITE_SENTRY_DSN to disable entirely.
Setup
# .env
VITE_SENTRY_DSN=https://...@...ingest.sentry.io/...
DSNs are public by design and safe to commit or expose client-side. The VITE_ prefix makes the value available to the browser bundle.
Providers
The SDK sends events to whatever host the DSN names, so switching backend is changing VITE_SENTRY_DSN, no code change:
| Provider | DSN from | Hosting | Source maps |
|---|---|---|---|
| Sentry | Project, Settings, Client Keys | Hosted or self-hosted | Yes (below) |
| Better Stack | Errors, Applications, your app, Data ingestion. DSN is https://<token>@<ingesting host>/<application id> | Hosted | Unverified |
| GlitchTip (MIT) | Project settings, DSN | Hosted (free tier) or self-hosted | Yes, set SENTRY_URL |
| Datadog Error Tracking | Datadog's Sentry SDK guide | Hosted | See Datadog's guide |
| Self-hosted Sentry | Project, Client Keys | Self-hosted | Yes, set SENTRY_URL |
Verified 2026-10-24 with SDK 10.58 against GlitchTip and Better Stack from a production build: browser errors, server errors and sessions all accepted (HTTP 200), no CSP violations. Source map upload not yet tested on either.
Not compatible (own SDKs): Honeybadger, Rollbar, Bugsnag, Raygun.
Nothing in the app names a provider: the CSP allows the DSN's origin (parseSentryDsn in src/lib/sentry-dsn.ts), and the init calls only read the DSN.
How it works
| Where | When | SDK |
|---|---|---|
src/lib/sentry.ts | __root.tsx module evaluation (browser only) | @sentry/tanstackstart-react |
Inline in server/start.ts | Server boot, guarded on VITE_SENTRY_DSN (only under bun run start, not bun run dev, see CLAUDE.md's "Server Boot Side Effects") | @sentry/tanstackstart-react |
src/lib/sentry.ts doesn't need to be imported from application code, it self-initializes on module evaluation. The server-side init() call is not, and cannot be, a separate self-loading file: it previously lived in server/plugins/sentry.ts as a Nitro plugin, but that mechanism never actually ran (Nitro's plugin auto-load requires registering Nitro's own Vite plugin, which this project never did, see docs/warpkit/features/jobs.md's "Why server/start.ts, not a Nitro plugin"). Do not call init() anywhere else.
Tunnel
Opt-in: set VITE_SENTRY_TUNNEL=true (or 1) and the browser SDK posts to /api/m on the app's own origin (tunnel in src/lib/sentry.ts), not to the backend's ingest host. Ad blockers drop requests to *.sentry.io and similar hosts by domain, so without this a large share of browser errors (developer audiences especially) never arrive.
| Piece | File |
|---|---|
Path constant (SENTRY_TUNNEL_PATH) and DSN parsing | src/lib/sentry-dsn.ts |
| Route, per-IP rate limit (60/min) | src/routes/api/m.ts |
| Forwarding logic | src/server/sentry-tunnel.ts |
- Not an open relay. The upstream URL is always built from
VITE_SENTRY_DSN. An envelope whose header names another host, project or key gets a 400 and nothing is sent. - Bytes pass through unchanged (attachments are binary), capped at 1MB. Upstream status and the
X-Sentry-Rate-Limits/Retry-Afterheaders are passed back so the SDK still backs off. - No visitor IP is forwarded. With
sendDefaultPii: falsethe backend wouldn't store it anyway; geo on browser events is the server's location. - Server-side events don't use it: the server SDK talks to the backend directly.
- Off by default. Unset, empty,
false,0or any other value: the browser SDK sends straight to the DSN's host, which the CSP already allows, and/api/mreturns 404 so nothing is left listening. Off is the stock Sentry behavior and keeps error traffic off your own server (and off a host that bills per request). Like the DSN, the flag is read at build time (browser) and at runtime (route), so set it in both. Tests:src/lib/sentry.test.ts,src/routes/api/m.test.ts. - The path is deliberately neutral: filter lists match paths like
/sentryand/tunnel. Renaming the route means changingSENTRY_TUNNEL_PATH;src/routes/api/m.test.tsfails if the two drift.
Verified 2026-10-24 against GlitchTip and Better Stack from a production build with the backend's host blocked in the browser: every error and session went through /api/m and got HTTP 200.
Capturing errors
Error boundary components (already wired in src/pages/error-pages/error.tsx, rendered both as __root.tsx's route-level errorComponent and as the fallback for the <ErrorBoundary> wrapping <Outlet/> in the same file):
import { captureException } from '@sentry/tanstackstart-react';
import { useEffect, useState } from 'react';
type Props = { error: unknown; reset?: () => void; eventId?: string | null };
export function ErrorPage({ error, reset, eventId: providedEventId }: Props) {
const [eventId, setEventId] = useState<string | null>(providedEventId ?? null);
useEffect(() => {
// Sentry's own <ErrorBoundary fallback> already captured this and
// computed an event id - only self-capture when nothing did (e.g. when
// this component is used as TanStack Router's own errorComponent
// instead, which doesn't pass eventId).
if (providedEventId !== undefined) return;
setEventId(captureException(error));
}, [error, providedEventId]);
// ...
}
<ErrorBoundary> MUST get a fallback prop. __root.tsx wraps <Outlet/> in @sentry/react's <ErrorBoundary> to catch render-time exceptions that TanStack Router's own errorComponent never sees (Router's errorComponent only fires for loader/beforeLoad errors, not exceptions thrown while a route's own component renders). Passing no fallback at all is a real bug that shipped to this app's production: @sentry/react's ErrorBoundary.render() returns null when it catches an error and fallback wasn't provided (confirmed by reading node_modules/@sentry/react/build/cjs/errorboundary.js directly) - the entire app goes silently blank, no error message, no recovery button, just the bare <body> background color. This shipped undetected because Sentry still captured the exception either way and nothing about the blank screen itself ever reached Sentry. Always wire it:
<ErrorBoundary
fallback={({ error, eventId, resetError }) => (
<ErrorPage error={error} eventId={eventId} reset={resetError} />
)}
>
<Outlet />
</ErrorBoundary>
Server-side errors (same SDK, works in both runtimes):
import { captureException } from '@sentry/tanstackstart-react';
captureException(err);
Source maps (do this - errors are unreadable without it)
By default, sentryTanstackStart() in vite.config.ts still runs, but without a Sentry auth token it cannot upload source maps - every production error you'll ever see in Sentry shows up as minified garbage: a bare Error: undefined (or similarly useless message) with no file, no line, no real stack trace, no matter how good your own error-handling code is. This is the default state of every fresh clone of this template, and it turned a real production bug into hours of guesswork before anyone noticed sourcemaps were never wired up in the first place. Set this up as soon as Sentry is configured at all, not after the first confusing error report.
1. Generate an auth token - Sentry dashboard → Settings → Auth Tokens (organization-level, not project-level) → Create New Token, scoped to project:releases (the minimum scope source map upload needs).
2. Add three env vars to your production environment (wherever you manage deploy-time secrets - these must be present during the build step, not just at server runtime, since sourcemap upload happens as part of vite build):
SENTRY_AUTH_TOKEN=sntrys_... # the token from step 1 - keep secret, do NOT prefix with VITE_
SENTRY_ORG=your-org-slug # from the Sentry dashboard URL
SENTRY_PROJECT=your-project-slug # from the Sentry project settings
sentryTanstackStart() (vite.config.ts) auto-detects all three from the environment - no code change needed once they're set. It also auto-enables 'hidden' source maps if you haven't explicitly configured sourcemap generation elsewhere, and deletes the local .map files after a successful upload so they never ship to the browser (Sentry has them; the public bundle doesn't).
Not on sentry.io? Also set SENTRY_URL to your backend's base URL (e.g. https://app.glitchtip.com); the upload plugin defaults to https://sentry.io. SENTRY_ORG/SENTRY_PROJECT are that backend's slugs.
3. Redeploy. Only errors captured after a build with these env vars present will have real stack traces - this cannot be retroactively applied to already-captured events.
Verify it worked: trigger any error in production, open the event in Sentry, and check for a real file/line in the stack trace (not a minified one-liner) and readable original source in the code context panel. If you still see minified output, double check the three env vars actually reached the build environment - a common mistake is adding them only to the server's runtime env (e.g. .env.production read at boot) without confirming the deploy pipeline also exports them during the vite build/bun run build step itself.
CSP
buildCsp (src/server/csp.ts) adds the origin of VITE_SENTRY_DSN (read at server runtime) to connect-src, so the browser can reach whichever backend the DSN points at. No DSN, no entry. Set VITE_SENTRY_DSN in the runtime env as well as at build time, or the browser has the DSN but the CSP blocks its reports.
Configuration
Sampling rates and other options are in src/lib/sentry.ts (browser) and inline in server/start.ts (server):
tracesSampleRate: 0.2, // 20% of transactions
sendDefaultPii: false // no PII in events
Adjust tracesSampleRate based on your traffic volume and your provider's quota.