Skip to content

Commit f1cdd11

Browse files
authored
fix(node): Register Vercel keep-alive listeners once
1 parent 559d8bd commit f1cdd11

3 files changed

Lines changed: 100 additions & 41 deletions

File tree

‎packages/node/src/sdk/vercel.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ interface VercelRequestContextGlobal {
1414
get?(): { waitUntil?: (task: Promise<unknown>) => void } | undefined;
1515
}
1616

17+
// A repeated `init()` replaces the client, so the listeners are registered once and flush the latest client.
18+
let keepAliveClient: Client | undefined;
19+
1720
/**
1821
* Keeps Vercel Node.js functions alive until the SDK has sent the telemetry of each request.
1922
*
@@ -23,11 +26,17 @@ interface VercelRequestContextGlobal {
2326
* end (2 seconds at most), and then flushes the client.
2427
*/
2528
export function setupVercelKeepAlive(client: Client): void {
29+
const isRegistered = !!keepAliveClient;
30+
keepAliveClient = client;
31+
if (isRegistered) {
32+
return;
33+
}
34+
2635
// Ensure we flush events when vercel functions are ended
2736
// See: https://vercel.com/docs/functions/functions-api-reference#sigterm-signal
2837
process.on('SIGTERM', async () => {
2938
// We have 500ms for processing here, so we try to make sure to have enough time to send the events
30-
await client.flush(200);
39+
await keepAliveClient?.flush(200);
3140
});
3241

3342
subscribeDiagnosticsChannel(HTTP_ON_SERVER_RESPONSE_FINISH, message => {
@@ -36,7 +45,8 @@ export function setupVercelKeepAlive(client: Client): void {
3645
// @ts-expect-error Vercel sets this global, so `GLOBAL_OBJ` does not type it
3746
GLOBAL_OBJ[Symbol.for('@vercel/request-context')];
3847
const requestContext = requestContextGlobal?.get?.();
39-
if (!response || !requestContext?.waitUntil) {
48+
const client = keepAliveClient;
49+
if (!client || !response || !requestContext?.waitUntil) {
4050
return;
4151
}
4252

‎packages/node/test/sdk/init.test.ts‎

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -322,62 +322,74 @@ describe('init()', () => {
322322
});
323323
});
324324

325-
it('registers a SIGTERM handler on Vercel', () => {
326-
const originalVercelEnv = process.env.VERCEL;
327-
process.env.VERCEL = '1';
325+
describe('Vercel keep-alive', () => {
326+
let vercelInit: typeof init;
327+
let VercelNodeClient: typeof NodeClient;
328+
329+
beforeEach(async () => {
330+
// The keep-alive listeners are registered once per module instance, so each test needs a fresh one.
331+
vi.resetModules();
332+
({ init: vercelInit } = await import('../../src/sdk'));
333+
({ NodeClient: VercelNodeClient } = await import('../../src/sdk/client'));
334+
});
328335

329-
const baselineListeners = process.listeners('SIGTERM');
336+
it('registers a SIGTERM handler on Vercel', () => {
337+
const originalVercelEnv = process.env.VERCEL;
338+
process.env.VERCEL = '1';
330339

331-
init({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false });
340+
const baselineListeners = process.listeners('SIGTERM');
332341

333-
const postInitListeners = process.listeners('SIGTERM');
334-
const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l));
342+
vercelInit({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false });
335343

336-
expect(addedListeners).toHaveLength(1);
344+
const postInitListeners = process.listeners('SIGTERM');
345+
const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l));
337346

338-
// Cleanup: remove the handler we added in this test.
339-
process.off('SIGTERM', addedListeners[0] as any);
340-
process.env.VERCEL = originalVercelEnv;
341-
});
347+
expect(addedListeners).toHaveLength(1);
342348

343-
it('flushes when SIGTERM is received on Vercel', () => {
344-
const originalVercelEnv = process.env.VERCEL;
345-
process.env.VERCEL = '1';
349+
// Cleanup: remove the handler we added in this test.
350+
process.off('SIGTERM', addedListeners[0] as any);
351+
process.env.VERCEL = originalVercelEnv;
352+
});
346353

347-
const baselineListeners = process.listeners('SIGTERM');
354+
it('flushes when SIGTERM is received on Vercel', () => {
355+
const originalVercelEnv = process.env.VERCEL;
356+
process.env.VERCEL = '1';
348357

349-
const client = init({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false });
350-
expect(client).toBeInstanceOf(NodeClient);
358+
const baselineListeners = process.listeners('SIGTERM');
351359

352-
const flushSpy = vi.spyOn(client as NodeClient, 'flush').mockResolvedValue(true);
360+
const client = vercelInit({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false });
361+
expect(client).toBeInstanceOf(VercelNodeClient);
353362

354-
const postInitListeners = process.listeners('SIGTERM');
355-
const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l));
356-
expect(addedListeners).toHaveLength(1);
363+
const flushSpy = vi.spyOn(client as InstanceType<typeof VercelNodeClient>, 'flush').mockResolvedValue(true);
357364

358-
process.emit('SIGTERM');
365+
const postInitListeners = process.listeners('SIGTERM');
366+
const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l));
367+
expect(addedListeners).toHaveLength(1);
359368

360-
expect(flushSpy).toHaveBeenCalledWith(200);
369+
process.emit('SIGTERM');
361370

362-
// Cleanup: remove the handler we added in this test.
363-
process.off('SIGTERM', addedListeners[0] as any);
364-
process.env.VERCEL = originalVercelEnv;
365-
});
371+
expect(flushSpy).toHaveBeenCalledWith(200);
372+
373+
// Cleanup: remove the handler we added in this test.
374+
process.off('SIGTERM', addedListeners[0] as any);
375+
process.env.VERCEL = originalVercelEnv;
376+
});
366377

367-
it('does not register a SIGTERM handler when not running on Vercel', () => {
368-
const originalVercelEnv = process.env.VERCEL;
369-
delete process.env.VERCEL;
378+
it('does not register a SIGTERM handler when not running on Vercel', () => {
379+
const originalVercelEnv = process.env.VERCEL;
380+
delete process.env.VERCEL;
370381

371-
const baselineListeners = process.listeners('SIGTERM');
382+
const baselineListeners = process.listeners('SIGTERM');
372383

373-
init({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false });
384+
vercelInit({ dsn: PUBLIC_DSN, enableOpenTelemetrySetup: false });
374385

375-
const postInitListeners = process.listeners('SIGTERM');
376-
const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l));
386+
const postInitListeners = process.listeners('SIGTERM');
387+
const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l));
377388

378-
expect(addedListeners).toHaveLength(0);
389+
expect(addedListeners).toHaveLength(0);
379390

380-
process.env.VERCEL = originalVercelEnv;
391+
process.env.VERCEL = originalVercelEnv;
392+
});
381393
});
382394

383395
describe('environment variable options', () => {

‎packages/node/test/sdk/vercel.test.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { getActiveSpan } from '@sentry/core';
55
import type * as SentryServerUtils from '@sentry/server-utils';
66
import { subscribeDiagnosticsChannel } from '@sentry/server-utils';
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8-
import { setupVercelKeepAlive } from '../../src/sdk/vercel';
98

109
vi.mock('@sentry/core', async importOriginal => ({
1110
...(await importOriginal<typeof SentryCore>()),
@@ -46,8 +45,12 @@ function finishResponse() {
4645

4746
describe('setupVercelKeepAlive', () => {
4847
let waitUntil: ReturnType<typeof vi.fn>;
48+
let setupVercelKeepAlive: (client: Client) => void;
4949

50-
beforeEach(() => {
50+
beforeEach(async () => {
51+
vi.resetModules();
52+
vi.mocked(subscribeDiagnosticsChannel).mockClear();
53+
({ setupVercelKeepAlive } = await import('../../src/sdk/vercel'));
5154
vi.useFakeTimers();
5255
waitUntil = vi.fn();
5356
(globalThis as any)[REQUEST_CONTEXT] = { get: () => ({ waitUntil }) };
@@ -167,4 +170,38 @@ describe('setupVercelKeepAlive', () => {
167170

168171
expect(client.flush).toHaveBeenCalledTimes(1);
169172
});
173+
174+
it('registers the listeners once and flushes the latest client on repeated calls', async () => {
175+
const firstClient = createClient();
176+
const secondClient = createClient();
177+
setupVercelKeepAlive(firstClient as unknown as Client);
178+
setupVercelKeepAlive(secondClient as unknown as Client);
179+
180+
expect(subscribeDiagnosticsChannel).toHaveBeenCalledTimes(1);
181+
expect(process.on).toHaveBeenCalledTimes(1);
182+
183+
const response = finishResponse();
184+
expect(waitUntil).toHaveBeenCalledTimes(1);
185+
response.emit('close');
186+
await (waitUntil.mock.calls[0]![0] as Promise<unknown>);
187+
188+
expect(firstClient.flush).not.toHaveBeenCalled();
189+
expect(secondClient.flush).toHaveBeenCalledTimes(1);
190+
});
191+
192+
it('flushes the latest client once on SIGTERM', async () => {
193+
const firstClient = createClient();
194+
const secondClient = createClient();
195+
setupVercelKeepAlive(firstClient as unknown as Client);
196+
setupVercelKeepAlive(secondClient as unknown as Client);
197+
198+
const sigtermHandlers = vi.mocked(process.on).mock.calls.filter(([event]) => event === 'SIGTERM');
199+
expect(sigtermHandlers).toHaveLength(1);
200+
201+
await (sigtermHandlers[0]![1] as () => Promise<void>)();
202+
203+
expect(firstClient.flush).not.toHaveBeenCalled();
204+
expect(secondClient.flush).toHaveBeenCalledTimes(1);
205+
expect(secondClient.flush).toHaveBeenCalledWith(200);
206+
});
170207
});

0 commit comments

Comments
 (0)