Skip to content

Commit 75ae8c7

Browse files
authored
fix(nextjs): Let flush finish in API routes (#3811)
As discussed in more detail in the penultimate paragraph of #3786, when deployed to vercel, API route lambdas have been shutting down too soon for sentry events to get reliably sent to our servers (in spite of the use of `flush`). This fixes that by wrapping the response's `.end()` method (which is what triggers the lambda shutdown) such that it waits for `flush` to finish before emitting its `finished` signal. Logs from API routes now consistently look like this: ``` [GET] /api/hello Sentry Logger [Log]: [Tracing] Starting http.server transaction - GET /api/hello Sentry Logger [Log]: [Tracing] Finishing http.server transaction - GET /api/hello Sentry Logger [Log]: Flushing events... Sentry Logger [Log]: Done flushing events ```
1 parent 50526a3 commit 75ae8c7

File tree

8 files changed

+147
-102
lines changed

8 files changed

+147
-102
lines changed

packages/nextjs/src/utils/handlers.ts

+91-56
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1-
import { captureException, flush, getCurrentHub, Handlers, startTransaction, withScope } from '@sentry/node';
2-
import { extractTraceparentData, getActiveTransaction, hasTracingEnabled } from '@sentry/tracing';
1+
import { captureException, flush, getCurrentHub, Handlers, startTransaction } from '@sentry/node';
2+
import { extractTraceparentData, hasTracingEnabled } from '@sentry/tracing';
3+
import { Transaction } from '@sentry/types';
34
import { addExceptionMechanism, isString, logger, stripUrlQueryAndFragment } from '@sentry/utils';
45
import * as domain from 'domain';
5-
import { NextApiHandler } from 'next';
6-
7-
import { addRequestDataToEvent, NextRequest } from './instrumentServer';
6+
import { NextApiHandler, NextApiResponse } from 'next';
87

98
const { parseRequest } = Handlers;
109

1110
// purely for clarity
1211
type WrappedNextApiHandler = NextApiHandler;
1312

13+
type AugmentedResponse = NextApiResponse & { __sentryTransaction?: Transaction };
14+
1415
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
1516
export const withSentry = (handler: NextApiHandler): WrappedNextApiHandler => {
1617
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
1718
return async (req, res) => {
18-
// wrap everything in a domain in order to prevent scope bleed between requests
19+
// first order of business: monkeypatch `res.end()` so that it will wait for us to send events to sentry before it
20+
// fires (if we don't do this, the lambda will close too early and events will be either delayed or lost)
21+
// eslint-disable-next-line @typescript-eslint/unbound-method
22+
res.end = wrapEndMethod(res.end);
23+
24+
// use a domain in order to prevent scope bleed between requests
1925
const local = domain.create();
2026
local.add(req);
2127
local.add(res);
@@ -24,73 +30,102 @@ export const withSentry = (handler: NextApiHandler): WrappedNextApiHandler => {
2430
// return a value. In our case, all any of the codepaths return is a promise of `void`, but nextjs still counts on
2531
// getting that before it will finish the response.
2632
const boundHandler = local.bind(async () => {
27-
try {
28-
const currentScope = getCurrentHub().getScope();
33+
const currentScope = getCurrentHub().getScope();
2934

30-
if (currentScope) {
31-
currentScope.addEventProcessor(event => addRequestDataToEvent(event, req as NextRequest));
32-
33-
if (hasTracingEnabled()) {
34-
// If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision)
35-
let traceparentData;
36-
if (req.headers && isString(req.headers['sentry-trace'])) {
37-
traceparentData = extractTraceparentData(req.headers['sentry-trace'] as string);
38-
logger.log(`[Tracing] Continuing trace ${traceparentData?.traceId}.`);
39-
}
35+
if (currentScope) {
36+
currentScope.addEventProcessor(event => parseRequest(event, req));
37+
38+
if (hasTracingEnabled()) {
39+
// If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision)
40+
let traceparentData;
41+
if (req.headers && isString(req.headers['sentry-trace'])) {
42+
traceparentData = extractTraceparentData(req.headers['sentry-trace'] as string);
43+
logger.log(`[Tracing] Continuing trace ${traceparentData?.traceId}.`);
44+
}
4045

41-
const url = `${req.url}`;
42-
// pull off query string, if any
43-
let reqPath = stripUrlQueryAndFragment(url);
44-
// Replace with placeholder
45-
if (req.query) {
46-
// TODO get this from next if possible, to avoid accidentally replacing non-dynamic parts of the path if
47-
// they match dynamic parts
48-
for (const [key, value] of Object.entries(req.query)) {
49-
reqPath = reqPath.replace(`${value}`, `[${key}]`);
50-
}
46+
const url = `${req.url}`;
47+
// pull off query string, if any
48+
let reqPath = stripUrlQueryAndFragment(url);
49+
// Replace with placeholder
50+
if (req.query) {
51+
// TODO get this from next if possible, to avoid accidentally replacing non-dynamic parts of the path if
52+
// they match dynamic parts
53+
for (const [key, value] of Object.entries(req.query)) {
54+
reqPath = reqPath.replace(`${value}`, `[${key}]`);
5155
}
52-
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;
53-
54-
const transaction = startTransaction(
55-
{
56-
name: `${reqMethod}${reqPath}`,
57-
op: 'http.server',
58-
...traceparentData,
59-
},
60-
// extra context passed to the `tracesSampler`
61-
{ request: req },
62-
);
63-
currentScope.setSpan(transaction);
6456
}
57+
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;
58+
59+
const transaction = startTransaction(
60+
{
61+
name: `${reqMethod}${reqPath}`,
62+
op: 'http.server',
63+
...traceparentData,
64+
},
65+
// extra context passed to the `tracesSampler`
66+
{ request: req },
67+
);
68+
currentScope.setSpan(transaction);
69+
70+
// save a link to the transaction on the response, so that even if there's an error (landing us outside of
71+
// the domain), we can still finish it (albeit possibly missing some scope data)
72+
(res as AugmentedResponse).__sentryTransaction = transaction;
6573
}
74+
}
6675

76+
try {
6777
return await handler(req, res); // Call original handler
6878
} catch (e) {
69-
withScope(scope => {
70-
scope.addEventProcessor(event => {
79+
if (currentScope) {
80+
currentScope.addEventProcessor(event => {
7181
addExceptionMechanism(event, {
7282
handled: false,
7383
});
74-
return parseRequest(event, req);
84+
return event;
7585
});
7686
captureException(e);
77-
});
78-
throw e;
79-
} finally {
80-
const transaction = getActiveTransaction();
81-
if (transaction) {
82-
transaction.setHttpStatus(res.statusCode);
83-
84-
transaction.finish();
85-
}
86-
try {
87-
await flush(2000);
88-
} catch (e) {
89-
// no-empty
9087
}
88+
throw e;
9189
}
9290
});
9391

9492
return await boundHandler();
9593
};
9694
};
95+
96+
type ResponseEndMethod = AugmentedResponse['end'];
97+
type WrappedResponseEndMethod = AugmentedResponse['end'];
98+
99+
function wrapEndMethod(origEnd: ResponseEndMethod): WrappedResponseEndMethod {
100+
return async function newEnd(this: AugmentedResponse, ...args: unknown[]) {
101+
// TODO: if the handler errored, it will have popped us out of the domain, so all of our scope data will be missing
102+
103+
const transaction = this.__sentryTransaction;
104+
105+
if (transaction) {
106+
transaction.setHttpStatus(this.statusCode);
107+
108+
// Push `transaction.finish` to the next event loop so open spans have a better chance of finishing before the
109+
// transaction closes, and make sure to wait until that's done before flushing events
110+
const transactionFinished: Promise<void> = new Promise(resolve => {
111+
setImmediate(() => {
112+
transaction.finish();
113+
resolve();
114+
});
115+
});
116+
await transactionFinished;
117+
}
118+
119+
// flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda
120+
// ends
121+
try {
122+
logger.log('Flushing events...');
123+
await flush(2000);
124+
logger.log('Done flushing events');
125+
} catch (e) {
126+
logger.log(`Error while flushing events:\n${e}`);
127+
}
128+
129+
return origEnd.call(this, ...args);
130+
};
131+
}

packages/nextjs/src/utils/instrumentServer.ts

+4-27
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import { captureException, deepReadDirSync, getCurrentHub, startTransaction } from '@sentry/node';
1+
import { captureException, deepReadDirSync, getCurrentHub, Handlers, startTransaction } from '@sentry/node';
22
import { extractTraceparentData, getActiveTransaction, hasTracingEnabled } from '@sentry/tracing';
3-
import { Event as SentryEvent } from '@sentry/types';
43
import { fill, isString, logger, stripUrlQueryAndFragment } from '@sentry/utils';
54
import * as domain from 'domain';
65
import * as http from 'http';
76
import { default as createNextServer } from 'next';
87
import * as querystring from 'querystring';
98
import * as url from 'url';
109

10+
const { parseRequest } = Handlers;
11+
1112
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1213
type PlainObject<T = any> = { [key: string]: T };
1314

@@ -193,7 +194,7 @@ function makeWrappedReqHandler(origReqHandler: ReqHandler): WrappedReqHandler {
193194
const currentScope = getCurrentHub().getScope();
194195

195196
if (currentScope) {
196-
currentScope.addEventProcessor(event => addRequestDataToEvent(event, req));
197+
currentScope.addEventProcessor(event => parseRequest(event, req));
197198

198199
// We only want to record page and API requests
199200
if (hasTracingEnabled() && shouldTraceRequest(req.url, publicDirFiles)) {
@@ -288,27 +289,3 @@ function shouldTraceRequest(url: string, publicDirFiles: Set<string>): boolean {
288289
// `static` is a deprecated but still-functional location for static resources
289290
return !url.startsWith('/_next/') && !url.startsWith('/static/') && !publicDirFiles.has(url);
290291
}
291-
292-
/**
293-
* Harvest specific data from the request, and add it to the event.
294-
*
295-
* @param event The event to which to add request data
296-
* @param req The request whose data is being added
297-
* @returns The modified event
298-
*/
299-
export function addRequestDataToEvent(event: SentryEvent, req: NextRequest): SentryEvent {
300-
// TODO (breaking change): Replace all calls to this function with `parseRequest(event, req, { transaction: false })`
301-
// (this is breaking because doing so will change `event.request.url` from a path into an absolute URL, which might
302-
// mess up various automations in the Sentry web app - alert rules, auto assignment, saved searches, etc)
303-
event.request = {
304-
...event.request,
305-
data: req.body,
306-
url: req.url.split('?')[0],
307-
cookies: req.cookies,
308-
headers: req.headers,
309-
method: req.method,
310-
query_string: req.query,
311-
};
312-
313-
return event;
314-
}

packages/nextjs/test/integration/test/runner.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ const runScenario = async (scenario, execute, env) => {
5252
};
5353

5454
const runScenarios = async (scenarios, execute, env) => {
55-
return Promise.all(scenarios.map(scenario => runScenario(scenario, execute, env)));
55+
return Promise.all(
56+
scenarios.map(scenario => {
57+
return runScenario(scenario, execute, env);
58+
}),
59+
);
5660
};
5761

5862
module.exports.run = async ({

packages/nextjs/test/integration/test/server/errorApiEndpoint.js

+28-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
const assert = require('assert');
22

33
const { sleep } = require('../utils/common');
4-
const { getAsync, interceptEventRequest } = require('../utils/server');
4+
const { getAsync, interceptEventRequest, interceptTracingRequest } = require('../utils/server');
55

6-
module.exports = async ({ url, argv }) => {
7-
const capturedRequest = interceptEventRequest(
6+
module.exports = async ({ url: urlBase, argv }) => {
7+
const url = `${urlBase}/api/error`;
8+
9+
const capturedErrorRequest = interceptEventRequest(
810
{
911
exception: {
1012
values: [
@@ -18,7 +20,7 @@ module.exports = async ({ url, argv }) => {
1820
runtime: 'node',
1921
},
2022
request: {
21-
url: `${url}/api/error`,
23+
url,
2224
method: 'GET',
2325
},
2426
transaction: 'GET /api/error',
@@ -27,8 +29,28 @@ module.exports = async ({ url, argv }) => {
2729
'errorApiEndpoint',
2830
);
2931

30-
await getAsync(`${url}/api/error`);
32+
const capturedTransactionRequest = interceptTracingRequest(
33+
{
34+
contexts: {
35+
trace: {
36+
op: 'http.server',
37+
status: 'internal_error',
38+
tags: { 'http.status_code': '500' },
39+
},
40+
},
41+
transaction: 'GET /api/error',
42+
type: 'transaction',
43+
request: {
44+
url,
45+
},
46+
},
47+
argv,
48+
'errorApiEndpoint',
49+
);
50+
51+
await getAsync(url);
3152
await sleep(100);
3253

33-
assert.ok(capturedRequest.isDone(), 'Did not intercept expected request');
54+
assert.ok(capturedErrorRequest.isDone(), 'Did not intercept expected error request');
55+
assert.ok(capturedTransactionRequest.isDone(), 'Did not intercept expected transaction request');
3456
};

packages/nextjs/test/integration/test/server/errorServerSideProps.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ const assert = require('assert');
33
const { sleep } = require('../utils/common');
44
const { getAsync, interceptEventRequest } = require('../utils/server');
55

6-
module.exports = async ({ url, argv }) => {
6+
module.exports = async ({ url: urlBase, argv }) => {
7+
const url = `${urlBase}/withServerSideProps`;
8+
79
const capturedRequest = interceptEventRequest(
810
{
911
exception: {
@@ -18,15 +20,15 @@ module.exports = async ({ url, argv }) => {
1820
runtime: 'node',
1921
},
2022
request: {
21-
url: '/withServerSideProps',
23+
url,
2224
method: 'GET',
2325
},
2426
},
2527
argv,
2628
'errorServerSideProps',
2729
);
2830

29-
await getAsync(`${url}/withServerSideProps`);
31+
await getAsync(url);
3032
await sleep(100);
3133

3234
assert.ok(capturedRequest.isDone(), 'Did not intercept expected request');

packages/nextjs/test/integration/test/server/tracing200.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ const assert = require('assert');
33
const { sleep } = require('../utils/common');
44
const { getAsync, interceptTracingRequest } = require('../utils/server');
55

6-
module.exports = async ({ url, argv }) => {
6+
module.exports = async ({ url: urlBase, argv }) => {
7+
const url = `${urlBase}/api/users`;
8+
79
const capturedRequest = interceptTracingRequest(
810
{
911
contexts: {
@@ -16,14 +18,14 @@ module.exports = async ({ url, argv }) => {
1618
transaction: 'GET /api/users',
1719
type: 'transaction',
1820
request: {
19-
url: '/api/users',
21+
url,
2022
},
2123
},
2224
argv,
2325
'tracing200',
2426
);
2527

26-
await getAsync(`${url}/api/users`);
28+
await getAsync(url);
2729
await sleep(100);
2830

2931
assert.ok(capturedRequest.isDone(), 'Did not intercept expected request');

packages/nextjs/test/integration/test/server/tracing500.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ const assert = require('assert');
33
const { sleep } = require('../utils/common');
44
const { getAsync, interceptTracingRequest } = require('../utils/server');
55

6-
module.exports = async ({ url, argv }) => {
6+
module.exports = async ({ url: urlBase, argv }) => {
7+
const url = `${urlBase}/api/broken`;
78
const capturedRequest = interceptTracingRequest(
89
{
910
contexts: {
@@ -16,14 +17,14 @@ module.exports = async ({ url, argv }) => {
1617
transaction: 'GET /api/broken',
1718
type: 'transaction',
1819
request: {
19-
url: '/api/broken',
20+
url,
2021
},
2122
},
2223
argv,
2324
'tracing500',
2425
);
2526

26-
await getAsync(`${url}/api/broken`);
27+
await getAsync(url);
2728
await sleep(100);
2829

2930
assert.ok(capturedRequest.isDone(), 'Did not intercept expected request');

0 commit comments

Comments
 (0)