From 202624b8e3cdca877c75a5307c92f8dc976d7ad9 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Fri, 25 Jul 2025 15:16:12 +0200 Subject: [PATCH 1/8] feat(core): Add shared `flushIfServerless` function --- .../server/plugins/customNitroErrorHandler.ts | 30 +---- packages/astro/src/server/middleware.ts | 15 +-- packages/cloudflare/src/handler.ts | 15 +-- packages/core/src/index.ts | 1 + packages/core/src/utils/flushIfServerless.ts | 72 +++++++++++ .../test/lib/utils/flushIfServerless.test.ts | 116 ++++++++++++++++++ .../nextjs/src/common/captureRequestError.ts | 5 +- .../pages-router-instrumentation/_error.ts | 5 +- .../wrapApiHandlerWithSentry.ts | 4 +- .../common/withServerActionInstrumentation.ts | 5 +- .../src/common/wrapMiddlewareWithSentry.ts | 5 +- .../common/wrapServerComponentWithSentry.ts | 5 +- packages/nextjs/src/edge/index.ts | 5 +- .../src/edge/wrapApiHandlerWithSentry.ts | 5 +- .../src/runtime/hooks/captureErrorHook.ts | 4 +- packages/nuxt/src/runtime/utils.ts | 32 +---- packages/nuxt/src/server/sdk.ts | 17 +-- packages/solidstart/src/server/utils.ts | 24 +--- .../server/withServerActionInstrumentation.ts | 9 +- .../sveltekit/src/server-common/handle.ts | 3 +- .../src/server-common/handleError.ts | 11 +- packages/sveltekit/src/server-common/load.ts | 3 +- .../src/server-common/serverRoute.ts | 3 +- packages/sveltekit/src/server-common/utils.ts | 25 ---- 24 files changed, 236 insertions(+), 183 deletions(-) create mode 100644 packages/core/src/utils/flushIfServerless.ts create mode 100644 packages/core/test/lib/utils/flushIfServerless.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts index 880b43061b93..8f6ef4516fab 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts @@ -1,4 +1,4 @@ -import { Context, GLOBAL_OBJ, flush, debug, vercelWaitUntil } from '@sentry/core'; +import { Context, flushIfServerless } from '@sentry/core'; import * as SentryNode from '@sentry/node'; import { H3Error } from 'h3'; import type { CapturedErrorContext } from 'nitropack'; @@ -53,31 +53,3 @@ function extractErrorContext(errorContext: CapturedErrorContext): Context { return ctx; } - -async function flushIfServerless(): Promise { - const isServerless = - !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions - !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda - !!process.env.VERCEL || - !!process.env.NETLIFY; - - // @ts-expect-error This is not typed - if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { - vercelWaitUntil(flushWithTimeout()); - } else if (isServerless) { - await flushWithTimeout(); - } -} - -async function flushWithTimeout(): Promise { - const sentryClient = SentryNode.getClient(); - const isDebug = sentryClient ? sentryClient.getOptions().debug : false; - - try { - isDebug && debug.log('Flushing events...'); - await flush(2000); - isDebug && debug.log('Done flushing events'); - } catch (e) { - isDebug && debug.log('Error while flushing events:\n', e); - } -} diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 9f04d5427fcf..3f5d81383ee9 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -1,17 +1,15 @@ import type { RequestEventData, Scope, SpanAttributes } from '@sentry/core'; import { addNonEnumerableProperty, - debug, extractQueryParamsFromUrl, + flushIfServerless, objectify, stripUrlQueryAndFragment, - vercelWaitUntil, winterCGRequestToRequestData, } from '@sentry/core'; import { captureException, continueTrace, - flush, getActiveSpan, getClient, getCurrentScope, @@ -233,16 +231,7 @@ async function instrumentRequest( ); return res; } finally { - vercelWaitUntil( - (async () => { - // Flushes pending Sentry events with a 2-second timeout and in a way that cannot create unhandled promise rejections. - try { - await flush(2000); - } catch (e) { - debug.log('Error while flushing events:\n', e); - } - })(), - ); + await flushIfServerless(); } // TODO: flush if serverless (first extract function) }, diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 354233154a0b..8038e5a066ab 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -1,12 +1,12 @@ import { captureException, - flush, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan, withIsolationScope, } from '@sentry/core'; +import { flushIfServerless } from '@sentry/core/src'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { isInstrumented, markAsInstrumented } from './instrument'; @@ -74,7 +74,6 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); - const waitUntil = context.waitUntil.bind(context); const client = init({ ...options, ctx: context }); isolationScope.setClient(client); @@ -100,7 +99,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); - const waitUntil = context.waitUntil.bind(context); const client = init({ ...options, ctx: context }); isolationScope.setClient(client); @@ -141,7 +139,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); - const waitUntil = context.waitUntil.bind(context); const client = init({ ...options, ctx: context }); isolationScope.setClient(client); @@ -191,7 +188,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); - const waitUntil = context.waitUntil.bind(context); - const client = init({ ...options, ctx: context }); isolationScope.setClient(client); @@ -223,7 +218,7 @@ export function withSentry): void; +}; + +async function flushWithTimeout(timeout: number): Promise { + try { + debug.log('Flushing events...'); + await flush(timeout); + debug.log('Done flushing events'); + } catch (e) { + debug.log('Error while flushing events:\n', e); + } +} + +/** + * Flushes the event queue with a timeout in serverless environments to ensure that events are sent to Sentry before the + * serverless function execution ends. + * + * The function is async, but in environments that support a `waitUntil` mechanism, it will run synchronously. + * + * This function is aware of the following serverless platforms: + * - Cloudflare: If a Cloudflare context is provided, it will use `ctx.waitUntil()` to flush events. + * - Vercel: It detects the Vercel environment and uses Vercel's `waitUntil` function. + * - Other Serverless (AWS Lambda, Google Cloud, etc.): It detects the environment via environment variables + * and uses a regular `await flush()`. + * + * @internal This function is supposed for internal Sentry SDK usage only. + * @hidden + */ +export async function flushIfServerless( + params: { + timeout?: number; + cloudflareCtx?: MinimalCloudflareContext; + } = {}, +): Promise { + const { timeout = 2000, cloudflareCtx } = params; + + if (cloudflareCtx && typeof cloudflareCtx.waitUntil === 'function') { + cloudflareCtx.waitUntil(flushWithTimeout(timeout)); + return; + } + + // @ts-expect-error This is not typed + if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { + // Vercel has a waitUntil equivalent that works without execution context + vercelWaitUntil(flushWithTimeout(timeout)); + return; + } + + if (typeof process === 'undefined') { + return; + } + + const isServerless = + !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions + !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda + !!process.env.K_SERVICE || // Google Cloud Run + !!process.env.CF_PAGES || // Cloudflare Pages + !!process.env.VERCEL || + !!process.env.NETLIFY; + + if (isServerless) { + // Use regular flush for environments without a generic waitUntil mechanism + await flushWithTimeout(timeout); + } +} diff --git a/packages/core/test/lib/utils/flushIfServerless.test.ts b/packages/core/test/lib/utils/flushIfServerless.test.ts new file mode 100644 index 000000000000..559140fe0c74 --- /dev/null +++ b/packages/core/test/lib/utils/flushIfServerless.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as flushModule from '../../../src/exports'; +import { flushIfServerless } from '../../../src/utils/flushIfServerless'; +import * as vercelWaitUntilModule from '../../../src/utils/vercelWaitUntil'; +import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; + +describe('flushIfServerless', () => { + let originalProcess: typeof process; + + beforeEach(() => { + vi.resetAllMocks(); + originalProcess = global.process; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('should bind context (preserve `this`) when calling waitUntil', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + + // Mock Cloudflare context with `waitUntil` (which should be called if `this` is bound correctly) + const mockCloudflareCtx = { + contextData: 'test-data', + waitUntil: function (promise: Promise) { + // This will fail if 'this' is not bound correctly + expect(this.contextData).toBe('test-data'); + return promise; + }, + }; + + const waitUntilSpy = vi.spyOn(mockCloudflareCtx, 'waitUntil'); + + await flushIfServerless({ cloudflareCtx: mockCloudflareCtx }); + + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(flushMock).toHaveBeenCalledWith(2000); + }); + + test('should use cloudflare waitUntil when valid cloudflare context is provided', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + const mockCloudflareCtx = { + waitUntil: vi.fn(), + }; + + await flushIfServerless({ cloudflareCtx: mockCloudflareCtx, timeout: 5000 }); + + expect(mockCloudflareCtx.waitUntil).toHaveBeenCalledTimes(1); + expect(flushMock).toHaveBeenCalledWith(5000); + }); + + test('should ignore cloudflare context when waitUntil is not a function (and use Vercel waitUntil instead)', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + const vercelWaitUntilSpy = vi.spyOn(vercelWaitUntilModule, 'vercelWaitUntil').mockImplementation(() => {}); + + // Mock Vercel environment + // @ts-expect-error This is not typed + GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { get: () => ({ waitUntil: vi.fn() }) }; + + const mockCloudflareCtx = { + waitUntil: 'not-a-function', // Invalid waitUntil + }; + + // @ts-expect-error Using the wrong type here on purpose + await flushIfServerless({ cloudflareCtx: mockCloudflareCtx }); + + expect(vercelWaitUntilSpy).toHaveBeenCalledTimes(1); + expect(flushMock).toHaveBeenCalledWith(2000); + }); + + test('should handle multiple serverless environment variables simultaneously', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + + global.process = { + ...originalProcess, + env: { + ...originalProcess.env, + LAMBDA_TASK_ROOT: '/var/task', + VERCEL: '1', + NETLIFY: 'true', + CF_PAGES: '1', + }, + }; + + await flushIfServerless({ timeout: 4000 }); + + expect(flushMock).toHaveBeenCalledWith(4000); + }); + + test('should use default timeout when not specified', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + const mockCloudflareCtx = { + waitUntil: vi.fn(), + }; + + await flushIfServerless({ cloudflareCtx: mockCloudflareCtx }); + + expect(flushMock).toHaveBeenCalledWith(2000); + }); + + test('should handle zero timeout value', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + + global.process = { + ...originalProcess, + env: { + ...originalProcess.env, + LAMBDA_TASK_ROOT: '/var/task', + }, + }; + + await flushIfServerless({ timeout: 0 }); + + expect(flushMock).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts index fec9d46d0e65..58557ae4d3f2 100644 --- a/packages/nextjs/src/common/captureRequestError.ts +++ b/packages/nextjs/src/common/captureRequestError.ts @@ -1,6 +1,5 @@ import type { RequestEventData } from '@sentry/core'; -import { captureException, headersToDict, vercelWaitUntil, withScope } from '@sentry/core'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; +import { captureException, flushIfServerless, headersToDict, withScope } from '@sentry/core'; type RequestInfo = { path: string; @@ -41,6 +40,6 @@ export function captureRequestError(error: unknown, request: RequestInfo, errorC }, }); - vercelWaitUntil(flushSafelyWithTimeout()); + flushIfServerless().catch(() => /* no-op */ {}); }); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts index b33c648839fa..c30e26eb96fb 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts @@ -1,6 +1,5 @@ -import { captureException, httpRequestToRequestData, vercelWaitUntil, withScope } from '@sentry/core'; +import { captureException, flushIfServerless, httpRequestToRequestData, withScope } from '@sentry/core'; import type { NextPageContext } from 'next'; -import { flushSafelyWithTimeout } from '../utils/responseEnd'; type ContextOrProps = { req?: NextPageContext['req']; @@ -54,5 +53,5 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP }); }); - vercelWaitUntil(flushSafelyWithTimeout()); + flushIfServerless().catch(() => /* no-op */ {}); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index ba50778d30ad..f38532b64da9 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -2,6 +2,7 @@ import { captureException, continueTrace, debug, + flushIfServerless, getActiveSpan, httpRequestToRequestData, isString, @@ -10,7 +11,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setHttpStatus, startSpanManual, - vercelWaitUntil, withIsolationScope, } from '@sentry/core'; import type { NextApiRequest } from 'next'; @@ -95,7 +95,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz apply(target, thisArg, argArray) { setHttpStatus(span, res.statusCode); span.end(); - vercelWaitUntil(flushSafelyWithTimeout()); + flushIfServerless().catch(() => /* no-op */ {}); return target.apply(thisArg, argArray); }, }); diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index 9f8673a2fab8..a926a38f47dd 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -3,6 +3,7 @@ import { captureException, continueTrace, debug, + flushIfServerless, getActiveSpan, getClient, getIsolationScope, @@ -10,12 +11,10 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SPAN_STATUS_ERROR, startSpan, - vercelWaitUntil, withIsolationScope, } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; interface Options { formData?: FormData; @@ -152,7 +151,7 @@ async function withServerActionInstrumentationImplementation /* no-op */ {}); } }, ); diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 66e598b5c10f..7c7c31b05830 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -1,6 +1,7 @@ import type { TransactionSource } from '@sentry/core'; import { captureException, + flushIfServerless, getActiveSpan, getCurrentScope, getRootSpan, @@ -9,12 +10,10 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, startSpan, - vercelWaitUntil, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; import type { EdgeRouteHandler } from '../edge/types'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; /** * Wraps Next.js middleware with Sentry error and performance instrumentation. @@ -108,7 +107,7 @@ export function wrapMiddlewareWithSentry( }); }, () => { - vercelWaitUntil(flushSafelyWithTimeout()); + flushIfServerless().catch(() => /* no-op */ {}); }, ); }, diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 16f6728deda1..da977e5be802 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -1,6 +1,7 @@ import type { RequestEventData } from '@sentry/core'; import { captureException, + flushIfServerless, getActiveSpan, getCapturedScopesOnSpan, getClient, @@ -15,7 +16,6 @@ import { SPAN_STATUS_OK, spanToJSON, startSpanManual, - vercelWaitUntil, winterCGHeadersToDict, withIsolationScope, withScope, @@ -23,7 +23,6 @@ import { import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; @@ -137,7 +136,7 @@ export function wrapServerComponentWithSentry any> }, () => { span.end(); - vercelWaitUntil(flushSafelyWithTimeout()); + flushIfServerless().catch(() => /* no-op */ {}); }, ); }, diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 7982667f0c3f..7d0feb507571 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,5 +1,6 @@ import { applySdkMetadata, + flushIfServerless, getGlobalScope, getRootSpan, GLOBAL_OBJ, @@ -9,12 +10,10 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, stripUrlQueryAndFragment, - vercelWaitUntil, } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { isBuild } from '../common/utils/isBuild'; -import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/vercel-edge'; @@ -90,7 +89,7 @@ export function init(options: VercelEdgeOptions = {}): void { client?.on('spanEnd', span => { if (span === getRootSpan(span)) { - vercelWaitUntil(flushSafelyWithTimeout()); + flushIfServerless().catch(() => /* no-op */ {}); } }); diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 466eb19eb1d1..0cb67ece6a72 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -1,5 +1,6 @@ import { captureException, + flushIfServerless, getActiveSpan, getCurrentScope, getRootSpan, @@ -9,11 +10,9 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, startSpan, - vercelWaitUntil, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; -import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from './types'; /** @@ -88,7 +87,7 @@ export function wrapApiHandlerWithSentry( }); }, () => { - vercelWaitUntil(flushSafelyWithTimeout()); + flushIfServerless().catch(() => /* no-op */ {}); }, ); }, diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 8f38bd11061c..7a27b7e6e4c6 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -1,8 +1,8 @@ -import { captureException, getClient, getCurrentScope } from '@sentry/core'; +import { captureException, flushIfServerless, getClient, getCurrentScope } from '@sentry/core'; // eslint-disable-next-line import/no-extraneous-dependencies import { H3Error } from 'h3'; import type { CapturedErrorContext } from 'nitropack/types'; -import { extractErrorContext, flushIfServerless } from '../utils'; +import { extractErrorContext } from '../utils'; /** * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 7c9b49612525..29abbe23ec62 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,5 +1,5 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; -import { captureException, debug, flush, getClient, getTraceMetaTags, GLOBAL_OBJ, vercelWaitUntil } from '@sentry/core'; +import { captureException, debug, getClient, getTraceMetaTags } from '@sentry/core'; import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; @@ -85,33 +85,3 @@ export function reportNuxtError(options: { }); }); } - -async function flushWithTimeout(): Promise { - try { - debug.log('Flushing events...'); - await flush(2000); - debug.log('Done flushing events'); - } catch (e) { - debug.log('Error while flushing events:\n', e); - } -} - -/** - * Flushes if in a serverless environment - */ -export async function flushIfServerless(): Promise { - const isServerless = - !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions - !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda - !!process.env.K_SERVICE || // Google Cloud Run - !!process.env.CF_PAGES || // Cloudflare - !!process.env.VERCEL || - !!process.env.NETLIFY; - - // @ts-expect-error This is not typed - if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { - vercelWaitUntil(flushWithTimeout()); - } else if (isServerless) { - await flushWithTimeout(); - } -} diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 0a1ede6b83a1..5dd8b3d178ae 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,6 +1,6 @@ import * as path from 'node:path'; import type { Client, EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; +import { applySdkMetadata, debug, flushIfServerless, getGlobalScope } from '@sentry/core'; import { type NodeOptions, getDefaultIntegrations as getDefaultNodeIntegrations, @@ -84,22 +84,9 @@ function getNuxtDefaultIntegrations(options: NodeOptions): Integration[] { instrumentation: { responseHook: () => { // Makes it possible to end the tracing span before closing the Vercel lambda (https://vercel.com/docs/functions/functions-api-reference#waituntil) - vercelWaitUntil(flushSafelyWithTimeout()); + flushIfServerless().catch(() => /* no-op */ {}); }, }, }), ]; } - -/** - * 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 && debug.log('Flushing events...'); - await flush(2000); - DEBUG_BUILD && debug.log('Done flushing events'); - } catch (e) { - DEBUG_BUILD && debug.log('Error while flushing events:\n', e); - } -} diff --git a/packages/solidstart/src/server/utils.ts b/packages/solidstart/src/server/utils.ts index fc7beea9daa0..1560b254bd22 100644 --- a/packages/solidstart/src/server/utils.ts +++ b/packages/solidstart/src/server/utils.ts @@ -1,28 +1,6 @@ import type { EventProcessor, Options } from '@sentry/core'; import { debug } from '@sentry/core'; -import { flush, getGlobalScope } from '@sentry/node'; -import { DEBUG_BUILD } from '../common/debug-build'; - -/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */ -export async function flushIfServerless(): Promise { - const isServerless = - !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions - !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda - !!process.env.K_SERVICE || // Google Cloud Run - !!process.env.CF_PAGES || // Cloudflare - !!process.env.VERCEL || - !!process.env.NETLIFY; - - if (isServerless) { - try { - DEBUG_BUILD && debug.log('Flushing events...'); - await flush(2000); - DEBUG_BUILD && debug.log('Done flushing events'); - } catch (e) { - DEBUG_BUILD && debug.log('Error while flushing events:\n', e); - } - } -} +import { getGlobalScope } from '@sentry/node'; /** * Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route. diff --git a/packages/solidstart/src/server/withServerActionInstrumentation.ts b/packages/solidstart/src/server/withServerActionInstrumentation.ts index a894837c3947..c5c726614279 100644 --- a/packages/solidstart/src/server/withServerActionInstrumentation.ts +++ b/packages/solidstart/src/server/withServerActionInstrumentation.ts @@ -1,6 +1,11 @@ -import { handleCallbackErrors, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR } from '@sentry/core'; +import { + flushIfServerless, + handleCallbackErrors, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, +} from '@sentry/core'; import { captureException, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, startSpan } from '@sentry/node'; -import { flushIfServerless, isRedirect } from './utils'; +import { isRedirect } from './utils'; /** * Wraps a server action (functions that use the 'use server' directive) diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index aa2649a28a3a..696c3d765c5b 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -2,6 +2,7 @@ import type { Span } from '@sentry/core'; import { continueTrace, debug, + flushIfServerless, getCurrentScope, getDefaultIsolationScope, getIsolationScope, @@ -15,7 +16,7 @@ import { } from '@sentry/core'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; -import { flushIfServerless, getTracePropagationData, sendErrorToSentry } from './utils'; +import { getTracePropagationData, sendErrorToSentry } from './utils'; export type SentryHandleOptions = { /** diff --git a/packages/sveltekit/src/server-common/handleError.ts b/packages/sveltekit/src/server-common/handleError.ts index 046e4201c3cb..0ca6597ea864 100644 --- a/packages/sveltekit/src/server-common/handleError.ts +++ b/packages/sveltekit/src/server-common/handleError.ts @@ -1,6 +1,5 @@ -import { captureException, consoleSandbox, flush } from '@sentry/core'; +import { captureException, consoleSandbox, flushIfServerless } from '@sentry/core'; import type { HandleServerError } from '@sveltejs/kit'; -import { flushIfServerless } from '../server-common/utils'; // The SvelteKit default error handler just logs the error's stack trace to the console // see: https://github.com/sveltejs/kit/blob/369e7d6851f543a40c947e033bfc4a9506fdc0a8/packages/kit/src/runtime/server/index.js#L43 @@ -48,14 +47,12 @@ export function handleErrorWithSentry(handleError?: HandleServerError): HandleSe }; }; - // Cloudflare workers have a `waitUntil` method that we can use to flush the event queue + // Cloudflare workers have a `waitUntil` method on `ctx` that we can use to flush the event queue // We already call this in `wrapRequestHandler` from `sentryHandleInitCloudflare` // However, `handleError` can be invoked when wrapRequestHandler already finished // (e.g. when responses are streamed / returning promises from load functions) - const cloudflareWaitUntil = platform?.context?.waitUntil; - if (typeof cloudflareWaitUntil === 'function') { - const waitUntil = cloudflareWaitUntil.bind(platform.context); - waitUntil(flush(2000)); + if (typeof platform?.context?.waitUntil === 'function') { + await flushIfServerless({ cloudflareCtx: platform.context as { waitUntil(promise: Promise): void } }); } else { await flushIfServerless(); } diff --git a/packages/sveltekit/src/server-common/load.ts b/packages/sveltekit/src/server-common/load.ts index ede0991d29c4..8b9cfca7de9b 100644 --- a/packages/sveltekit/src/server-common/load.ts +++ b/packages/sveltekit/src/server-common/load.ts @@ -1,12 +1,13 @@ import { addNonEnumerableProperty, + flushIfServerless, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan, } from '@sentry/core'; import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit'; import type { SentryWrappedFlag } from '../common/utils'; -import { flushIfServerless, sendErrorToSentry } from './utils'; +import { sendErrorToSentry } from './utils'; type PatchedLoadEvent = LoadEvent & SentryWrappedFlag; type PatchedServerLoadEvent = ServerLoadEvent & SentryWrappedFlag; diff --git a/packages/sveltekit/src/server-common/serverRoute.ts b/packages/sveltekit/src/server-common/serverRoute.ts index 72607318ecb3..d09233cb3633 100644 --- a/packages/sveltekit/src/server-common/serverRoute.ts +++ b/packages/sveltekit/src/server-common/serverRoute.ts @@ -1,11 +1,12 @@ import { addNonEnumerableProperty, + flushIfServerless, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan, } from '@sentry/core'; import type { RequestEvent } from '@sveltejs/kit'; -import { flushIfServerless, sendErrorToSentry } from './utils'; +import { sendErrorToSentry } from './utils'; type PatchedServerRouteEvent = RequestEvent & { __sentry_wrapped__?: boolean }; diff --git a/packages/sveltekit/src/server-common/utils.ts b/packages/sveltekit/src/server-common/utils.ts index 03601cb3bbb5..e4b5e144170c 100644 --- a/packages/sveltekit/src/server-common/utils.ts +++ b/packages/sveltekit/src/server-common/utils.ts @@ -16,31 +16,6 @@ export function getTracePropagationData(event: RequestEvent): { sentryTrace: str return { sentryTrace, baggage }; } -/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */ -export async function flushIfServerless(): Promise { - if (typeof process === 'undefined') { - return; - } - - const isServerless = - !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions - !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda - !!process.env.K_SERVICE || // Google Cloud Run - !!process.env.CF_PAGES || // Cloudflare - !!process.env.VERCEL || - !!process.env.NETLIFY; - - if (isServerless) { - try { - DEBUG_BUILD && debug.log('Flushing events...'); - await flush(2000); - DEBUG_BUILD && debug.log('Done flushing events'); - } catch (e) { - DEBUG_BUILD && debug.log('Error while flushing events:\n', e); - } - } -} - /** * Extracts a server-side sveltekit error, filters a couple of known errors we don't want to capture * and captures the error via `captureException`. From 4478ec623c75760a817b861efcabc9dd25f8161d Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Fri, 25 Jul 2025 15:46:06 +0200 Subject: [PATCH 2/8] fix import --- packages/cloudflare/src/handler.ts | 2 +- packages/nuxt/src/runtime/plugins/sentry.server.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 8038e5a066ab..182648380e50 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -1,12 +1,12 @@ import { captureException, + flushIfServerless, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan, withIsolationScope, } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/src'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { isInstrumented, markAsInstrumented } from './instrument'; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 543a8a78ebe1..c76f7ffce5bf 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,4 +1,10 @@ -import { debug, getDefaultIsolationScope, getIsolationScope, withIsolationScope } from '@sentry/core'; +import { + debug, + flushIfServerless, + getDefaultIsolationScope, + getIsolationScope, + withIsolationScope, +} from '@sentry/core'; // eslint-disable-next-line import/no-extraneous-dependencies import { type EventHandler } from 'h3'; // eslint-disable-next-line import/no-extraneous-dependencies @@ -6,7 +12,7 @@ import { defineNitroPlugin } from 'nitropack/runtime'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; -import { addSentryTracingMetaTags, flushIfServerless } from '../utils'; +import { addSentryTracingMetaTags } from '../utils'; export default defineNitroPlugin(nitroApp => { nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); From 788f79928935e15f86244a18b999d32b1313cdf3 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 29 Jul 2025 13:37:48 +0200 Subject: [PATCH 3/8] accept direct waitUntil function --- packages/core/src/utils/flushIfServerless.ts | 21 ++++++++++++------- .../test/lib/utils/flushIfServerless.test.ts | 14 ++++++++++++- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/core/src/utils/flushIfServerless.ts b/packages/core/src/utils/flushIfServerless.ts index b18ce2cb07c0..71a534aead29 100644 --- a/packages/core/src/utils/flushIfServerless.ts +++ b/packages/core/src/utils/flushIfServerless.ts @@ -25,7 +25,8 @@ async function flushWithTimeout(timeout: number): Promise { * The function is async, but in environments that support a `waitUntil` mechanism, it will run synchronously. * * This function is aware of the following serverless platforms: - * - Cloudflare: If a Cloudflare context is provided, it will use `ctx.waitUntil()` to flush events. + * - Cloudflare: If a Cloudflare context is provided, it will use `ctx.waitUntil()` to flush events (keeps the `this` context of `ctx`). + * If a `cloudflareWaitUntil` function is provided, it will use that to flush events (looses the `this` context of `ctx`). * - Vercel: It detects the Vercel environment and uses Vercel's `waitUntil` function. * - Other Serverless (AWS Lambda, Google Cloud, etc.): It detects the environment via environment variables * and uses a regular `await flush()`. @@ -34,15 +35,19 @@ async function flushWithTimeout(timeout: number): Promise { * @hidden */ export async function flushIfServerless( - params: { - timeout?: number; - cloudflareCtx?: MinimalCloudflareContext; - } = {}, + params: // eslint-disable-next-line @typescript-eslint/no-explicit-any + | { timeout?: number; cloudflareWaitUntil?: (task: Promise) => void } + | { timeout?: number; cloudflareCtx?: MinimalCloudflareContext }, ): Promise { - const { timeout = 2000, cloudflareCtx } = params; + const { timeout = 2000 } = params; - if (cloudflareCtx && typeof cloudflareCtx.waitUntil === 'function') { - cloudflareCtx.waitUntil(flushWithTimeout(timeout)); + if ('cloudflareWaitUntil' in params && typeof params?.cloudflareWaitUntil === 'function') { + params.cloudflareWaitUntil(flushWithTimeout(timeout)); + return; + } + + if ('cloudflareCtx' in params && typeof params.cloudflareCtx?.waitUntil === 'function') { + params.cloudflareCtx.waitUntil(flushWithTimeout(timeout)); return; } diff --git a/packages/core/test/lib/utils/flushIfServerless.test.ts b/packages/core/test/lib/utils/flushIfServerless.test.ts index 559140fe0c74..aa0314f183dc 100644 --- a/packages/core/test/lib/utils/flushIfServerless.test.ts +++ b/packages/core/test/lib/utils/flushIfServerless.test.ts @@ -16,7 +16,7 @@ describe('flushIfServerless', () => { vi.restoreAllMocks(); }); - test('should bind context (preserve `this`) when calling waitUntil', async () => { + test('should bind context (preserve `this`) when calling waitUntil from the Cloudflare execution context', async () => { const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); // Mock Cloudflare context with `waitUntil` (which should be called if `this` is bound correctly) @@ -49,6 +49,18 @@ describe('flushIfServerless', () => { expect(flushMock).toHaveBeenCalledWith(5000); }); + test('should use cloudflare waitUntil when Cloudflare `waitUntil` is provided', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + const mockCloudflareCtx = { + waitUntil: vi.fn(), + }; + + await flushIfServerless({ cloudflareWaitUntil: mockCloudflareCtx.waitUntil, timeout: 5000 }); + + expect(mockCloudflareCtx.waitUntil).toHaveBeenCalledTimes(1); + expect(flushMock).toHaveBeenCalledWith(5000); + }); + test('should ignore cloudflare context when waitUntil is not a function (and use Vercel waitUntil instead)', async () => { const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); const vercelWaitUntilSpy = vi.spyOn(vercelWaitUntilModule, 'vercelWaitUntil').mockImplementation(() => {}); From 2cf8428a1e3b4361b1c8865645eeda7ca187480c Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 29 Jul 2025 14:25:36 +0200 Subject: [PATCH 4/8] make params optional --- packages/core/src/utils/flushIfServerless.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/flushIfServerless.ts b/packages/core/src/utils/flushIfServerless.ts index 71a534aead29..2f8d387990c9 100644 --- a/packages/core/src/utils/flushIfServerless.ts +++ b/packages/core/src/utils/flushIfServerless.ts @@ -37,7 +37,7 @@ async function flushWithTimeout(timeout: number): Promise { export async function flushIfServerless( params: // eslint-disable-next-line @typescript-eslint/no-explicit-any | { timeout?: number; cloudflareWaitUntil?: (task: Promise) => void } - | { timeout?: number; cloudflareCtx?: MinimalCloudflareContext }, + | { timeout?: number; cloudflareCtx?: MinimalCloudflareContext } = {}, ): Promise { const { timeout = 2000 } = params; From f7038452a01c90c24ab17f883b447bd11b9dd596 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 29 Jul 2025 14:40:26 +0200 Subject: [PATCH 5/8] add standard flush in cloudflare again --- packages/cloudflare/src/handler.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 182648380e50..354233154a0b 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -1,6 +1,6 @@ import { captureException, - flushIfServerless, + flush, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -74,6 +74,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); const client = init({ ...options, ctx: context }); isolationScope.setClient(client); @@ -99,7 +100,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); const client = init({ ...options, ctx: context }); isolationScope.setClient(client); @@ -139,7 +141,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); const client = init({ ...options, ctx: context }); isolationScope.setClient(client); @@ -188,7 +191,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); + const client = init({ ...options, ctx: context }); isolationScope.setClient(client); @@ -218,7 +223,7 @@ export function withSentry Date: Tue, 29 Jul 2025 14:40:40 +0200 Subject: [PATCH 6/8] remove unused imports --- packages/sveltekit/src/server-common/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/sveltekit/src/server-common/utils.ts b/packages/sveltekit/src/server-common/utils.ts index e4b5e144170c..b861bf758697 100644 --- a/packages/sveltekit/src/server-common/utils.ts +++ b/packages/sveltekit/src/server-common/utils.ts @@ -1,6 +1,5 @@ -import { captureException, debug, flush, objectify } from '@sentry/core'; +import { captureException, objectify } from '@sentry/core'; import type { RequestEvent } from '@sveltejs/kit'; -import { DEBUG_BUILD } from '../common/debug-build'; import { isHttpError, isRedirect } from '../common/utils'; /** From ed97658a9593777f3d88527fb00e57205d40dfc9 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 29 Jul 2025 16:02:31 +0200 Subject: [PATCH 7/8] revert vercel-specific functionality --- .../nextjs/src/common/captureRequestError.ts | 5 +++-- .../pages-router-instrumentation/_error.ts | 5 +++-- .../wrapApiHandlerWithSentry.ts | 4 ++-- .../common/withServerActionInstrumentation.ts | 5 +++-- .../src/common/wrapMiddlewareWithSentry.ts | 5 +++-- .../src/common/wrapServerComponentWithSentry.ts | 5 +++-- packages/nextjs/src/edge/index.ts | 5 +++-- .../nextjs/src/edge/wrapApiHandlerWithSentry.ts | 5 +++-- packages/nuxt/src/server/sdk.ts | 17 +++++++++++++++-- 9 files changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts index 58557ae4d3f2..fec9d46d0e65 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, flushIfServerless, headersToDict, withScope } from '@sentry/core'; +import { captureException, headersToDict, vercelWaitUntil, withScope } from '@sentry/core'; +import { flushSafelyWithTimeout } from './utils/responseEnd'; type RequestInfo = { path: string; @@ -40,6 +41,6 @@ export function captureRequestError(error: unknown, request: RequestInfo, errorC }, }); - flushIfServerless().catch(() => /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); }); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts index c30e26eb96fb..b33c648839fa 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts @@ -1,5 +1,6 @@ -import { captureException, flushIfServerless, httpRequestToRequestData, withScope } from '@sentry/core'; +import { captureException, httpRequestToRequestData, vercelWaitUntil, withScope } from '@sentry/core'; import type { NextPageContext } from 'next'; +import { flushSafelyWithTimeout } from '../utils/responseEnd'; type ContextOrProps = { req?: NextPageContext['req']; @@ -53,5 +54,5 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP }); }); - flushIfServerless().catch(() => /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index f38532b64da9..ba50778d30ad 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -2,7 +2,6 @@ import { captureException, continueTrace, debug, - flushIfServerless, getActiveSpan, httpRequestToRequestData, isString, @@ -11,6 +10,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setHttpStatus, startSpanManual, + vercelWaitUntil, withIsolationScope, } from '@sentry/core'; import type { NextApiRequest } from 'next'; @@ -95,7 +95,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz apply(target, thisArg, argArray) { setHttpStatus(span, res.statusCode); span.end(); - flushIfServerless().catch(() => /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); return target.apply(thisArg, argArray); }, }); diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index a926a38f47dd..e3cc2831d5e4 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -3,7 +3,6 @@ import { captureException, continueTrace, debug, - flushIfServerless, getActiveSpan, getClient, getIsolationScope, @@ -11,8 +10,10 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SPAN_STATUS_ERROR, startSpan, + vercelWaitUntil, withIsolationScope, } from '@sentry/core'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { DEBUG_BUILD } from './debug-build'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; @@ -151,7 +152,7 @@ async function withServerActionInstrumentationImplementation /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); } }, ); diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 7c7c31b05830..3a9ca786d697 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -1,7 +1,6 @@ import type { TransactionSource } from '@sentry/core'; import { captureException, - flushIfServerless, getActiveSpan, getCurrentScope, getRootSpan, @@ -10,9 +9,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, startSpan, + vercelWaitUntil, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from '../edge/types'; /** @@ -107,7 +108,7 @@ export function wrapMiddlewareWithSentry( }); }, () => { - flushIfServerless().catch(() => /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); }, ); }, diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index da977e5be802..d4ac11f3e848 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -1,7 +1,6 @@ import type { RequestEventData } from '@sentry/core'; import { captureException, - flushIfServerless, getActiveSpan, getCapturedScopesOnSpan, getClient, @@ -16,12 +15,14 @@ import { SPAN_STATUS_OK, spanToJSON, 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 { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; @@ -136,7 +137,7 @@ export function wrapServerComponentWithSentry any> }, () => { span.end(); - flushIfServerless().catch(() => /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); }, ); }, diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 7d0feb507571..7982667f0c3f 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,6 +1,5 @@ import { applySdkMetadata, - flushIfServerless, getGlobalScope, getRootSpan, GLOBAL_OBJ, @@ -10,10 +9,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, stripUrlQueryAndFragment, + vercelWaitUntil, } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { isBuild } from '../common/utils/isBuild'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/vercel-edge'; @@ -89,7 +90,7 @@ export function init(options: VercelEdgeOptions = {}): void { client?.on('spanEnd', span => { if (span === getRootSpan(span)) { - flushIfServerless().catch(() => /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); } }); diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 0cb67ece6a72..466eb19eb1d1 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -1,6 +1,5 @@ import { captureException, - flushIfServerless, getActiveSpan, getCurrentScope, getRootSpan, @@ -10,9 +9,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, startSpan, + vercelWaitUntil, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from './types'; /** @@ -87,7 +88,7 @@ export function wrapApiHandlerWithSentry( }); }, () => { - flushIfServerless().catch(() => /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); }, ); }, diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 5dd8b3d178ae..ed04267a2536 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,6 +1,6 @@ import * as path from 'node:path'; import type { Client, EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, debug, flushIfServerless, getGlobalScope } from '@sentry/core'; +import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; import { type NodeOptions, getDefaultIntegrations as getDefaultNodeIntegrations, @@ -84,9 +84,22 @@ function getNuxtDefaultIntegrations(options: NodeOptions): Integration[] { instrumentation: { responseHook: () => { // Makes it possible to end the tracing span before closing the Vercel lambda (https://vercel.com/docs/functions/functions-api-reference#waituntil) - flushIfServerless().catch(() => /* no-op */ {}); + vercelWaitUntil(flushSafelyWithTimeout()); }, }, }), ]; } + +/** + * Flushes pending Sentry events with a 2-second timeout and in a way that cannot create unhandled promise rejections. + */ +async function flushSafelyWithTimeout(): Promise { + try { + DEBUG_BUILD && debug.log('Flushing events...'); + await flush(2000); + DEBUG_BUILD && debug.log('Done flushing events'); + } catch (e) { + DEBUG_BUILD && debug.log('Error while flushing events:\n', e); + } +} From 036c380d77c232a5384935de166be8a58104bddf Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 29 Jul 2025 16:32:17 +0200 Subject: [PATCH 8/8] fix tests solidstart --- .../test/server/withServerActionInstrumentation.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/solidstart/test/server/withServerActionInstrumentation.test.ts b/packages/solidstart/test/server/withServerActionInstrumentation.test.ts index 76acc1e46b12..d2bc90259942 100644 --- a/packages/solidstart/test/server/withServerActionInstrumentation.test.ts +++ b/packages/solidstart/test/server/withServerActionInstrumentation.test.ts @@ -1,4 +1,4 @@ -import { SentrySpan } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import * as SentryNode from '@sentry/node'; import { createTransport, @@ -16,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { withServerActionInstrumentation } from '../../src/server'; const mockCaptureException = vi.spyOn(SentryNode, 'captureException').mockImplementation(() => ''); -const mockFlush = vi.spyOn(SentryNode, 'flush').mockImplementation(async () => true); +const mockFlush = vi.spyOn(SentryCore, 'flushIfServerless').mockImplementation(async () => {}); const mockGetActiveSpan = vi.spyOn(SentryNode, 'getActiveSpan'); const mockGetRequestEvent = vi.fn(); @@ -126,7 +126,7 @@ describe('withServerActionInstrumentation', () => { }); it('sets a server action name on the active span', async () => { - const span = new SentrySpan(); + const span = new SentryCore.SentrySpan(); span.setAttribute('http.target', '/_server'); mockGetActiveSpan.mockReturnValue(span); const mockSpanSetAttribute = vi.spyOn(span, 'setAttribute'); @@ -145,7 +145,7 @@ describe('withServerActionInstrumentation', () => { }); it('does not set a server action name if the active span had a non `/_server` target', async () => { - const span = new SentrySpan(); + const span = new SentryCore.SentrySpan(); span.setAttribute('http.target', '/users/5'); mockGetActiveSpan.mockReturnValue(span); const mockSpanSetAttribute = vi.spyOn(span, 'setAttribute');