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();
+ });
});