From 202624b8e3cdca877c75a5307c92f8dc976d7ad9 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Fri, 25 Jul 2025 15:16:12 +0200 Subject: [PATCH 1/2] 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/2] 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);