From 85bb357ca687cb3489077414d9e1c3e6ac0ed88a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 26 Nov 2025 11:21:37 +0200 Subject: [PATCH 1/3] feat: add cloudflare waitUntil util --- .../nextjs/src/common/captureRequestError.ts | 6 +-- .../pages-router-instrumentation/_error.ts | 6 +-- .../wrapApiHandlerWithSentry.ts | 5 +- .../nextjs/src/common/utils/responseEnd.ts | 52 ++++++++++++++++++- .../common/withServerActionInstrumentation.ts | 5 +- .../src/common/wrapMiddlewareWithSentry.ts | 5 +- .../src/common/wrapRouteHandlerWithSentry.ts | 5 +- .../common/wrapServerComponentWithSentry.ts | 5 +- packages/nextjs/src/edge/index.ts | 5 +- .../src/edge/wrapApiHandlerWithSentry.ts | 5 +- 10 files changed, 71 insertions(+), 28 deletions(-) diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts index 6fd2e83d3188..41fd5d15bea6 100644 --- a/packages/nextjs/src/common/captureRequestError.ts +++ b/packages/nextjs/src/common/captureRequestError.ts @@ -1,6 +1,6 @@ import type { RequestEventData } from '@sentry/core'; -import { captureException, headersToDict, vercelWaitUntil, withScope } from '@sentry/core'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; +import { captureException, headersToDict, withScope } from '@sentry/core'; +import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd'; type RequestInfo = { path: string; @@ -42,6 +42,6 @@ export function captureRequestError(error: unknown, request: RequestInfo, errorC }, }); - vercelWaitUntil(flushSafelyWithTimeout()); + waitUntil(flushSafelyWithTimeout()); }); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts index 354fbe0438e2..8c372f89e39c 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts @@ -1,6 +1,6 @@ -import { captureException, httpRequestToRequestData, vercelWaitUntil, withScope } from '@sentry/core'; +import { captureException, httpRequestToRequestData, withScope } from '@sentry/core'; import type { NextPageContext } from 'next'; -import { flushSafelyWithTimeout } from '../utils/responseEnd'; +import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd'; type ContextOrProps = { req?: NextPageContext['req']; @@ -54,5 +54,5 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP }); }); - vercelWaitUntil(flushSafelyWithTimeout()); + waitUntil(flushSafelyWithTimeout()); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index 8f02df798f84..60a9b0d617f7 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -10,12 +10,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setHttpStatus, startSpanManual, - vercelWaitUntil, withIsolationScope, } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { AugmentedNextApiResponse, NextApiHandler } from '../types'; -import { flushSafelyWithTimeout } from '../utils/responseEnd'; +import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd'; import { dropNextjsRootContext, escapeNextjsTracing } from '../utils/tracingUtils'; export type AugmentedNextApiRequest = NextApiRequest & { @@ -95,7 +94,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz apply(target, thisArg, argArray) { setHttpStatus(span, res.statusCode); span.end(); - vercelWaitUntil(flushSafelyWithTimeout()); + waitUntil(flushSafelyWithTimeout()); return target.apply(thisArg, argArray); }, }); diff --git a/packages/nextjs/src/common/utils/responseEnd.ts b/packages/nextjs/src/common/utils/responseEnd.ts index 745908c2bb61..9be057d89a61 100644 --- a/packages/nextjs/src/common/utils/responseEnd.ts +++ b/packages/nextjs/src/common/utils/responseEnd.ts @@ -1,5 +1,5 @@ import type { Span } from '@sentry/core'; -import { debug, fill, flush, setHttpStatus } from '@sentry/core'; +import { debug, fill, flush, GLOBAL_OBJ, setHttpStatus, vercelWaitUntil } from '@sentry/core'; import type { ServerResponse } from 'http'; import { DEBUG_BUILD } from '../debug-build'; import type { ResponseEndMethod, WrappedResponseEndMethod } from '../types'; @@ -54,3 +54,53 @@ export async function flushSafelyWithTimeout(): Promise { DEBUG_BUILD && debug.log('Error while flushing events:\n', e); } } + +/** + * Uses platform-specific waitUntil function to wait for the provided task to complete without blocking. + */ +export function waitUntil(task: Promise): void { + // If deployed on Cloudflare, use the Cloudflare waitUntil function to flush the events + if (isCloudflareWaitUntilAvailable()) { + cloudflareWaitUntil(task); + return; + } + + // otherwise, use vercel's + vercelWaitUntil(task); +} + +type MinimalCloudflareContext = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + waitUntil(promise: Promise): void; +}; + +/** + * Gets the Cloudflare context from the global object. + * Relevant to opennext + * https://github.com/opennextjs/opennextjs-cloudflare/blob/b53a046bd5c30e94a42e36b67747cefbf7785f9a/packages/cloudflare/src/cli/templates/init.ts#L17 + */ +function _getCloudflareContext(): MinimalCloudflareContext | undefined { + const cfContextSymbol = Symbol.for('__cloudflare-context__'); + + return ( + GLOBAL_OBJ as typeof GLOBAL_OBJ & { + [cfContextSymbol]?: { + ctx: MinimalCloudflareContext; + }; + } + )[cfContextSymbol]?.ctx; +} + +/** + * Function that delays closing of a Cloudflare lambda until the provided promise is resolved. + */ +export function cloudflareWaitUntil(task: Promise): void { + _getCloudflareContext()?.waitUntil(task); +} + +/** + * Checks if the Cloudflare waitUntil function is available globally. + */ +export function isCloudflareWaitUntilAvailable(): boolean { + return typeof _getCloudflareContext()?.waitUntil === 'function'; +} diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index f5a1170cc44b..2096d1004e01 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -11,10 +11,9 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SPAN_STATUS_ERROR, startSpan, - vercelWaitUntil, withIsolationScope, } from '@sentry/core'; -import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; +import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; import { DEBUG_BUILD } from './debug-build'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; @@ -155,7 +154,7 @@ async function withServerActionInstrumentationImplementation( }); }, () => { - vercelWaitUntil(flushSafelyWithTimeout()); + waitUntil(flushSafelyWithTimeout()); }, ); }, diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index 068ab7960ae4..dc5e7fb4f79b 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -12,14 +12,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, setHttpStatus, - vercelWaitUntil, winterCGHeadersToDict, withIsolationScope, withScope, } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; +import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd'; import { commonObjectToIsolationScope } from './utils/tracingUtils'; /** @@ -96,7 +95,7 @@ export function wrapRouteHandlerWithSentry any>( } }, () => { - vercelWaitUntil(flushSafelyWithTimeout()); + waitUntil(flushSafelyWithTimeout()); }, ); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 63871413f006..bdf8f77f4f97 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -13,14 +13,13 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpanManual, - vercelWaitUntil, winterCGHeadersToDict, withIsolationScope, withScope, } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; -import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; +import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; @@ -117,7 +116,7 @@ export function wrapServerComponentWithSentry any> }, () => { span.end(); - vercelWaitUntil(flushSafelyWithTimeout()); + waitUntil(flushSafelyWithTimeout()); }, ); }, diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 2232d259ec11..cefec05dcfe5 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -15,7 +15,6 @@ import { setCapturedScopesOnSpan, spanToJSON, stripUrlQueryAndFragment, - vercelWaitUntil, } from '@sentry/core'; import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; @@ -24,7 +23,7 @@ import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attribu import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; -import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; +import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; @@ -142,7 +141,7 @@ export function init(options: VercelEdgeOptions = {}): void { client?.on('spanEnd', span => { if (span === getRootSpan(span)) { - vercelWaitUntil(flushSafelyWithTimeout()); + waitUntil(flushSafelyWithTimeout()); } }); diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 9d3d4e4427fa..528c174e45fa 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -9,12 +9,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, startSpan, - vercelWaitUntil, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; -import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; +import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from './types'; /** @@ -94,7 +93,7 @@ export function wrapApiHandlerWithSentry( }); }, () => { - vercelWaitUntil(flushSafelyWithTimeout()); + waitUntil(flushSafelyWithTimeout()); }, ); }, From c174908ee7263c961e2da0a9a9880074c314507a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 26 Nov 2025 11:26:06 +0200 Subject: [PATCH 2/3] tests: added unit tests --- .../test/common/utils/responseEnd.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/nextjs/test/common/utils/responseEnd.test.ts diff --git a/packages/nextjs/test/common/utils/responseEnd.test.ts b/packages/nextjs/test/common/utils/responseEnd.test.ts new file mode 100644 index 000000000000..77755e605c85 --- /dev/null +++ b/packages/nextjs/test/common/utils/responseEnd.test.ts @@ -0,0 +1,99 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { waitUntil } from '../../../src/common/utils/responseEnd'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + debug: { + log: vi.fn(), + }, + flush: vi.fn(), + vercelWaitUntil: vi.fn(), + }; +}); + +describe('responseEnd utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Clear Cloudflare context + const cfContextSymbol = Symbol.for('__cloudflare-context__'); + (GLOBAL_OBJ as any)[cfContextSymbol] = undefined; + // Clear Vercel context + const vercelContextSymbol = Symbol.for('@vercel/request-context'); + (GLOBAL_OBJ as any)[vercelContextSymbol] = undefined; + }); + + describe('waitUntil', () => { + it('should use cloudflareWaitUntil when Cloudflare context is available', async () => { + const cfContextSymbol = Symbol.for('__cloudflare-context__'); + const cfWaitUntilMock = vi.fn(); + (GLOBAL_OBJ as any)[cfContextSymbol] = { + ctx: { + waitUntil: cfWaitUntilMock, + }, + }; + + const testTask = Promise.resolve('test'); + waitUntil(testTask); + + expect(cfWaitUntilMock).toHaveBeenCalledWith(testTask); + expect(cfWaitUntilMock).toHaveBeenCalledTimes(1); + + // Should not call vercelWaitUntil when Cloudflare is available + const { vercelWaitUntil } = await import('@sentry/core'); + expect(vercelWaitUntil).not.toHaveBeenCalled(); + }); + + it('should use vercelWaitUntil when Cloudflare context is not available', async () => { + const { vercelWaitUntil } = await import('@sentry/core'); + const testTask = Promise.resolve('test'); + + waitUntil(testTask); + + expect(vercelWaitUntil).toHaveBeenCalledWith(testTask); + expect(vercelWaitUntil).toHaveBeenCalledTimes(1); + }); + + it('should prefer Cloudflare over Vercel when both are available', async () => { + // Set up Cloudflare context + const cfContextSymbol = Symbol.for('__cloudflare-context__'); + const cfWaitUntilMock = vi.fn(); + (GLOBAL_OBJ as any)[cfContextSymbol] = { + ctx: { + waitUntil: cfWaitUntilMock, + }, + }; + + // Set up Vercel context + const vercelWaitUntilMock = vi.fn(); + (GLOBAL_OBJ as any)[Symbol.for('@vercel/request-context')] = { + get: () => ({ waitUntil: vercelWaitUntilMock }), + }; + + const testTask = Promise.resolve('test'); + waitUntil(testTask); + + // Should use Cloudflare + expect(cfWaitUntilMock).toHaveBeenCalledWith(testTask); + expect(cfWaitUntilMock).toHaveBeenCalledTimes(1); + + // Should not use Vercel + const { vercelWaitUntil } = await import('@sentry/core'); + expect(vercelWaitUntil).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully when waitUntil is called with a rejected promise', async () => { + const { vercelWaitUntil } = await import('@sentry/core'); + const testTask = Promise.reject(new Error('test error')); + + // Should not throw synchronously + expect(() => waitUntil(testTask)).not.toThrow(); + expect(vercelWaitUntil).toHaveBeenCalledWith(testTask); + + // Prevent unhandled rejection in test + testTask.catch(() => {}); + }); + }); +}); From 740cf85054ff61bb92ef596144afb32e93819203 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 26 Nov 2025 15:41:00 +0200 Subject: [PATCH 3/3] refactor: rename cf stuff to opennext --- packages/nextjs/src/common/utils/responseEnd.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/nextjs/src/common/utils/responseEnd.ts b/packages/nextjs/src/common/utils/responseEnd.ts index 9be057d89a61..5159897f076e 100644 --- a/packages/nextjs/src/common/utils/responseEnd.ts +++ b/packages/nextjs/src/common/utils/responseEnd.ts @@ -79,28 +79,28 @@ type MinimalCloudflareContext = { * Relevant to opennext * https://github.com/opennextjs/opennextjs-cloudflare/blob/b53a046bd5c30e94a42e36b67747cefbf7785f9a/packages/cloudflare/src/cli/templates/init.ts#L17 */ -function _getCloudflareContext(): MinimalCloudflareContext | undefined { - const cfContextSymbol = Symbol.for('__cloudflare-context__'); +function _getOpenNextCloudflareContext(): MinimalCloudflareContext | undefined { + const openNextCloudflareContextSymbol = Symbol.for('__cloudflare-context__'); return ( GLOBAL_OBJ as typeof GLOBAL_OBJ & { - [cfContextSymbol]?: { + [openNextCloudflareContextSymbol]?: { ctx: MinimalCloudflareContext; }; } - )[cfContextSymbol]?.ctx; + )[openNextCloudflareContextSymbol]?.ctx; } /** * Function that delays closing of a Cloudflare lambda until the provided promise is resolved. */ export function cloudflareWaitUntil(task: Promise): void { - _getCloudflareContext()?.waitUntil(task); + _getOpenNextCloudflareContext()?.waitUntil(task); } /** * Checks if the Cloudflare waitUntil function is available globally. */ export function isCloudflareWaitUntilAvailable(): boolean { - return typeof _getCloudflareContext()?.waitUntil === 'function'; + return typeof _getOpenNextCloudflareContext()?.waitUntil === 'function'; }