Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<TransportMakeRequestResponse> {
bufferedEnvelopes.push(envelope);
return Promise.resolve({ statusCode: 200 });
},
flush(_timeout?: number): PromiseLike<boolean> {
// 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);
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void>(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);
});
4 changes: 4 additions & 0 deletions dev-packages/node-integration-tests/utils/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ type StartResult = {
childHasExited(): boolean;
getLogs(): string[];
getPort(): number | undefined;
sendSignal(signal: NodeJS.Signals): void;
makeRequest<T>(
method: 'get' | 'post' | 'put' | 'delete' | 'patch',
path: string,
Expand Down Expand Up @@ -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 <T>(
method: 'get' | 'post' | 'put' | 'delete' | 'patch',
path: string,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/utils/flushIfServerless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export async function flushIfServerless(
return;
}

// Note: vercelWaitUntil only does something in Vercel Edge runtime
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this comment be in the other file vercelWaitUntil?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a comment in both places 👍

// In Node runtime, we use process.on('SIGTERM') instead
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flushIfServerless returns early without flushing in Vercel Node

In Vercel Node runtime, when @vercel/request-context exists, flushIfServerless calls vercelWaitUntil and immediately returns. However, vercelWaitUntil now returns early when EdgeRuntime is not defined (which is the case in Node). This means flushIfServerless returns without awaiting the flush and without registering with waitUntil. The code never reaches the serverless fallback that would await flushWithTimeout(timeout). While the SIGTERM handler provides a safety net at function termination, explicit calls to await flushIfServerless() (e.g., in framework integrations like astro, nuxt, react-router) won't actually wait for the flush to complete in Vercel Node runtime.

Additional Locations (1)

Fix in Cursor Fix in Web

// @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
Comment on lines +54 to 58

This comment was marked as outdated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we instead moved the flushing this to node-core

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/vercelWaitUntil.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { GLOBAL_OBJ } from './worldwide';

declare const EdgeRuntime: string | undefined;

interface VercelRequestContextGlobal {
get?():
| {
Expand All @@ -14,6 +16,11 @@ interface VercelRequestContextGlobal {
* Vendored from https://www.npmjs.com/package/@vercel/functions
*/
export function vercelWaitUntil(task: Promise<unknown>): void {
// We only flush manually in Vercel Edge runtime
// In Node runtime, we use process.on('SIGTERM') instead
Comment on lines 18 to +20

This comment was marked as outdated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, that's the whole point of this PR my friend

if (typeof EdgeRuntime !== 'string') {
return;
}
const vercelRequestContextGlobal: VercelRequestContextGlobal | undefined =
// @ts-expect-error This is not typed
GLOBAL_OBJ[Symbol.for('@vercel/request-context')];
Expand Down
37 changes: 30 additions & 7 deletions packages/core/test/lib/utils/vercelWaitUntil.test.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,63 @@
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<symbol, unknown>;

// `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';
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests don't verify new non-Edge early return behavior (Bugbot Rules)

Per the review rules requiring "tests actually test the newly added behaviour": The beforeEach sets EdgeRuntime = 'edge-runtime' for all tests, so none of them exercise the newly added early return path when EdgeRuntime is not a string (the Node runtime case). The core behavior change of this PR — vercelWaitUntil becoming a no-op in Node runtime — has no test coverage. A test verifying that waitUntil is NOT called when EdgeRuntime is undefined would validate this new behavior.

Fix in Cursor Fix in Web


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);
// No assertions needed, just ensuring no errors are thrown
});

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;
});
});
9 changes: 9 additions & 0 deletions packages/node-core/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you test this on a vercel function? Generally makes sense but we could bump this more up I'd say?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did not test this so far at all, maybe you can take this PR over when you have some time and properly test it. The reason I went with 200 here is because this is just the timeout used for finishing events, we still need time to actually make the http request so there needs to be some wiggle room -to the max. 500ms - if we spend 500ms on processing events, we have no more time to actually send the events 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah that makes sense I thought this time is included already, I'll take it over

});
}
Comment on lines +148 to +153

This comment was marked as outdated.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple SIGTERM handlers accumulate on repeated init calls

Each call to init() on Vercel registers a new SIGTERM handler using process.on('SIGTERM', ...) without checking if one was already registered. Since calling init() multiple times is a supported scenario (as confirmed by the existing multiple-init integration test), this can lead to multiple handlers being registered. When SIGTERM is received, each handler will call client.flush(200) on its respective client, potentially causing multiple concurrent flush operations within the 500ms Vercel shutdown window, which could lead to race conditions or unexpected behavior.

Fix in Cursor Fix in Web

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can safely ignore this case.


Comment on lines +149 to +154
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The async SIGTERM handler for process.on() is not awaited, creating a race condition where the process may exit before client.flush() completes, leading to data loss.
Severity: CRITICAL | Confidence: High

🔍 Detailed Analysis

The SIGTERM handler is an async function, but Node.js's process.on() does not wait for the returned Promise to resolve. When a SIGTERM signal is received, the client.flush(200) operation is initiated, but the process may terminate before this asynchronous operation completes. This creates a race condition that can lead to the loss of buffered events that were supposed to be sent to Sentry before shutdown. The integration tests mask this issue by using setInterval to artificially keep the process alive, which is not a reliable mechanism for production environments.

💡 Suggested Fix

To ensure the flush completes, the process exit should be explicitly managed. After the await client.flush(200) call, add process.exit(0) to gracefully terminate the process only after the flush has finished. This prevents the race condition and ensures events are not lost.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/node-core/src/sdk/index.ts#L149-L154

Potential issue: The `SIGTERM` handler is an `async` function, but Node.js's
`process.on()` does not wait for the returned Promise to resolve. When a `SIGTERM`
signal is received, the `client.flush(200)` operation is initiated, but the process may
terminate before this asynchronous operation completes. This creates a race condition
that can lead to the loss of buffered events that were supposed to be sent to Sentry
before shutdown. The integration tests mask this issue by using `setInterval` to
artificially keep the process alive, which is not a reliable mechanism for production
environments.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 8029537

return client;
}

Expand Down
58 changes: 58 additions & 0 deletions packages/node-core/test/sdk/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
Loading