mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## 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 -->
133 lines
3.7 KiB
TypeScript
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 }),
|
|
]),
|
|
},
|
|
}),
|
|
]
|
|
: []
|
|
)
|
|
}
|
|
)
|
|
})
|