Skip to content

Commit 47e5709

Browse files
authored
fix(nextjs): Expose an event id when captureUnderscoreErrorException captures an exception (#19185)
`captureException` returns an event id that can be used for further logging and exposing it to users. `captureUnderscoreErrorException` uses `captureException` under the hood but swallows the underlying event id if the error ends up being captured. We also have `Sentry.lastEventId()` which could be used in the case the event was actually captured, however, in the case that `captureUnderscoreErrorException` does not call `captureException`, `Sentry.lastEventId()` might still return a previously attained id, misleading users. This PR surfaces the underlying event id, if an event was captured and resolves in undefined otherwise. Closes: #19166
1 parent b37fa88 commit 47e5709

2 files changed

Lines changed: 127 additions & 5 deletions

File tree

packages/nextjs/src/common/pages-router-instrumentation/_error.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@ type ContextOrProps = {
1313
/**
1414
* Capture the exception passed by nextjs to the `_error` page, adding context data as appropriate.
1515
*
16+
* This will not capture the exception if the status code is < 500 or if the pathname is not provided and will thus not return an event ID.
17+
*
1618
* @param contextOrProps The data passed to either `getInitialProps` or `render` by nextjs
19+
* @returns The Sentry event ID, or `undefined` if no event was captured
1720
*/
18-
export async function captureUnderscoreErrorException(contextOrProps: ContextOrProps): Promise<void> {
21+
export async function captureUnderscoreErrorException(contextOrProps: ContextOrProps): Promise<string | undefined> {
1922
const { req, res, err } = contextOrProps;
2023

2124
// 404s (and other 400-y friends) can trigger `_error`, but we don't want to send them to Sentry
2225
const statusCode = res?.statusCode || contextOrProps.statusCode;
2326
if (statusCode && statusCode < 500) {
24-
return Promise.resolve();
27+
return;
2528
}
2629

2730
// In previous versions of the suggested `_error.js` page in which this function is meant to be used, there was a
@@ -32,18 +35,18 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP
3235
// twice, we just bail if we sense we're in that now-extraneous second call. (We can tell which function we're in
3336
// because Nextjs passes `pathname` to `getInitialProps` but not to `render`.)
3437
if (!contextOrProps.pathname) {
35-
return Promise.resolve();
38+
return;
3639
}
3740

38-
withScope(scope => {
41+
const eventId = withScope(scope => {
3942
if (req) {
4043
const normalizedRequest = httpRequestToRequestData(req);
4144
scope.setSDKProcessingMetadata({ normalizedRequest });
4245
}
4346

4447
// If third-party libraries (or users themselves) throw something falsy, we want to capture it as a message (which
4548
// is what passing a string to `captureException` will wind up doing)
46-
captureException(err || `_error.js called with falsy error (${err})`, {
49+
return captureException(err || `_error.js called with falsy error (${err})`, {
4750
mechanism: {
4851
type: 'auto.function.nextjs.underscore_error',
4952
handled: false,
@@ -55,4 +58,6 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP
5558
});
5659

5760
waitUntil(flushSafelyWithTimeout());
61+
62+
return eventId;
5863
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { captureUnderscoreErrorException } from '../../../src/common/pages-router-instrumentation/_error';
3+
4+
const mockCaptureException = vi.fn(() => 'test-event-id');
5+
const mockWithScope = vi.fn((callback: (scope: any) => any) => {
6+
const mockScope = {
7+
setSDKProcessingMetadata: vi.fn(),
8+
};
9+
return callback(mockScope);
10+
});
11+
12+
vi.mock('@sentry/core', async () => {
13+
const actual = await vi.importActual('@sentry/core');
14+
return {
15+
...actual,
16+
captureException: (...args: unknown[]) => mockCaptureException(...args),
17+
withScope: (callback: (scope: any) => any) => mockWithScope(callback),
18+
httpRequestToRequestData: vi.fn(() => ({ url: 'http://test.com' })),
19+
};
20+
});
21+
22+
vi.mock('../../../src/common/utils/responseEnd', () => ({
23+
flushSafelyWithTimeout: vi.fn(() => Promise.resolve()),
24+
waitUntil: vi.fn(),
25+
}));
26+
27+
describe('captureUnderscoreErrorException', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks();
30+
});
31+
32+
afterEach(() => {
33+
vi.clearAllMocks();
34+
});
35+
36+
it('should return the event ID when capturing an exception', async () => {
37+
const error = new Error('Test error');
38+
const result = await captureUnderscoreErrorException({
39+
err: error,
40+
pathname: '/test',
41+
res: { statusCode: 500 } as any,
42+
});
43+
44+
expect(result).toBe('test-event-id');
45+
expect(mockCaptureException).toHaveBeenCalledWith(error, {
46+
mechanism: {
47+
type: 'auto.function.nextjs.underscore_error',
48+
handled: false,
49+
data: {
50+
function: '_error.getInitialProps',
51+
},
52+
},
53+
});
54+
});
55+
56+
it('should return undefined for 4xx status codes', async () => {
57+
const result = await captureUnderscoreErrorException({
58+
err: new Error('Not found'),
59+
pathname: '/test',
60+
res: { statusCode: 404 } as any,
61+
});
62+
63+
expect(result).toBeUndefined();
64+
expect(mockCaptureException).not.toHaveBeenCalled();
65+
});
66+
67+
it('should return undefined when pathname is not provided (render call)', async () => {
68+
const result = await captureUnderscoreErrorException({
69+
err: new Error('Test error'),
70+
res: { statusCode: 500 } as any,
71+
});
72+
73+
expect(result).toBeUndefined();
74+
expect(mockCaptureException).not.toHaveBeenCalled();
75+
});
76+
77+
it('should capture falsy errors as messages', async () => {
78+
const result = await captureUnderscoreErrorException({
79+
err: undefined,
80+
pathname: '/test',
81+
res: { statusCode: 500 } as any,
82+
});
83+
84+
expect(result).toBe('test-event-id');
85+
expect(mockCaptureException).toHaveBeenCalledWith('_error.js called with falsy error (undefined)', {
86+
mechanism: {
87+
type: 'auto.function.nextjs.underscore_error',
88+
handled: false,
89+
data: {
90+
function: '_error.getInitialProps',
91+
},
92+
},
93+
});
94+
});
95+
96+
it('should use statusCode from contextOrProps when res is not available', async () => {
97+
const result = await captureUnderscoreErrorException({
98+
err: new Error('Test error'),
99+
pathname: '/test',
100+
statusCode: 500,
101+
});
102+
103+
expect(result).toBe('test-event-id');
104+
expect(mockCaptureException).toHaveBeenCalled();
105+
});
106+
107+
it('should return undefined when statusCode from contextOrProps is 4xx', async () => {
108+
const result = await captureUnderscoreErrorException({
109+
err: new Error('Bad request'),
110+
pathname: '/test',
111+
statusCode: 400,
112+
});
113+
114+
expect(result).toBeUndefined();
115+
expect(mockCaptureException).not.toHaveBeenCalled();
116+
});
117+
});

0 commit comments

Comments
 (0)