Skip to content

Commit 69a2e06

Browse files
sergicalclaude
andcommitted
fix(node): Bound the Vercel keep-alive wait and skip it on Bun
- Wait at most 2 seconds for both the response to close and the root span to end, so a response that never emits `close` cannot hold the function open. - Unref the timer and type the request-context global the same way `vercelWaitUntil` does. - Bun does not publish `http.server.response.finish`, so the keep-alive suite is Node-only. Co-authored-by: Claude <claude@anthropic.com>
1 parent 50ac024 commit 69a2e06

3 files changed

Lines changed: 49 additions & 20 deletions

File tree

‎dev-packages/bun-integration-tests/node-suites/excludes.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
// fails on Bun is skipped with `test.skipIf` on `RUNTIME` in the Node suite, not listed here.
33

44
// Node-only features: ANR and native thread watchdogs, child processes, the AWS Lambda Node runtime,
5-
// and `node:sqlite`, which `flue` needs.
5+
// `node:sqlite`, which `flue` needs, and the Vercel keep-alive, which needs `http.server.response.finish`.
66
const NODE_ONLY = [
77
'suites/anr/test.ts',
88
'suites/aws-serverless/**',
99
'suites/breadcrumbs/**',
1010
'suites/child-process/test.ts',
1111
'suites/thread-blocked-native/test.ts',
1212
'suites/tracing/flue/test.ts',
13+
'suites/vercel/keep-alive/test.ts',
1314
];
1415

1516
// Bun does not publish `http.server.request.start`, so `@sentry/node` creates no `http.server`

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

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { Client, Span } from '@sentry/core';
22
import { getActiveSpan, getRootSpan, GLOBAL_OBJ } from '@sentry/core';
33
import type { HttpServerResponse } from '@sentry/core/server';
4+
import { _INTERNAL_safeUnref } from '@sentry/core/server';
45
import { subscribeDiagnosticsChannel } from '@sentry/server-utils';
56

67
// On Vercel, `http.server.request.start` fires without the request context, while this channel fires with it.
78
const HTTP_ON_SERVER_RESPONSE_FINISH = 'http.server.response.finish';
89

910
// Some frameworks end the request's root span shortly after the response closes.
10-
const ROOT_SPAN_END_TIMEOUT_MS = 2000;
11+
const REQUEST_END_TIMEOUT_MS = 2000;
1112

12-
interface VercelRequestContext {
13-
waitUntil?: (task: Promise<unknown>) => void;
13+
interface VercelRequestContextGlobal {
14+
get?(): { waitUntil?: (task: Promise<unknown>) => void } | undefined;
1415
}
1516

1617
/**
@@ -19,7 +20,7 @@ interface VercelRequestContext {
1920
* Vercel can suspend a function as soon as the response is sent, so buffered events, spans, logs and metrics
2021
* (and the timers that flush them) arrive late or never. When a response
2122
* finishes, we register one `waitUntil` that waits for the response to close and the request's root span to
22-
* end, and then flushes the client.
23+
* end (2 seconds at most), and then flushes the client.
2324
*/
2425
export function setupVercelKeepAlive(client: Client): void {
2526
// Ensure we flush events when vercel functions are ended
@@ -31,9 +32,10 @@ export function setupVercelKeepAlive(client: Client): void {
3132

3233
subscribeDiagnosticsChannel(HTTP_ON_SERVER_RESPONSE_FINISH, message => {
3334
const { response } = message as { response?: HttpServerResponse };
34-
const requestContext = (
35-
GLOBAL_OBJ as unknown as Record<symbol, { get?(): VercelRequestContext | undefined } | undefined>
36-
)[Symbol.for('@vercel/request-context')]?.get?.();
35+
const requestContextGlobal: VercelRequestContextGlobal | undefined =
36+
// @ts-expect-error Vercel sets this global, so `GLOBAL_OBJ` does not type it
37+
GLOBAL_OBJ[Symbol.for('@vercel/request-context')];
38+
const requestContext = requestContextGlobal?.get?.();
3739
if (!response || !requestContext?.waitUntil) {
3840
return;
3941
}
@@ -42,30 +44,43 @@ export function setupVercelKeepAlive(client: Client): void {
4244
const rootSpan = activeSpan && getRootSpan(activeSpan);
4345

4446
requestContext.waitUntil(
45-
new Promise<void>(resolve => response.once('close', resolve))
46-
.then(() => rootSpan && waitForSpanEnd(client, rootSpan, ROOT_SPAN_END_TIMEOUT_MS))
47-
.then(() => client.flush(2000)),
47+
waitForRequestEnd(client, response, rootSpan, REQUEST_END_TIMEOUT_MS).then(() => client.flush(2000)),
4848
);
4949
});
5050
}
5151

52-
function waitForSpanEnd(client: Client, span: Span, timeout: number): Promise<void> {
53-
// Ended and unsampled spans are not recording.
54-
if (!span.isRecording()) {
55-
return Promise.resolve();
56-
}
57-
52+
function waitForRequestEnd(
53+
client: Client,
54+
response: HttpServerResponse,
55+
rootSpan: Span | undefined,
56+
timeout: number,
57+
): Promise<void> {
5858
return new Promise(resolve => {
59+
let responseClosed = false;
60+
// Ended and unsampled spans are not recording.
61+
let rootSpanEnded = !rootSpan?.isRecording();
62+
5963
const done = (): void => {
6064
clearTimeout(timer);
6165
unsubscribe();
6266
resolve();
6367
};
64-
const timer = setTimeout(done, timeout);
65-
const unsubscribe = client.on('spanEnd', endedSpan => {
66-
if (endedSpan === span) {
68+
const doneIfRequestEnded = (): void => {
69+
if (responseClosed && rootSpanEnded) {
6770
done();
6871
}
72+
};
73+
74+
const timer = _INTERNAL_safeUnref(setTimeout(done, timeout));
75+
const unsubscribe = client.on('spanEnd', span => {
76+
if (span === rootSpan) {
77+
rootSpanEnded = true;
78+
doneIfRequestEnded();
79+
}
80+
});
81+
response.once('close', () => {
82+
responseClosed = true;
83+
doneIfRequestEnded();
6984
});
7085
});
7186
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,19 @@ describe('setupVercelKeepAlive', () => {
127127
expect(client.flush).toHaveBeenCalledTimes(1);
128128
});
129129

130+
it('flushes after 2 seconds when the response does not close', async () => {
131+
const client = createClient();
132+
setupVercelKeepAlive(client as unknown as Client);
133+
134+
finishResponse();
135+
136+
await vi.advanceTimersByTimeAsync(1999);
137+
expect(client.flush).not.toHaveBeenCalled();
138+
139+
await vi.advanceTimersByTimeAsync(1);
140+
expect(client.flush).toHaveBeenCalledTimes(1);
141+
});
142+
130143
it('does nothing without a request context', () => {
131144
(globalThis as any)[REQUEST_CONTEXT] = undefined;
132145
setupVercelKeepAlive(createClient() as unknown as Client);

0 commit comments

Comments
 (0)