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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default function TestRenderErrorPage() {
throw new Error('Test render error to trigger _error.tsx page');
}

// IMPORTANT: Specifically test without `getServerSideProps`
// Opt out of static pre-rendering (otherwise, we get build-time errors)
TestRenderErrorPage.getInitialProps = async () => {
return {};
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,32 @@ test('lastEventId() should return the event ID after captureUnderscoreErrorExcep
expect(errorEvent.event_id).toBe(returnedEventId);
expect(errorEvent.event_id).toBe(lastEventId);
});

test('lastEventId() should return the event ID for component render errors', async ({ page }) => {
test.skip(isDevMode, 'should be skipped for non-dev mode');
test.skip(isNext13, 'should be skipped for Next.js 13');

const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Test render error to trigger _error.tsx page';
});

await page.goto('/underscore-error/test-error-page-no-server');
const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.function.nextjs.page_function');
expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false);

const eventIdFromReturn = await page.locator('[data-testid="event-id"]').textContent();
const returnedEventId = eventIdFromReturn?.replace('Event ID from return: ', '');

const lastEventIdFromFunction = await page.locator('[data-testid="last-event-id"]').textContent();
const lastEventId = lastEventIdFromFunction?.replace('Event ID from lastEventId(): ', '');

expect(returnedEventId).toBeDefined();
expect(returnedEventId).not.toBe('No event ID');
expect(lastEventId).toBeDefined();
expect(lastEventId).not.toBe('No event ID');

expect(returnedEventId).toBe(errorEvent.event_id);
expect(lastEventId).toBe(returnedEventId);
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP
// return the existing event ID instead of capturing it again (needed for lastEventId() to work)
if (err && isAlreadyCaptured(err)) {
waitUntil(flushSafelyWithTimeout());

const storedEventId =
typeof err === 'object' ? (err as unknown as Record<string, unknown>).__sentry_event_id__ : undefined;

if (typeof storedEventId === 'string') {
getIsolationScope().setLastEventId(storedEventId);
return storedEventId;
}

return getIsolationScope().lastEventId();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { captureException, extractTraceparentData, getCurrentScope, withIsolationScope } from '@sentry/core';
import {
addNonEnumerableProperty,
captureException,
extractTraceparentData,
getCurrentScope,
withIsolationScope,
} from '@sentry/core';

interface FunctionComponent {
(...args: unknown[]): unknown;
Expand All @@ -11,6 +17,12 @@ interface ClassComponent {
};
}

function storeCapturedEventIdOnError(error: unknown, eventId: string | undefined): void {
if (error && typeof error === 'object') {
addNonEnumerableProperty(error as Record<string, unknown>, '__sentry_event_id__', eventId);
}
}

function isReactClassComponent(target: unknown): target is ClassComponent {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return typeof target === 'function' && target?.prototype?.isReactComponent;
Expand Down Expand Up @@ -45,12 +57,13 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C
try {
return super.render(...args);
} catch (e) {
captureException(e, {
const eventId = captureException(e, {
mechanism: {
handled: false,
type: 'auto.function.nextjs.page_class',
},
});
storeCapturedEventIdOnError(e, eventId);
throw e;
}
});
Expand All @@ -75,12 +88,13 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C
try {
return target.apply(thisArg, argArray);
} catch (e) {
captureException(e, {
const eventId = captureException(e, {
mechanism: {
handled: false,
type: 'auto.function.nextjs.page_function',
},
});
storeCapturedEventIdOnError(e, eventId);
throw e;
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { captureUnderscoreErrorException } from '../../../src/common/pages-route

let storedLastEventId: string | undefined = undefined;

const mockCaptureException = vi.fn(() => 'test-event-id');
const mockCaptureException = vi.fn((_exception?: unknown, _hint?: unknown) => 'test-event-id');
const mockWithScope = vi.fn((callback: (scope: any) => any) => {
const mockScope = {
setSDKProcessingMetadata: vi.fn(),
Expand All @@ -21,7 +21,7 @@ vi.mock('@sentry/core', async () => {
const actual = await vi.importActual('@sentry/core');
return {
...actual,
captureException: (...args: unknown[]) => mockCaptureException(...args),
captureException: (exception: unknown, hint?: unknown) => mockCaptureException(exception, hint),
withScope: (callback: (scope: any) => any) => mockWithScope(callback),
httpRequestToRequestData: vi.fn(() => ({ url: 'http://test.com' })),
lastEventId: () => mockGetIsolationScope().lastEventId(),
Expand Down Expand Up @@ -146,6 +146,23 @@ describe('captureUnderscoreErrorException', () => {
expect(mockCaptureException).not.toHaveBeenCalled();
});

it('should prefer the stored event ID on already captured errors', async () => {
storedLastEventId = 'scope-event-id';

const error = new Error('Already captured render error');
(error as any).__sentry_captured__ = true;
(error as any).__sentry_event_id__ = 'stored-event-id';

const eventId = await captureUnderscoreErrorException({
err: error,
pathname: '/test',
res: { statusCode: 500 } as any,
});

expect(eventId).toBe('stored-event-id');
expect(mockCaptureException).not.toHaveBeenCalled();
});

it('should capture string errors even if they were marked as captured', async () => {
// String errors can't have __sentry_captured__ property, so they should always be captured
const errorString = 'String error';
Expand Down
Loading