Skip to content

Commit 186bcda

Browse files
authored
ref(nextjs): Use flush code from withSentry in all backend wrappers (#5814)
When deployed to Vercel, nextjs apps have their route handlers converted to AWS lambdas, which use the vercel node runtime. In this runtime, AWS's [`callbackWaitsForEmptyEventLoop` context option](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-context.html) is [set to `false`](https://github.com/vercel/fun/blob/6329dcbd3f7d99a519cbbd6ac615fec4b46fc28e/src/runtimes/nodejs/bootstrap.ts#L122). As a result, it's possible for a route-handling lambda to be shut down before events have finished sending to Sentry. To ensure that this doesn't happen, in `withSentry` we wrap `res.end`, so that it flushes all of our events before the response marks itself as finished. This extracts that logic into its own module, so that it can also be used in the other `withSentryX` wrappers (`withSentryServerSideProps` and the like).
1 parent f767905 commit 186bcda

File tree

6 files changed

+89
-87
lines changed

6 files changed

+89
-87
lines changed

packages/nextjs/src/config/wrappers/types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Transaction } from '@sentry/types';
1+
import type { Transaction, WrappedFunction } from '@sentry/types';
22
import type { NextApiRequest, NextApiResponse } from 'next';
33

44
// The `NextApiHandler` and `WrappedNextApiHandler` types are the same as the official `NextApiHandler` type, except:
@@ -42,4 +42,4 @@ export type AugmentedNextApiResponse = NextApiResponse & {
4242
};
4343

4444
export type ResponseEndMethod = AugmentedNextApiResponse['end'];
45-
export type WrappedResponseEndMethod = AugmentedNextApiResponse['end'];
45+
export type WrappedResponseEndMethod = AugmentedNextApiResponse['end'] & WrappedFunction;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { flush } from '@sentry/node';
2+
import { Transaction } from '@sentry/types';
3+
import { fill, logger } from '@sentry/utils';
4+
import { ServerResponse } from 'http';
5+
6+
import { ResponseEndMethod, WrappedResponseEndMethod } from '../types';
7+
8+
/**
9+
* Wrap `res.end()` so that it closes the transaction and flushes events before letting the request finish.
10+
*
11+
* Note: This wraps a sync method with an async method. While in general that's not a great idea in terms of keeping
12+
* things in the right order, in this case it's safe, because the native `.end()` actually *is* (effectively) async, and
13+
* its run actually *is* (literally) awaited, just manually so (which reflects the fact that the core of the
14+
* request/response code in Node by far predates the introduction of `async`/`await`). When `.end()` is done, it emits
15+
* the `prefinish` event, and only once that fires does request processing continue. See
16+
* https://github.com/nodejs/node/commit/7c9b607048f13741173d397795bac37707405ba7.
17+
*
18+
* Also note: `res.end()` isn't called until *after* all response data and headers have been sent, so blocking inside of
19+
* `end` doesn't delay data getting to the end user. See
20+
* https://nodejs.org/api/http.html#responseenddata-encoding-callback.
21+
*
22+
* @param transaction The transaction tracing request handling
23+
* @param res: The request's corresponding response
24+
*/
25+
export function autoEndTransactionOnResponseEnd(transaction: Transaction, res: ServerResponse): void {
26+
const wrapEndMethod = (origEnd: ResponseEndMethod): WrappedResponseEndMethod => {
27+
return async function sentryWrappedEnd(this: ServerResponse, ...args: unknown[]) {
28+
await finishTransaction(transaction, this);
29+
await flushQueue();
30+
31+
return origEnd.call(this, ...args);
32+
};
33+
};
34+
35+
// Prevent double-wrapping
36+
if (!(res.end as WrappedResponseEndMethod).__sentry_original__) {
37+
fill(res, 'end', wrapEndMethod);
38+
}
39+
}
40+
41+
/** Finish the given response's transaction and set HTTP status data */
42+
export async function finishTransaction(transaction: Transaction | undefined, res: ServerResponse): Promise<void> {
43+
if (transaction) {
44+
transaction.setHttpStatus(res.statusCode);
45+
46+
// If any open spans are set to finish when the response ends, it sets up a race condition between their `finish`
47+
// calls and the transaction's `finish` call - and any spans which lose the race will get dropped from the
48+
// transaction. To prevent this, push `transaction.finish` to the next event loop so that it's guaranteed to lose
49+
// the race, and wait for it to be done before flushing events.
50+
const transactionFinished: Promise<void> = new Promise(resolve => {
51+
setImmediate(() => {
52+
transaction.finish();
53+
resolve();
54+
});
55+
});
56+
await transactionFinished;
57+
}
58+
}
59+
60+
/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
61+
export async function flushQueue(): Promise<void> {
62+
try {
63+
__DEBUG_BUILD__ && logger.log('Flushing events...');
64+
await flush(2000);
65+
__DEBUG_BUILD__ && logger.log('Done flushing events');
66+
} catch (e) {
67+
__DEBUG_BUILD__ && logger.log('Error while flushing events:\n', e);
68+
}
69+
}

packages/nextjs/src/config/wrappers/withSentryAPI.ts

+10-74
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { captureException, flush, getCurrentHub, startTransaction } from '@sentry/node';
1+
import { captureException, getCurrentHub, startTransaction } from '@sentry/node';
22
import { extractTraceparentData, hasTracingEnabled } from '@sentry/tracing';
33
import {
44
addExceptionMechanism,
@@ -11,14 +11,8 @@ import {
1111
import * as domain from 'domain';
1212

1313
import { formatAsCode, nextLogger } from '../../utils/nextLogger';
14-
import type {
15-
AugmentedNextApiRequest,
16-
AugmentedNextApiResponse,
17-
NextApiHandler,
18-
ResponseEndMethod,
19-
WrappedNextApiHandler,
20-
WrappedResponseEndMethod,
21-
} from './types';
14+
import type { AugmentedNextApiRequest, AugmentedNextApiResponse, NextApiHandler, WrappedNextApiHandler } from './types';
15+
import { autoEndTransactionOnResponseEnd, finishTransaction, flushQueue } from './utils/responseEnd';
2216

2317
/**
2418
* Wrap the given API route handler for tracing and error capturing. Thin wrapper around `withSentry`, which only
@@ -72,11 +66,6 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
7266
}
7367
req.__withSentry_applied__ = true;
7468

75-
// first order of business: monkeypatch `res.end()` so that it will wait for us to send events to sentry before it
76-
// fires (if we don't do this, the lambda will close too early and events will be either delayed or lost)
77-
// eslint-disable-next-line @typescript-eslint/unbound-method
78-
res.end = wrapEndMethod(res.end);
79-
8069
// use a domain in order to prevent scope bleed between requests
8170
const local = domain.create();
8271
local.add(req);
@@ -86,6 +75,7 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
8675
// return a value. In our case, all any of the codepaths return is a promise of `void`, but nextjs still counts on
8776
// getting that before it will finish the response.
8877
const boundHandler = local.bind(async () => {
78+
let transaction;
8979
const currentScope = getCurrentHub().getScope();
9080

9181
if (currentScope) {
@@ -121,7 +111,7 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
121111

122112
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;
123113

124-
const transaction = startTransaction(
114+
transaction = startTransaction(
125115
{
126116
name: `${reqMethod}${reqPath}`,
127117
op: 'http.server',
@@ -137,9 +127,7 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
137127
);
138128
currentScope.setSpan(transaction);
139129

140-
// save a link to the transaction on the response, so that even if there's an error (landing us outside of
141-
// the domain), we can still finish it (albeit possibly missing some scope data)
142-
res.__sentryTransaction = transaction;
130+
autoEndTransactionOnResponseEnd(transaction, res);
143131
}
144132
}
145133

@@ -188,8 +176,10 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
188176
// Make sure we have a chance to finish the transaction and flush events to Sentry before the handler errors
189177
// out. (Apps which are deployed on Vercel run their API routes in lambdas, and those lambdas will shut down the
190178
// moment they detect an error, so it's important to get this done before rethrowing the error. Apps not
191-
// deployed serverlessly will run into this cleanup function again in `res.end(), but it'll just no-op.)
192-
await finishSentryProcessing(res);
179+
// deployed serverlessly will run into this cleanup code again in `res.end(), but the transaction will already
180+
// be finished and the queue will already be empty, so effectively it'll just no-op.)
181+
await finishTransaction(transaction, res);
182+
await flushQueue();
193183

194184
// We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it
195185
// would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark
@@ -203,57 +193,3 @@ export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: str
203193
return boundHandler();
204194
};
205195
}
206-
207-
/**
208-
* Wrap `res.end()` so that it closes the transaction and flushes events before letting the request finish.
209-
*
210-
* Note: This wraps a sync method with an async method. While in general that's not a great idea in terms of keeping
211-
* things in the right order, in this case it's safe, because the native `.end()` actually *is* async, and its run
212-
* actually *is* awaited, just manually so (which reflects the fact that the core of the request/response code in Node
213-
* by far predates the introduction of `async`/`await`). When `.end()` is done, it emits the `prefinish` event, and
214-
* only once that fires does request processing continue. See
215-
* https://github.com/nodejs/node/commit/7c9b607048f13741173d397795bac37707405ba7.
216-
*
217-
* @param origEnd The original `res.end()` method
218-
* @returns The wrapped version
219-
*/
220-
function wrapEndMethod(origEnd: ResponseEndMethod): WrappedResponseEndMethod {
221-
return async function newEnd(this: AugmentedNextApiResponse, ...args: unknown[]) {
222-
await finishSentryProcessing(this);
223-
224-
return origEnd.call(this, ...args);
225-
};
226-
}
227-
228-
/**
229-
* Close the open transaction (if any) and flush events to Sentry.
230-
*
231-
* @param res The outgoing response for this request, on which the transaction is stored
232-
*/
233-
async function finishSentryProcessing(res: AugmentedNextApiResponse): Promise<void> {
234-
const { __sentryTransaction: transaction } = res;
235-
236-
if (transaction) {
237-
transaction.setHttpStatus(res.statusCode);
238-
239-
// Push `transaction.finish` to the next event loop so open spans have a better chance of finishing before the
240-
// transaction closes, and make sure to wait until that's done before flushing events
241-
const transactionFinished: Promise<void> = new Promise(resolve => {
242-
setImmediate(() => {
243-
transaction.finish();
244-
resolve();
245-
});
246-
});
247-
await transactionFinished;
248-
}
249-
250-
// Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda
251-
// ends. If there was an error, rethrow it so that the normal exception-handling mechanisms can apply.
252-
try {
253-
__DEBUG_BUILD__ && logger.log('Flushing events...');
254-
await flush(2000);
255-
__DEBUG_BUILD__ && logger.log('Done flushing events');
256-
} catch (e) {
257-
__DEBUG_BUILD__ && logger.log('Error while flushing events:\n', e);
258-
}
259-
}

packages/nextjs/src/config/wrappers/wrapperUtils.ts

+3-10
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { captureException, getCurrentHub, startTransaction } from '@sentry/core';
22
import { getActiveTransaction } from '@sentry/tracing';
33
import { Transaction } from '@sentry/types';
4-
import { baggageHeaderToDynamicSamplingContext, extractTraceparentData, fill } from '@sentry/utils';
4+
import { baggageHeaderToDynamicSamplingContext, extractTraceparentData } from '@sentry/utils';
55
import * as domain from 'domain';
66
import { IncomingMessage, ServerResponse } from 'http';
77

8+
import { autoEndTransactionOnResponseEnd } from './utils/responseEnd';
9+
810
declare module 'http' {
911
interface IncomingMessage {
1012
_sentryTransaction?: Transaction;
@@ -26,15 +28,6 @@ function setTransactionOnRequest(transaction: Transaction, req: IncomingMessage)
2628
req._sentryTransaction = transaction;
2729
}
2830

29-
function autoEndTransactionOnResponseEnd(transaction: Transaction, res: ServerResponse): void {
30-
fill(res, 'end', (originalEnd: ServerResponse['end']) => {
31-
return function (this: unknown, ...endArguments: Parameters<ServerResponse['end']>) {
32-
transaction.finish();
33-
return originalEnd.call(this, ...endArguments);
34-
};
35-
});
36-
}
37-
3831
/**
3932
* Wraps a function that potentially throws. If it does, the error is passed to `captureException` and rethrown.
4033
*

packages/nextjs/test/config/withSentry.test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ describe('withSentry', () => {
9696
});
9797

9898
it('flushes events before finishing non-erroring response', async () => {
99+
jest
100+
.spyOn(hub.Hub.prototype, 'getClient')
101+
.mockReturnValueOnce({ getOptions: () => ({ tracesSampleRate: 1 } as ClientOptions) } as Client);
102+
99103
await callWrappedHandler(wrappedHandlerNoError, req, res);
100104

101105
expect(flushSpy).toHaveBeenCalled();

packages/nextjs/test/config/wrappers.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('data-fetching function wrappers', () => {
2424
describe('starts a transaction and puts request in metadata if tracing enabled', () => {
2525
beforeEach(() => {
2626
req = { headers: {}, url: 'http://dogs.are.great/tricks/kangaroo' } as IncomingMessage;
27-
res = {} as ServerResponse;
27+
res = { end: jest.fn() } as unknown as ServerResponse;
2828

2929
jest.spyOn(SentryTracing, 'hasTracingEnabled').mockReturnValueOnce(true);
3030
});

0 commit comments

Comments
 (0)