Skip to content

Commit 9582aa4

Browse files
s1gr1dchargome
authored andcommitted
feat(core): Add shared flushIfServerless function (#17177)
Follow-up for this PR: #17168 Creating a shared function `flushIfServerless` as this functionality is used quite often in various SDKs.
1 parent 84ba3d7 commit 9582aa4

File tree

20 files changed

+247
-147
lines changed

20 files changed

+247
-147
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, logger, 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 && logger.log('Flushing events...');
78-
await flush(2000);
79-
isDebug && logger.log('Done flushing events');
80-
} catch (e) {
81-
isDebug && logger.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,
@@ -240,16 +238,7 @@ async function instrumentRequest(
240238
);
241239
return res;
242240
} finally {
243-
vercelWaitUntil(
244-
(async () => {
245-
// Flushes pending Sentry events with a 2-second timeout and in a way that cannot create unhandled promise rejections.
246-
try {
247-
await flush(2000);
248-
} catch (e) {
249-
debug.log('Error while flushing events:\n', e);
250-
}
251-
})(),
252-
);
241+
await flushIfServerless();
253242
}
254243
// TODO: flush if serverless (first extract function)
255244
},

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export { callFrameToStackFrame, watchdogTimer } from './utils/anr';
275275
export { LRUMap } from './utils/lru';
276276
export { generateTraceId, generateSpanId } from './utils/propagationContext';
277277
export { vercelWaitUntil } from './utils/vercelWaitUntil';
278+
export { flushIfServerless } from './utils/flushIfServerless';
278279
export { SDK_VERSION } from './utils/version';
279280
export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids';
280281
export { escapeStringForRegex } from './vendor/escapeStringForRegex';
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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 (keeps the `this` context of `ctx`).
29+
* If a `cloudflareWaitUntil` function is provided, it will use that to flush events (looses the `this` context of `ctx`).
30+
* - Vercel: It detects the Vercel environment and uses Vercel's `waitUntil` function.
31+
* - Other Serverless (AWS Lambda, Google Cloud, etc.): It detects the environment via environment variables
32+
* and uses a regular `await flush()`.
33+
*
34+
* @internal This function is supposed for internal Sentry SDK usage only.
35+
* @hidden
36+
*/
37+
export async function flushIfServerless(
38+
params: // eslint-disable-next-line @typescript-eslint/no-explicit-any
39+
| { timeout?: number; cloudflareWaitUntil?: (task: Promise<any>) => void }
40+
| { timeout?: number; cloudflareCtx?: MinimalCloudflareContext } = {},
41+
): Promise<void> {
42+
const { timeout = 2000 } = params;
43+
44+
if ('cloudflareWaitUntil' in params && typeof params?.cloudflareWaitUntil === 'function') {
45+
params.cloudflareWaitUntil(flushWithTimeout(timeout));
46+
return;
47+
}
48+
49+
if ('cloudflareCtx' in params && typeof params.cloudflareCtx?.waitUntil === 'function') {
50+
params.cloudflareCtx.waitUntil(flushWithTimeout(timeout));
51+
return;
52+
}
53+
54+
// @ts-expect-error This is not typed
55+
if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) {
56+
// Vercel has a waitUntil equivalent that works without execution context
57+
vercelWaitUntil(flushWithTimeout(timeout));
58+
return;
59+
}
60+
61+
if (typeof process === 'undefined') {
62+
return;
63+
}
64+
65+
const isServerless =
66+
!!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions
67+
!!process.env.LAMBDA_TASK_ROOT || // AWS Lambda
68+
!!process.env.K_SERVICE || // Google Cloud Run
69+
!!process.env.CF_PAGES || // Cloudflare Pages
70+
!!process.env.VERCEL ||
71+
!!process.env.NETLIFY;
72+
73+
if (isServerless) {
74+
// Use regular flush for environments without a generic waitUntil mechanism
75+
await flushWithTimeout(timeout);
76+
}
77+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 from the Cloudflare execution context', 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 use cloudflare waitUntil when Cloudflare `waitUntil` is provided', async () => {
53+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
54+
const mockCloudflareCtx = {
55+
waitUntil: vi.fn(),
56+
};
57+
58+
await flushIfServerless({ cloudflareWaitUntil: mockCloudflareCtx.waitUntil, timeout: 5000 });
59+
60+
expect(mockCloudflareCtx.waitUntil).toHaveBeenCalledTimes(1);
61+
expect(flushMock).toHaveBeenCalledWith(5000);
62+
});
63+
64+
test('should ignore cloudflare context when waitUntil is not a function (and use Vercel waitUntil instead)', async () => {
65+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
66+
const vercelWaitUntilSpy = vi.spyOn(vercelWaitUntilModule, 'vercelWaitUntil').mockImplementation(() => {});
67+
68+
// Mock Vercel environment
69+
// @ts-expect-error This is not typed
70+
GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { get: () => ({ waitUntil: vi.fn() }) };
71+
72+
const mockCloudflareCtx = {
73+
waitUntil: 'not-a-function', // Invalid waitUntil
74+
};
75+
76+
// @ts-expect-error Using the wrong type here on purpose
77+
await flushIfServerless({ cloudflareCtx: mockCloudflareCtx });
78+
79+
expect(vercelWaitUntilSpy).toHaveBeenCalledTimes(1);
80+
expect(flushMock).toHaveBeenCalledWith(2000);
81+
});
82+
83+
test('should handle multiple serverless environment variables simultaneously', async () => {
84+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
85+
86+
global.process = {
87+
...originalProcess,
88+
env: {
89+
...originalProcess.env,
90+
LAMBDA_TASK_ROOT: '/var/task',
91+
VERCEL: '1',
92+
NETLIFY: 'true',
93+
CF_PAGES: '1',
94+
},
95+
};
96+
97+
await flushIfServerless({ timeout: 4000 });
98+
99+
expect(flushMock).toHaveBeenCalledWith(4000);
100+
});
101+
102+
test('should use default timeout when not specified', async () => {
103+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
104+
const mockCloudflareCtx = {
105+
waitUntil: vi.fn(),
106+
};
107+
108+
await flushIfServerless({ cloudflareCtx: mockCloudflareCtx });
109+
110+
expect(flushMock).toHaveBeenCalledWith(2000);
111+
});
112+
113+
test('should handle zero timeout value', async () => {
114+
const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true);
115+
116+
global.process = {
117+
...originalProcess,
118+
env: {
119+
...originalProcess.env,
120+
LAMBDA_TASK_ROOT: '/var/task',
121+
},
122+
};
123+
124+
await flushIfServerless({ timeout: 0 });
125+
126+
expect(flushMock).toHaveBeenCalledWith(0);
127+
});
128+
});

packages/nextjs/src/common/withServerActionInstrumentation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import {
1313
vercelWaitUntil,
1414
withIsolationScope,
1515
} from '@sentry/core';
16+
import { flushSafelyWithTimeout } from '../common/utils/responseEnd';
1617
import { DEBUG_BUILD } from './debug-build';
1718
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
18-
import { flushSafelyWithTimeout } from './utils/responseEnd';
1919

2020
interface Options {
2121
formData?: FormData;

packages/nextjs/src/common/wrapMiddlewareWithSentry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
winterCGRequestToRequestData,
1414
withIsolationScope,
1515
} from '@sentry/core';
16+
import { flushSafelyWithTimeout } from '../common/utils/responseEnd';
1617
import type { EdgeRouteHandler } from '../edge/types';
17-
import { flushSafelyWithTimeout } from './utils/responseEnd';
1818

1919
/**
2020
* Wraps Next.js middleware with Sentry error and performance instrumentation.

packages/nextjs/src/common/wrapServerComponentWithSentry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import {
2222
} from '@sentry/core';
2323
import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
2424
import type { ServerComponentContext } from '../common/types';
25+
import { flushSafelyWithTimeout } from '../common/utils/responseEnd';
2526
import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached';
26-
import { flushSafelyWithTimeout } from './utils/responseEnd';
2727
import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils';
2828
import { getSanitizedRequestUrl } from './utils/urls';
2929
import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils';

packages/nuxt/src/runtime/hooks/captureErrorHook.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { captureException, getClient, getCurrentScope } from '@sentry/core';
1+
import { captureException, flushIfServerless, getClient, getCurrentScope } from '@sentry/core';
22
// eslint-disable-next-line import/no-extraneous-dependencies
33
import { H3Error } from 'h3';
44
import type { CapturedErrorContext } from 'nitropack/types';
5-
import { extractErrorContext, flushIfServerless } from '../utils';
5+
import { extractErrorContext } from '../utils';
66

77
/**
88
* Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry.

packages/nuxt/src/runtime/plugins/sentry.server.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
import { debug, getDefaultIsolationScope, getIsolationScope, withIsolationScope } from '@sentry/core';
1+
import {
2+
debug,
3+
flushIfServerless,
4+
getDefaultIsolationScope,
5+
getIsolationScope,
6+
withIsolationScope,
7+
} from '@sentry/core';
28
// eslint-disable-next-line import/no-extraneous-dependencies
39
import { type EventHandler } from 'h3';
410
// eslint-disable-next-line import/no-extraneous-dependencies
511
import { defineNitroPlugin } from 'nitropack/runtime';
612
import type { NuxtRenderHTMLContext } from 'nuxt/app';
713
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
814
import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse';
9-
import { addSentryTracingMetaTags, flushIfServerless } from '../utils';
15+
import { addSentryTracingMetaTags } from '../utils';
1016

1117
export default defineNitroPlugin(nitroApp => {
1218
nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler);

0 commit comments

Comments
 (0)