Skip to content

Commit 8000012

Browse files
authored
chore(nextjs): Move withSentry into withSentryAPI module (#5801)
This moves `withSentry.ts` (and its corresponding test file) to live alongside our other `withSentryX` wrappers. More specifically, it moves `withSentry`'s types into a new `wrappers/types` file, and moves its functions into `withSentryAPI`, since `withSentryAPI` is becoming the primary wrapper and `withSentry` is becoming just a helper function.
1 parent 1d8370e commit 8000012

File tree

7 files changed

+277
-254
lines changed

7 files changed

+277
-254
lines changed

packages/nextjs/src/config/templates/apiProxyLoaderTemplate.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import * as origModule from '__RESOURCE_PATH__';
1212
import * as Sentry from '@sentry/nextjs';
1313
import type { PageConfig } from 'next';
1414

15-
// We import this from `withSentry` rather than directly from `next` because our version can work simultaneously with
16-
// multiple versions of next. See note in `withSentry` for more.
17-
import type { NextApiHandler } from '../../utils/withSentry';
15+
// We import this from `wrappers` rather than directly from `next` because our version can work simultaneously with
16+
// multiple versions of next. See note in `wrappers/types` for more.
17+
import type { NextApiHandler } from '../wrappers';
1818

1919
type NextApiModule = {
2020
default: NextApiHandler;
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
export type { AugmentedNextApiResponse, NextApiHandler, WrappedNextApiHandler } from './types';
2+
13
export { withSentryGetStaticProps } from './withSentryGetStaticProps';
24
export { withSentryServerSideGetInitialProps } from './withSentryServerSideGetInitialProps';
35
export { withSentryServerSideAppGetInitialProps } from './withSentryServerSideAppGetInitialProps';
46
export { withSentryServerSideDocumentGetInitialProps } from './withSentryServerSideDocumentGetInitialProps';
57
export { withSentryServerSideErrorGetInitialProps } from './withSentryServerSideErrorGetInitialProps';
68
export { withSentryGetServerSideProps } from './withSentryGetServerSideProps';
7-
export { withSentryAPI } from './withSentryAPI';
9+
export { withSentry, withSentryAPI } from './withSentryAPI';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Transaction } from '@sentry/types';
2+
import type { NextApiRequest, NextApiResponse } from 'next';
3+
4+
// The `NextApiHandler` and `WrappedNextApiHandler` types are the same as the official `NextApiHandler` type, except:
5+
//
6+
// a) The wrapped version returns only promises, because wrapped handlers are always async.
7+
//
8+
// b) Instead of having a return types based on `void` (Next < 12.1.6) or `unknown` (Next 12.1.6+), both the wrapped and
9+
// unwrapped versions of the type have both. This doesn't matter to users, because they exist solely on one side of that
10+
// version divide or the other. For us, though, it's entirely possible to have one version of Next installed in our
11+
// local repo (as a dev dependency) and have another Next version installed in a test app which also has the local SDK
12+
// linked in.
13+
//
14+
// In that case, if those two versions are on either side of the 12.1.6 divide, importing the official `NextApiHandler`
15+
// type here would break the test app's build, because it would set up a situation in which the linked SDK's
16+
// `withSentry` would refer to one version of the type (from the local repo's `node_modules`) while any typed handler in
17+
// the test app would refer to the other version of the type (from the test app's `node_modules`). By using a custom
18+
// version of the type compatible with both the old and new official versions, we can use any Next version we want in a
19+
// test app without worrying about type errors.
20+
//
21+
// c) These have internal SDK flags which the official Next types obviously don't have, one to allow our auto-wrapping
22+
// function, `withSentryAPI`, to pass the parameterized route into `withSentry`, and the other to prevent a manually
23+
// wrapped route from being wrapped again by the auto-wrapper.
24+
25+
export type NextApiHandler = {
26+
__sentry_route__?: string;
27+
(req: NextApiRequest, res: NextApiResponse): void | Promise<void> | unknown | Promise<unknown>;
28+
};
29+
30+
export type WrappedNextApiHandler = {
31+
__sentry_route__?: string;
32+
__sentry_wrapped__?: boolean;
33+
(req: NextApiRequest, res: NextApiResponse): Promise<void> | Promise<unknown>;
34+
};
35+
36+
export type AugmentedNextApiRequest = NextApiRequest & {
37+
__withSentry_applied__?: boolean;
38+
};
39+
40+
export type AugmentedNextApiResponse = NextApiResponse & {
41+
__sentryTransaction?: Transaction;
42+
};
43+
44+
export type ResponseEndMethod = AugmentedNextApiResponse['end'];
45+
export type WrappedResponseEndMethod = AugmentedNextApiResponse['end'];

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

+224-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
1+
import { captureException, flush, getCurrentHub, startTransaction } from '@sentry/node';
2+
import { extractTraceparentData, hasTracingEnabled } from '@sentry/tracing';
3+
import {
4+
addExceptionMechanism,
5+
baggageHeaderToDynamicSamplingContext,
6+
isString,
7+
logger,
8+
objectify,
9+
stripUrlQueryAndFragment,
10+
} from '@sentry/utils';
11+
import * as domain from 'domain';
12+
113
import { formatAsCode, nextLogger } from '../../utils/nextLogger';
2-
// We import these types from `withSentry` rather than directly from `next` because our version can work simultaneously
3-
// with multiple versions of next. See note in `withSentry` for more.
4-
import type { NextApiHandler, WrappedNextApiHandler } from '../../utils/withSentry';
5-
import { withSentry } from '../../utils/withSentry';
14+
import type {
15+
AugmentedNextApiRequest,
16+
AugmentedNextApiResponse,
17+
NextApiHandler,
18+
ResponseEndMethod,
19+
WrappedNextApiHandler,
20+
WrappedResponseEndMethod,
21+
} from './types';
622

723
/**
824
* Wrap the given API route handler for tracing and error capturing. Thin wrapper around `withSentry`, which only
@@ -37,3 +53,207 @@ export function withSentryAPI(
3753

3854
return withSentry(maybeWrappedHandler, parameterizedRoute);
3955
}
56+
57+
/**
58+
* Legacy function for manually wrapping API route handlers, now used as the innards of `withSentryAPI`.
59+
*
60+
* @param origHandler The user's original API route handler
61+
* @param parameterizedRoute The route whose handler is being wrapped. Meant for internal use only.
62+
* @returns A wrapped version of the handler
63+
*/
64+
export function withSentry(origHandler: NextApiHandler, parameterizedRoute?: string): WrappedNextApiHandler {
65+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
66+
return async function sentryWrappedHandler(req: AugmentedNextApiRequest, res: AugmentedNextApiResponse) {
67+
// We're now auto-wrapping API route handlers using `withSentryAPI` (which uses `withSentry` under the hood), but
68+
// users still may have their routes manually wrapped with `withSentry`. This check makes `sentryWrappedHandler`
69+
// idempotent so that those cases don't break anything.
70+
if (req.__withSentry_applied__) {
71+
return origHandler(req, res);
72+
}
73+
req.__withSentry_applied__ = true;
74+
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+
80+
// use a domain in order to prevent scope bleed between requests
81+
const local = domain.create();
82+
local.add(req);
83+
local.add(res);
84+
85+
// `local.bind` causes everything to run inside a domain, just like `local.run` does, but it also lets the callback
86+
// return a value. In our case, all any of the codepaths return is a promise of `void`, but nextjs still counts on
87+
// getting that before it will finish the response.
88+
const boundHandler = local.bind(async () => {
89+
const currentScope = getCurrentHub().getScope();
90+
91+
if (currentScope) {
92+
currentScope.setSDKProcessingMetadata({ request: req });
93+
94+
if (hasTracingEnabled()) {
95+
// If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision)
96+
let traceparentData;
97+
if (req.headers && isString(req.headers['sentry-trace'])) {
98+
traceparentData = extractTraceparentData(req.headers['sentry-trace']);
99+
__DEBUG_BUILD__ && logger.log(`[Tracing] Continuing trace ${traceparentData?.traceId}.`);
100+
}
101+
102+
const baggageHeader = req.headers && req.headers.baggage;
103+
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(baggageHeader);
104+
105+
// prefer the parameterized route, if we have it (which we will if we've auto-wrapped the route handler)
106+
let reqPath = parameterizedRoute;
107+
108+
// If not, fake it by just replacing parameter values with their names, hoping that none of them match either
109+
// each other or any hard-coded parts of the path
110+
if (!reqPath) {
111+
const url = `${req.url}`;
112+
// pull off query string, if any
113+
reqPath = stripUrlQueryAndFragment(url);
114+
// Replace with placeholder
115+
if (req.query) {
116+
for (const [key, value] of Object.entries(req.query)) {
117+
reqPath = reqPath.replace(`${value}`, `[${key}]`);
118+
}
119+
}
120+
}
121+
122+
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;
123+
124+
const transaction = startTransaction(
125+
{
126+
name: `${reqMethod}${reqPath}`,
127+
op: 'http.server',
128+
...traceparentData,
129+
metadata: {
130+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
131+
source: 'route',
132+
request: req,
133+
},
134+
},
135+
// extra context passed to the `tracesSampler`
136+
{ request: req },
137+
);
138+
currentScope.setSpan(transaction);
139+
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;
143+
}
144+
}
145+
146+
try {
147+
const handlerResult = await origHandler(req, res);
148+
149+
if (process.env.NODE_ENV === 'development' && !process.env.SENTRY_IGNORE_API_RESOLUTION_ERROR) {
150+
// eslint-disable-next-line no-console
151+
console.warn(
152+
`[sentry] If Next.js logs a warning "API resolved without sending a response", it's a false positive, which we're working to rectify.
153+
In the meantime, to suppress this warning, set \`SENTRY_IGNORE_API_RESOLUTION_ERROR\` to 1 in your env.
154+
To suppress the nextjs warning, use the \`externalResolver\` API route option (see https://nextjs.org/docs/api-routes/api-middlewares#custom-config for details).`,
155+
);
156+
}
157+
158+
return handlerResult;
159+
} catch (e) {
160+
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
161+
// store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced
162+
// to take, it can happen that the same thrown object gets caught in two different ways, and flagging it is a
163+
// way to prevent it from actually being reported twice.)
164+
const objectifiedErr = objectify(e);
165+
166+
if (currentScope) {
167+
currentScope.addEventProcessor(event => {
168+
addExceptionMechanism(event, {
169+
type: 'instrument',
170+
handled: true,
171+
data: {
172+
wrapped_handler: origHandler.name,
173+
function: 'withSentry',
174+
},
175+
});
176+
return event;
177+
});
178+
179+
captureException(objectifiedErr);
180+
}
181+
182+
// Because we're going to finish and send the transaction before passing the error onto nextjs, it won't yet
183+
// have had a chance to set the status to 500, so unless we do it ourselves now, we'll incorrectly report that
184+
// the transaction was error-free
185+
res.statusCode = 500;
186+
res.statusMessage = 'Internal Server Error';
187+
188+
// Make sure we have a chance to finish the transaction and flush events to Sentry before the handler errors
189+
// out. (Apps which are deployed on Vercel run their API routes in lambdas, and those lambdas will shut down the
190+
// 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);
193+
194+
// We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it
195+
// would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark
196+
// the error as already having been captured.)
197+
throw objectifiedErr;
198+
}
199+
});
200+
201+
// Since API route handlers are all async, nextjs always awaits the return value (meaning it's fine for us to return
202+
// a promise here rather than a real result, and it saves us the overhead of an `await` call.)
203+
return boundHandler();
204+
};
205+
}
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/index.server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,8 @@ export {
135135
withSentryServerSideDocumentGetInitialProps,
136136
withSentryServerSideErrorGetInitialProps,
137137
withSentryAPI,
138+
withSentry,
138139
} from './config/wrappers';
139-
export { withSentry } from './utils/withSentry';
140140

141141
// Wrap various server methods to enable error monitoring and tracing. (Note: This only happens for non-Vercel
142142
// deployments, because the current method of doing the wrapping a) crashes Next 12 apps deployed to Vercel and

0 commit comments

Comments
 (0)