mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## Problem The website initializes Sentry only on the server and edge runtimes, leaving browser crashes unreported. Its crash-reporting setup also needs the same consent and third-party filtering policy that docs and Studio otherwise maintain separately. ## Fix Add www browser initialization and tagged crash capture for both Next.js routers, with accessible fallback focus. Move the shared consent/platform and third-party filtering into common/sentry, reuse it from all three apps, and remove the duplicated docs/www helpers and tests. Preserve each app's initialization and Studio's additional noise filtering, sampling, and sanitization. Include the source-map upload token in www's build cache inputs, and trigger the shared/www and Studio test workflows when the shared policy changes. ## How to test - Run `pnpm --filter www test ../../packages/common/sentry.test.ts lib/sentry-capture.test.tsx`: all 22 shared-policy and real-SDK capture tests passed locally. - Run `pnpm --filter studio exec vitest run lib/sentry-client-options.test.ts`: all 42 Studio options and policy-parity tests passed locally. - The www capture tests exercise the actual initializer and both router handlers with an in-memory transport, verify crash tags and fallback focus, and enforce consent. Removing initialization, capture calls, boundary tags, or consent gating was verified to fail these tests. - On a www preview with its DSN configured, accept telemetry consent and trigger temporary render errors in both routers. Verify they reach the www Sentry project with the boundary tag and readable stack traces. Formatting passes. Full local app typechecks encounter existing dependency/generated-file drift, with no diagnostics in changed files. Three unchanged TanStack mock call-count tests fail locally and reproduce against the pre-refactor implementation. Live Sentry ingestion and source-map uploads remain deployment checks. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Accessibility** - Error pages now automatically move focus to a clearly labeled error message, helping screen-reader and keyboard users understand when a page fails. - **Reliability** - Browser error reporting now captures application crashes more consistently across supported page types and navigation transitions. - Reporting respects consent and platform availability while filtering unrelated third-party failures. - **Testing** - Expanded automated coverage for error capture, reporting rules, consent handling, and accessible error-page behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
353 lines
14 KiB
TypeScript
353 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 { filterSentryEvent, isSentryErrorBoundaryCrash } from 'common/sentry'
|
|
|
|
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) {
|
|
if (!filterSentryEvent(event, { isPlatform: IS_PLATFORM, hasConsent: hasConsented() })) {
|
|
return null
|
|
}
|
|
|
|
const isErrorBoundaryCrash = isSentryErrorBoundaryCrash(event)
|
|
|
|
// 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',
|
|
],
|
|
}
|
|
}
|