Skip to content

Commit 2043f2d

Browse files
Luca Forstnermydea
andauthored
feat(nextjs): Use Vercel's waitUntil to defer freezing of Vercel Lambdas (#12133)
Co-authored-by: Francesco Novy <[email protected]>
1 parent 3e179e1 commit 2043f2d

12 files changed

+57
-69
lines changed

packages/nextjs/src/common/_error.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { captureException, withScope } from '@sentry/core';
22
import type { NextPageContext } from 'next';
3-
import { flushQueue } from './utils/responseEnd';
3+
import { flushSafelyWithTimeout } from './utils/responseEnd';
4+
import { vercelWaitUntil } from './utils/vercelWaitUntil';
45

56
type ContextOrProps = {
67
req?: NextPageContext['req'];
@@ -53,7 +54,5 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP
5354
});
5455
});
5556

56-
// In case this is being run as part of a serverless function (as is the case with the server half of nextjs apps
57-
// deployed to vercel), make sure the error gets sent to Sentry before the lambda exits.
58-
await flushQueue();
57+
vercelWaitUntil(flushSafelyWithTimeout());
5958
}

packages/nextjs/src/common/types.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,6 @@ export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefin
4848
export type NextApiHandler = {
4949
(req: NextApiRequest, res: NextApiResponse): void | Promise<void> | unknown | Promise<unknown>;
5050
__sentry_route__?: string;
51-
52-
/**
53-
* A property we set in our integration tests to simulate running an API route on platforms that don't support streaming.
54-
*/
55-
__sentry_test_doesnt_support_streaming__?: true;
5651
};
5752

5853
export type WrappedNextApiHandler = {

packages/nextjs/src/common/utils/edgeWrapperUtils.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
import { winterCGRequestToRequestData } from '@sentry/utils';
1313

1414
import type { EdgeRouteHandler } from '../../edge/types';
15-
import { flushQueue } from './responseEnd';
15+
import { flushSafelyWithTimeout } from './responseEnd';
1616
import { commonObjectToIsolationScope, escapeNextjsTracing } from './tracingUtils';
17+
import { vercelWaitUntil } from './vercelWaitUntil';
1718

1819
/**
1920
* Wraps a function on the edge runtime with error and performance monitoring.
@@ -80,9 +81,11 @@ export function withEdgeWrapping<H extends EdgeRouteHandler>(
8081

8182
return handlerResult;
8283
},
83-
).finally(() => flushQueue());
84+
);
8485
},
85-
);
86+
).finally(() => {
87+
vercelWaitUntil(flushSafelyWithTimeout());
88+
});
8689
});
8790
});
8891
};

packages/nextjs/src/common/utils/platformSupportsStreaming.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/nextjs/src/common/utils/responseEnd.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,14 @@ export function finishSpan(span: Span, res: ServerResponse): void {
4444
span.end();
4545
}
4646

47-
/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
48-
export async function flushQueue(): Promise<void> {
47+
/**
48+
* Flushes pending Sentry events with a 2 second timeout and in a way that cannot create unhandled promise rejections.
49+
*/
50+
export async function flushSafelyWithTimeout(): Promise<void> {
4951
try {
5052
DEBUG_BUILD && logger.log('Flushing events...');
53+
// We give things that are currently stuck in event processors a tiny bit more time to finish before flushing. 50ms was chosen very unscientifically.
54+
await new Promise(resolve => setTimeout(resolve, 50));
5155
await flush(2000);
5256
DEBUG_BUILD && logger.log('Done flushing events');
5357
} catch (e) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { GLOBAL_OBJ } from '@sentry/utils';
2+
3+
interface VercelRequestContextGlobal {
4+
get?(): {
5+
waitUntil?: (task: Promise<unknown>) => void;
6+
};
7+
}
8+
9+
/**
10+
* Function that delays closing of a Vercel lambda until the provided promise is resolved.
11+
*
12+
* Vendored from https://www.npmjs.com/package/@vercel/functions
13+
*/
14+
export function vercelWaitUntil(task: Promise<unknown>): void {
15+
const vercelRequestContextGlobal: VercelRequestContextGlobal | undefined =
16+
// @ts-expect-error This is not typed
17+
GLOBAL_OBJ[Symbol.for('@vercel/request-context')];
18+
19+
const ctx = vercelRequestContextGlobal?.get?.() ?? {};
20+
ctx.waitUntil?.(task);
21+
}

packages/nextjs/src/common/utils/wrapperUtils.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
import type { Span } from '@sentry/types';
1616
import { isString } from '@sentry/utils';
1717

18-
import { platformSupportsStreaming } from './platformSupportsStreaming';
19-
import { autoEndSpanOnResponseEnd, flushQueue } from './responseEnd';
18+
import { autoEndSpanOnResponseEnd, flushSafelyWithTimeout } from './responseEnd';
2019
import { commonObjectToIsolationScope, escapeNextjsTracing } from './tracingUtils';
20+
import { vercelWaitUntil } from './vercelWaitUntil';
2121

2222
declare module 'http' {
2323
interface IncomingMessage {
@@ -124,15 +124,14 @@ export function withTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
124124
throw e;
125125
} finally {
126126
dataFetcherSpan.end();
127-
if (!platformSupportsStreaming()) {
128-
await flushQueue();
129-
}
130127
}
131128
},
132129
);
133130
});
134131
});
135132
});
133+
}).finally(() => {
134+
vercelWaitUntil(flushSafelyWithTimeout());
136135
});
137136
};
138137
}
@@ -198,10 +197,9 @@ export async function callDataFetcherTraced<F extends (...args: any[]) => Promis
198197
throw e;
199198
} finally {
200199
dataFetcherSpan.end();
201-
if (!platformSupportsStreaming()) {
202-
await flushQueue();
203-
}
204200
}
205201
},
206-
);
202+
).finally(() => {
203+
vercelWaitUntil(flushSafelyWithTimeout());
204+
});
207205
}

packages/nextjs/src/common/withServerActionInstrumentation.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import { logger } from '@sentry/utils';
99

1010
import { DEBUG_BUILD } from './debug-build';
1111
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
12-
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
13-
import { flushQueue } from './utils/responseEnd';
12+
import { flushSafelyWithTimeout } from './utils/responseEnd';
1413
import { escapeNextjsTracing } from './utils/tracingUtils';
14+
import { vercelWaitUntil } from './utils/vercelWaitUntil';
1515

1616
interface Options {
1717
formData?: FormData;
@@ -131,16 +131,7 @@ async function withServerActionInstrumentationImplementation<A extends (...args:
131131
},
132132
);
133133
} finally {
134-
if (!platformSupportsStreaming()) {
135-
// Lambdas require manual flushing to prevent execution freeze before the event is sent
136-
await flushQueue();
137-
}
138-
139-
if (process.env.NEXT_RUNTIME === 'edge') {
140-
// flushQueue should not throw
141-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
142-
flushQueue();
143-
}
134+
vercelWaitUntil(flushSafelyWithTimeout());
144135
}
145136
},
146137
);

packages/nextjs/src/common/wrapApiHandlerWithSentry.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { consoleSandbox, isString, logger, objectify } from '@sentry/utils';
1010

1111
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
1212
import type { AugmentedNextApiRequest, AugmentedNextApiResponse, NextApiHandler } from './types';
13-
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
14-
import { flushQueue } from './utils/responseEnd';
13+
import { flushSafelyWithTimeout } from './utils/responseEnd';
1514
import { escapeNextjsTracing } from './utils/tracingUtils';
15+
import { vercelWaitUntil } from './utils/vercelWaitUntil';
1616

1717
/**
1818
* Wrap the given API route handler for tracing and error capturing. Thin wrapper around `withSentry`, which only
@@ -83,15 +83,8 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz
8383
apply(target, thisArg, argArray) {
8484
setHttpStatus(span, res.statusCode);
8585
span.end();
86-
if (platformSupportsStreaming() && !wrappingTarget.__sentry_test_doesnt_support_streaming__) {
87-
target.apply(thisArg, argArray);
88-
} else {
89-
// flushQueue will not reject
90-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
91-
flushQueue().then(() => {
92-
target.apply(thisArg, argArray);
93-
});
94-
}
86+
vercelWaitUntil(flushSafelyWithTimeout());
87+
target.apply(thisArg, argArray);
9588
},
9689
});
9790

@@ -138,14 +131,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz
138131
setHttpStatus(span, res.statusCode);
139132
span.end();
140133

141-
// Make sure we have a chance to finish the transaction and flush events to Sentry before the handler errors
142-
// out. (Apps which are deployed on Vercel run their API routes in lambdas, and those lambdas will shut down the
143-
// moment they detect an error, so it's important to get this done before rethrowing the error. Apps not
144-
// deployed serverlessly will run into this cleanup code again in `res.end(), but the transaction will already
145-
// be finished and the queue will already be empty, so effectively it'll just no-op.)
146-
if (platformSupportsStreaming() && !wrappingTarget.__sentry_test_doesnt_support_streaming__) {
147-
await flushQueue();
148-
}
134+
vercelWaitUntil(flushSafelyWithTimeout());
149135

150136
// We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it
151137
// would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark

packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ import {
1313
import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils';
1414
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
1515
import type { RouteHandlerContext } from './types';
16-
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
17-
import { flushQueue } from './utils/responseEnd';
16+
import { flushSafelyWithTimeout } from './utils/responseEnd';
1817
import {
1918
commonObjectToIsolationScope,
2019
commonObjectToPropagationContext,
2120
escapeNextjsTracing,
2221
} from './utils/tracingUtils';
22+
import { vercelWaitUntil } from './utils/vercelWaitUntil';
2323

2424
/**
2525
* Wraps a Next.js route handler with performance and error instrumentation.
@@ -97,11 +97,7 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
9797
},
9898
);
9999
} finally {
100-
if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') {
101-
// 1. Edge transport requires manual flushing
102-
// 2. Lambdas require manual flushing to prevent execution freeze before the event is sent
103-
await flushQueue();
104-
}
100+
vercelWaitUntil(flushSafelyWithTimeout());
105101
}
106102
});
107103
});

packages/nextjs/src/common/wrapServerComponentWithSentry.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@se
1414
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
1515
import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
1616
import type { ServerComponentContext } from '../common/types';
17-
import { flushQueue } from './utils/responseEnd';
17+
import { flushSafelyWithTimeout } from './utils/responseEnd';
1818
import {
1919
commonObjectToIsolationScope,
2020
commonObjectToPropagationContext,
2121
escapeNextjsTracing,
2222
} from './utils/tracingUtils';
23+
import { vercelWaitUntil } from './utils/vercelWaitUntil';
2324

2425
/**
2526
* Wraps an `app` directory server component with Sentry error instrumentation.
@@ -93,10 +94,7 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
9394
},
9495
() => {
9596
span.end();
96-
97-
// flushQueue should not throw
98-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
99-
flushQueue();
97+
vercelWaitUntil(flushSafelyWithTimeout());
10098
},
10199
);
102100
},

packages/nextjs/test/integration/pages/api/doubleEndMethodOnVercel.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,4 @@ const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void
66
res.end();
77
};
88

9-
handler.__sentry_test_doesnt_support_streaming__ = true;
10-
119
export default handler;

0 commit comments

Comments
 (0)