Skip to content

Commit 202624b

Browse files
committed
feat(core): Add shared flushIfServerless function
1 parent b0909cd commit 202624b

File tree

24 files changed

+236
-183
lines changed

24 files changed

+236
-183
lines changed

dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Context, GLOBAL_OBJ, flush, debug, vercelWaitUntil } from '@sentry/core';
1+
import { Context, flushIfServerless } from '@sentry/core';
22
import * as SentryNode from '@sentry/node';
33
import { H3Error } from 'h3';
44
import type { CapturedErrorContext } from 'nitropack';
@@ -53,31 +53,3 @@ function extractErrorContext(errorContext: CapturedErrorContext): Context {
5353

5454
return ctx;
5555
}
56-
57-
async function flushIfServerless(): Promise<void> {
58-
const isServerless =
59-
!!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions
60-
!!process.env.LAMBDA_TASK_ROOT || // AWS Lambda
61-
!!process.env.VERCEL ||
62-
!!process.env.NETLIFY;
63-
64-
// @ts-expect-error This is not typed
65-
if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) {
66-
vercelWaitUntil(flushWithTimeout());
67-
} else if (isServerless) {
68-
await flushWithTimeout();
69-
}
70-
}
71-
72-
async function flushWithTimeout(): Promise<void> {
73-
const sentryClient = SentryNode.getClient();
74-
const isDebug = sentryClient ? sentryClient.getOptions().debug : false;
75-
76-
try {
77-
isDebug && debug.log('Flushing events...');
78-
await flush(2000);
79-
isDebug && debug.log('Done flushing events');
80-
} catch (e) {
81-
isDebug && debug.log('Error while flushing events:\n', e);
82-
}
83-
}

packages/astro/src/server/middleware.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import type { RequestEventData, Scope, SpanAttributes } from '@sentry/core';
22
import {
33
addNonEnumerableProperty,
4-
debug,
54
extractQueryParamsFromUrl,
5+
flushIfServerless,
66
objectify,
77
stripUrlQueryAndFragment,
8-
vercelWaitUntil,
98
winterCGRequestToRequestData,
109
} from '@sentry/core';
1110
import {
1211
captureException,
1312
continueTrace,
14-
flush,
1513
getActiveSpan,
1614
getClient,
1715
getCurrentScope,
@@ -233,16 +231,7 @@ async function instrumentRequest(
233231
);
234232
return res;
235233
} finally {
236-
vercelWaitUntil(
237-
(async () => {
238-
// Flushes pending Sentry events with a 2-second timeout and in a way that cannot create unhandled promise rejections.
239-
try {
240-
await flush(2000);
241-
} catch (e) {
242-
debug.log('Error while flushing events:\n', e);
243-
}
244-
})(),
245-
);
234+
await flushIfServerless();
246235
}
247236
// TODO: flush if serverless (first extract function)
248237
},

packages/cloudflare/src/handler.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import {
22
captureException,
3-
flush,
43
SEMANTIC_ATTRIBUTE_SENTRY_OP,
54
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
65
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
76
startSpan,
87
withIsolationScope,
98
} from '@sentry/core';
9+
import { flushIfServerless } from '@sentry/core/src';
1010
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
1111
import type { CloudflareOptions } from './client';
1212
import { isInstrumented, markAsInstrumented } from './instrument';
@@ -74,7 +74,6 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
7474
const [event, env, context] = args;
7575
return withIsolationScope(isolationScope => {
7676
const options = getFinalOptions(optionsCallback(env), env);
77-
const waitUntil = context.waitUntil.bind(context);
7877

7978
const client = init({ ...options, ctx: context });
8079
isolationScope.setClient(client);
@@ -100,7 +99,7 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
10099
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
101100
throw e;
102101
} finally {
103-
waitUntil(flush(2000));
102+
await flushIfServerless({ cloudflareCtx: context });
104103
}
105104
},
106105
);
@@ -117,7 +116,6 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
117116
const [emailMessage, env, context] = args;
118117
return withIsolationScope(isolationScope => {
119118
const options = getFinalOptions(optionsCallback(env), env);
120-
const waitUntil = context.waitUntil.bind(context);
121119

122120
const client = init({ ...options, ctx: context });
123121
isolationScope.setClient(client);
@@ -141,7 +139,7 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
141139
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
142140
throw e;
143141
} finally {
144-
waitUntil(flush(2000));
142+
await flushIfServerless({ cloudflareCtx: context });
145143
}
146144
},
147145
);
@@ -159,7 +157,6 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
159157

160158
return withIsolationScope(isolationScope => {
161159
const options = getFinalOptions(optionsCallback(env), env);
162-
const waitUntil = context.waitUntil.bind(context);
163160

164161
const client = init({ ...options, ctx: context });
165162
isolationScope.setClient(client);
@@ -191,7 +188,7 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
191188
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
192189
throw e;
193190
} finally {
194-
waitUntil(flush(2000));
191+
await flushIfServerless({ cloudflareCtx: context });
195192
}
196193
},
197194
);
@@ -210,8 +207,6 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
210207
return withIsolationScope(async isolationScope => {
211208
const options = getFinalOptions(optionsCallback(env), env);
212209

213-
const waitUntil = context.waitUntil.bind(context);
214-
215210
const client = init({ ...options, ctx: context });
216211
isolationScope.setClient(client);
217212

@@ -223,7 +218,7 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
223218
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
224219
throw e;
225220
} finally {
226-
waitUntil(flush(2000));
221+
await flushIfServerless({ cloudflareCtx: context });
227222
}
228223
});
229224
},

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ export { callFrameToStackFrame, watchdogTimer } from './utils/anr';
269269
export { LRUMap } from './utils/lru';
270270
export { generateTraceId, generateSpanId } from './utils/propagationContext';
271271
export { vercelWaitUntil } from './utils/vercelWaitUntil';
272+
export { flushIfServerless } from './utils/flushIfServerless';
272273
export { SDK_VERSION } from './utils/version';
273274
export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids';
274275
export { escapeStringForRegex } from './vendor/escapeStringForRegex';
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { flush } from '../exports';
2+
import { debug } from './debug-logger';
3+
import { vercelWaitUntil } from './vercelWaitUntil';
4+
import { GLOBAL_OBJ } from './worldwide';
5+
6+
type MinimalCloudflareContext = {
7+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8+
waitUntil(promise: Promise<any>): void;
9+
};
10+
11+
async function flushWithTimeout(timeout: number): Promise<void> {
12+
try {
13+
debug.log('Flushing events...');
14+
await flush(timeout);
15+
debug.log('Done flushing events');
16+
} catch (e) {
17+
debug.log('Error while flushing events:\n', e);
18+
}
19+
}
20+
21+
/**
22+
* Flushes the event queue with a timeout in serverless environments to ensure that events are sent to Sentry before the
23+
* serverless function execution ends.
24+
*
25+
* The function is async, but in environments that support a `waitUntil` mechanism, it will run synchronously.
26+
*
27+
* This function is aware of the following serverless platforms:
28+
* - Cloudflare: If a Cloudflare context is provided, it will use `ctx.waitUntil()` to flush events.
29+
* - Vercel: It detects the Vercel environment and uses Vercel's `waitUntil` function.
30+
* - Other Serverless (AWS Lambda, Google Cloud, etc.): It detects the environment via environment variables
31+
* and uses a regular `await flush()`.
32+
*
33+
* @internal This function is supposed for internal Sentry SDK usage only.
34+
* @hidden
35+
*/
36+
export async function flushIfServerless(
37+
params: {
38+
timeout?: number;
39+
cloudflareCtx?: MinimalCloudflareContext;
40+
} = {},
41+
): Promise<void> {
42+
const { timeout = 2000, cloudflareCtx } = params;
43+
44+
if (cloudflareCtx && typeof cloudflareCtx.waitUntil === 'function') {
45+
cloudflareCtx.waitUntil(flushWithTimeout(timeout));
46+
return;
47+
}
48+
49+
// @ts-expect-error This is not typed
50+
if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) {
51+
// Vercel has a waitUntil equivalent that works without execution context
52+
vercelWaitUntil(flushWithTimeout(timeout));
53+
return;
54+
}
55+
56+
if (typeof process === 'undefined') {
57+
return;
58+
}
59+
60+
const isServerless =
61+
!!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions
62+
!!process.env.LAMBDA_TASK_ROOT || // AWS Lambda
63+
!!process.env.K_SERVICE || // Google Cloud Run
64+
!!process.env.CF_PAGES || // Cloudflare Pages
65+
!!process.env.VERCEL ||
66+
!!process.env.NETLIFY;
67+
68+
if (isServerless) {
69+
// Use regular flush for environments without a generic waitUntil mechanism
70+
await flushWithTimeout(timeout);
71+
}
72+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2+
import * as flushModule from '../../../src/exports';
3+
import { flushIfServerless } from '../../../src/utils/flushIfServerless';
4+
import * as vercelWaitUntilModule from '../../../src/utils/vercelWaitUntil';
5+
import { GLOBAL_OBJ } from '../../../src/utils/worldwide';
6+
7+
describe('flushIfServerless', () => {
8+
let originalProcess: typeof process;
9+
10+
beforeEach(() => {
11+
vi.resetAllMocks();
12+
originalProcess = global.process;
13+
});
14+
15+
afterEach(() => {
16+
vi.restoreAllMocks();
17+
});
18+
19+
test('should bind context (preserve `this`) when calling waitUntil', async () => {
20+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
21+
22+
// Mock Cloudflare context with `waitUntil` (which should be called if `this` is bound correctly)
23+
const mockCloudflareCtx = {
24+
contextData: 'test-data',
25+
waitUntil: function (promise: Promise<unknown>) {
26+
// This will fail if 'this' is not bound correctly
27+
expect(this.contextData).toBe('test-data');
28+
return promise;
29+
},
30+
};
31+
32+
const waitUntilSpy = vi.spyOn(mockCloudflareCtx, 'waitUntil');
33+
34+
await flushIfServerless({ cloudflareCtx: mockCloudflareCtx });
35+
36+
expect(waitUntilSpy).toHaveBeenCalledTimes(1);
37+
expect(flushMock).toHaveBeenCalledWith(2000);
38+
});
39+
40+
test('should use cloudflare waitUntil when valid cloudflare context is provided', async () => {
41+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
42+
const mockCloudflareCtx = {
43+
waitUntil: vi.fn(),
44+
};
45+
46+
await flushIfServerless({ cloudflareCtx: mockCloudflareCtx, timeout: 5000 });
47+
48+
expect(mockCloudflareCtx.waitUntil).toHaveBeenCalledTimes(1);
49+
expect(flushMock).toHaveBeenCalledWith(5000);
50+
});
51+
52+
test('should ignore cloudflare context when waitUntil is not a function (and use Vercel waitUntil instead)', async () => {
53+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
54+
const vercelWaitUntilSpy = vi.spyOn(vercelWaitUntilModule, 'vercelWaitUntil').mockImplementation(() => {});
55+
56+
// Mock Vercel environment
57+
// @ts-expect-error This is not typed
58+
GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { get: () => ({ waitUntil: vi.fn() }) };
59+
60+
const mockCloudflareCtx = {
61+
waitUntil: 'not-a-function', // Invalid waitUntil
62+
};
63+
64+
// @ts-expect-error Using the wrong type here on purpose
65+
await flushIfServerless({ cloudflareCtx: mockCloudflareCtx });
66+
67+
expect(vercelWaitUntilSpy).toHaveBeenCalledTimes(1);
68+
expect(flushMock).toHaveBeenCalledWith(2000);
69+
});
70+
71+
test('should handle multiple serverless environment variables simultaneously', async () => {
72+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
73+
74+
global.process = {
75+
...originalProcess,
76+
env: {
77+
...originalProcess.env,
78+
LAMBDA_TASK_ROOT: '/var/task',
79+
VERCEL: '1',
80+
NETLIFY: 'true',
81+
CF_PAGES: '1',
82+
},
83+
};
84+
85+
await flushIfServerless({ timeout: 4000 });
86+
87+
expect(flushMock).toHaveBeenCalledWith(4000);
88+
});
89+
90+
test('should use default timeout when not specified', async () => {
91+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
92+
const mockCloudflareCtx = {
93+
waitUntil: vi.fn(),
94+
};
95+
96+
await flushIfServerless({ cloudflareCtx: mockCloudflareCtx });
97+
98+
expect(flushMock).toHaveBeenCalledWith(2000);
99+
});
100+
101+
test('should handle zero timeout value', async () => {
102+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
103+
104+
global.process = {
105+
...originalProcess,
106+
env: {
107+
...originalProcess.env,
108+
LAMBDA_TASK_ROOT: '/var/task',
109+
},
110+
};
111+
112+
await flushIfServerless({ timeout: 0 });
113+
114+
expect(flushMock).toHaveBeenCalledWith(0);
115+
});
116+
});

packages/nextjs/src/common/captureRequestError.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { RequestEventData } from '@sentry/core';
2-
import { captureException, headersToDict, vercelWaitUntil, withScope } from '@sentry/core';
3-
import { flushSafelyWithTimeout } from './utils/responseEnd';
2+
import { captureException, flushIfServerless, headersToDict, withScope } from '@sentry/core';
43

54
type RequestInfo = {
65
path: string;
@@ -41,6 +40,6 @@ export function captureRequestError(error: unknown, request: RequestInfo, errorC
4140
},
4241
});
4342

44-
vercelWaitUntil(flushSafelyWithTimeout());
43+
flushIfServerless().catch(() => /* no-op */ {});
4544
});
4645
}

packages/nextjs/src/common/pages-router-instrumentation/_error.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { captureException, httpRequestToRequestData, vercelWaitUntil, withScope } from '@sentry/core';
1+
import { captureException, flushIfServerless, httpRequestToRequestData, withScope } from '@sentry/core';
22
import type { NextPageContext } from 'next';
3-
import { flushSafelyWithTimeout } from '../utils/responseEnd';
43

54
type ContextOrProps = {
65
req?: NextPageContext['req'];
@@ -54,5 +53,5 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP
5453
});
5554
});
5655

57-
vercelWaitUntil(flushSafelyWithTimeout());
56+
flushIfServerless().catch(() => /* no-op */ {});
5857
}

0 commit comments

Comments
 (0)