diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/_error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/_error.tsx new file mode 100644 index 000000000000..40aba775d1a6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/_error.tsx @@ -0,0 +1,36 @@ +import type { NextPageContext } from 'next'; +import * as Sentry from '@sentry/nextjs'; + +interface ErrorProps { + statusCode?: number; + eventId?: string; + lastEventId?: string; +} + +function ErrorPage({ statusCode, eventId, lastEventId }: ErrorProps) { + return ( +
+

Error Page

+

Status Code: {statusCode}

+

Event ID from return: {eventId || 'No event ID'}

+

Event ID from lastEventId(): {lastEventId || 'No event ID'}

+
+ ); +} + +ErrorPage.getInitialProps = async (context: NextPageContext) => { + const { res, err } = context; + + const statusCode = res?.statusCode || err?.statusCode || 404; + + // Capture the error using captureUnderscoreErrorException + // This should return the already-captured event ID from the data fetcher + const eventId = await Sentry.captureUnderscoreErrorException(context); + + // Also get the last event ID from lastEventId() + const lastEventId = Sentry.lastEventId(); + + return { statusCode, eventId, lastEventId }; +}; + +export default ErrorPage; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/underscore-error/test-error-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/underscore-error/test-error-page.tsx new file mode 100644 index 000000000000..204d7d2a9b35 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/underscore-error/test-error-page.tsx @@ -0,0 +1,7 @@ +export default function TestErrorPage() { + return
This page should never render
; +} + +export function getServerSideProps() { + throw new Error('Test error to trigger _error.tsx page'); +} 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 new file mode 100644 index 000000000000..399c5700e8f2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/error-page-lasteventid.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; +import { isNext13 } from './nextjsVersion'; + +test('lastEventId() should return the event ID after captureUnderscoreErrorException', 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 error to trigger _error.tsx page'; + }); + + await page.goto('/underscore-error/test-error-page'); + const errorEvent = await errorEventPromise; + + // Since the error is already captured by withErrorInstrumentation in getServerSideProps, + // the mechanism should be 'auto.function.nextjs.wrapped', not 'auto.function.nextjs.underscore_error' + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.function.nextjs.wrapped'); + // The function name might be e.g. 'getServerSideProps$1' + expect(errorEvent.exception?.values?.[0]?.mechanism?.data?.function).toContain('getServerSideProps'); + 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(lastEventId).toBe(returnedEventId); + expect(errorEvent.event_id).toBe(returnedEventId); + expect(errorEvent.event_id).toBe(lastEventId); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/nextjsVersion.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/nextjsVersion.ts new file mode 100644 index 000000000000..6d38e00ee50e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/nextjsVersion.ts @@ -0,0 +1,5 @@ +const packageJson = require('../package.json'); +const nextjsVersion = packageJson.dependencies.next; +const nextjsMajor = Number(nextjsVersion.split('.')[0]); + +export const isNext13 = !isNaN(nextjsMajor) && nextjsMajor === 13; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bcac80b06ea5..d76917bf0cdd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -221,6 +221,7 @@ export { addExceptionMechanism, addExceptionTypeValue, checkOrSetAlreadyCaught, + isAlreadyCaptured, getEventDescription, parseSemver, uuid4, diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 86ddd52b05c3..b68d31a47d28 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -227,7 +227,13 @@ export function checkOrSetAlreadyCaught(exception: unknown): boolean { return false; } -function isAlreadyCaptured(exception: unknown): boolean | void { +/** + * Checks whether we've already captured the given exception (note: not an identical exception - the very object). + * It is considered already captured if it has the `__sentry_captured__` property set to `true`. + * + * @internal Only considered for internal usage + */ +export function isAlreadyCaptured(exception: unknown): boolean | void { try { return (exception as { __sentry_captured__?: boolean }).__sentry_captured__; } catch {} // eslint-disable-line no-empty diff --git a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts index 5f201dfa216b..a82508d22e62 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts @@ -1,4 +1,10 @@ -import { captureException, httpRequestToRequestData, withScope } from '@sentry/core'; +import { + captureException, + getIsolationScope, + httpRequestToRequestData, + isAlreadyCaptured, + withScope, +} from '@sentry/core'; import type { NextPageContext } from 'next'; import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd'; @@ -38,6 +44,13 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP return; } + // If the error was already captured (e.g., by wrapped functions in data fetchers), + // return the existing event ID instead of capturing it again (needed for lastEventId() to work) + if (err && isAlreadyCaptured(err)) { + waitUntil(flushSafelyWithTimeout()); + return getIsolationScope().lastEventId(); + } + const eventId = withScope(scope => { if (req) { const normalizedRequest = httpRequestToRequestData(req); 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 755ef8354b7c..250052d3c991 100644 --- a/packages/nextjs/test/common/pages-router-instrumentation/captureUnderscoreErrorException.test.ts +++ b/packages/nextjs/test/common/pages-router-instrumentation/captureUnderscoreErrorException.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { captureUnderscoreErrorException } from '../../../src/common/pages-router-instrumentation/_error'; +let storedLastEventId: string | undefined = undefined; + const mockCaptureException = vi.fn(() => 'test-event-id'); const mockWithScope = vi.fn((callback: (scope: any) => any) => { const mockScope = { @@ -8,6 +10,12 @@ const mockWithScope = vi.fn((callback: (scope: any) => any) => { }; return callback(mockScope); }); +const mockGetIsolationScope = vi.fn(() => ({ + setLastEventId: (id: string | undefined) => { + storedLastEventId = id; + }, + lastEventId: () => storedLastEventId, +})); vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -16,6 +24,8 @@ vi.mock('@sentry/core', async () => { captureException: (...args: unknown[]) => mockCaptureException(...args), withScope: (callback: (scope: any) => any) => mockWithScope(callback), httpRequestToRequestData: vi.fn(() => ({ url: 'http://test.com' })), + lastEventId: () => mockGetIsolationScope().lastEventId(), + getIsolationScope: () => mockGetIsolationScope(), }; }); @@ -27,6 +37,7 @@ vi.mock('../../../src/common/utils/responseEnd', () => ({ describe('captureUnderscoreErrorException', () => { beforeEach(() => { vi.clearAllMocks(); + storedLastEventId = undefined; }); afterEach(() => { @@ -114,4 +125,38 @@ describe('captureUnderscoreErrorException', () => { expect(result).toBeUndefined(); expect(mockCaptureException).not.toHaveBeenCalled(); }); + + it('should return existing event ID for already captured errors without re-capturing', async () => { + // Set up an existing event ID in the isolation scope + storedLastEventId = 'existing-event-id'; + + // Create an error that has already been captured (marked with __sentry_captured__) + const error = new Error('Already captured error'); + (error as any).__sentry_captured__ = true; + + const eventId = await captureUnderscoreErrorException({ + err: error, + pathname: '/test', + res: { statusCode: 500 } as any, + }); + + // Should return the existing event ID + expect(eventId).toBe('existing-event-id'); + // Should NOT call captureException again + 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'; + + const eventId = await captureUnderscoreErrorException({ + err: errorString, + pathname: '/test', + res: { statusCode: 500 } as any, + }); + + expect(eventId).toBe('test-event-id'); + expect(mockCaptureException).toHaveBeenCalled(); + }); });