mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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>
This commit is contained in:
1 parent
f34fdd6c8f
commit
74bc0a8e27
8 files changed
+691
-309
No files matched your search
+2
-2
@@ -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,
|
||||
|
||||
@@ -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<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))
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
+80
-2
@@ -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<typeof buildSentryClientOptions>) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<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',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user