Files
supabase/apps/www/lib/sentry-capture.test.tsx
Jordi Enric 8984305b1e feat: sample non-crash sentry errors at one percent (#50339)
## Problem

Browser Sentry reporting sends ordinary application errors at full
volume even though full-page crashes are the highest-priority signal.

## Fix

Sample eligible browser errors without `globalErrorBoundary` at 1%
across Studio, www, and docs. Keep 100% of eligible errors tagged with
`globalErrorBoundary`, preserve consent and existing noise filters, and
record the applied rate in `codeSampleRate`.

## How to test

- Run `node node_modules/vitest/vitest.mjs run
../../packages/common/sentry.test.ts lib/sentry-capture.test.tsx` from
`apps/www`.
- Run `node node_modules/vitest/vitest.mjs run
lib/sentry-client-options.test.ts` from `apps/studio`.
- Expected result: tagged page crashes bypass sampling, ordinary errors
use the 1% cutoff, and Studio applies sampling once while preserving its
existing filters.


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

- **Bug Fixes**
- Improved error reporting reliability by ensuring page-crash errors are
captured without sampling.
- Non-crash application errors are now sampled at a low rate, with
sampling metadata retained for monitoring.
- Updated filtering behavior so relevant Studio errors continue to be
reported consistently, including errors previously affected by
client-side filtering.
- Preserved filtering for third-party-only errors that do not represent
application failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-15 11:43:24 +02:00

133 lines
3.7 KiB
TypeScript

import * as Sentry from '@sentry/nextjs'
import { consentState } from 'common/consent-state'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import GlobalError from '../app/global-error'
import CustomError from '../pages/_error'
type Transport = ReturnType<NonNullable<Parameters<typeof Sentry.init>[0]['transport']>>
const envelopes: Parameters<Transport['send']>[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(() => {
vi.spyOn(Math, 'random').mockReturnValue(0)
envelopes.length = 0
consentState.hasConsented = true
})
afterEach(() => {
vi.restoreAllMocks()
})
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(<GlobalError error={error} />))
} 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(<CustomError {...props} />))
} 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 }),
]),
},
}),
]
: []
)
}
)
})