Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions packages/nextjs/src/common/pages-router-instrumentation/_error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ type ContextOrProps = {
/**
* Capture the exception passed by nextjs to the `_error` page, adding context data as appropriate.
*
* 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.
*
* @param contextOrProps The data passed to either `getInitialProps` or `render` by nextjs
* @returns The Sentry event ID, or `undefined` if no event was captured
*/
export async function captureUnderscoreErrorException(contextOrProps: ContextOrProps): Promise<void> {
export async function captureUnderscoreErrorException(contextOrProps: ContextOrProps): Promise<string | undefined> {
const { req, res, err } = contextOrProps;

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

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

withScope(scope => {
const eventId = withScope(scope => {
if (req) {
const normalizedRequest = httpRequestToRequestData(req);
scope.setSDKProcessingMetadata({ normalizedRequest });
}

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

waitUntil(flushSafelyWithTimeout());

return eventId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { captureUnderscoreErrorException } from '../../../src/common/pages-router-instrumentation/_error';

const mockCaptureException = vi.fn(() => 'test-event-id');
const mockWithScope = vi.fn((callback: (scope: any) => any) => {
const mockScope = {
setSDKProcessingMetadata: vi.fn(),
};
return callback(mockScope);
});

vi.mock('@sentry/core', async () => {
const actual = await vi.importActual('@sentry/core');
return {
...actual,
captureException: (...args: unknown[]) => mockCaptureException(...args),
withScope: (callback: (scope: any) => any) => mockWithScope(callback),
httpRequestToRequestData: vi.fn(() => ({ url: 'http://test.com' })),
};
});

vi.mock('../../../src/common/utils/responseEnd', () => ({
flushSafelyWithTimeout: vi.fn(() => Promise.resolve()),
waitUntil: vi.fn(),
}));

describe('captureUnderscoreErrorException', () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.clearAllMocks();
});

it('should return the event ID when capturing an exception', async () => {
const error = new Error('Test error');
const result = await captureUnderscoreErrorException({
err: error,
pathname: '/test',
res: { statusCode: 500 } as any,
});

expect(result).toBe('test-event-id');
expect(mockCaptureException).toHaveBeenCalledWith(error, {
mechanism: {
type: 'auto.function.nextjs.underscore_error',
handled: false,
data: {
function: '_error.getInitialProps',
},
},
});
});

it('should return undefined for 4xx status codes', async () => {
const result = await captureUnderscoreErrorException({
err: new Error('Not found'),
pathname: '/test',
res: { statusCode: 404 } as any,
});

expect(result).toBeUndefined();
expect(mockCaptureException).not.toHaveBeenCalled();
});

it('should return undefined when pathname is not provided (render call)', async () => {
const result = await captureUnderscoreErrorException({
err: new Error('Test error'),
res: { statusCode: 500 } as any,
});

expect(result).toBeUndefined();
expect(mockCaptureException).not.toHaveBeenCalled();
});

it('should capture falsy errors as messages', async () => {
const result = await captureUnderscoreErrorException({
err: undefined,
pathname: '/test',
res: { statusCode: 500 } as any,
});

expect(result).toBe('test-event-id');
expect(mockCaptureException).toHaveBeenCalledWith('_error.js called with falsy error (undefined)', {
mechanism: {
type: 'auto.function.nextjs.underscore_error',
handled: false,
data: {
function: '_error.getInitialProps',
},
},
});
});

it('should use statusCode from contextOrProps when res is not available', async () => {
const result = await captureUnderscoreErrorException({
err: new Error('Test error'),
pathname: '/test',
statusCode: 500,
});

expect(result).toBe('test-event-id');
expect(mockCaptureException).toHaveBeenCalled();
});

it('should return undefined when statusCode from contextOrProps is 4xx', async () => {
const result = await captureUnderscoreErrorException({
err: new Error('Bad request'),
pathname: '/test',
statusCode: 400,
});

expect(result).toBeUndefined();
expect(mockCaptureException).not.toHaveBeenCalled();
});
});
Loading