Skip to content

Commit 90eb35c

Browse files
authored
fix(nextjs): Use domains to prevent scope bleed (getsentry#3788)
Use domains to keep scope data attached to simultaneous requests from bleeding between those requests. (For example, when testing tracing on API routes deployed to Vercel, in certain cases all transactions were using the same `traceId`, even though the original transaction corresponding to that id had long since finished.)
1 parent 85f2fe8 commit 90eb35c

File tree

1 file changed

+67
-54
lines changed

1 file changed

+67
-54
lines changed

packages/nextjs/src/utils/handlers.ts

Lines changed: 67 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { captureException, flush, getCurrentHub, Handlers, startTransaction, withScope } from '@sentry/node';
22
import { extractTraceparentData, getActiveTransaction, hasTracingEnabled } from '@sentry/tracing';
33
import { addExceptionMechanism, isString, logger, stripUrlQueryAndFragment } from '@sentry/utils';
4+
import * as domain from 'domain';
45
import { NextApiHandler } from 'next';
56

67
import { addRequestDataToEvent, NextRequest } from './instrumentServer';
@@ -14,70 +15,82 @@ type WrappedNextApiHandler = NextApiHandler;
1415
export const withSentry = (handler: NextApiHandler): WrappedNextApiHandler => {
1516
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
1617
return async (req, res) => {
17-
try {
18-
const currentScope = getCurrentHub().getScope();
18+
// wrap everything in a domain in order to prevent scope bleed between requests
19+
const local = domain.create();
20+
local.add(req);
21+
local.add(res);
1922

20-
if (currentScope) {
21-
currentScope.addEventProcessor(event => addRequestDataToEvent(event, req as NextRequest));
23+
// `local.bind` causes everything to run inside a domain, just like `local.run` does, but it also lets the callback
24+
// return a value. In our case, all any of the codepaths return is a promise of `void`, but nextjs still counts on
25+
// getting that before it will finish the response.
26+
const boundHandler = local.bind(async () => {
27+
try {
28+
const currentScope = getCurrentHub().getScope();
2229

23-
if (hasTracingEnabled()) {
24-
// If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision)
25-
let traceparentData;
26-
if (req.headers && isString(req.headers['sentry-trace'])) {
27-
traceparentData = extractTraceparentData(req.headers['sentry-trace'] as string);
28-
logger.log(`[Tracing] Continuing trace ${traceparentData?.traceId}.`);
29-
}
30+
if (currentScope) {
31+
currentScope.addEventProcessor(event => addRequestDataToEvent(event, req as NextRequest));
3032

31-
const url = `${req.url}`;
32-
// pull off query string, if any
33-
let reqPath = stripUrlQueryAndFragment(url);
34-
// Replace with placeholder
35-
if (req.query) {
36-
// TODO get this from next if possible, to avoid accidentally replacing non-dynamic parts of the path if
37-
// they match dynamic parts
38-
for (const [key, value] of Object.entries(req.query)) {
39-
reqPath = reqPath.replace(`${value}`, `[${key}]`);
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}.`);
4039
}
41-
}
42-
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;
4340

44-
const transaction = startTransaction(
45-
{
46-
name: `${reqMethod}${reqPath}`,
47-
op: 'http.server',
48-
...traceparentData,
49-
},
50-
// extra context passed to the `tracesSampler`
51-
{ request: req },
52-
);
53-
currentScope.setSpan(transaction);
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+
}
51+
}
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);
64+
}
5465
}
55-
}
5666

57-
return await handler(req, res); // Call original handler
58-
} catch (e) {
59-
withScope(scope => {
60-
scope.addEventProcessor(event => {
61-
addExceptionMechanism(event, {
62-
handled: false,
67+
return await handler(req, res); // Call original handler
68+
} catch (e) {
69+
withScope(scope => {
70+
scope.addEventProcessor(event => {
71+
addExceptionMechanism(event, {
72+
handled: false,
73+
});
74+
return parseRequest(event, req);
6375
});
64-
return parseRequest(event, req);
76+
captureException(e);
6577
});
66-
captureException(e);
67-
});
68-
throw e;
69-
} finally {
70-
const transaction = getActiveTransaction();
71-
if (transaction) {
72-
transaction.setHttpStatus(res.statusCode);
78+
throw e;
79+
} finally {
80+
const transaction = getActiveTransaction();
81+
if (transaction) {
82+
transaction.setHttpStatus(res.statusCode);
7383

74-
transaction.finish();
75-
}
76-
try {
77-
await flush(2000);
78-
} catch (e) {
79-
// no-empty
84+
transaction.finish();
85+
}
86+
try {
87+
await flush(2000);
88+
} catch (e) {
89+
// no-empty
90+
}
8091
}
81-
}
92+
});
93+
94+
return await boundHandler();
8295
};
8396
};

0 commit comments

Comments
 (0)