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

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

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

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

## To test

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



---

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

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

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


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

## Summary by CodeRabbit

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

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

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

---------

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

498 lines
16 KiB
TypeScript

import type { Event as SentryEvent, StackFrame } from '@sentry/react'
import { describe, expect, it } from 'vitest'
import {
buildSentryClientOptions,
isBrowserWalletExtensionError,
isCancellationRejection,
isChallengeExpiredError,
isUserAbortedOperation,
} from './sentry-client-options'
describe('Sentry beforeSend filtering functions', () => {
describe('isBrowserWalletExtensionError', () => {
it('returns true for Gate.io wallet extension error (gt-window-provider.js)', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'en.shouldSetTallyForCurrentProvider is not a function',
stacktrace: {
frames: [
{ filename: 'app:///_next/static/chunks/main.js' } as StackFrame,
{ filename: 'app:///gt-window-provider.js' } as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('returns true for Gate.io BTC wallet extension error (gt-window-provider-btc.js)', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'f.shouldSetTallyForCurrentProvider is not a function',
stacktrace: {
frames: [{ filename: 'app:///gt-window-provider-btc.js' } as StackFrame],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('returns true for wallet-provider in abs_path', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'Error',
value: 'wallet error',
stacktrace: {
frames: [
{ abs_path: 'chrome-extension://abc123/wallet-provider.js' } as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('returns false for regular application errors', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'Error',
value: 'Regular error',
stacktrace: {
frames: [
{ filename: 'app:///_next/static/chunks/main.js' } as StackFrame,
{ filename: 'app:///_next/static/chunks/pages/index.js' } as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(false)
})
it('returns false for empty event', () => {
const event: SentryEvent = {}
expect(isBrowserWalletExtensionError(event)).toBe(false)
})
it('returns false when exception values are empty', () => {
const event: SentryEvent = {
exception: {
values: [],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(false)
})
it('returns false when stacktrace frames are undefined', () => {
const event: SentryEvent = {
exception: {
values: [
{
type: 'Error',
value: 'Error without stacktrace',
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(false)
})
})
describe('isUserAbortedOperation', () => {
it('returns true for "operation was aborted" error', () => {
const error = new Error('The operation was aborted.')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true for "signal is aborted" error', () => {
const error = new Error('signal is aborted without reason')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true for "manually canceled" error', () => {
const error = new Error('operation is manually canceled')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true for "AbortError" message', () => {
const error = new Error('AbortError: The operation was aborted')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true when message is in event.message (no error object)', () => {
const error = null
const event: SentryEvent = {
message: '[CRITICAL][sign in via EP] Failed: The operation was aborted.',
}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('returns true for event message with "signal is aborted"', () => {
const event: SentryEvent = {
message: '[CRITICAL][sign in via EP] Failed: signal is aborted without reason',
}
expect(isUserAbortedOperation(undefined, event)).toBe(true)
})
it('returns false for regular errors', () => {
const error = new Error('Something went wrong')
const event: SentryEvent = {}
expect(isUserAbortedOperation(error, event)).toBe(false)
})
it('returns false for empty inputs', () => {
expect(isUserAbortedOperation(null, {})).toBe(false)
expect(isUserAbortedOperation(undefined, {})).toBe(false)
})
it('handles non-Error objects gracefully', () => {
const error = { message: 'The operation was aborted.' }
const event: SentryEvent = {}
// Non-Error objects should not match since we check instanceof Error
expect(isUserAbortedOperation(error, event)).toBe(false)
})
})
describe('isCancellationRejection', () => {
it('returns true for cancellation type in extra.__serialized__', () => {
const event: SentryEvent = {
extra: {
__serialized__: {
msg: 'operation is manually canceled',
type: 'cancelation',
},
},
}
expect(isCancellationRejection(event)).toBe(true)
})
it('returns false when type is not cancelation', () => {
const event: SentryEvent = {
extra: {
__serialized__: {
msg: 'some error',
type: 'error',
},
},
}
expect(isCancellationRejection(event)).toBe(false)
})
it('returns false when __serialized__ is undefined', () => {
const event: SentryEvent = {
extra: {},
}
expect(isCancellationRejection(event)).toBe(false)
})
it('returns false when extra is undefined', () => {
const event: SentryEvent = {}
expect(isCancellationRejection(event)).toBe(false)
})
it('returns false when __serialized__ has no type property', () => {
const event: SentryEvent = {
extra: {
__serialized__: {
msg: 'some message',
},
},
}
expect(isCancellationRejection(event)).toBe(false)
})
})
describe('isChallengeExpiredError', () => {
it('returns true for challenge-expired error message', () => {
const error = new Error('Non-Error promise rejection captured with value: challenge-expired')
const event: SentryEvent = {}
expect(isChallengeExpiredError(error, event)).toBe(true)
})
it('returns true when challenge-expired is in event.message', () => {
const event: SentryEvent = {
message: 'challenge-expired',
}
expect(isChallengeExpiredError(null, event)).toBe(true)
})
it('returns false for regular errors', () => {
const error = new Error('Something went wrong')
const event: SentryEvent = {}
expect(isChallengeExpiredError(error, event)).toBe(false)
})
it('returns false for empty inputs', () => {
expect(isChallengeExpiredError(null, {})).toBe(false)
expect(isChallengeExpiredError(undefined, {})).toBe(false)
})
it('returns false for similar but different messages', () => {
const error = new Error('challenge expired') // No hyphen
const event: SentryEvent = {}
expect(isChallengeExpiredError(error, event)).toBe(false)
})
})
describe('integration scenarios', () => {
it('correctly identifies SUPABASE-APP-353 pattern (cancellation rejection)', () => {
// Based on actual Sentry issue SUPABASE-APP-353
const event: SentryEvent = {
exception: {
values: [
{
type: 'UnhandledRejection',
value: 'Object captured as promise rejection with keys: msg, type',
},
],
},
extra: {
__serialized__: {
msg: 'operation is manually canceled',
type: 'cancelation',
},
},
}
expect(isCancellationRejection(event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-AFC pattern (wallet extension)', () => {
// Based on actual Sentry issue SUPABASE-APP-AFC
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'f.shouldSetTallyForCurrentProvider is not a function',
stacktrace: {
frames: [
{
filename:
'node_modules/.pnpm/@sentry+browser@10.27.0/node_modules/@sentry/browser/src/helpers.ts',
function: 'n',
} as StackFrame,
{
filename: 'app:///gt-window-provider-btc.js',
function: 'GateWindowProvider.internalListener',
} as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-92A pattern (wallet extension)', () => {
// Based on actual Sentry issue SUPABASE-APP-92A
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'en.shouldSetTallyForCurrentProvider is not a function',
stacktrace: {
frames: [
{
filename:
'node_modules/.pnpm/@sentry+browser@10.27.0/node_modules/@sentry/browser/src/helpers.ts',
function: 'n',
} as StackFrame,
{
filename: 'app:///gt-window-provider.js',
function: 'GateWindowProvider.internalListener',
} as StackFrame,
],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-BG6 pattern (user aborted)', () => {
// Based on actual Sentry issue SUPABASE-APP-BG6
const error = new Error('The operation was aborted.')
const event: SentryEvent = {
message: '[CRITICAL][sign in via EP] Failed: The operation was aborted.',
}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-BG7 pattern (signal aborted)', () => {
// Based on actual Sentry issue SUPABASE-APP-BG7
const error = new Error('signal is aborted without reason')
const event: SentryEvent = {
message: '[CRITICAL][sign in via EP] Failed: signal is aborted without reason',
}
expect(isUserAbortedOperation(error, event)).toBe(true)
})
it('correctly identifies SUPABASE-APP-ACC pattern (challenge expired)', () => {
// Based on actual Sentry issue SUPABASE-APP-ACC
const error = new Error('Non-Error promise rejection captured with value: challenge-expired')
const event: SentryEvent = {
exception: {
values: [
{
type: 'UnhandledRejection',
value: 'Non-Error promise rejection captured with value: challenge-expired',
},
],
},
}
expect(isChallengeExpiredError(error, event)).toBe(true)
})
it('does not filter legitimate errors', () => {
const error = new Error('Cannot read property "foo" of undefined')
const event: SentryEvent = {
exception: {
values: [
{
type: 'TypeError',
value: 'Cannot read property "foo" of undefined',
stacktrace: {
frames: [{ filename: 'app:///_next/static/chunks/pages/index.js' } as StackFrame],
},
},
],
},
}
expect(isBrowserWalletExtensionError(event)).toBe(false)
expect(isUserAbortedOperation(error, event)).toBe(false)
expect(isCancellationRejection(event)).toBe(false)
expect(isChallengeExpiredError(error, event)).toBe(false)
})
})
})
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)
})
})