From ccb2a5edf6e80b0cd4bf9e7ddc5872ce09decefa Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:13:12 +0100 Subject: [PATCH] fix(nextjs): Log correct `lastEventId` when error is thrown in component render --- .../test-error-page-no-server.tsx | 9 ++++++ .../tests/error-page-lasteventid.test.ts | 29 +++++++++++++++++++ .../pages-router-instrumentation/_error.ts | 9 ++++++ .../wrapPageComponentWithSentry.ts | 20 +++++++++++-- .../captureUnderscoreErrorException.test.ts | 21 ++++++++++++-- 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/underscore-error/test-error-page-no-server.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/underscore-error/test-error-page-no-server.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/underscore-error/test-error-page-no-server.tsx new file mode 100644 index 000000000000..b662bce040b8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/underscore-error/test-error-page-no-server.tsx @@ -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 {}; +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/error-page-lasteventid.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/error-page-lasteventid.test.ts index 399c5700e8f2..224fbc075488 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/error-page-lasteventid.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/error-page-lasteventid.test.ts @@ -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); +}); diff --git a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts index a82508d22e62..80103b42568f 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts @@ -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).__sentry_event_id__ : undefined; + + if (typeof storedEventId === 'string') { + getIsolationScope().setLastEventId(storedEventId); + return storedEventId; + } + return getIsolationScope().lastEventId(); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapPageComponentWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapPageComponentWithSentry.ts index 693341024726..ddbb123b458b 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapPageComponentWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapPageComponentWithSentry.ts @@ -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; @@ -11,6 +17,12 @@ interface ClassComponent { }; } +function storeCapturedEventIdOnError(error: unknown, eventId: string | undefined): void { + if (error && typeof error === 'object') { + addNonEnumerableProperty(error as Record, '__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; @@ -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; } }); @@ -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; } }); diff --git a/packages/nextjs/test/common/pages-router-instrumentation/captureUnderscoreErrorException.test.ts b/packages/nextjs/test/common/pages-router-instrumentation/captureUnderscoreErrorException.test.ts index 250052d3c991..796f4b2be663 100644 --- a/packages/nextjs/test/common/pages-router-instrumentation/captureUnderscoreErrorException.test.ts +++ b/packages/nextjs/test/common/pages-router-instrumentation/captureUnderscoreErrorException.test.ts @@ -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(), @@ -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(), @@ -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';