diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 92c9478b7079..423e8c457492 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -36,6 +36,16 @@ interface VueRouter { type VueRouteProviderRouter = ReturnType[0]>; +// 3xx/4xx Nuxt errors are expected control flow (redirects, not-found pages), not bugs. The server skips them too. +function isIgnoredNuxtError(error: unknown): boolean { + if (!isNuxtError(error)) { + return false; + } + // oxlint-disable-next-line typescript/no-deprecated + const statusCode = error?.status || error?.statusCode; + return !!statusCode && statusCode >= 300 && statusCode < 500; +} + // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -87,19 +97,18 @@ export default defineNuxtPlugin({ }); nuxtApp.hook('app:error', error => { - if (isNuxtError(error)) { - // oxlint-disable-next-line typescript/no-deprecated - const statusCode = error?.status || error?.statusCode; - - // Do not report if status code is 3xx or 4xx - if (statusCode && statusCode >= 300 && statusCode < 500) { - return; - } + if (isIgnoredNuxtError(error)) { + return; } reportNuxtError({ error }); }); + // Nuxt calls `vue:error` for every error that reaches the root component, so an error thrown during setup + // (e.g. `createError({ statusCode: 404 })`) arrives here too, not only in `app:error`. nuxtApp.hook('vue:error', (error, instance, info) => { + if (isIgnoredNuxtError(error)) { + return; + } reportNuxtError({ error, instance, info }); }); }, diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts new file mode 100644 index 000000000000..14b75383d1cb --- /dev/null +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { reportNuxtError } from '../../../src/runtime/utils'; + +vi.mock('nuxt/app', () => ({ + defineNuxtPlugin: (plugin: unknown) => plugin, + isNuxtError: (error: unknown) => !!error && typeof error === 'object' && '__nuxt_error' in error, +})); + +vi.mock('../../../src/runtime/utils', () => ({ + reportNuxtError: vi.fn(), +})); + +type Hook = (...args: unknown[]) => void; + +async function setupHook(hookName: string): Promise { + const { default: plugin } = await import('../../../src/runtime/plugins/sentry.client'); + const hooks: Record = {}; + const nuxtApp = { hook: (name: string, hook: Hook) => (hooks[name] = hook) }; + await (plugin as unknown as { setup: (app: unknown) => Promise }).setup(nuxtApp); + expect(hooks).toHaveProperty(hookName); + return hooks[hookName]!; +} + +function nuxtError(statusCode: number): Error { + return Object.assign(new Error(`status ${statusCode}`), { __nuxt_error: true, statusCode }); +} + +describe('sentry client plugin error hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(['app:error', 'vue:error'])('does not report 3xx/4xx Nuxt errors from %s', async hookName => { + const hook = await setupHook(hookName); + + hook(nuxtError(404)); + hook(nuxtError(301)); + + expect(reportNuxtError).not.toHaveBeenCalled(); + }); + + it.each(['app:error', 'vue:error'])('reports 5xx Nuxt errors from %s', async hookName => { + const hook = await setupHook(hookName); + + hook(nuxtError(503)); + + expect(reportNuxtError).toHaveBeenCalledTimes(1); + }); + + it('reports errors that are not Nuxt errors from vue:error', async () => { + const hook = await setupHook('vue:error'); + const error = Object.assign(new Error('boom'), { statusCode: 404 }); + + hook(error, undefined, 'setup function'); + + expect(reportNuxtError).toHaveBeenCalledWith({ error, instance: undefined, info: 'setup function' }); + }); +});