diff --git a/.github/workflows/studio-unit-tests.yml b/.github/workflows/studio-unit-tests.yml index 597cf609386..db87050763f 100644 --- a/.github/workflows/studio-unit-tests.yml +++ b/.github/workflows/studio-unit-tests.yml @@ -8,6 +8,8 @@ on: branches: [master, studio] paths: - 'apps/studio/**' + - 'packages/common/sentry.ts' + - 'packages/common/sentry.test.ts' - 'packages/ui/**' - 'packages/ui-patterns/**' - 'pnpm-lock.yaml' @@ -53,6 +55,8 @@ jobs: - 'packages/ui/**' - 'packages/ui-patterns/**' - 'apps/studio/**' + - 'packages/common/sentry.ts' + - 'packages/common/sentry.test.ts' - 'pnpm-lock.yaml' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 if: steps.filter.outputs.relevant == 'true' diff --git a/.github/workflows/www-tests.yml b/.github/workflows/www-tests.yml index 281a6a30127..321df0844af 100644 --- a/.github/workflows/www-tests.yml +++ b/.github/workflows/www-tests.yml @@ -5,6 +5,8 @@ on: branches: ['master'] paths: - 'apps/www/**/*.ts*' + - 'packages/common/sentry.ts' + - 'packages/common/sentry.test.ts' - 'apps/www/next.config.mjs' - 'apps/www/next.config.js' - 'apps/www/lib/**/*.js' diff --git a/apps/docs/instrumentation-client.ts b/apps/docs/instrumentation-client.ts index 52afede50c1..e3490825598 100644 --- a/apps/docs/instrumentation-client.ts +++ b/apps/docs/instrumentation-client.ts @@ -4,9 +4,9 @@ import * as Sentry from '@sentry/nextjs' import { hasConsented, IS_PLATFORM } from 'common' +import { filterSentryEvent } from 'common/sentry' import { IS_DEV } from './lib/constants' -import { filterSentryEvent } from './lib/sentry-client' if (!IS_DEV) { Sentry.init({ diff --git a/apps/docs/lib/sentry-client.test.ts b/apps/docs/lib/sentry-client.test.ts deleted file mode 100644 index ba7529af432..00000000000 --- a/apps/docs/lib/sentry-client.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ErrorEvent } from '@sentry/nextjs' -import { describe, expect, it } from 'vitest' - -import { filterSentryEvent } from './sentry-client' - -describe('filterSentryEvent', () => { - it('forwards first-party exceptions', () => { - const event: ErrorEvent = { - type: undefined, - exception: { - values: [ - { stacktrace: { frames: [{ filename: 'https://supabase.com/docs/_next/app.js' }] } }, - ], - }, - } - expect(filterSentryEvent(event, { isPlatform: true, hasConsent: true })).toBe(event) - }) - - it('forwards errors without stack frames', () => { - const event: ErrorEvent = { - type: undefined, - exception: { values: [{ value: 'Page crashed' }] }, - } - expect(filterSentryEvent(event, { isPlatform: true, hasConsent: true })).toBe(event) - }) - - it.each([true, 'true'])('drops third-party-only errors tagged %s', (tag) => { - expect( - filterSentryEvent( - { type: undefined, tags: { third_party_code: tag } }, - { isPlatform: true, hasConsent: true } - ) - ).toBeNull() - }) - - it.each([true, 'true'])('retains error-boundary crashes tagged %s', (tag) => { - const event: ErrorEvent = { - type: undefined, - tags: { third_party_code: true, globalErrorBoundary: tag }, - } - expect(filterSentryEvent(event, { isPlatform: true, hasConsent: true })).toBe(event) - }) - - it('does not forward errors outside the platform', () => { - expect( - filterSentryEvent({ type: undefined }, { isPlatform: false, hasConsent: true }) - ).toBeNull() - }) - - it('retains errors explicitly tagged as first-party', () => { - const event: ErrorEvent = { type: undefined, tags: { third_party_code: false } } - expect(filterSentryEvent(event, { isPlatform: true, hasConsent: true })).toBe(event) - }) - - it.each([ - {}, - { globalErrorBoundary: true }, - { globalErrorBoundary: 'true', third_party_code: true }, - ])( - 'does not forward errors without permission to report, including boundary crashes: %j', - (tags) => { - expect( - filterSentryEvent({ type: undefined, tags }, { isPlatform: true, hasConsent: false }) - ).toBeNull() - } - ) -}) diff --git a/apps/docs/lib/sentry-client.ts b/apps/docs/lib/sentry-client.ts deleted file mode 100644 index 5105425c2eb..00000000000 --- a/apps/docs/lib/sentry-client.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ErrorEvent } from '@sentry/nextjs' - -export function filterSentryEvent( - event: ErrorEvent, - { isPlatform, hasConsent }: { isPlatform: boolean; hasConsent: boolean } -) { - if (!isPlatform || !hasConsent) 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' - - return isThirdPartyOnly && !isErrorBoundaryCrash ? null : event -} diff --git a/apps/studio/lib/sentry-client-options.test.ts b/apps/studio/lib/sentry-client-options.test.ts index 74732c21557..542ac33e611 100644 --- a/apps/studio/lib/sentry-client-options.test.ts +++ b/apps/studio/lib/sentry-client-options.test.ts @@ -1,5 +1,5 @@ import type { Event as SentryEvent, StackFrame } from '@sentry/react' -import { describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { buildSentryClientOptions, @@ -495,3 +495,51 @@ describe('buildSentryClientOptions', () => { expect(nextRest.ignoreErrors).toEqual(tanstackRest.ignoreErrors) }) }) + +describe('which errors Studio sends to Sentry', () => { + let beforeSend: NonNullable['beforeSend']> + let restoreConsent: () => void + + beforeAll(async () => { + vi.stubEnv('NEXT_PUBLIC_IS_PLATFORM', 'true') + vi.resetModules() + const { consentState } = await import('common') + const previousConsent = consentState.hasConsented + consentState.hasConsented = true + restoreConsent = () => { + consentState.hasConsented = previousConsent + } + const { buildSentryClientOptions } = await import('./sentry-client-options') + const options = buildSentryClientOptions({ includeThirdPartyErrorFilter: true }) + if (!options.beforeSend) throw new Error('Missing Sentry beforeSend') + beforeSend = options.beforeSend + }) + + afterAll(() => { + restoreConsent?.() + vi.unstubAllEnvs() + vi.resetModules() + }) + + it('drops errors from outside the app', async () => { + expect(await beforeSend({ type: undefined, tags: { third_party_code: true } }, {})).toBeNull() + }) + + it.each([true, 'true'])( + 'sends page crashes even when the code location is missing: %s', + async (tag) => { + const event: Parameters[0] = { + type: undefined, + tags: { third_party_code: true, globalErrorBoundary: tag }, + exception: { values: [{ value: 'Page crashed' }] }, + } + expect(await beforeSend(event, {})).toBe(event) + } + ) + + it('drops errors with no code location when they did not crash the page', async () => { + expect( + await beforeSend({ type: undefined, exception: { values: [{ value: 'No stack' }] } }, {}) + ).toBeNull() + }) +}) diff --git a/apps/studio/lib/sentry-client-options.ts b/apps/studio/lib/sentry-client-options.ts index 693f01021df..6a8bbd0b47e 100644 --- a/apps/studio/lib/sentry-client-options.ts +++ b/apps/studio/lib/sentry-client-options.ts @@ -16,6 +16,7 @@ import * as Sentry from '@sentry/react' import { thirdPartyErrorFilterIntegration } from '@sentry/react' import { hasConsented } from 'common' import { IS_PLATFORM } from 'common/constants/environment' +import { filterSentryEvent, isSentryErrorBoundaryCrash } from 'common/sentry' import { MIRRORED_BREADCRUMBS } from '@/lib/breadcrumbs' import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from '@/lib/sanitize' @@ -195,28 +196,11 @@ export function buildSentryClientOptions({ return cleanedBreadcrumb }, beforeSend(event, hint) { - const consent = hasConsented() - - if (!consent) { + if (!filterSentryEvent(event, { isPlatform: IS_PLATFORM, hasConsent: hasConsented() })) { 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 - } + const isErrorBoundaryCrash = isSentryErrorBoundaryCrash(event) // Downsample only known high-noise classes; keep all other errors at full rate. const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( diff --git a/apps/www/app/global-error.tsx b/apps/www/app/global-error.tsx new file mode 100644 index 00000000000..7be6fd02e5f --- /dev/null +++ b/apps/www/app/global-error.tsx @@ -0,0 +1,24 @@ +'use client' + +import * as Sentry from '@sentry/nextjs' +import NextError from 'next/error' +import { useEffect, useRef } from 'react' + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + const errorRef = useRef(null) + + useEffect(() => { + errorRef.current?.focus() + Sentry.captureException(error, { tags: { globalErrorBoundary: true } }) + }, [error]) + + return ( + + +
+ +
+ + + ) +} diff --git a/apps/www/instrumentation-client.ts b/apps/www/instrumentation-client.ts new file mode 100644 index 00000000000..0d5b8b8f77a --- /dev/null +++ b/apps/www/instrumentation-client.ts @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/nextjs' +import { hasConsented } from 'common/consent-state' +import { IS_PLATFORM } from 'common/constants/environment' +import { filterSentryEvent } from 'common/sentry' + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + debug: false, + integrations: (defaultIntegrations) => [ + ...defaultIntegrations, + Sentry.thirdPartyErrorFilterIntegration({ + filterKeys: ['supabase-www'], + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', + }), + ], + beforeSend(event) { + return filterSentryEvent(event, { isPlatform: IS_PLATFORM, hasConsent: hasConsented() }) + }, +}) + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart diff --git a/apps/www/lib/sentry-capture.test.tsx b/apps/www/lib/sentry-capture.test.tsx new file mode 100644 index 00000000000..3191e77e0ac --- /dev/null +++ b/apps/www/lib/sentry-capture.test.tsx @@ -0,0 +1,127 @@ +import * as Sentry from '@sentry/nextjs' +import { consentState } from 'common/consent-state' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import GlobalError from '../app/global-error' +import CustomError from '../pages/_error' + +type Transport = ReturnType[0]['transport']>> + +const envelopes: Parameters[0][] = [] +const events = () => + envelopes.flatMap(([, items]) => + items.flatMap(([header, payload]) => (header.type === 'event' ? [payload] : [])) + ) + +beforeAll(async () => { + const init = Sentry.init + const initialize = vi.spyOn(Sentry, 'init').mockImplementation((options) => + init({ + ...options, + transport: (): Transport => ({ + send: async (envelope) => { + envelopes.push(envelope) + return { statusCode: 200 } + }, + flush: async () => true, + }), + }) + ) + await import('../instrumentation-client') + initialize.mockRestore() +}) + +beforeEach(() => { + envelopes.length = 0 + consentState.hasConsented = true +}) + +afterAll(async () => { + consentState.hasConsented = false + await Sentry.close() +}) + +const captureGlobalError = async (error: Error) => { + const frame = document.createElement('iframe') + document.body.appendChild(frame) + const frameDocument = frame.contentDocument + if (!frameDocument) throw new Error('Missing frame document') + const root = createRoot(frameDocument) + try { + await act(async () => root.render()) + } finally { + await act(async () => root.unmount()) + frame.remove() + } +} + +const capturePagesError = async (err: Error) => { + const props = await CustomError.getInitialProps({ + err, + pathname: '/crash', + query: {}, + AppTree: () => null, + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + try { + await act(async () => root.render()) + } finally { + await act(async () => root.unmount()) + container.remove() + } +} + +describe.each([ + ['app router', captureGlobalError], + ['pages router', capturePagesError], +])('%s page crashes', (_, capture) => { + it('sends the error to Sentry marked as a page crash', async () => { + await capture(new Error('render failed')) + await Sentry.flush() + expect(events()).toEqual([ + expect.objectContaining({ + exception: { + values: expect.arrayContaining([expect.objectContaining({ value: 'render failed' })]), + }, + tags: expect.objectContaining({ globalErrorBoundary: true, third_party_code: true }), + }), + ]) + }) + + it("does not send the page crash without the user's permission", async () => { + consentState.hasConsented = false + await capture(new Error('private render failed')) + await Sentry.flush() + expect(events()).toEqual([]) + }) +}) + +describe('browser error reporting', () => { + it.each([true, false])( + "sends app errors only with the user's permission: %s", + async (hasConsent) => { + consentState.hasConsented = hasConsent + const error = new Error(`application failed with consent ${hasConsent}`) + error.stack = undefined + Sentry.captureException(error) + await Sentry.flush() + expect(events()).toEqual( + hasConsent + ? [ + expect.objectContaining({ + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ value: error.message }), + ]), + }, + }), + ] + : [] + ) + } + ) +}) diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index 0f4d2489277..b65878cf727 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -247,6 +247,9 @@ export default withSentryConfig(configExport, { org: 'supabase', project: 'www', + unstable_sentryWebpackPluginOptions: { + applicationKey: 'supabase-www', + }, // Only print logs for uploading source maps in CI silent: !process.env.CI, diff --git a/apps/www/pages/_error.tsx b/apps/www/pages/_error.tsx new file mode 100644 index 00000000000..20ef569c09e --- /dev/null +++ b/apps/www/pages/_error.tsx @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/nextjs' +import type { NextPageContext } from 'next' +import NextError, { type ErrorProps } from 'next/error' +import { useEffect, useRef } from 'react' + +export default function CustomError(props: ErrorProps) { + const errorRef = useRef(null) + + useEffect(() => { + errorRef.current?.focus() + }, []) + + return ( +
+ +
+ ) +} + +CustomError.getInitialProps = async (context: NextPageContext) => { + await Sentry.withScope(async (scope) => { + scope.setTag('globalErrorBoundary', true) + await Sentry.captureUnderscoreErrorException(context) + }) + + return NextError.getInitialProps(context) +} diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index a42001db674..61fef916f7d 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -33,6 +33,7 @@ "HCAPTCHA_SECRET_KEY", "NODE_ENV", "NEXT_PUBLIC_SENTRY_DSN", + "SENTRY_AUTH_TOKEN", "NEXT_PUBLIC_SURVEY_SUPABASE_URL", "NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY", "VERCEL", diff --git a/apps/www/vitest.config.ts b/apps/www/vitest.config.ts index 438b33c74b1..7d4eaa16eff 100644 --- a/apps/www/vitest.config.ts +++ b/apps/www/vitest.config.ts @@ -1,8 +1,19 @@ import { configDefaults, defineConfig } from 'vitest/config' +import sentryConfig from './vitest.sentry.config.mjs' + export default defineConfig({ - resolve: { tsconfigPaths: true }, test: { - exclude: [...configDefaults.exclude, '.next/*'], + projects: [ + { + resolve: { tsconfigPaths: true }, + test: { + name: 'unit', + include: [...configDefaults.include, '../../packages/common/sentry.test.ts'], + exclude: [...configDefaults.exclude, '.next/*', 'lib/sentry-capture.test.tsx'], + }, + }, + sentryConfig, + ], }, }) diff --git a/apps/www/vitest.sentry.config.mts b/apps/www/vitest.sentry.config.mts new file mode 100644 index 00000000000..39d6a93f6f4 --- /dev/null +++ b/apps/www/vitest.sentry.config.mts @@ -0,0 +1,27 @@ +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { defineConfig } from 'vitest/config' + +const require = createRequire(import.meta.url) + +export default defineConfig({ + resolve: { + alias: { + '@sentry/nextjs': resolve( + dirname(require.resolve('@sentry/nextjs/package.json')), + 'build/esm/index.client.js' + ), + }, + }, + oxc: { jsx: { runtime: 'automatic' } }, + test: { + name: 'sentry-browser', + environment: 'jsdom', + server: { deps: { inline: [/@sentry/] } }, + include: ['lib/sentry-capture.test.tsx'], + env: { + NEXT_PUBLIC_IS_PLATFORM: 'true', + NEXT_PUBLIC_SENTRY_DSN: 'https://public@sentry.test/1', + }, + }, +}) diff --git a/packages/common/sentry.test.ts b/packages/common/sentry.test.ts new file mode 100644 index 00000000000..87b6b09193d --- /dev/null +++ b/packages/common/sentry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { filterSentryEvent } from './sentry' + +const enabled = { isPlatform: true, hasConsent: true } + +describe('which errors get sent to Sentry', () => { + it.each([undefined, {}, { third_party_code: false }, { third_party_code: 'false' }])( + 'sends app errors without changing their details: %j', + (tags) => { + const event = { tags, exception: { values: [{ value: 'Page crashed' }] } } + expect(filterSentryEvent(event, enabled)).toBe(event) + } + ) + + it.each([true, 'true'])('drops errors marked as coming from outside the app: %s', (tag) => { + expect(filterSentryEvent({ tags: { third_party_code: tag } }, enabled)).toBeNull() + }) + + it.each([true, 'true'])( + 'sends page crashes even when the code location is missing: %s', + (tag) => { + const event = { + tags: { third_party_code: true, globalErrorBoundary: tag }, + exception: { values: [{ value: 'Page crashed' }] }, + } + expect(filterSentryEvent(event, enabled)).toBe(event) + } + ) + + it.each([undefined, false, 'false', null, 1])( + 'drops errors from outside the app unless marked as a page crash: %s', + (tag) => { + expect( + filterSentryEvent({ tags: { third_party_code: true, globalErrorBoundary: tag } }, enabled) + ).toBeNull() + } + ) + + it.each([ + { isPlatform: false, hasConsent: true }, + { isPlatform: true, hasConsent: false }, + { isPlatform: false, hasConsent: false }, + ])('drops all errors and page crashes when reporting is turned off: %j', (settings) => { + for (const tags of [undefined, { globalErrorBoundary: true, third_party_code: true }]) { + expect(filterSentryEvent({ tags }, settings)).toBeNull() + } + }) +}) diff --git a/packages/common/sentry.ts b/packages/common/sentry.ts new file mode 100644 index 00000000000..7b3647ae12e --- /dev/null +++ b/packages/common/sentry.ts @@ -0,0 +1,22 @@ +type SentryEventTags = { + tags?: { + globalErrorBoundary?: string | number | boolean | null + third_party_code?: string | number | boolean | null + } +} + +export function isSentryErrorBoundaryCrash(event: SentryEventTags): boolean { + return event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true' +} + +export function filterSentryEvent( + event: T, + { isPlatform, hasConsent }: { isPlatform: boolean; hasConsent: boolean } +): T | null { + if (!isPlatform || !hasConsent) return null + + const isThirdPartyOnly = + event.tags?.third_party_code === true || event.tags?.third_party_code === 'true' + + return isThirdPartyOnly && !isSentryErrorBoundaryCrash(event) ? null : event +}