Files
supabase/apps/studio/lib/sentry-client-options.ts
Alaister Young 74bc0a8e27 fix(studio): initialize Sentry on the TanStack build (captures were silent no-ops) (#47666)
Stacked on #47657 (base is `alaister/tanstack-migration-fixes`; retarget
to `master` once that merges).

The TanStack runtime never ran `Sentry.init` —
`instrumentation-client.ts` is a Next-convention file nothing imports
under TanStack Start, so every `Sentry.captureException` on that build
(including the `routes/__root.tsx` error-boundary /
`routerErrorComponent` reports) was a silent no-op.

- **Shared config source**: the entire client config moves verbatim from
`instrumentation-client.ts` into `lib/sentry-client-options.ts`
(`buildSentryClientOptions`). Both runtimes build from it, so Next and
TanStack can't drift — the builds differ only in two explicit knobs.
- **TanStack init**: `sentry.tanstack.ts` initializes `@sentry/react`
from `getRouter()` (TanStack Start's real client bootstrap — the
earliest point with the router instance), wiring
`tanstackRouterBrowserTracingIntegration(router)`. Window-guarded +
idempotent; `router.tsx` is TanStack-only so the Next build is
untouched. (Named without `.client.` — Start's import-protection fails
the build for `*.client.*` in the server graph.)
- **Third-party error filter is intentionally Next-only**: without the
bundler-injected `applicationKey` metadata (only `withSentryConfig`
provides it), the SDK tags *every* event `third_party_code: true` and
`beforeSend` would drop them all — recreating the silent no-op with a
DSN set. Follow-up: add `@sentry/vite-plugin` moduleMetadata, then
enable.
- **DSN-less builds stay crash-free**: `vite.config.ts` inlines
`undefined` for unset
`NEXT_PUBLIC_SENTRY_DSN`/`NEXT_PUBLIC_SENTRY_ENVIRONMENT` (a literal
`process.env.*` in the bundle is the exact `process is not defined`
class #47657 fixed). No-DSN → disabled client, plus the existing
`IS_PLATFORM`/consent gates.
- Tests: `instrumentation-client.test.ts` moved to
`lib/sentry-client-options.test.ts` with all 36 assertions kept, plus
integration-gating and Next/TanStack parity tests. `tsc` clean; full
`vite build --mode test` passes.

Follow-up (separate): server-side Sentry for the Start handler
(`server.ts` entry + `@sentry/node`-style init).

## To test

- **Locally (no DSN set)**: load the TanStack build — no Sentry network
requests, no console errors, and crucially no `ReferenceError: process
is not defined` (the define fallback). Forcing an error must not POST to
any `/envelope` endpoint.
- **On a preview/deploy (DSN set, telemetry consent accepted)**: throw a
test error (e.g. crash a route component) → a POST to
`o…ingest.sentry.io/api/…/envelope/` fires, and the event lands in
Sentry with a `codeSampleRate` tag and **no** `third_party_code` tag.
Navigation spans named after TanStack routes appear when the 2% pageload
trace samples in.
- **Next build regression check**: the Next dev/preview still reports
errors exactly as before (`instrumentation-client.ts` now builds its
options from the same shared source).



---

### Review feedback: Sentry `/envelope` never fires on TanStack (Joshen)

Root-caused: `@sentry/core`'s `Client.sendSession` silently drops the
session when the client has no `release`. The Next build gets a release
injected by `withSentryConfig` (the Vercel commit SHA); the Vite build
runs no Sentry bundler plugin, so it had no release → session envelopes
were discarded before transport → zero `/envelope` traffic
(errors/transactions are separate). Fix: inject `release:
NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA` on the TanStack build (vite.config
re-exposes `VERCEL_GIT_COMMIT_SHA` under the `NEXT_PUBLIC_` name, same
SHA the Next release resolves to). Also switched `integrations` to the
function form so defaults are preserved by contract (not just by current
SDK behavior). 45 unit tests green.

**To test (deploys only — the SHA is unset locally, so this can't be
reproduced on a local dev build):** on this PR's Vercel preview with a
DSN + telemetry consent, load any page and watch the Network tab for a
POST to `…ingest.sentry.io/…/envelope/` — a session envelope should now
fire on load, matching the Next build.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Improved client-side error and performance monitoring for the Studio
app across both router setups.
* Added support for passing release/version information into monitoring
data.

* **Bug Fixes**
* Reduced noisy error reporting by better filtering common browser,
extension, cancellation, and load-related issues.
* Prevented browser bundles from referencing missing environment values
at runtime.
* Made monitoring initialization safer in server-rendered and
client-only environments.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-09 18:41:03 +08:00

369 lines
14 KiB
TypeScript

// Shared Sentry client-side configuration for BOTH Studio builds:
//
// - Next (pages router): `instrumentation-client.ts` — a Next convention
// file, auto-loaded by Next only — calls `Sentry.init` with these options.
// - TanStack Start (Vite): `sentry.tanstack.ts` calls `Sentry.init`
// with these options from `getRouter()` (router.tsx). TanStack Start does
// not load Next's convention files, so without its own init every
// `Sentry.captureException` there would be a silent no-op.
//
// Keep every shared option in this builder so the two runtimes cannot drift.
//
// `@sentry/react` is what `@sentry/nextjs` wraps on the client (same 10.x
// version, same module instance under pnpm), so building the options against
// it works for both `Sentry.init`s.
import * as Sentry from '@sentry/react'
import { thirdPartyErrorFilterIntegration } from '@sentry/react'
import { hasConsented } from 'common'
import { IS_PLATFORM } from 'common/constants/environment'
import { MIRRORED_BREADCRUMBS } from '@/lib/breadcrumbs'
import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from '@/lib/sanitize'
type Integration = Parameters<typeof Sentry.addIntegration>[0]
const DEFAULT_ERROR_SAMPLE_RATE = 1.0
const LOW_PRIORITY_ERROR_SAMPLE_RATE = 0.01
const CHUNK_LOAD_ERROR_PATTERNS = [
/ChunkLoadError/i,
/Loading chunk [\d]+ failed/i,
/Loading CSS chunk [\d]+ failed/i,
]
// This is a workaround to ignore hCaptcha related errors.
function isHCaptchaRelatedError(event: Sentry.Event): boolean {
const errors = event.exception?.values ?? []
for (const error of errors) {
if (
error.value?.includes('is not a function') &&
error.stacktrace?.frames?.some((f) => f.filename === 'api.js')
) {
return true
}
}
return false
}
// Filter browser wallet extension errors (e.g., Gate.io wallet)
// These errors come from injected wallet scripts and are not actionable
// Examples: SUPABASE-APP-AFC, SUPABASE-APP-92A
export function isBrowserWalletExtensionError(event: Sentry.Event): boolean {
const frames = event.exception?.values?.flatMap((e) => e.stacktrace?.frames || []) || []
return frames.some((frame) => {
const filename = frame.filename || frame.abs_path || ''
return filename.includes('gt-window-provider') || filename.includes('wallet-provider')
})
}
// Filter user-aborted operations (intentional cancellations)
// These are expected when users cancel requests or navigate away
// Examples: SUPABASE-APP-BG6, SUPABASE-APP-BG7
export function isUserAbortedOperation(error: unknown, event: Sentry.Event): boolean {
const errorMessage = error instanceof Error ? error.message : ''
const eventMessage = event.message || ''
const message = errorMessage || eventMessage
return (
message.includes('operation was aborted') ||
message.includes('signal is aborted') ||
message.includes('manually canceled') ||
message.includes('AbortError')
)
}
// Filter cancellation promise rejections (e.g., from query cancellation)
// These occur when operations are intentionally cancelled by the user
// Example: SUPABASE-APP-353 (~466k events)
export function isCancellationRejection(event: Sentry.Event): boolean {
const serialized = event.extra?.__serialized__ as Record<string, unknown> | undefined
return serialized?.type === 'cancelation'
}
// Filter challenge/captcha expired errors (user timeout)
// These happen when users don't complete captcha in time - expected behavior
// Example: SUPABASE-APP-ACC
export function isChallengeExpiredError(error: unknown, event: Sentry.Event): boolean {
const errorMessage = error instanceof Error ? error.message : ''
const eventMessage = event.message || ''
const message = errorMessage || eventMessage
return message.includes('challenge-expired')
}
function isChunkLoadError(error: unknown, event: Sentry.Event): boolean {
const errorMessage = error instanceof Error ? error.message : ''
const eventMessage = event.message || ''
const exceptionMessages = event.exception?.values?.map((ex) => ex.value ?? '') ?? []
const combinedMessages = [errorMessage, eventMessage, ...exceptionMessages].filter(Boolean)
return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) =>
combinedMessages.some((message) => pattern.test(message))
)
}
// Tag errors whose stack trace only contains third-party frames (browser extensions,
// injected scripts, etc.). This uses build-time code annotation via the applicationKey
// in next.config.ts to reliably distinguish our code from third-party code.
// We use 'apply-tag' instead of 'drop' so that beforeSend can exempt error boundary
// crashes — these may originate in third-party code but are caused by first-party bugs.
function buildThirdPartyErrorFilterIntegration(): Integration {
return thirdPartyErrorFilterIntegration({
filterKeys: ['supabase-studio'],
behaviour: 'apply-tag-if-exclusively-contains-third-party-frames',
})
}
export interface SentryClientOptionsParams {
/**
* Whether to include `thirdPartyErrorFilterIntegration`.
*
* Only enable this on builds whose bundler annotates stack frames with the
* `supabase-studio` applicationKey (the Next build does, via
* `withSentryConfig` in next.config.ts). On a build WITHOUT the annotation
* no frame carries first-party metadata, so the integration tags EVERY
* event `third_party_code: true` and `beforeSend` would then drop all
* non-error-boundary events.
*/
includeThirdPartyErrorFilter: boolean
/** Build-specific integrations (e.g. TanStack Router browser tracing). */
extraIntegrations?: Integration[]
/**
* Release identifier for the client.
*
* The SDK SILENTLY DROPS session envelopes when the client has no release
* (`Client.sendSession` early-returns), so a build without a release sends
* no Release Health traffic at all — errors and traces still flow.
*
* The Next build must NOT pass this: `withSentryConfig` injects the release
* (`SENTRY_RELEASE` ?? the Vercel commit SHA) into the bundle at build time,
* and an explicit `release` key — even `undefined` — would override it.
* The TanStack/Vite build runs no Sentry bundler plugin, so it passes the
* commit SHA here instead (see sentry.tanstack.ts).
*/
release?: string
}
export function buildSentryClientOptions({
includeThirdPartyErrorFilter,
extraIntegrations = [],
release,
}: SentryClientOptionsParams): Sentry.BrowserOptions {
return {
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && {
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
}),
// Conditional spread: see the `release` doc comment above — the key must
// be ABSENT (not `undefined`) so the Next build's injected release wins.
...(release && { release }),
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
// Enable performance monitoring
tracesSampleRate: 0.02,
// Function form so Sentry's default integrations (browserSession,
// globalHandlers, breadcrumbs, dedupe, …) are explicitly preserved — this
// is the documented way to extend the defaults, and it can never be
// misread as replacing them.
integrations: (defaultIntegrations) => [
...defaultIntegrations,
...(includeThirdPartyErrorFilter ? [buildThirdPartyErrorFilterIntegration()] : []),
...extraIntegrations,
],
// Only capture errors originating from our own code.
// This is a whitelist on the source URL in stack frames — it drops errors from
// browser extensions, injected scripts, third-party widgets, etc. (FE-2094)
allowUrls: [
/https?:\/\/(.*\.)?supabase\.(com|co|green|io)/,
/app:\/\//, // Next.js rewrites source URLs to app:// with source maps
],
beforeBreadcrumb(breadcrumb, _hint) {
const cleanedBreadcrumb = { ...breadcrumb }
if (cleanedBreadcrumb.category === 'navigation') {
if (typeof cleanedBreadcrumb.data?.from === 'string') {
cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from)
}
if (typeof cleanedBreadcrumb.data?.to === 'string') {
cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to)
}
}
MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb)
return cleanedBreadcrumb
},
beforeSend(event, hint) {
const consent = hasConsented()
if (!consent) {
return null
}
if (!IS_PLATFORM) {
return null
}
const isErrorBoundaryCrash =
event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true'
const isThirdPartyOnly =
event.tags?.third_party_code === true || event.tags?.third_party_code === 'true'
// Drop third-party-only errors UNLESS they crashed the page via the global error boundary.
// This preserves noise reduction for browser extensions and injected scripts,
// while ensuring page-crashing errors from third-party libs (caused by first-party bugs)
// are always reported.
if (isThirdPartyOnly && !isErrorBoundaryCrash) {
return null
}
// Downsample only known high-noise classes; keep all other errors at full rate.
const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes(
`Failed to construct 'URL': Invalid URL`
)
const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes(
'Session error detected'
)
const isChunkLoadFailure = isChunkLoadError(hint.originalException, event)
const codeSampleRate =
isInvalidUrlEvent || isSessionTimeoutEvent || isChunkLoadFailure
? LOW_PRIORITY_ERROR_SAMPLE_RATE
: DEFAULT_ERROR_SAMPLE_RATE
if (Math.random() > codeSampleRate) {
return null
}
event.tags = {
...event.tags,
codeSampleRate: codeSampleRate.toString(),
}
if (isHCaptchaRelatedError(event)) {
return null
}
// Drop events where every exception has no stack trace — these are not debuggable.
// Exempt error boundary crashes: even without stack frames, a page crash is always worth reporting.
const exceptions = event.exception?.values ?? []
if (
!isErrorBoundaryCrash &&
exceptions.length > 0 &&
exceptions.every((ex) => !ex.stacktrace?.frames?.length)
) {
return null
}
// Filter out errors like 'e._5BLbSXV[t] is not a function' or anything matching '[t] is not a function'
if (
hint.originalException instanceof Error &&
hint.originalException.message.includes('[t] is not a function')
) {
return null
}
if (isBrowserWalletExtensionError(event)) {
return null
}
if (isUserAbortedOperation(hint.originalException, event)) {
return null
}
if (isCancellationRejection(event)) {
return null
}
if (isChallengeExpiredError(hint.originalException, event)) {
return null
}
if (event.breadcrumbs) {
event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[]
}
return event
},
ignoreErrors: [
// === Monaco Editor ===
'ResizeObserver',
's.getModifierState is not a function',
/^Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'/,
// === Browser extension errors ===
// Gate.io wallet
'shouldSetTallyForCurrentProvider is not a function',
// SAP browser extensions (SAP GUI, SAP Companion)
'sap is not defined',
// Non-Error objects thrown as exceptions (e.g., Event objects)
'[object Event]',
// === Third-party SDK errors ===
// stripe-js: https://github.com/stripe/stripe-js/issues/26
'Failed to load Stripe.js',
// hCaptcha
"undefined is not an object (evaluating 'n.chat.setReady')",
"undefined is not an object (evaluating 'i.chat.setReady')",
// === Next.js internals ===
// Ref: https://github.com/supabase/supabase/pull/9729
/The provided `href` \(\/org\/\[slug\]\/.*\) value is missing query values/,
// Next.js throws these during navigation, not actual errors
'NEXT_NOT_FOUND',
'NEXT_REDIRECT',
// === User input errors (not bugs) ===
// sql-formatter lexer on invalid SQL input
/^Parse error: Unexpected ".+" at line \d+ column \d+$/,
// === Network / infrastructure (not actionable on FE) ===
/504 Gateway Time-out/,
'Network request failed',
'Failed to fetch',
'Load failed',
'AbortError',
'TypeError: cancelled',
'TypeError: Cancelled',
// === Browser extensions & Google Translate DOM manipulation ===
'Node.insertBefore: Child to insert before is not a child of this node',
'Node.removeChild: The node to be removed is not a child of this node',
"NotFoundError: Failed to execute 'removeChild' on 'Node'",
"NotFoundError: Failed to execute 'insertBefore' on 'Node'",
'NotFoundError: The object can not be found here.',
"Cannot read properties of null (reading 'parentNode')",
"Cannot read properties of null (reading 'removeChild')",
"TypeError: can't access dead object",
/^NS_ERROR_/,
// === Non-Error throws (extensions, third-party libs throwing strings/objects) ===
'Non-Error exception captured',
'Non-Error promise rejection captured',
/^Object captured as exception with keys:/,
// === Cross-origin script errors (no useful info) ===
'Script error.',
'Script error',
// === React hydration mismatches caused by extensions modifying DOM ===
// Note: we only suppress the generic browser messages, NOT "Hydration failed because..."
// which can indicate real SSR/client mismatches in our own code.
/text content does not match/i,
/There was an error while hydrating/i,
// === Web crawler / bot errors ===
'instantSearchSDKJSBridgeClearHighlight',
// === Third-party library race conditions ===
// cmdk: useSyncExternalStore subscribe called before store context is available
"Cannot read properties of undefined (reading 'subscribe')",
"undefined is not an object (evaluating 't.subscribe')",
// === Misc known noise ===
'r.default.setDefaultLevel is not a function',
// Clipboard permission denied
'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.',
// Facebook pixel
'fb_xd_fragment',
],
}
}