Skip to content

Commit fe97d67

Browse files
authored
fix(nuxt): check for H3 error cause before re-capturing (#18035)
The flakey tests in nuxt-4 pointed out that we have a race condition where a middleware error can bubble up as an H3 event error, which wraps the original error we caught. This means the [`checkOrSetAlreadyCaught`](https://github.com/getsentry/sentry-javascript/blob/749638766641b552e1193353c3f0430cf970787d/packages/core/src/utils/misc.ts#L212-L232) won't actually detect it since it doesn't check the [`.cause` property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause). I added the logic needed for that for the Nuxt SDK but feels like this may come up later if it hadn't already. Note that this does not affect the spans created, just the mechanism of the caught error, the spans would still be marked correctly as errored.
1 parent 7496387 commit fe97d67

File tree

2 files changed

+166
-0
lines changed

2 files changed

+166
-0
lines changed

packages/nuxt/src/runtime/hooks/captureErrorHook.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture
2525
if (error.statusCode >= 300 && error.statusCode < 500) {
2626
return;
2727
}
28+
29+
// Check if the cause (original error) was already captured by middleware instrumentation
30+
// H3 wraps errors, so we need to check the cause property
31+
if (
32+
'cause' in error &&
33+
typeof error.cause === 'object' &&
34+
error.cause !== null &&
35+
'__sentry_captured__' in error.cause
36+
) {
37+
return;
38+
}
2839
}
2940

3041
const { method, path } = {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as SentryCore from '@sentry/core';
2+
import { H3Error } from 'h3';
3+
import type { CapturedErrorContext } from 'nitropack/types';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { sentryCaptureErrorHook } from '../../../src/runtime/hooks/captureErrorHook';
6+
7+
vi.mock('@sentry/core', async importOriginal => {
8+
const mod = await importOriginal();
9+
return {
10+
...(mod as any),
11+
captureException: vi.fn(),
12+
flushIfServerless: vi.fn(),
13+
getClient: vi.fn(),
14+
getCurrentScope: vi.fn(() => ({
15+
setTransactionName: vi.fn(),
16+
})),
17+
};
18+
});
19+
20+
vi.mock('../../../src/runtime/utils', () => ({
21+
extractErrorContext: vi.fn(() => ({ test: 'context' })),
22+
}));
23+
24+
describe('sentryCaptureErrorHook', () => {
25+
const mockErrorContext: CapturedErrorContext = {
26+
event: {
27+
_method: 'GET',
28+
_path: '/test-path',
29+
} as any,
30+
};
31+
32+
beforeEach(() => {
33+
vi.clearAllMocks();
34+
(SentryCore.getClient as any).mockReturnValue({
35+
getOptions: () => ({}),
36+
});
37+
(SentryCore.flushIfServerless as any).mockResolvedValue(undefined);
38+
});
39+
40+
it('should capture regular errors', async () => {
41+
const error = new Error('Test error');
42+
43+
await sentryCaptureErrorHook(error, mockErrorContext);
44+
45+
expect(SentryCore.captureException).toHaveBeenCalledWith(
46+
error,
47+
expect.objectContaining({
48+
mechanism: { handled: false, type: 'auto.function.nuxt.nitro' },
49+
}),
50+
);
51+
});
52+
53+
it('should skip H3Error with 4xx status codes', async () => {
54+
const error = new H3Error('Not found');
55+
error.statusCode = 404;
56+
57+
await sentryCaptureErrorHook(error, mockErrorContext);
58+
59+
expect(SentryCore.captureException).not.toHaveBeenCalled();
60+
});
61+
62+
it('should skip H3Error with 3xx status codes', async () => {
63+
const error = new H3Error('Redirect');
64+
error.statusCode = 302;
65+
66+
await sentryCaptureErrorHook(error, mockErrorContext);
67+
68+
expect(SentryCore.captureException).not.toHaveBeenCalled();
69+
});
70+
71+
it('should capture H3Error with 5xx status codes', async () => {
72+
const error = new H3Error('Server error');
73+
error.statusCode = 500;
74+
75+
await sentryCaptureErrorHook(error, mockErrorContext);
76+
77+
expect(SentryCore.captureException).toHaveBeenCalledWith(
78+
error,
79+
expect.objectContaining({
80+
mechanism: { handled: false, type: 'auto.function.nuxt.nitro' },
81+
}),
82+
);
83+
});
84+
85+
it('should skip H3Error when cause has __sentry_captured__ flag', async () => {
86+
const originalError = new Error('Original error');
87+
// Mark the original error as already captured by middleware
88+
Object.defineProperty(originalError, '__sentry_captured__', {
89+
value: true,
90+
enumerable: false,
91+
});
92+
93+
const h3Error = new H3Error('Wrapped error', { cause: originalError });
94+
h3Error.statusCode = 500;
95+
96+
await sentryCaptureErrorHook(h3Error, mockErrorContext);
97+
98+
expect(SentryCore.captureException).not.toHaveBeenCalled();
99+
});
100+
101+
it('should capture H3Error when cause does not have __sentry_captured__ flag', async () => {
102+
const originalError = new Error('Original error');
103+
const h3Error = new H3Error('Wrapped error', { cause: originalError });
104+
h3Error.statusCode = 500;
105+
106+
await sentryCaptureErrorHook(h3Error, mockErrorContext);
107+
108+
expect(SentryCore.captureException).toHaveBeenCalledWith(
109+
h3Error,
110+
expect.objectContaining({
111+
mechanism: { handled: false, type: 'auto.function.nuxt.nitro' },
112+
}),
113+
);
114+
});
115+
116+
it('should capture H3Error when cause is not an object', async () => {
117+
const h3Error = new H3Error('Error with string cause', { cause: 'string cause' });
118+
h3Error.statusCode = 500;
119+
120+
await sentryCaptureErrorHook(h3Error, mockErrorContext);
121+
122+
expect(SentryCore.captureException).toHaveBeenCalledWith(
123+
h3Error,
124+
expect.objectContaining({
125+
mechanism: { handled: false, type: 'auto.function.nuxt.nitro' },
126+
}),
127+
);
128+
});
129+
130+
it('should capture H3Error when there is no cause', async () => {
131+
const h3Error = new H3Error('Error without cause');
132+
h3Error.statusCode = 500;
133+
134+
await sentryCaptureErrorHook(h3Error, mockErrorContext);
135+
136+
expect(SentryCore.captureException).toHaveBeenCalledWith(
137+
h3Error,
138+
expect.objectContaining({
139+
mechanism: { handled: false, type: 'auto.function.nuxt.nitro' },
140+
}),
141+
);
142+
});
143+
144+
it('should skip when enableNitroErrorHandler is false', async () => {
145+
(SentryCore.getClient as any).mockReturnValue({
146+
getOptions: () => ({ enableNitroErrorHandler: false }),
147+
});
148+
149+
const error = new Error('Test error');
150+
151+
await sentryCaptureErrorHook(error, mockErrorContext);
152+
153+
expect(SentryCore.captureException).not.toHaveBeenCalled();
154+
});
155+
});

0 commit comments

Comments
 (0)