diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/hanging-fetch-data/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/hanging-fetch-data/route.ts
new file mode 100644
index 000000000000..8906fe0372d5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/hanging-fetch-data/route.ts
@@ -0,0 +1,3 @@
+export async function GET() {
+ return Response.json({ value: 'hanging-fetch-data' });
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/hanging-fetch/loading.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/hanging-fetch/loading.tsx
new file mode 100644
index 000000000000..b965fb5c3cf8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/hanging-fetch/loading.tsx
@@ -0,0 +1,3 @@
+export default function Loading() {
+ return
Loading...
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/hanging-fetch/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/hanging-fetch/page.tsx
new file mode 100644
index 000000000000..2beab3b0ebee
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/hanging-fetch/page.tsx
@@ -0,0 +1,10 @@
+// This `fetch()` deliberately has no cache configuration. Under Cache Components, Next.js does not
+// issue such a request during a prerender - it hands out a promise that never settles and rejects it
+// with a `HANGING_PROMISE_REJECTION` digest once the prerender is aborted. That rejection surfaces in
+// this component and therefore in the Sentry server component wrapper, which must not report it.
+export default async function Page() {
+ const response = await fetch('http://localhost:3030/api/hanging-fetch-data');
+ const data = (await response.json()) as { value: string };
+
+ return {data.value}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json
index 63d8602ff6df..50628e2b478c 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json
@@ -46,6 +46,12 @@
"extends": "../../package.json"
},
"sentryTest": {
- "//": "TODO: Add variants for webpack once supported"
+ "variants": [
+ {
+ "build-command": "pnpm test:build-webpack",
+ "label": "nextjs-16-cacheComponents (webpack)",
+ "assert-command": "pnpm test:assert-webpack"
+ }
+ ]
}
}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/hangingPromiseRejection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/hangingPromiseRejection.spec.ts
new file mode 100644
index 000000000000..500f2647f434
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/hangingPromiseRejection.spec.ts
@@ -0,0 +1,54 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+
+const HANGING_PROMISE_DIGEST_MESSAGE = 'rejects when the prerender is complete';
+
+function collectHangingPromiseErrors(): { events: unknown[] } {
+ const collected: { events: unknown[] } = { events: [] };
+
+ void waitForError('nextjs-16-cacheComponents', errorEvent => {
+ const value = errorEvent.exception?.values?.[0]?.value ?? '';
+ return value.includes(HANGING_PROMISE_DIGEST_MESSAGE);
+ }).then(event => {
+ collected.events.push(event);
+ });
+
+ return collected;
+}
+
+// Under Cache Components, Next.js aborts prerenders by rejecting the promises it handed out for
+// uncached `fetch()` calls. React discards those rejections - they never affect the response - so the
+// Sentry wrappers must not report them. See https://github.com/getsentry/sentry-javascript/issues/23592
+//
+// Note this only exercises the regression under the webpack variant: server components are wrapped by
+// `wrappingLoader`, which Turbopack builds do not run, so there is no wrapper to observe the rejection
+// there. Under Turbopack the test still asserts the route renders and reports no errors.
+test('does not capture hanging prerender promise rejections on a runtime prefetch', async ({ page, request }) => {
+ const collected = collectHangingPromiseErrors();
+
+ // `Next-Router-Prefetch: 2` is what the Next.js router sends for a runtime prefetch. It makes Next.js
+ // run a prerender at request time, which is what produces the hanging promise rejection. A plain
+ // document request only replays the shell that was prerendered at build time and would not trigger it.
+ const prefetchResponse = await request.get('/hanging-fetch', {
+ headers: { RSC: '1', 'Next-Router-Prefetch': '2' },
+ });
+ expect(prefetchResponse.ok()).toBe(true);
+
+ const serverTransactionPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /hanging-fetch'
+ );
+ });
+
+ await page.goto('/hanging-fetch');
+ await expect(page.locator('#fetched-value')).toHaveText('hanging-fetch-data');
+
+ // Waiting for the transaction proves the SDK was wired up and flushing events for this route, so an
+ // empty error list below means "nothing was captured" rather than "nothing was listening".
+ const serverTransaction = await serverTransactionPromise;
+ expect(serverTransaction).toBeDefined();
+
+ await page.waitForTimeout(5000);
+
+ expect(collected.events).toEqual([]);
+});
diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts
index a34a868859ac..e0e1d094a211 100644
--- a/packages/nextjs/src/common/captureRequestError.ts
+++ b/packages/nextjs/src/common/captureRequestError.ts
@@ -1,5 +1,6 @@
import type { RequestEventData } from '@sentry/core';
import { captureException, headersToDict, withScope } from '@sentry/core';
+import { isPrerenderControlFlowError } from './nextNavigationErrorUtils';
import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd';
type RequestInfo = {
@@ -18,6 +19,10 @@ type ErrorContext = {
* Reports errors passed to the the Next.js `onRequestError` instrumentation hook.
*/
export function captureRequestError(error: unknown, request: RequestInfo, errorContext: ErrorContext): void {
+ if (isPrerenderControlFlowError(error)) {
+ return;
+ }
+
withScope(scope => {
scope.setSDKProcessingMetadata({
normalizedRequest: {
diff --git a/packages/nextjs/src/common/nextNavigationErrorUtils.ts b/packages/nextjs/src/common/nextNavigationErrorUtils.ts
index b7f4d1de9b4c..94960cc32ca9 100644
--- a/packages/nextjs/src/common/nextNavigationErrorUtils.ts
+++ b/packages/nextjs/src/common/nextNavigationErrorUtils.ts
@@ -1,16 +1,32 @@
import { isError } from '@sentry/core';
+// Next.js nests the "real" reason in `cause` when an error crosses certain boundaries, and
+// `unstable_rethrow` walks that chain. The cap guards against self-referencing causes.
+const MAX_CAUSE_DEPTH = 5;
+
+function hasDigest(subject: unknown, predicate: (digest: string) => boolean, depth = 0): boolean {
+ if (!isError(subject)) {
+ return false;
+ }
+
+ const digest = (subject as Error & { digest?: unknown }).digest;
+ if (typeof digest === 'string' && predicate(digest)) {
+ return true;
+ }
+
+ if (depth < MAX_CAUSE_DEPTH && 'cause' in subject) {
+ return hasDigest(subject.cause, predicate, depth + 1);
+ }
+
+ return false;
+}
+
/**
* Determines whether input is a Next.js not-found error.
* https://beta.nextjs.org/docs/api-reference/notfound#notfound
*/
export function isNotFoundNavigationError(subject: unknown): boolean {
- return (
- isError(subject) &&
- ['NEXT_NOT_FOUND', 'NEXT_HTTP_ERROR_FALLBACK;404'].includes(
- (subject as Error & { digest?: unknown }).digest as string,
- )
- );
+ return hasDigest(subject, digest => ['NEXT_NOT_FOUND', 'NEXT_HTTP_ERROR_FALLBACK;404'].includes(digest));
}
/**
@@ -18,9 +34,29 @@ export function isNotFoundNavigationError(subject: unknown): boolean {
* https://beta.nextjs.org/docs/api-reference/redirect#redirect
*/
export function isRedirectNavigationError(subject: unknown): boolean {
- return (
- isError(subject) &&
- typeof (subject as Error & { digest?: unknown }).digest === 'string' &&
- (subject as Error & { digest: string }).digest.startsWith('NEXT_REDIRECT;') // a redirect digest looks like "NEXT_REDIRECT;[redirect path]"
- );
+ // a redirect digest looks like "NEXT_REDIRECT;[redirect path]"
+ return hasDigest(subject, digest => digest.startsWith('NEXT_REDIRECT;'));
+}
+
+const PRERENDER_CONTROL_FLOW_DIGESTS = [
+ // Next.js hands out promises that never settle (e.g. from `fetch()` under Cache Components) and rejects
+ // them once the prerender is aborted. React discards them - anything else observing them must ignore them.
+ 'HANGING_PROMISE_REJECTION',
+ // Thrown to abort a prerender the moment dynamic data is accessed.
+ 'NEXT_PRERENDER_INTERRUPTED',
+ // Thrown to bail out of static generation into dynamic rendering.
+ 'DYNAMIC_SERVER_USAGE',
+ // Thrown by `next/dynamic` to bail out of SSR into client-side rendering.
+ 'BAILOUT_TO_CLIENT_SIDE_RENDERING',
+];
+
+/**
+ * Determines whether input is one of the errors Next.js throws to steer rendering rather than to signal a failure.
+ *
+ * This mirrors the non-navigation half of Next.js' `unstable_rethrow`, which is the contract any code wrapping
+ * user land in a `try`/`catch` has to honor.
+ * https://nextjs.org/docs/app/api-reference/functions/unstable_rethrow
+ */
+export function isPrerenderControlFlowError(subject: unknown): boolean {
+ return hasDigest(subject, digest => PRERENDER_CONTROL_FLOW_DIGESTS.includes(digest));
}
diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts
index 295b06548af4..d90678ded6ed 100644
--- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts
+++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts
@@ -9,7 +9,11 @@ import {
winterCGHeadersToDict,
} from '@sentry/core';
import type { GenerationFunctionContext } from '../common/types';
-import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
+import {
+ isNotFoundNavigationError,
+ isPrerenderControlFlowError,
+ isRedirectNavigationError,
+} from './nextNavigationErrorUtils';
import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd';
/**
@@ -45,34 +49,39 @@ export function wrapGenerationFunctionWithSentry a
error => {
const span = getActiveSpan();
const { componentRoute, componentType, generationFunctionIdentifier } = context;
- let shouldCapture = true;
isolationScope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`);
- if (span) {
- if (isNotFoundNavigationError(error)) {
- // We don't want to report "not-found"s
- shouldCapture = false;
- span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
- } else if (isRedirectNavigationError(error)) {
- // We don't want to report redirects
- shouldCapture = false;
- span.setStatus({ code: SPAN_STATUS_OK });
- } else {
- span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
- }
+ // Next.js uses thrown errors for control flow. Whether or not we happen to have an active span here
+ // must not decide if we report them, so these checks deliberately run outside of the span handling.
+ if (isNotFoundNavigationError(error)) {
+ // We don't want to report "not-found"s
+ span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
+ return;
}
- if (shouldCapture) {
- captureException(error, {
- mechanism: {
- handled: false,
- type: 'auto.function.nextjs.generation_function',
- data: {
- function: generationFunctionIdentifier,
- },
- },
- });
+ if (isRedirectNavigationError(error)) {
+ // We don't want to report redirects
+ span?.setStatus({ code: SPAN_STATUS_OK });
+ return;
+ }
+
+ if (isPrerenderControlFlowError(error)) {
+ // Next.js aborts prerenders by rejecting the promises it handed out. React discards those rejections,
+ // so they are expected, do not affect the response, and must not be reported.
+ return;
}
+
+ span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
+
+ captureException(error, {
+ mechanism: {
+ handled: false,
+ type: 'auto.function.nextjs.generation_function',
+ data: {
+ function: generationFunctionIdentifier,
+ },
+ },
+ });
},
() => {
waitUntil(flushSafelyWithTimeout());
diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
index c0311faf0244..73af164de3a7 100644
--- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
+++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
@@ -16,7 +16,11 @@ import {
withIsolationScope,
withScope,
} from '@sentry/core';
-import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
+import {
+ isNotFoundNavigationError,
+ isPrerenderControlFlowError,
+ isRedirectNavigationError,
+} from './nextNavigationErrorUtils';
import type { RouteHandlerContext } from './types';
import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd';
import { commonObjectToIsolationScope } from './utils/tracingUtils';
@@ -89,6 +93,9 @@ export function wrapRouteHandlerWithSentry any>(
if (rootSpan) {
setHttpStatus(rootSpan, 404);
}
+ } else if (isPrerenderControlFlowError(error)) {
+ // Next.js aborts prerenders by rejecting the promises it handed out. React discards those
+ // rejections, so they are expected, do not affect the response, and must not be reported.
} else {
const errorStatus = { code: SPAN_STATUS_ERROR, message: 'internal_error' } as const;
activeSpan?.setStatus(errorStatus);
diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts
index be23decd6014..6deaef200f67 100644
--- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts
+++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts
@@ -8,7 +8,11 @@ import {
SPAN_STATUS_OK,
winterCGHeadersToDict,
} from '@sentry/core';
-import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
+import {
+ isNotFoundNavigationError,
+ isPrerenderControlFlowError,
+ isRedirectNavigationError,
+} from '../common/nextNavigationErrorUtils';
import type { ServerComponentContext } from '../common/types';
import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd';
@@ -40,31 +44,36 @@ export function wrapServerComponentWithSentry any>
error => {
const span = getActiveSpan();
const { componentRoute, componentType } = context;
- let shouldCapture = true;
isolationScope.setTransactionName(`${componentType} Server Component (${componentRoute})`);
- if (span) {
- if (isNotFoundNavigationError(error)) {
- // We don't want to report "not-found"s
- shouldCapture = false;
- span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
- } else if (isRedirectNavigationError(error)) {
- // We don't want to report redirects
- shouldCapture = false;
- span.setStatus({ code: SPAN_STATUS_OK });
- } else {
- span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
- }
+ // Next.js uses thrown errors for control flow. Whether or not we happen to have an active span here
+ // must not decide if we report them, so these checks deliberately run outside of the span handling.
+ if (isNotFoundNavigationError(error)) {
+ // We don't want to report "not-found"s
+ span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
+ return;
+ }
+
+ if (isRedirectNavigationError(error)) {
+ // We don't want to report redirects
+ span?.setStatus({ code: SPAN_STATUS_OK });
+ return;
}
- if (shouldCapture) {
- captureException(error, {
- mechanism: {
- handled: false,
- type: 'auto.function.nextjs.server_component',
- },
- });
+ if (isPrerenderControlFlowError(error)) {
+ // Next.js aborts prerenders by rejecting the promises it handed out. React discards those rejections,
+ // so they are expected, do not affect the response, and must not be reported.
+ return;
}
+
+ span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
+
+ captureException(error, {
+ mechanism: {
+ handled: false,
+ type: 'auto.function.nextjs.server_component',
+ },
+ });
},
() => {
waitUntil(flushSafelyWithTimeout());
diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts
index 452887f0d8f4..33d417507b39 100644
--- a/packages/nextjs/src/server/index.ts
+++ b/packages/nextjs/src/server/index.ts
@@ -9,6 +9,7 @@ import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sent
import { DEBUG_BUILD } from '../common/debug-build';
import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor';
import { getVercelEnv } from '../common/getVercelEnv';
+import { isPrerenderControlFlowError } from '../common/nextNavigationErrorUtils';
import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached';
import { isBuild } from '../common/utils/isBuild';
import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd';
@@ -240,6 +241,13 @@ export function init(options: NodeOptions): NodeClient | undefined {
return null;
}
+ if (isPrerenderControlFlowError(originalException)) {
+ // Next.js aborts prerenders by rejecting the promises it handed out (e.g. `fetch()` under Cache
+ // Components) and throws to bail out of static rendering. These never reach the user, so drop them
+ // here as well - the wrappers cannot cover every path they escape through.
+ return null;
+ }
+
// We don't want to capture suspense errors as they are simply used by React/Next.js for control flow
const exceptionMessage = event.exception?.values?.[0]?.value;
if (
diff --git a/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts b/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts
new file mode 100644
index 000000000000..072893b8f53b
--- /dev/null
+++ b/packages/nextjs/test/common/nextNavigationErrorUtils.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isNotFoundNavigationError,
+ isPrerenderControlFlowError,
+ isRedirectNavigationError,
+} from '../../src/common/nextNavigationErrorUtils';
+
+function errorWithDigest(digest: string, cause?: unknown): Error {
+ return Object.assign(new Error('some message'), { digest, cause });
+}
+
+describe('isNotFoundNavigationError', () => {
+ it.each(['NEXT_NOT_FOUND', 'NEXT_HTTP_ERROR_FALLBACK;404'])('detects the %s digest', digest => {
+ expect(isNotFoundNavigationError(errorWithDigest(digest))).toBe(true);
+ });
+
+ it('does not detect unrelated errors', () => {
+ expect(isNotFoundNavigationError(new Error('boom'))).toBe(false);
+ expect(isNotFoundNavigationError(errorWithDigest('NEXT_REDIRECT;/foo'))).toBe(false);
+ expect(isNotFoundNavigationError({ digest: 'NEXT_NOT_FOUND' })).toBe(false);
+ });
+});
+
+describe('isRedirectNavigationError', () => {
+ it('detects a redirect digest', () => {
+ expect(isRedirectNavigationError(errorWithDigest('NEXT_REDIRECT;/some-path'))).toBe(true);
+ });
+
+ it('does not detect unrelated errors', () => {
+ expect(isRedirectNavigationError(errorWithDigest('NEXT_NOT_FOUND'))).toBe(false);
+ expect(isRedirectNavigationError(new Error('boom'))).toBe(false);
+ });
+});
+
+describe('isPrerenderControlFlowError', () => {
+ it.each([
+ 'HANGING_PROMISE_REJECTION',
+ 'NEXT_PRERENDER_INTERRUPTED',
+ 'DYNAMIC_SERVER_USAGE',
+ 'BAILOUT_TO_CLIENT_SIDE_RENDERING',
+ ])('detects the %s digest', digest => {
+ expect(isPrerenderControlFlowError(errorWithDigest(digest))).toBe(true);
+ });
+
+ it('detects a control flow error nested in a cause chain', () => {
+ const nested = new Error('outer', { cause: errorWithDigest('HANGING_PROMISE_REJECTION') });
+ expect(isPrerenderControlFlowError(nested)).toBe(true);
+ });
+
+ it('does not detect navigation errors or regular errors', () => {
+ expect(isPrerenderControlFlowError(errorWithDigest('NEXT_REDIRECT;/foo'))).toBe(false);
+ expect(isPrerenderControlFlowError(errorWithDigest('NEXT_NOT_FOUND'))).toBe(false);
+ expect(isPrerenderControlFlowError(new Error('boom'))).toBe(false);
+ expect(isPrerenderControlFlowError(undefined)).toBe(false);
+ });
+
+ it('terminates on a self-referencing cause chain', () => {
+ const error = new Error('boom');
+ (error as Error & { cause: unknown }).cause = error;
+ expect(isPrerenderControlFlowError(error)).toBe(false);
+ });
+});
diff --git a/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts b/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts
new file mode 100644
index 000000000000..aed2553c7baa
--- /dev/null
+++ b/packages/nextjs/test/common/wrapServerComponentWithSentry.test.ts
@@ -0,0 +1,79 @@
+import type { Span } from '@sentry/core';
+import * as SentryCore from '@sentry/core';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { wrapServerComponentWithSentry } from '../../src/common/wrapServerComponentWithSentry';
+
+const context = { componentRoute: '/rdva', componentType: 'Page' };
+
+function mockActiveSpan(span: Span | undefined): { setStatus: ReturnType } {
+ const setStatus = vi.fn();
+ vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(span ? ({ setStatus } as unknown as Span) : undefined);
+ return { setStatus };
+}
+
+async function runWrapped(error: unknown): Promise {
+ const wrapped = wrapServerComponentWithSentry(async () => {
+ throw error;
+ }, context);
+
+ await expect(wrapped()).rejects.toBe(error);
+}
+
+describe('wrapServerComponentWithSentry', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('captures regular errors and marks the active span as errored', async () => {
+ const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => '');
+ const { setStatus } = mockActiveSpan({} as Span);
+
+ const error = new Error('boom');
+ await runWrapped(error);
+
+ expect(captureException).toHaveBeenCalledWith(error, {
+ mechanism: { handled: false, type: 'auto.function.nextjs.server_component' },
+ });
+ expect(setStatus).toHaveBeenCalledWith({ code: SentryCore.SPAN_STATUS_ERROR, message: 'internal_error' });
+ });
+
+ it.each([
+ ['not-found', 'NEXT_NOT_FOUND'],
+ ['redirect', 'NEXT_REDIRECT;/somewhere'],
+ ['hanging prerender promise', 'HANGING_PROMISE_REJECTION'],
+ ['prerender interruption', 'NEXT_PRERENDER_INTERRUPTED'],
+ ['dynamic server usage', 'DYNAMIC_SERVER_USAGE'],
+ ['bailout to client side rendering', 'BAILOUT_TO_CLIENT_SIDE_RENDERING'],
+ ])('does not capture %s control flow errors', async (_name, digest) => {
+ const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => '');
+ mockActiveSpan({} as Span);
+
+ await runWrapped(Object.assign(new Error('control flow'), { digest }));
+
+ expect(captureException).not.toHaveBeenCalled();
+ });
+
+ it.each(['NEXT_NOT_FOUND', 'NEXT_REDIRECT;/somewhere', 'HANGING_PROMISE_REJECTION'])(
+ 'does not capture the %s control flow error when there is no active span',
+ async digest => {
+ const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => '');
+ mockActiveSpan(undefined);
+
+ await runWrapped(Object.assign(new Error('control flow'), { digest }));
+
+ expect(captureException).not.toHaveBeenCalled();
+ },
+ );
+
+ it('still captures regular errors when there is no active span', async () => {
+ const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => '');
+ mockActiveSpan(undefined);
+
+ const error = new Error('boom');
+ await runWrapped(error);
+
+ expect(captureException).toHaveBeenCalledWith(error, {
+ mechanism: { handled: false, type: 'auto.function.nextjs.server_component' },
+ });
+ });
+});