diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue index 42d53ade03f7..92ea714ae489 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue @@ -5,6 +5,10 @@ const props = defineProps({ errorText: { type: String, required: true + }, + id: { + type: String, + required: true } }) @@ -14,5 +18,5 @@ const triggerError = () => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue index 25eaa672c87c..5e1a14931f84 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue @@ -3,7 +3,8 @@ import ErrorButton from '../components/ErrorButton.vue'; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue index 2ac1b9095a0f..379e8e417b35 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue @@ -1,7 +1,7 @@ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts index fb03a08b4033..4cb23e8b81df 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts @@ -55,4 +55,51 @@ test.describe('client-side errors', async () => { }, }); }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-3', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-3 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-3', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from Nuxt-3 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + }); }); diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 95dc954c4b89..b89a2fa87a8d 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -1,6 +1,7 @@ import { getClient } from '@sentry/core'; import { browserTracingIntegration, vueIntegration } from '@sentry/vue'; import { defineNuxtPlugin } from 'nuxt/app'; +import { reportNuxtError } from '../utils'; // --- Types are copied from @sentry/vue (so it does not need to be exported) --- // The following type is an intersection of the Route type from VueRouter v2, v3, and v4. @@ -49,8 +50,19 @@ export default defineNuxtPlugin({ const sentryClient = getClient(); if (sentryClient) { - sentryClient.addIntegration(vueIntegration({ app: vueApp })); + // Adding the Vue integration without the Vue error handler + // Nuxt is registering their own error handler, which is unset after hydration: https://github.com/nuxt/nuxt/blob/d3fdbcaac6cf66d21e25d259390d7824696f1a87/packages/nuxt/src/app/entry.ts#L64-L73 + // We don't want to wrap the existing error handler, as it leads to a 500 error: https://github.com/getsentry/sentry-javascript/issues/12515 + sentryClient.addIntegration(vueIntegration({ app: vueApp, attachErrorHandler: false })); } }); + + nuxtApp.hook('app:error', error => { + reportNuxtError({ error }); + }); + + nuxtApp.hook('vue:error', (error, instance, info) => { + reportNuxtError({ error, instance, info }); + }); }, }); diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 585387f59003..7b56a258f708 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,8 +1,10 @@ -import { getTraceMetaTags } from '@sentry/core'; -import type { Context } from '@sentry/types'; +import { captureException, getClient, getTraceMetaTags } from '@sentry/core'; +import type { ClientOptions, Context } from '@sentry/types'; import { dropUndefinedKeys } from '@sentry/utils'; +import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; +import type { ComponentPublicInstance } from 'vue'; /** * Extracts the relevant context information from the error context (H3Event in Nitro Error) @@ -41,3 +43,40 @@ export function addSentryTracingMetaTags(head: NuxtRenderHTMLContext['head']): v head.push(metaTags); } } + +/** + * Reports an error to Sentry. This function is similar to `attachErrorHandler` in `@sentry/vue`. + * The Nuxt SDK does not register an error handler, but uses the Nuxt error hooks to report errors. + * + * We don't want to use the error handling from `@sentry/vue` as it wraps the existing error handler, which leads to a 500 error: https://github.com/getsentry/sentry-javascript/issues/12515 + */ +export function reportNuxtError(options: { + error: unknown; + instance?: ComponentPublicInstance | null; + info?: string; +}): void { + const { error, instance, info } = options; + + const metadata: Record = { + info, + // todo: add component name and trace (like in the vue integration) + }; + + if (instance && instance.$props) { + const sentryClient = getClient(); + const sentryOptions = sentryClient ? (sentryClient.getOptions() as ClientOptions & VueOptions) : null; + + // `attachProps` is enabled by default and props should only not be attached if explicitly disabled (see DEFAULT_CONFIG in `vueIntegration`). + if (sentryOptions && sentryOptions.attachProps && instance.$props !== false) { + metadata.propsData = instance.$props; + } + } + + // Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time. + setTimeout(() => { + captureException(error, { + captureContext: { contexts: { nuxt: metadata } }, + mechanism: { handled: false }, + }); + }); +} diff --git a/packages/nuxt/test/runtime/utils.test.ts b/packages/nuxt/test/runtime/utils.test.ts index 08c66193caa3..a6afc03b05da 100644 --- a/packages/nuxt/test/runtime/utils.test.ts +++ b/packages/nuxt/test/runtime/utils.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; -import { extractErrorContext } from '../../src/runtime/utils'; +import { captureException, getClient } from '@sentry/core'; +import { type Mock, afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; +import type { ComponentPublicInstance } from 'vue'; +import { extractErrorContext, reportNuxtError } from '../../src/runtime/utils'; describe('extractErrorContext', () => { it('returns empty object for undefined or empty context', () => { @@ -77,3 +79,73 @@ describe('extractErrorContext', () => { expect(() => extractErrorContext(weirdContext3)).not.toThrow(); }); }); + +describe('reportNuxtError', () => { + vi.mock('@sentry/core', () => ({ + captureException: vi.fn(), + getClient: vi.fn(), + })); + + const mockError = new Error('Test error'); + + const mockInstance: ComponentPublicInstance = { + $props: { foo: 'bar' }, + } as any; + + const mockClient = { + getOptions: vi.fn().mockReturnValue({ attachProps: true }), + }; + + beforeEach(() => { + // Using fake timers as setTimeout is used in `reportNuxtError` + vi.useFakeTimers(); + vi.clearAllMocks(); + (getClient as Mock).mockReturnValue(mockClient); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('captures exception with correct error and metadata', () => { + reportNuxtError({ error: mockError }); + vi.runAllTimers(); + + expect(captureException).toHaveBeenCalledWith(mockError, { + captureContext: { contexts: { nuxt: { info: undefined } } }, + mechanism: { handled: false }, + }); + }); + + test('includes instance props if attachProps is not explicitly defined', () => { + reportNuxtError({ error: mockError, instance: mockInstance }); + vi.runAllTimers(); + + expect(captureException).toHaveBeenCalledWith(mockError, { + captureContext: { contexts: { nuxt: { info: undefined, propsData: { foo: 'bar' } } } }, + mechanism: { handled: false }, + }); + }); + + test('does not include instance props if attachProps is disabled', () => { + mockClient.getOptions.mockReturnValue({ attachProps: false }); + + reportNuxtError({ error: mockError, instance: mockInstance }); + vi.runAllTimers(); + + expect(captureException).toHaveBeenCalledWith(mockError, { + captureContext: { contexts: { nuxt: { info: undefined } } }, + mechanism: { handled: false }, + }); + }); + + test('handles absence of instance correctly', () => { + reportNuxtError({ error: mockError, info: 'Some info' }); + vi.runAllTimers(); + + expect(captureException).toHaveBeenCalledWith(mockError, { + captureContext: { contexts: { nuxt: { info: 'Some info' } } }, + mechanism: { handled: false }, + }); + }); +}); diff --git a/packages/vue/src/errorhandler.ts b/packages/vue/src/errorhandler.ts index 725f9b56c714..07caeaf0f9cf 100644 --- a/packages/vue/src/errorhandler.ts +++ b/packages/vue/src/errorhandler.ts @@ -7,7 +7,7 @@ import { formatComponentName, generateComponentTrace } from './vendor/components type UnknownFunc = (...args: unknown[]) => void; export const attachErrorHandler = (app: Vue, options: VueOptions): void => { - const { errorHandler, warnHandler, silent } = app.config; + const { errorHandler: originalErrorHandler, warnHandler, silent } = app.config; app.config.errorHandler = (error: Error, vm: ViewModel, lifecycleHook: string): void => { const componentName = formatComponentName(vm, false); @@ -36,8 +36,9 @@ export const attachErrorHandler = (app: Vue, options: VueOptions): void => { }); }); - if (typeof errorHandler === 'function') { - (errorHandler as UnknownFunc).call(app, error, vm, lifecycleHook); + // Check if the current `app.config.errorHandler` is explicitly set by the user before calling it. + if (typeof originalErrorHandler === 'function' && app.config.errorHandler) { + (originalErrorHandler as UnknownFunc).call(app, error, vm, lifecycleHook); } if (options.logErrors) { diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index b62c43375bb5..900fa686dbcf 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -14,6 +14,7 @@ const DEFAULT_CONFIG: VueOptions = { Vue: globalWithVue.Vue, attachProps: true, logErrors: true, + attachErrorHandler: true, hooks: DEFAULT_HOOKS, timeout: 2000, trackComponents: false, @@ -76,7 +77,9 @@ const vueInit = (app: Vue, options: Options): void => { } } - attachErrorHandler(app, options); + if (options.attachErrorHandler) { + attachErrorHandler(app, options); + } if (hasTracingEnabled(options)) { app.mixin( diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 13d9e8588350..9735923cd52c 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -47,6 +47,17 @@ export interface VueOptions extends TracingOptions { */ logErrors: boolean; + /** + * By default, Sentry attaches an error handler to capture exceptions and report them to Sentry. + * When `attachErrorHandler` is set to `false`, automatic error reporting is disabled. + * + * Usually, this option should stay enabled, unless you want to set up Sentry error reporting yourself. + * For example, the Sentry Nuxt SDK does not attach an error handler as it's using the error hooks provided by Nuxt. + * + * @default true + */ + attachErrorHandler: boolean; + /** {@link TracingOptions} */ tracingOptions?: Partial; }