diff --git a/packages/node/src/sdk/vercel.ts b/packages/node/src/sdk/vercel.ts index 9a18c5e1c9a2..0702787c43b6 100644 --- a/packages/node/src/sdk/vercel.ts +++ b/packages/node/src/sdk/vercel.ts @@ -14,6 +14,9 @@ interface VercelRequestContextGlobal { get?(): { waitUntil?: (task: Promise) => void } | undefined; } +// A repeated `init()` replaces the client, so the listeners are registered once and flush the latest client. +let keepAliveClient: Client | undefined; + /** * Keeps Vercel Node.js functions alive until the SDK has sent the telemetry of each request. * @@ -23,11 +26,17 @@ interface VercelRequestContextGlobal { * end (2 seconds at most), and then flushes the client. */ export function setupVercelKeepAlive(client: Client): void { + const isRegistered = !!keepAliveClient; + keepAliveClient = client; + if (isRegistered) { + return; + } + // Ensure we flush events when vercel functions are ended // See: https://vercel.com/docs/functions/functions-api-reference#sigterm-signal 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); + await keepAliveClient?.flush(200); }); subscribeDiagnosticsChannel(HTTP_ON_SERVER_RESPONSE_FINISH, message => { @@ -36,7 +45,8 @@ export function setupVercelKeepAlive(client: Client): void { // @ts-expect-error Vercel sets this global, so `GLOBAL_OBJ` does not type it GLOBAL_OBJ[Symbol.for('@vercel/request-context')]; const requestContext = requestContextGlobal?.get?.(); - if (!response || !requestContext?.waitUntil) { + const client = keepAliveClient; + if (!client || !response || !requestContext?.waitUntil) { return; } diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 1063c856c21a..9ebfd0d05e31 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -322,62 +322,74 @@ describe('init()', () => { }); }); - it('registers a SIGTERM handler on Vercel', () => { - const originalVercelEnv = process.env.VERCEL; - process.env.VERCEL = '1'; + describe('Vercel keep-alive', () => { + let vercelInit: typeof init; + let VercelNodeClient: typeof NodeClient; + + beforeEach(async () => { + // The keep-alive listeners are registered once per module instance, so each test needs a fresh one. + vi.resetModules(); + ({ init: vercelInit } = await import('../../src/sdk')); + ({ NodeClient: VercelNodeClient } = await import('../../src/sdk/client')); + }); - const baselineListeners = process.listeners('SIGTERM'); + it('registers a SIGTERM handler on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + process.env.VERCEL = '1'; - init({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false }); + const baselineListeners = process.listeners('SIGTERM'); - const postInitListeners = process.listeners('SIGTERM'); - const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + vercelInit({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false }); - expect(addedListeners).toHaveLength(1); + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); - // Cleanup: remove the handler we added in this test. - process.off('SIGTERM', addedListeners[0] as any); - process.env.VERCEL = originalVercelEnv; - }); + expect(addedListeners).toHaveLength(1); - it('flushes when SIGTERM is received on Vercel', () => { - const originalVercelEnv = process.env.VERCEL; - process.env.VERCEL = '1'; + // Cleanup: remove the handler we added in this test. + process.off('SIGTERM', addedListeners[0] as any); + process.env.VERCEL = originalVercelEnv; + }); - const baselineListeners = process.listeners('SIGTERM'); + it('flushes when SIGTERM is received on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + process.env.VERCEL = '1'; - const client = init({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false }); - expect(client).toBeInstanceOf(NodeClient); + const baselineListeners = process.listeners('SIGTERM'); - const flushSpy = vi.spyOn(client as NodeClient, 'flush').mockResolvedValue(true); + const client = vercelInit({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false }); + expect(client).toBeInstanceOf(VercelNodeClient); - const postInitListeners = process.listeners('SIGTERM'); - const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); - expect(addedListeners).toHaveLength(1); + const flushSpy = vi.spyOn(client as InstanceType, 'flush').mockResolvedValue(true); - process.emit('SIGTERM'); + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + expect(addedListeners).toHaveLength(1); - expect(flushSpy).toHaveBeenCalledWith(200); + process.emit('SIGTERM'); - // Cleanup: remove the handler we added in this test. - process.off('SIGTERM', addedListeners[0] as any); - process.env.VERCEL = originalVercelEnv; - }); + 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; + 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'); + const baselineListeners = process.listeners('SIGTERM'); - init({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false }); + vercelInit({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false }); - const postInitListeners = process.listeners('SIGTERM'); - const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); - expect(addedListeners).toHaveLength(0); + expect(addedListeners).toHaveLength(0); - process.env.VERCEL = originalVercelEnv; + process.env.VERCEL = originalVercelEnv; + }); }); describe('environment variable options', () => { diff --git a/packages/node/test/sdk/vercel.test.ts b/packages/node/test/sdk/vercel.test.ts index 211feef8bc60..0ef5f97fd81c 100644 --- a/packages/node/test/sdk/vercel.test.ts +++ b/packages/node/test/sdk/vercel.test.ts @@ -5,7 +5,6 @@ import { getActiveSpan } from '@sentry/core'; import type * as SentryServerUtils from '@sentry/server-utils'; import { subscribeDiagnosticsChannel } from '@sentry/server-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setupVercelKeepAlive } from '../../src/sdk/vercel'; vi.mock('@sentry/core', async importOriginal => ({ ...(await importOriginal()), @@ -46,8 +45,12 @@ function finishResponse() { describe('setupVercelKeepAlive', () => { let waitUntil: ReturnType; + let setupVercelKeepAlive: (client: Client) => void; - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.mocked(subscribeDiagnosticsChannel).mockClear(); + ({ setupVercelKeepAlive } = await import('../../src/sdk/vercel')); vi.useFakeTimers(); waitUntil = vi.fn(); (globalThis as any)[REQUEST_CONTEXT] = { get: () => ({ waitUntil }) }; @@ -167,4 +170,38 @@ describe('setupVercelKeepAlive', () => { expect(client.flush).toHaveBeenCalledTimes(1); }); + + it('registers the listeners once and flushes the latest client on repeated calls', async () => { + const firstClient = createClient(); + const secondClient = createClient(); + setupVercelKeepAlive(firstClient as unknown as Client); + setupVercelKeepAlive(secondClient as unknown as Client); + + expect(subscribeDiagnosticsChannel).toHaveBeenCalledTimes(1); + expect(process.on).toHaveBeenCalledTimes(1); + + const response = finishResponse(); + expect(waitUntil).toHaveBeenCalledTimes(1); + response.emit('close'); + await (waitUntil.mock.calls[0]![0] as Promise); + + expect(firstClient.flush).not.toHaveBeenCalled(); + expect(secondClient.flush).toHaveBeenCalledTimes(1); + }); + + it('flushes the latest client once on SIGTERM', async () => { + const firstClient = createClient(); + const secondClient = createClient(); + setupVercelKeepAlive(firstClient as unknown as Client); + setupVercelKeepAlive(secondClient as unknown as Client); + + const sigtermHandlers = vi.mocked(process.on).mock.calls.filter(([event]) => event === 'SIGTERM'); + expect(sigtermHandlers).toHaveLength(1); + + await (sigtermHandlers[0]![1] as () => Promise)(); + + expect(firstClient.flush).not.toHaveBeenCalled(); + expect(secondClient.flush).toHaveBeenCalledTimes(1); + expect(secondClient.flush).toHaveBeenCalledWith(200); + }); });