Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 3 additions & 3 deletions packages/nextjs/src/common/captureRequestError.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -42,6 +42,6 @@ export function captureRequestError(error: unknown, request: RequestInfo, errorC
},
});

vercelWaitUntil(flushSafelyWithTimeout());
waitUntil(flushSafelyWithTimeout());
});
}
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -54,5 +54,5 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP
});
});

vercelWaitUntil(flushSafelyWithTimeout());
waitUntil(flushSafelyWithTimeout());
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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);
},
});
Expand Down
52 changes: 51 additions & 1 deletion packages/nextjs/src/common/utils/responseEnd.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,3 +54,53 @@ export async function flushSafelyWithTimeout(): Promise<void> {
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<unknown>): 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<any>): 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__');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super-l: Since this is Opennext specific we could theoretically rename it to make it more obvious.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I though the comments would suffice, but you are right, it should be clearer with the naming. I will do that 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Do you think it would be possible to add this Symbol somehow in our Cloudflare SDK so we don't have to rely 100% on opennext?

Copy link
Collaborator Author

@logaretm logaretm Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think our SDK can do something similar by putting the context on the global scope, so that we don't have to toss it around. But yes this is 100% opennext specific.

Will be very useful for SDKs that do use cloudflare SDK setup I suppose, but not necessarily the Next.js one at its current state with Opennext adapters.


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<unknown>): void {
_getCloudflareContext()?.waitUntil(task);
}

/**
* Checks if the Cloudflare waitUntil function is available globally.
*/
export function isCloudflareWaitUntilAvailable(): boolean {
return typeof _getCloudflareContext()?.waitUntil === 'function';
}
5 changes: 2 additions & 3 deletions packages/nextjs/src/common/withServerActionInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -155,7 +154,7 @@ async function withServerActionInstrumentationImplementation<A extends (...args:
},
);
} finally {
vercelWaitUntil(flushSafelyWithTimeout());
waitUntil(flushSafelyWithTimeout());
}
},
);
Expand Down
5 changes: 2 additions & 3 deletions packages/nextjs/src/common/wrapMiddlewareWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
setCapturedScopesOnSpan,
startSpan,
vercelWaitUntil,
winterCGRequestToRequestData,
withIsolationScope,
} from '@sentry/core';
import { flushSafelyWithTimeout } from '../common/utils/responseEnd';
import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd';
import type { EdgeRouteHandler } from '../edge/types';

/**
Expand Down Expand Up @@ -108,7 +107,7 @@ export function wrapMiddlewareWithSentry<H extends EdgeRouteHandler>(
});
},
() => {
vercelWaitUntil(flushSafelyWithTimeout());
waitUntil(flushSafelyWithTimeout());
},
);
},
Expand Down
5 changes: 2 additions & 3 deletions packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -96,7 +95,7 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
}
},
() => {
vercelWaitUntil(flushSafelyWithTimeout());
waitUntil(flushSafelyWithTimeout());
},
);

Expand Down
5 changes: 2 additions & 3 deletions packages/nextjs/src/common/wrapServerComponentWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -117,7 +116,7 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
},
() => {
span.end();
vercelWaitUntil(flushSafelyWithTimeout());
waitUntil(flushSafelyWithTimeout());
},
);
},
Expand Down
5 changes: 2 additions & 3 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
setCapturedScopesOnSpan,
spanToJSON,
stripUrlQueryAndFragment,
vercelWaitUntil,
} from '@sentry/core';
import { getScopesFromContext } from '@sentry/opentelemetry';
import type { VercelEdgeOptions } from '@sentry/vercel-edge';
Expand All @@ -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';

Expand Down Expand Up @@ -142,7 +141,7 @@ export function init(options: VercelEdgeOptions = {}): void {

client?.on('spanEnd', span => {
if (span === getRootSpan(span)) {
vercelWaitUntil(flushSafelyWithTimeout());
waitUntil(flushSafelyWithTimeout());
}
});

Expand Down
5 changes: 2 additions & 3 deletions packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -94,7 +93,7 @@ export function wrapApiHandlerWithSentry<H extends EdgeRouteHandler>(
});
},
() => {
vercelWaitUntil(flushSafelyWithTimeout());
waitUntil(flushSafelyWithTimeout());
},
);
},
Expand Down
99 changes: 99 additions & 0 deletions packages/nextjs/test/common/utils/responseEnd.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {});
});
});
});
Loading