From 2fd044a5ebba0531755eb2852886372162d92689 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 10 Sep 2025 14:33:22 +0200 Subject: [PATCH 1/4] feat(node): Use `process.on('SIGTERM')` for flushing in Vercel functions --- packages/core/src/utils/flushIfServerless.ts | 2 ++ packages/core/src/utils/vercelWaitUntil.ts | 7 +++++++ packages/node-core/src/sdk/index.ts | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/packages/core/src/utils/flushIfServerless.ts b/packages/core/src/utils/flushIfServerless.ts index 2f8d387990c9..6258f2222915 100644 --- a/packages/core/src/utils/flushIfServerless.ts +++ b/packages/core/src/utils/flushIfServerless.ts @@ -51,6 +51,8 @@ export async function flushIfServerless( return; } + // Note: vercelWaitUntil only does something in Vercel Edge runtime + // In Node runtime, we use process.on('SIGTERM') instead // @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 diff --git a/packages/core/src/utils/vercelWaitUntil.ts b/packages/core/src/utils/vercelWaitUntil.ts index bfcaa6b4b832..32d801a6723c 100644 --- a/packages/core/src/utils/vercelWaitUntil.ts +++ b/packages/core/src/utils/vercelWaitUntil.ts @@ -1,5 +1,7 @@ import { GLOBAL_OBJ } from './worldwide'; +declare const EdgeRuntime: string | undefined; + interface VercelRequestContextGlobal { get?(): | { @@ -14,6 +16,11 @@ interface VercelRequestContextGlobal { * Vendored from https://www.npmjs.com/package/@vercel/functions */ export function vercelWaitUntil(task: Promise): void { + // We only flush manually in Vercel Edge runtime + // In Node runtime, we use process.on('SIGTERM') instead + if (typeof EdgeRuntime !== 'string') { + return; + } const vercelRequestContextGlobal: VercelRequestContextGlobal | undefined = // @ts-expect-error This is not typed GLOBAL_OBJ[Symbol.for('@vercel/request-context')]; diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index e5b12166d962..a2f402ad3ae5 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -140,6 +140,15 @@ function _init( enhanceDscWithOpenTelemetryRootSpanName(client); setupEventContextTrace(client); + // Ensure we flush events when vercel functions are ended + // See: https://vercel.com/docs/functions/functions-api-reference#sigterm-signal + if (process.env.VERCEL) { + process.on('SIGTERM', async () => { + // We have 500ms for processing here, so we try to make sure to have enough time to send the events + await client.flush(200); + }); + } + return client; } From 29e1c80b8cdf2503bc8debf7f89b01a2ebec07d2 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 30 Dec 2025 13:54:06 +0100 Subject: [PATCH 2/4] sprinkle some unit tests --- packages/node-core/test/sdk/init.test.ts | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts index d5f150f03a59..144ff3e2dc37 100644 --- a/packages/node-core/test/sdk/init.test.ts +++ b/packages/node-core/test/sdk/init.test.ts @@ -111,6 +111,64 @@ describe('init()', () => { expect(client).toBeInstanceOf(NodeClient); }); + it('registers a SIGTERM handler on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + process.env.VERCEL = '1'; + + const baselineListeners = process.listeners('SIGTERM'); + + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + + expect(addedListeners).toHaveLength(1); + + // Cleanup: remove the handler we added in this test. + process.off('SIGTERM', addedListeners[0] as any); + process.env.VERCEL = originalVercelEnv; + }); + + it('flushes when SIGTERM is received on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + process.env.VERCEL = '1'; + + const baselineListeners = process.listeners('SIGTERM'); + + const client = init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + expect(client).toBeInstanceOf(NodeClient); + + const flushSpy = vi.spyOn(client as NodeClient, 'flush').mockResolvedValue(true); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + expect(addedListeners).toHaveLength(1); + + process.emit('SIGTERM'); + + expect(flushSpy).toHaveBeenCalledWith(200); + + // Cleanup: remove the handler we added in this test. + process.off('SIGTERM', addedListeners[0] as any); + process.env.VERCEL = originalVercelEnv; + }); + + it('does not register a SIGTERM handler when not running on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + delete process.env.VERCEL; + + const baselineListeners = process.listeners('SIGTERM'); + + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + + expect(addedListeners).toHaveLength(0); + + process.env.VERCEL = originalVercelEnv; + }); + describe('environment variable options', () => { const originalProcessEnv = { ...process.env }; From 6ad0b102a0ac2f83a6829d2bf56822c2b258713b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 30 Dec 2025 14:03:45 +0100 Subject: [PATCH 3/4] update wait until tests --- .../test/lib/utils/vercelWaitUntil.test.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/core/test/lib/utils/vercelWaitUntil.test.ts b/packages/core/test/lib/utils/vercelWaitUntil.test.ts index 78637cb3ef18..1f6be3b7924f 100644 --- a/packages/core/test/lib/utils/vercelWaitUntil.test.ts +++ b/packages/core/test/lib/utils/vercelWaitUntil.test.ts @@ -1,8 +1,28 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { vercelWaitUntil } from '../../../src/utils/vercelWaitUntil'; import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; describe('vercelWaitUntil', () => { + const VERCEL_REQUEST_CONTEXT_SYMBOL = Symbol.for('@vercel/request-context'); + const globalWithEdgeRuntime = globalThis as typeof globalThis & { EdgeRuntime?: string }; + const globalWithVercelRequestContext = GLOBAL_OBJ as unknown as Record; + + // `vercelWaitUntil` only runs in Vercel Edge runtime, which is detected via the global `EdgeRuntime` variable. + // In tests we set it explicitly so the logic is actually exercised. + const originalEdgeRuntime = globalWithEdgeRuntime.EdgeRuntime; + + beforeEach(() => { + globalWithEdgeRuntime.EdgeRuntime = 'edge-runtime'; + }); + + afterEach(() => { + if (originalEdgeRuntime === undefined) { + delete globalWithEdgeRuntime.EdgeRuntime; + } else { + globalWithEdgeRuntime.EdgeRuntime = originalEdgeRuntime; + } + }); + it('should do nothing if GLOBAL_OBJ does not have the @vercel/request-context symbol', () => { const task = Promise.resolve(); vercelWaitUntil(task); @@ -10,31 +30,34 @@ describe('vercelWaitUntil', () => { }); it('should do nothing if get method is not defined', () => { - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = {}; + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = {}; const task = Promise.resolve(); vercelWaitUntil(task); // No assertions needed, just ensuring no errors are thrown + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); it('should do nothing if waitUntil method is not defined', () => { - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = { get: () => ({}), }; const task = Promise.resolve(); vercelWaitUntil(task); // No assertions needed, just ensuring no errors are thrown + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); it('should call waitUntil method if it is defined', () => { + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; const waitUntilMock = vi.fn(); - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = { get: () => ({ waitUntil: waitUntilMock }), }; const task = Promise.resolve(); vercelWaitUntil(task); expect(waitUntilMock).toHaveBeenCalledWith(task); + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); }); From e20d4a915c2148f3d05dbebc27b55280bd5faa67 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 30 Dec 2025 14:35:12 +0100 Subject: [PATCH 4/4] add integration test --- .../suites/vercel/sigterm-flush/scenario.ts | 36 +++++++++++++++++ .../suites/vercel/sigterm-flush/test.ts | 39 +++++++++++++++++++ .../node-integration-tests/utils/runner.ts | 4 ++ 3 files changed, 79 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts diff --git a/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts new file mode 100644 index 000000000000..51e1b4d09ccf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts @@ -0,0 +1,36 @@ +import type { BaseTransportOptions, Envelope, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +function bufferedLoggingTransport(_options: BaseTransportOptions): Transport { + const bufferedEnvelopes: Envelope[] = []; + + return { + send(envelope: Envelope): Promise { + bufferedEnvelopes.push(envelope); + return Promise.resolve({ statusCode: 200 }); + }, + flush(_timeout?: number): PromiseLike { + // Print envelopes once flushed to verify they were sent. + for (const envelope of bufferedEnvelopes.splice(0, bufferedEnvelopes.length)) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(envelope)); + } + + return Promise.resolve(true); + }, + }; +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: bufferedLoggingTransport, +}); + +Sentry.captureMessage('SIGTERM flush message'); + +// Signal that we're ready to receive SIGTERM. +// eslint-disable-next-line no-console +console.log('READY'); + +// Keep the process alive so the integration test can send SIGTERM. +setInterval(() => undefined, 1_000); diff --git a/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts new file mode 100644 index 000000000000..d605895555ae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts @@ -0,0 +1,39 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('flushes buffered events when SIGTERM is received on Vercel', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .withEnv({ VERCEL: '1' }) + .expect({ + event: { + message: 'SIGTERM flush message', + }, + }) + .start(); + + // Wait for the scenario to signal it's ready (SIGTERM handler is registered). + const waitForReady = async (): Promise => { + const maxWait = 10_000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + if (runner.getLogs().some(line => line.includes('READY'))) { + return; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error('Timed out waiting for scenario to be ready'); + }; + + await waitForReady(); + + runner.sendSignal('SIGTERM'); + + await runner.completed(); + + // Check that the child didn't crash (it may be killed by the runner after completion). + expect(runner.getLogs().join('\n')).not.toMatch(/Error starting child process/i); +}); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index 97c4021ccc89..985db0a80e6c 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -172,6 +172,7 @@ type StartResult = { childHasExited(): boolean; getLogs(): string[]; getPort(): number | undefined; + sendSignal(signal: NodeJS.Signals): void; makeRequest( method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, @@ -668,6 +669,9 @@ export function createRunner(...paths: string[]) { getPort(): number | undefined { return scenarioServerPort; }, + sendSignal(signal: NodeJS.Signals): void { + child?.kill(signal); + }, makeRequest: async function ( method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string,