diff --git a/packages/nextjs/src/common/_error.ts b/packages/nextjs/src/common/_error.ts index f79c844adba7..f3a198919a72 100644 --- a/packages/nextjs/src/common/_error.ts +++ b/packages/nextjs/src/common/_error.ts @@ -1,6 +1,7 @@ import { captureException, withScope } from '@sentry/core'; import type { NextPageContext } from 'next'; -import { flushQueue } from './utils/responseEnd'; +import { flushSafelyWithTimeout } from './utils/responseEnd'; +import { vercelWaitUntil } from './utils/vercelWaitUntil'; type ContextOrProps = { req?: NextPageContext['req']; @@ -53,7 +54,5 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP }); }); - // In case this is being run as part of a serverless function (as is the case with the server half of nextjs apps - // deployed to vercel), make sure the error gets sent to Sentry before the lambda exits. - await flushQueue(); + vercelWaitUntil(flushSafelyWithTimeout()); } diff --git a/packages/nextjs/src/common/types.ts b/packages/nextjs/src/common/types.ts index c182e80c2d20..05d89d3e3159 100644 --- a/packages/nextjs/src/common/types.ts +++ b/packages/nextjs/src/common/types.ts @@ -48,11 +48,6 @@ export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefin export type NextApiHandler = { (req: NextApiRequest, res: NextApiResponse): void | Promise | unknown | Promise; __sentry_route__?: string; - - /** - * A property we set in our integration tests to simulate running an API route on platforms that don't support streaming. - */ - __sentry_test_doesnt_support_streaming__?: true; }; export type WrappedNextApiHandler = { diff --git a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts index 9324d59829c1..65bdabc93dda 100644 --- a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts +++ b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts @@ -12,8 +12,9 @@ import { import { winterCGRequestToRequestData } from '@sentry/utils'; import type { EdgeRouteHandler } from '../../edge/types'; -import { flushQueue } from './responseEnd'; +import { flushSafelyWithTimeout } from './responseEnd'; import { commonObjectToIsolationScope, escapeNextjsTracing } from './tracingUtils'; +import { vercelWaitUntil } from './vercelWaitUntil'; /** * Wraps a function on the edge runtime with error and performance monitoring. @@ -80,9 +81,11 @@ export function withEdgeWrapping( return handlerResult; }, - ).finally(() => flushQueue()); + ); }, - ); + ).finally(() => { + vercelWaitUntil(flushSafelyWithTimeout()); + }); }); }); }; diff --git a/packages/nextjs/src/common/utils/platformSupportsStreaming.ts b/packages/nextjs/src/common/utils/platformSupportsStreaming.ts deleted file mode 100644 index 39b19f0ab8db..000000000000 --- a/packages/nextjs/src/common/utils/platformSupportsStreaming.ts +++ /dev/null @@ -1 +0,0 @@ -export const platformSupportsStreaming = (): boolean => !process.env.LAMBDA_TASK_ROOT && !process.env.VERCEL; diff --git a/packages/nextjs/src/common/utils/responseEnd.ts b/packages/nextjs/src/common/utils/responseEnd.ts index c287933f6c39..e5aedd9be773 100644 --- a/packages/nextjs/src/common/utils/responseEnd.ts +++ b/packages/nextjs/src/common/utils/responseEnd.ts @@ -44,10 +44,14 @@ export function finishSpan(span: Span, res: ServerResponse): void { span.end(); } -/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */ -export async function flushQueue(): Promise { +/** + * Flushes pending Sentry events with a 2 second timeout and in a way that cannot create unhandled promise rejections. + */ +export async function flushSafelyWithTimeout(): Promise { try { DEBUG_BUILD && logger.log('Flushing events...'); + // We give things that are currently stuck in event processors a tiny bit more time to finish before flushing. 50ms was chosen very unscientifically. + await new Promise(resolve => setTimeout(resolve, 50)); await flush(2000); DEBUG_BUILD && logger.log('Done flushing events'); } catch (e) { diff --git a/packages/nextjs/src/common/utils/vercelWaitUntil.ts b/packages/nextjs/src/common/utils/vercelWaitUntil.ts new file mode 100644 index 000000000000..15c6015fe4c9 --- /dev/null +++ b/packages/nextjs/src/common/utils/vercelWaitUntil.ts @@ -0,0 +1,21 @@ +import { GLOBAL_OBJ } from '@sentry/utils'; + +interface VercelRequestContextGlobal { + get?(): { + waitUntil?: (task: Promise) => void; + }; +} + +/** + * Function that delays closing of a Vercel lambda until the provided promise is resolved. + * + * Vendored from https://www.npmjs.com/package/@vercel/functions + */ +export function vercelWaitUntil(task: Promise): void { + const vercelRequestContextGlobal: VercelRequestContextGlobal | undefined = + // @ts-expect-error This is not typed + GLOBAL_OBJ[Symbol.for('@vercel/request-context')]; + + const ctx = vercelRequestContextGlobal?.get?.() ?? {}; + ctx.waitUntil?.(task); +} diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index d1d1cd961b3f..306bc96e30f6 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -15,9 +15,9 @@ import { import type { Span } from '@sentry/types'; import { isString } from '@sentry/utils'; -import { platformSupportsStreaming } from './platformSupportsStreaming'; -import { autoEndSpanOnResponseEnd, flushQueue } from './responseEnd'; +import { autoEndSpanOnResponseEnd, flushSafelyWithTimeout } from './responseEnd'; import { commonObjectToIsolationScope, escapeNextjsTracing } from './tracingUtils'; +import { vercelWaitUntil } from './vercelWaitUntil'; declare module 'http' { interface IncomingMessage { @@ -124,15 +124,14 @@ export function withTracedServerSideDataFetcher Pr throw e; } finally { dataFetcherSpan.end(); - if (!platformSupportsStreaming()) { - await flushQueue(); - } } }, ); }); }); }); + }).finally(() => { + vercelWaitUntil(flushSafelyWithTimeout()); }); }; } @@ -198,10 +197,9 @@ export async function callDataFetcherTraced Promis throw e; } finally { dataFetcherSpan.end(); - if (!platformSupportsStreaming()) { - await flushQueue(); - } } }, - ); + ).finally(() => { + vercelWaitUntil(flushSafelyWithTimeout()); + }); } diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index 109743eea01a..14c701638ee5 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -9,9 +9,9 @@ import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; -import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; -import { flushQueue } from './utils/responseEnd'; +import { flushSafelyWithTimeout } from './utils/responseEnd'; import { escapeNextjsTracing } from './utils/tracingUtils'; +import { vercelWaitUntil } from './utils/vercelWaitUntil'; interface Options { formData?: FormData; @@ -131,16 +131,7 @@ async function withServerActionInstrumentationImplementation { - target.apply(thisArg, argArray); - }); - } + vercelWaitUntil(flushSafelyWithTimeout()); + target.apply(thisArg, argArray); }, }); @@ -138,14 +131,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz setHttpStatus(span, res.statusCode); span.end(); - // Make sure we have a chance to finish the transaction and flush events to Sentry before the handler errors - // out. (Apps which are deployed on Vercel run their API routes in lambdas, and those lambdas will shut down the - // moment they detect an error, so it's important to get this done before rethrowing the error. Apps not - // deployed serverlessly will run into this cleanup code again in `res.end(), but the transaction will already - // be finished and the queue will already be empty, so effectively it'll just no-op.) - if (platformSupportsStreaming() && !wrappingTarget.__sentry_test_doesnt_support_streaming__) { - await flushQueue(); - } + vercelWaitUntil(flushSafelyWithTimeout()); // We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it // would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index be378dc8cd5e..e55eedd9802e 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -13,13 +13,13 @@ import { import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; -import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; -import { flushQueue } from './utils/responseEnd'; +import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope, commonObjectToPropagationContext, escapeNextjsTracing, } from './utils/tracingUtils'; +import { vercelWaitUntil } from './utils/vercelWaitUntil'; /** * Wraps a Next.js route handler with performance and error instrumentation. @@ -97,11 +97,7 @@ export function wrapRouteHandlerWithSentry any>( }, ); } finally { - if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') { - // 1. Edge transport requires manual flushing - // 2. Lambdas require manual flushing to prevent execution freeze before the event is sent - await flushQueue(); - } + vercelWaitUntil(flushSafelyWithTimeout()); } }); }); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index fe185679528d..0d1e224bdf47 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -14,12 +14,13 @@ import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@se import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; -import { flushQueue } from './utils/responseEnd'; +import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope, commonObjectToPropagationContext, escapeNextjsTracing, } from './utils/tracingUtils'; +import { vercelWaitUntil } from './utils/vercelWaitUntil'; /** * Wraps an `app` directory server component with Sentry error instrumentation. @@ -93,10 +94,7 @@ export function wrapServerComponentWithSentry any> }, () => { span.end(); - - // flushQueue should not throw - // eslint-disable-next-line @typescript-eslint/no-floating-promises - flushQueue(); + vercelWaitUntil(flushSafelyWithTimeout()); }, ); }, diff --git a/packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts b/packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts index f32fcf55fafd..b0cfca8651be 100644 --- a/packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts +++ b/packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts @@ -6,6 +6,4 @@ const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise