Skip to content
Draft
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,3 @@
export async function GET() {
return Response.json({ value: 'hanging-fetch-data' });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Loading() {
return <div id="loading">Loading...</div>;
}
Original file line number Diff line number Diff line change
@@ -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 <p id="fetched-value">{data.value}</p>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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([]);
});
5 changes: 5 additions & 0 deletions packages/nextjs/src/common/captureRequestError.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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: {
Expand Down
58 changes: 47 additions & 11 deletions packages/nextjs/src/common/nextNavigationErrorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,62 @@
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));
}

/**
* Determines whether input is a Next.js redirect error.
* 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));
}
57 changes: 33 additions & 24 deletions packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -45,34 +49,39 @@ export function wrapGenerationFunctionWithSentry<F extends (...args: any[]) => 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());
Expand Down
9 changes: 8 additions & 1 deletion packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -89,6 +93,9 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => 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);
Expand Down
51 changes: 30 additions & 21 deletions packages/nextjs/src/common/wrapServerComponentWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -40,31 +44,36 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => 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());
Expand Down
8 changes: 8 additions & 0 deletions packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand Down
Loading
Loading