diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 5926095fa21..3257114252d 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -3,7 +3,7 @@ "react-hooks/exhaustive-deps": 161, "import/no-anonymous-default-export": 57, "@tanstack/query/exhaustive-deps": 9, - "@typescript-eslint/no-explicit-any": 898, + "@typescript-eslint/no-explicit-any": 897, "no-restricted-imports": 0, "no-restricted-exports": 198, "react/no-unstable-nested-components": 38, @@ -556,7 +556,6 @@ "hooks/misc/withAuth.tsx": 1, "hooks/ui/useClickedOutside.ts": 2, "hooks/ui/useFlag.ts": 1, - "instrumentation-client.ts": 3, "lib/ai/generate-assistant-response.ts": 2, "lib/ai/model.ts": 2, "lib/ai/model.utils.ts": 3, @@ -571,6 +570,7 @@ "lib/pg-format.ts": 6, "lib/profile.tsx": 1, "lib/role-impersonation.ts": 1, + "lib/sentry-client-options.ts": 2, "lib/telemetry/track.ts": 1, "pages/_app.tsx": 1, "pages/api/ai/feedback/rate.ts": 2, diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index 0097516a418..132068af7e9 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -1,314 +1,22 @@ -// This file configures the initialization of Sentry on the client. -// The config you add here will be used whenever a user loads a page in their browser. +// This file configures the initialization of Sentry on the client for the +// NEXT build — Next auto-loads it whenever a user loads a page in their +// browser. The TanStack Start (Vite) build never loads Next convention files; +// it initializes Sentry with the same shared options in +// sentry.tanstack.ts instead. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from '@sentry/nextjs' -import { hasConsented } from 'common' -import { IS_PLATFORM } from 'common/constants/environment' -import { MIRRORED_BREADCRUMBS } from '@/lib/breadcrumbs' -import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from '@/lib/sanitize' +import { buildSentryClientOptions } from '@/lib/sentry-client-options' -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') +Sentry.init( + buildSentryClientOptions({ + // next.config.ts (withSentryConfig) annotates the bundles with the + // 'supabase-studio' applicationKey, so third-party frame tagging works + // on this build. + includeThirdPartyErrorFilter: true, }) -} - -// 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 | 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)) - ) -} - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, - }), - // 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, - - integrations: (() => { - const thirdPartyErrorFilterIntegration = (Sentry as any).thirdPartyErrorFilterIntegration - if (!thirdPartyErrorFilterIntegration) return [] - - // 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. - return [ - thirdPartyErrorFilterIntegration({ - filterKeys: ['supabase-studio'], - behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', - }), - ] - })(), - - // 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', - ], -}) +) // This export will instrument router navigations, and is only relevant if you enable tracing. export const onRouterTransitionStart = Sentry.captureRouterTransitionStart diff --git a/apps/studio/instrumentation-client.test.ts b/apps/studio/lib/sentry-client-options.test.ts similarity index 77% rename from apps/studio/instrumentation-client.test.ts rename to apps/studio/lib/sentry-client-options.test.ts index c8f5d1aab13..74732c21557 100644 --- a/apps/studio/instrumentation-client.test.ts +++ b/apps/studio/lib/sentry-client-options.test.ts @@ -1,12 +1,13 @@ -import type { Event as SentryEvent, StackFrame } from '@sentry/nextjs' +import type { Event as SentryEvent, StackFrame } from '@sentry/react' import { describe, expect, it } from 'vitest' import { + buildSentryClientOptions, isBrowserWalletExtensionError, isCancellationRejection, isChallengeExpiredError, isUserAbortedOperation, -} from './instrumentation-client' +} from './sentry-client-options' describe('Sentry beforeSend filtering functions', () => { describe('isBrowserWalletExtensionError', () => { @@ -417,3 +418,80 @@ describe('Sentry beforeSend filtering functions', () => { }) }) }) + +describe('buildSentryClientOptions', () => { + // Representative subset of Sentry's default integrations. `integrations` + // is the function form: Sentry.init calls it with the defaults and installs + // whatever it returns, so dropping these here would disable session + // envelopes (BrowserSession) and window.onerror capture (GlobalHandlers). + const fakeDefaultIntegrations = [{ name: 'BrowserSession' }, { name: 'GlobalHandlers' }] + + const getIntegrationNames = (options: ReturnType) => { + const integrations = options.integrations + if (typeof integrations !== 'function') { + throw new Error('expected the function form of integrations') + } + return integrations(fakeDefaultIntegrations).map((integration) => integration.name) + } + + it('preserves the default integrations passed in by Sentry.init', () => { + for (const includeThirdPartyErrorFilter of [true, false]) { + const names = getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter })) + // browserSessionIntegration is what sends the session envelope on every + // page load; globalHandlers is window.onerror / unhandledrejection. + expect(names).toContain('BrowserSession') + expect(names).toContain('GlobalHandlers') + } + }) + + it('sets the release only when one is provided', () => { + const withRelease = buildSentryClientOptions({ + includeThirdPartyErrorFilter: false, + release: 'abc123', + }) + expect(withRelease.release).toBe('abc123') + + // The key must be ABSENT when no release is passed: on the Next build a + // `release: undefined` entry would override the release injected into + // @sentry/nextjs's init by withSentryConfig (options are spread last). + const withoutRelease = buildSentryClientOptions({ includeThirdPartyErrorFilter: true }) + expect('release' in withoutRelease).toBe(false) + }) + + it('includes the third-party error filter only when the build annotates frames', () => { + // Next build: withSentryConfig injects the applicationKey metadata. + expect( + getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter: true })) + ).toContain('ThirdPartyErrorsFilter') + + // TanStack/Vite build: no bundler metadata — including the integration + // would tag every event third_party_code=true and beforeSend would drop + // them all. + expect( + getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter: false })) + ).not.toContain('ThirdPartyErrorsFilter') + }) + + it('appends build-specific extra integrations', () => { + const options = buildSentryClientOptions({ + includeThirdPartyErrorFilter: false, + extraIntegrations: [{ name: 'FakeRouterTracing' }], + }) + + expect(getIntegrationNames(options)).toContain('FakeRouterTracing') + }) + + it('builds the same shared options for both builds (parity)', () => { + const nextOptions = buildSentryClientOptions({ includeThirdPartyErrorFilter: true }) + const tanstackOptions = buildSentryClientOptions({ includeThirdPartyErrorFilter: false }) + + // Everything except the integrations array must be identical between the + // two runtimes. + const { integrations: _next, ...nextRest } = nextOptions + const { integrations: _tanstack, ...tanstackRest } = tanstackOptions + expect(Object.keys(nextRest)).toEqual(Object.keys(tanstackRest)) + expect(nextRest.tracesSampleRate).toBe(tanstackRest.tracesSampleRate) + expect(nextRest.allowUrls).toEqual(tanstackRest.allowUrls) + expect(nextRest.ignoreErrors).toEqual(tanstackRest.ignoreErrors) + }) +}) diff --git a/apps/studio/lib/sentry-client-options.ts b/apps/studio/lib/sentry-client-options.ts new file mode 100644 index 00000000000..693f01021df --- /dev/null +++ b/apps/studio/lib/sentry-client-options.ts @@ -0,0 +1,368 @@ +// 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[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 | 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', + ], + } +} diff --git a/apps/studio/router.tsx b/apps/studio/router.tsx index 35a24884b76..81f101d4c91 100644 --- a/apps/studio/router.tsx +++ b/apps/studio/router.tsx @@ -3,6 +3,7 @@ import { createRouter } from '@tanstack/react-router' import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' import { routeTree } from './routeTree.gen' +import { initSentryTanStackClient } from './sentry.tanstack' import { getQueryClient } from '@/data/query-client' import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' import { parseSearch, stringifySearch } from '@/lib/router-search-params' @@ -87,6 +88,12 @@ export function getRouter() { basepath: process.env.NEXT_PUBLIC_BASE_PATH || undefined, }) + // Sentry: nothing loads Next's convention files (instrumentation-client.ts) + // under TanStack Start, so init happens here — the earliest point with + // access to the router instance, which the tracing integration needs. + // No-op on the server and when no DSN is configured (see module). + initSentryTanStackClient(router) + // @tanstack/react-router-ssr-query@1.166.12 pulls in @tanstack/query-core@5.100 // as a peer, but our app pins react-query to 5.83. The QueryClient class is // structurally identical between the two, but TS treats them as nominally diff --git a/apps/studio/sentry.tanstack.test.ts b/apps/studio/sentry.tanstack.test.ts new file mode 100644 index 00000000000..2adb968ef6a --- /dev/null +++ b/apps/studio/sentry.tanstack.test.ts @@ -0,0 +1,136 @@ +import type { AnyRouter } from '@tanstack/react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sentryMocks = vi.hoisted(() => ({ + init: vi.fn(), + tanstackRouterBrowserTracingIntegration: vi.fn(() => ({ + name: 'TanStackRouterBrowserTracing', + })), + // Imported at module scope by lib/sentry-client-options.ts, so the mock + // must provide it even though the TanStack init never enables it. + thirdPartyErrorFilterIntegration: vi.fn(() => ({ name: 'ThirdPartyErrorsFilter' })), +})) + +vi.mock('@sentry/react', () => sentryMocks) + +// The integration only needs a router reference to hook navigation events, and +// it is mocked here — a stub stands in for the real router at this boundary. +const fakeRouter = { subscribe: vi.fn() } as unknown as AnyRouter + +// sentry.tanstack.ts keeps a module-level `initialized` flag, so each test +// imports a fresh copy of the module. +async function loadInitializer() { + vi.resetModules() + const { initSentryTanStackClient } = await import('./sentry.tanstack') + return initSentryTanStackClient +} + +describe('initSentryTanStackClient', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('does not initialize Sentry during SSR/prerender (no window)', async () => { + const initSentryTanStackClient = await loadInitializer() + vi.stubGlobal('window', undefined) + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).not.toHaveBeenCalled() + }) + + it('initializes Sentry in the browser with the shared client options', async () => { + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', 'https://public@sentry.example.com/1') + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + expect(sentryMocks.init).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@sentry.example.com/1', + tracesSampleRate: 0.02, + }) + ) + }) + + it('passes an undefined dsn when NEXT_PUBLIC_SENTRY_DSN is unset (disabled-client no-op)', async () => { + vi.stubEnv('NEXT_PUBLIC_SENTRY_DSN', undefined) + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + // `Sentry.init` without a dsn creates a disabled client, so calling init + // unconditionally is safe for local/self-hosted builds. + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + expect(sentryMocks.init).toHaveBeenCalledWith(expect.objectContaining({ dsn: undefined })) + }) + + it('only initializes once across repeated calls', async () => { + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + }) + + it('still initializes in the browser after an earlier SSR call', async () => { + const initSentryTanStackClient = await loadInitializer() + + // An SSR call must not trip the idempotency guard for the browser call. + vi.stubGlobal('window', undefined) + initSentryTanStackClient(fakeRouter) + expect(sentryMocks.init).not.toHaveBeenCalled() + + vi.unstubAllGlobals() + initSentryTanStackClient(fakeRouter) + expect(sentryMocks.init).toHaveBeenCalledTimes(1) + }) + + it('wires the TanStack Router browser tracing integration for the given router', async () => { + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.tanstackRouterBrowserTracingIntegration).toHaveBeenCalledWith(fakeRouter) + + const [options] = sentryMocks.init.mock.calls[0] + // `integrations` is the function form: Sentry.init calls it with the + // default integrations (browserSession, globalHandlers, …) and installs + // whatever it returns, so the defaults must survive the merge. + expect(options.integrations).toBeTypeOf('function') + const defaultIntegrations = [{ name: 'BrowserSession' }, { name: 'GlobalHandlers' }] + const integrations = options.integrations(defaultIntegrations) + + // Defaults passed in by Sentry.init survive the merge. + expect(integrations).toContainEqual({ name: 'BrowserSession' }) + expect(integrations).toContainEqual({ name: 'GlobalHandlers' }) + expect(integrations).toContainEqual({ name: 'TanStackRouterBrowserTracing' }) + // The Vite build runs no Sentry bundler plugin, so frames carry no + // applicationKey metadata — the third-party filter must stay off or every + // event would be tagged third_party_code=true and dropped by beforeSend. + expect(sentryMocks.thirdPartyErrorFilterIntegration).not.toHaveBeenCalled() + expect(integrations).not.toContainEqual({ name: 'ThirdPartyErrorsFilter' }) + }) + + it('passes the Vercel commit SHA as the release so session envelopes are sent', async () => { + // The SDK silently drops session envelopes when the client has no release + // (`Client.sendSession` early-returns) — without this, Release Health + // sends no /envelope traffic at all on the TanStack build. The Next build + // instead gets its release injected at build time by withSentryConfig. + vi.stubEnv('NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA', 'abc123commit') + const initSentryTanStackClient = await loadInitializer() + + initSentryTanStackClient(fakeRouter) + + expect(sentryMocks.init).toHaveBeenCalledWith( + expect.objectContaining({ release: 'abc123commit' }) + ) + }) +}) diff --git a/apps/studio/sentry.tanstack.ts b/apps/studio/sentry.tanstack.ts new file mode 100644 index 00000000000..075198c3904 --- /dev/null +++ b/apps/studio/sentry.tanstack.ts @@ -0,0 +1,66 @@ +// Sentry client init for the TanStack Start (Vite) build. +// +// NOTE: deliberately not named `sentry.client.tanstack.ts` — TanStack Start's +// import-protection denies `**/*.client.*` modules in the server bundle, and +// this module is imported from router.tsx (shared between client and server). +// It is isomorphic by design: the `typeof window` guard below makes it a +// no-op on the server. +// +// The Next build initializes Sentry via instrumentation-client.ts — a Next +// convention file that nothing loads under TanStack Start. Without this init +// every `Sentry.captureException` in the TanStack runtime (including the +// globalErrorBoundary / routerErrorComponent captures in routes/__root.tsx) +// would be a silent no-op. +// +// Called from `getRouter()` (router.tsx) — the earliest point in the TanStack +// client bootstrap with access to the router instance, which +// `tanstackRouterBrowserTracingIntegration` needs at init time so the +// pageload span is captured, not just later navigations. +// +// Imports `@sentry/react` directly (not `@sentry/nextjs`): this module never +// runs on the Next build, and the real `@sentry/nextjs` doesn't export the +// TanStack Router integration. Under Vite both ids resolve to the same +// `@sentry/react` instance anyway (vite.config.ts aliases `@sentry/nextjs` +// to compat/sentry-nextjs.ts), so app code capturing via `@sentry/nextjs` +// reports through the client initialized here. +import * as Sentry from '@sentry/react' +import type { AnyRouter } from '@tanstack/react-router' + +import { buildSentryClientOptions } from '@/lib/sentry-client-options' + +let isInitialized = false + +export function initSentryTanStackClient(router: AnyRouter) { + // Client-only: getRouter() also runs during SSR/prerender, and the TanStack + // build has no server-side Sentry story yet (the Next build's + // sentry.server.config.ts equivalent would live in a custom server entry). + if (typeof window === 'undefined') return + // getRouter() is called once per pageload today; keep the guard so a future + // second call can't double-init the client. + if (isInitialized) return + isInitialized = true + + // No-ops cleanly when NEXT_PUBLIC_SENTRY_DSN is unset (local/self-hosted): + // `init` without a dsn creates a disabled client, and beforeSend drops + // everything when !IS_PLATFORM regardless. + Sentry.init( + buildSentryClientOptions({ + // The Vite build doesn't run a Sentry bundler plugin, so stack frames + // carry no `supabase-studio` applicationKey metadata. Without the + // metadata the integration would tag EVERY event third_party_code=true + // and beforeSend would drop them all. Leave it off until the Vite build + // annotates frames (@sentry/vite-plugin moduleMetadata). + includeThirdPartyErrorFilter: false, + extraIntegrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)], + // Without a release the SDK silently drops session envelopes + // (`Client.sendSession` early-returns), so Release Health sends nothing + // on this build. The Next build gets its release injected at build time + // by withSentryConfig, which resolves to the Vercel commit SHA; inline + // the same SHA here (vite.config.ts re-exposes VERCEL_GIT_COMMIT_SHA + // under the NEXT_PUBLIC_ name) so both builds report the same release. + // Unset outside Vercel (local/self-hosted), where sessions don't matter — + // so session envelopes only fire on deploys, not on a local dev build. + release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, + }) + ) +} diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 23979bed19b..60849d6f3d0 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -372,6 +372,11 @@ export default defineConfig(({ command, mode }) => { // router.tsx). Both are build-time system env vars on Vercel. 'VERCEL_DEPLOYMENT_ID', 'VERCEL_SKEW_PROTECTION_ENABLED', + // Sentry release (sentry.tanstack.ts): the SDK silently drops session + // envelopes when the client has no release, so Release Health would send + // nothing. The commit SHA is also what withSentryConfig resolves the Next + // build's release to, keeping release names aligned across both builds. + 'VERCEL_GIT_COMMIT_SHA', ] as const for (const key of vercelPublicVars) { const value = env[key] @@ -380,6 +385,20 @@ export default defineConfig(({ command, mode }) => { } } + // Sentry init (lib/sentry-client-options.ts, reached via router.tsx) reads + // these at runtime in the browser. When a var is unset it gets no define + // entry above, which would leave a literal `process.env.*` in the built + // bundle — and an undeclared `process` throws in the browser. Inline + // `undefined` as the fallback, mirroring how Next inlines unset + // NEXT_PUBLIC_* vars. + for (const key of [ + 'NEXT_PUBLIC_SENTRY_DSN', + 'NEXT_PUBLIC_SENTRY_ENVIRONMENT', + 'NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA', + ]) { + publicEnvDefines[`process.env.${key}`] ??= 'undefined' + } + // Mirror Next's `basePath` via NEXT_PUBLIC_BASE_PATH. Unlike Next, TanStack // Start has no single knob — the prefix has to be declared in three places // (see BASE_PATH_REDIRECT_GUIDE.md):