From fb2253443958bd7247db5b80cdd0749055ec1d7d Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:28:08 +0200 Subject: [PATCH] fix: share sentry crash policy and enable www reporting (#50232) ## Problem The website initializes Sentry only on the server and edge runtimes, leaving browser crashes unreported. Its crash-reporting setup also needs the same consent and third-party filtering policy that docs and Studio otherwise maintain separately. ## Fix Add www browser initialization and tagged crash capture for both Next.js routers, with accessible fallback focus. Move the shared consent/platform and third-party filtering into common/sentry, reuse it from all three apps, and remove the duplicated docs/www helpers and tests. Preserve each app's initialization and Studio's additional noise filtering, sampling, and sanitization. Include the source-map upload token in www's build cache inputs, and trigger the shared/www and Studio test workflows when the shared policy changes. ## How to test - Run `pnpm --filter www test ../../packages/common/sentry.test.ts lib/sentry-capture.test.tsx`: all 22 shared-policy and real-SDK capture tests passed locally. - Run `pnpm --filter studio exec vitest run lib/sentry-client-options.test.ts`: all 42 Studio options and policy-parity tests passed locally. - The www capture tests exercise the actual initializer and both router handlers with an in-memory transport, verify crash tags and fallback focus, and enforce consent. Removing initialization, capture calls, boundary tags, or consent gating was verified to fail these tests. - On a www preview with its DSN configured, accept telemetry consent and trigger temporary render errors in both routers. Verify they reach the www Sentry project with the boundary tag and readable stack traces. Formatting passes. Full local app typechecks encounter existing dependency/generated-file drift, with no diagnostics in changed files. Three unchanged TanStack mock call-count tests fail locally and reproduce against the pre-refactor implementation. Live Sentry ingestion and source-map uploads remain deployment checks. ## Summary by CodeRabbit - **Accessibility** - Error pages now automatically move focus to a clearly labeled error message, helping screen-reader and keyboard users understand when a page fails. - **Reliability** - Browser error reporting now captures application crashes more consistently across supported page types and navigation transitions. - Reporting respects consent and platform availability while filtering unrelated third-party failures. - **Testing** - Expanded automated coverage for error capture, reporting rules, consent handling, and accessible error-page behavior. --- .github/workflows/studio-unit-tests.yml | 4 + .github/workflows/www-tests.yml | 2 + apps/docs/instrumentation-client.ts | 2 +- apps/docs/lib/sentry-client.test.ts | 67 --------- apps/docs/lib/sentry-client.ts | 15 --- apps/studio/lib/sentry-client-options.test.ts | 50 ++++++- apps/studio/lib/sentry-client-options.ts | 22 +-- apps/www/app/global-error.tsx | 24 ++++ apps/www/instrumentation-client.ts | 21 +++ apps/www/lib/sentry-capture.test.tsx | 127 ++++++++++++++++++ apps/www/next.config.mjs | 3 + apps/www/pages/_error.tsx | 27 ++++ apps/www/turbo.jsonc | 1 + apps/www/vitest.config.ts | 15 ++- apps/www/vitest.sentry.config.mts | 27 ++++ packages/common/sentry.test.ts | 49 +++++++ packages/common/sentry.ts | 22 +++ 17 files changed, 373 insertions(+), 105 deletions(-) delete mode 100644 apps/docs/lib/sentry-client.test.ts delete mode 100644 apps/docs/lib/sentry-client.ts create mode 100644 apps/www/app/global-error.tsx create mode 100644 apps/www/instrumentation-client.ts create mode 100644 apps/www/lib/sentry-capture.test.tsx create mode 100644 apps/www/pages/_error.tsx create mode 100644 apps/www/vitest.sentry.config.mts create mode 100644 packages/common/sentry.test.ts create mode 100644 packages/common/sentry.ts 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 +}