Skip to content

Commit c0a5a3e

Browse files
s1gr1dLms24
andauthored
fix(nuxt): Use Nuxt error hooks instead of errorHandler to prevent 500 (#13748)
Adds a new option `attachErrorHandler` to the `vueIntegration`. This option is used in the Nuxt SDK to prevent wrapping the existing Nuxt error handler. Instead, the errors are captured in the Nuxt hooks. fixes: #12515 --------- Co-authored-by: Lukas Stracke <[email protected]>
1 parent 568ab8a commit c0a5a3e

File tree

10 files changed

+202
-12
lines changed

10 files changed

+202
-12
lines changed

dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue

+5-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ const props = defineProps({
55
errorText: {
66
type: String,
77
required: true
8+
},
9+
id: {
10+
type: String,
11+
required: true
812
}
913
})
1014
@@ -14,5 +18,5 @@ const triggerError = () => {
1418
</script>
1519

1620
<template>
17-
<button id="errorBtn" @click="triggerError">Trigger Error</button>
21+
<button :id="props.id" @click="triggerError">Trigger Error</button>
1822
</template>

dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import ErrorButton from '../components/ErrorButton.vue';
33
</script>
44

55
<template>
6-
<ErrorButton error-text="Error thrown from Nuxt-3 E2E test app"/>
6+
<ErrorButton id="errorBtn" error-text="Error thrown from Nuxt-3 E2E test app"/>
7+
<ErrorButton id="errorBtn2" error-text="Another Error thrown from Nuxt-3 E2E test app"/>
78
</template>
89

910

dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
22
<p>{{ $route.params.param }} - {{ $route.params.param }}</p>
33

4-
<ErrorButton errorText="Error thrown from Param Route Button" />
4+
<ErrorButton id="errorBtn" errorText="Error thrown from Param Route Button" />
55
<button @click="fetchData">Fetch Server Data</button>
66
</template>
77

dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts

+47
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,51 @@ test.describe('client-side errors', async () => {
5555
},
5656
});
5757
});
58+
59+
test('page is still interactive after client error', async ({ page }) => {
60+
const error1Promise = waitForError('nuxt-3', async errorEvent => {
61+
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-3 E2E test app';
62+
});
63+
64+
await page.goto(`/client-error`);
65+
await page.locator('#errorBtn').click();
66+
67+
const error1 = await error1Promise;
68+
69+
const error2Promise = waitForError('nuxt-3', async errorEvent => {
70+
return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from Nuxt-3 E2E test app';
71+
});
72+
73+
await page.locator('#errorBtn2').click();
74+
75+
const error2 = await error2Promise;
76+
77+
expect(error1).toMatchObject({
78+
exception: {
79+
values: [
80+
{
81+
type: 'Error',
82+
value: 'Error thrown from Nuxt-3 E2E test app',
83+
mechanism: {
84+
handled: false,
85+
},
86+
},
87+
],
88+
},
89+
});
90+
91+
expect(error2).toMatchObject({
92+
exception: {
93+
values: [
94+
{
95+
type: 'Error',
96+
value: 'Another Error thrown from Nuxt-3 E2E test app',
97+
mechanism: {
98+
handled: false,
99+
},
100+
},
101+
],
102+
},
103+
});
104+
});
58105
});

packages/nuxt/src/runtime/plugins/sentry.client.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getClient } from '@sentry/core';
22
import { browserTracingIntegration, vueIntegration } from '@sentry/vue';
33
import { defineNuxtPlugin } from 'nuxt/app';
4+
import { reportNuxtError } from '../utils';
45

56
// --- Types are copied from @sentry/vue (so it does not need to be exported) ---
67
// The following type is an intersection of the Route type from VueRouter v2, v3, and v4.
@@ -49,8 +50,19 @@ export default defineNuxtPlugin({
4950
const sentryClient = getClient();
5051

5152
if (sentryClient) {
52-
sentryClient.addIntegration(vueIntegration({ app: vueApp }));
53+
// Adding the Vue integration without the Vue error handler
54+
// 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
55+
// 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
56+
sentryClient.addIntegration(vueIntegration({ app: vueApp, attachErrorHandler: false }));
5357
}
5458
});
59+
60+
nuxtApp.hook('app:error', error => {
61+
reportNuxtError({ error });
62+
});
63+
64+
nuxtApp.hook('vue:error', (error, instance, info) => {
65+
reportNuxtError({ error, instance, info });
66+
});
5567
},
5668
});

packages/nuxt/src/runtime/utils.ts

+41-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { getTraceMetaTags } from '@sentry/core';
2-
import type { Context } from '@sentry/types';
1+
import { captureException, getClient, getTraceMetaTags } from '@sentry/core';
2+
import type { ClientOptions, Context } from '@sentry/types';
33
import { dropUndefinedKeys } from '@sentry/utils';
4+
import type { VueOptions } from '@sentry/vue/src/types';
45
import type { CapturedErrorContext } from 'nitropack';
56
import type { NuxtRenderHTMLContext } from 'nuxt/app';
7+
import type { ComponentPublicInstance } from 'vue';
68

79
/**
810
* Extracts the relevant context information from the error context (H3Event in Nitro Error)
@@ -41,3 +43,40 @@ export function addSentryTracingMetaTags(head: NuxtRenderHTMLContext['head']): v
4143
head.push(metaTags);
4244
}
4345
}
46+
47+
/**
48+
* Reports an error to Sentry. This function is similar to `attachErrorHandler` in `@sentry/vue`.
49+
* The Nuxt SDK does not register an error handler, but uses the Nuxt error hooks to report errors.
50+
*
51+
* 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
52+
*/
53+
export function reportNuxtError(options: {
54+
error: unknown;
55+
instance?: ComponentPublicInstance | null;
56+
info?: string;
57+
}): void {
58+
const { error, instance, info } = options;
59+
60+
const metadata: Record<string, unknown> = {
61+
info,
62+
// todo: add component name and trace (like in the vue integration)
63+
};
64+
65+
if (instance && instance.$props) {
66+
const sentryClient = getClient();
67+
const sentryOptions = sentryClient ? (sentryClient.getOptions() as ClientOptions & VueOptions) : null;
68+
69+
// `attachProps` is enabled by default and props should only not be attached if explicitly disabled (see DEFAULT_CONFIG in `vueIntegration`).
70+
if (sentryOptions && sentryOptions.attachProps && instance.$props !== false) {
71+
metadata.propsData = instance.$props;
72+
}
73+
}
74+
75+
// Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time.
76+
setTimeout(() => {
77+
captureException(error, {
78+
captureContext: { contexts: { nuxt: metadata } },
79+
mechanism: { handled: false },
80+
});
81+
});
82+
}

packages/nuxt/test/runtime/utils.test.ts

+74-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { describe, expect, it } from 'vitest';
2-
import { extractErrorContext } from '../../src/runtime/utils';
1+
import { captureException, getClient } from '@sentry/core';
2+
import { type Mock, afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
3+
import type { ComponentPublicInstance } from 'vue';
4+
import { extractErrorContext, reportNuxtError } from '../../src/runtime/utils';
35

46
describe('extractErrorContext', () => {
57
it('returns empty object for undefined or empty context', () => {
@@ -77,3 +79,73 @@ describe('extractErrorContext', () => {
7779
expect(() => extractErrorContext(weirdContext3)).not.toThrow();
7880
});
7981
});
82+
83+
describe('reportNuxtError', () => {
84+
vi.mock('@sentry/core', () => ({
85+
captureException: vi.fn(),
86+
getClient: vi.fn(),
87+
}));
88+
89+
const mockError = new Error('Test error');
90+
91+
const mockInstance: ComponentPublicInstance = {
92+
$props: { foo: 'bar' },
93+
} as any;
94+
95+
const mockClient = {
96+
getOptions: vi.fn().mockReturnValue({ attachProps: true }),
97+
};
98+
99+
beforeEach(() => {
100+
// Using fake timers as setTimeout is used in `reportNuxtError`
101+
vi.useFakeTimers();
102+
vi.clearAllMocks();
103+
(getClient as Mock).mockReturnValue(mockClient);
104+
});
105+
106+
afterEach(() => {
107+
vi.clearAllMocks();
108+
});
109+
110+
test('captures exception with correct error and metadata', () => {
111+
reportNuxtError({ error: mockError });
112+
vi.runAllTimers();
113+
114+
expect(captureException).toHaveBeenCalledWith(mockError, {
115+
captureContext: { contexts: { nuxt: { info: undefined } } },
116+
mechanism: { handled: false },
117+
});
118+
});
119+
120+
test('includes instance props if attachProps is not explicitly defined', () => {
121+
reportNuxtError({ error: mockError, instance: mockInstance });
122+
vi.runAllTimers();
123+
124+
expect(captureException).toHaveBeenCalledWith(mockError, {
125+
captureContext: { contexts: { nuxt: { info: undefined, propsData: { foo: 'bar' } } } },
126+
mechanism: { handled: false },
127+
});
128+
});
129+
130+
test('does not include instance props if attachProps is disabled', () => {
131+
mockClient.getOptions.mockReturnValue({ attachProps: false });
132+
133+
reportNuxtError({ error: mockError, instance: mockInstance });
134+
vi.runAllTimers();
135+
136+
expect(captureException).toHaveBeenCalledWith(mockError, {
137+
captureContext: { contexts: { nuxt: { info: undefined } } },
138+
mechanism: { handled: false },
139+
});
140+
});
141+
142+
test('handles absence of instance correctly', () => {
143+
reportNuxtError({ error: mockError, info: 'Some info' });
144+
vi.runAllTimers();
145+
146+
expect(captureException).toHaveBeenCalledWith(mockError, {
147+
captureContext: { contexts: { nuxt: { info: 'Some info' } } },
148+
mechanism: { handled: false },
149+
});
150+
});
151+
});

packages/vue/src/errorhandler.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { formatComponentName, generateComponentTrace } from './vendor/components
77
type UnknownFunc = (...args: unknown[]) => void;
88

99
export const attachErrorHandler = (app: Vue, options: VueOptions): void => {
10-
const { errorHandler, warnHandler, silent } = app.config;
10+
const { errorHandler: originalErrorHandler, warnHandler, silent } = app.config;
1111

1212
app.config.errorHandler = (error: Error, vm: ViewModel, lifecycleHook: string): void => {
1313
const componentName = formatComponentName(vm, false);
@@ -36,8 +36,9 @@ export const attachErrorHandler = (app: Vue, options: VueOptions): void => {
3636
});
3737
});
3838

39-
if (typeof errorHandler === 'function') {
40-
(errorHandler as UnknownFunc).call(app, error, vm, lifecycleHook);
39+
// Check if the current `app.config.errorHandler` is explicitly set by the user before calling it.
40+
if (typeof originalErrorHandler === 'function' && app.config.errorHandler) {
41+
(originalErrorHandler as UnknownFunc).call(app, error, vm, lifecycleHook);
4142
}
4243

4344
if (options.logErrors) {

packages/vue/src/integration.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const DEFAULT_CONFIG: VueOptions = {
1414
Vue: globalWithVue.Vue,
1515
attachProps: true,
1616
logErrors: true,
17+
attachErrorHandler: true,
1718
hooks: DEFAULT_HOOKS,
1819
timeout: 2000,
1920
trackComponents: false,
@@ -76,7 +77,9 @@ const vueInit = (app: Vue, options: Options): void => {
7677
}
7778
}
7879

79-
attachErrorHandler(app, options);
80+
if (options.attachErrorHandler) {
81+
attachErrorHandler(app, options);
82+
}
8083

8184
if (hasTracingEnabled(options)) {
8285
app.mixin(

packages/vue/src/types.ts

+11
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ export interface VueOptions extends TracingOptions {
4747
*/
4848
logErrors: boolean;
4949

50+
/**
51+
* By default, Sentry attaches an error handler to capture exceptions and report them to Sentry.
52+
* When `attachErrorHandler` is set to `false`, automatic error reporting is disabled.
53+
*
54+
* Usually, this option should stay enabled, unless you want to set up Sentry error reporting yourself.
55+
* For example, the Sentry Nuxt SDK does not attach an error handler as it's using the error hooks provided by Nuxt.
56+
*
57+
* @default true
58+
*/
59+
attachErrorHandler: boolean;
60+
5061
/** {@link TracingOptions} */
5162
tracingOptions?: Partial<TracingOptions>;
5263
}

0 commit comments

Comments
 (0)