Skip to content

Commit 1d8370e

Browse files
authored
feat(nextjs): Auto-wrap API routes (#5778)
As part of #5505, this applies to API route handlers the same kind of auto-wrapping we've done with the data fetchers (`withServerSideProps` and the like). Though the general idea is the same, the one extra complicating factor here is that there's a good chance the handlers get to us already wrapped in `withSentry`, which we've up until now been telling users to use as a manual wrapper. This is handled by making `withSentry` idempotent - if it detects that it's already been run on the current request, it simply acts as a pass-through to the function it wraps. Notes: - A new template has been created to act as a proxy module for API routes, but the proxying work itself is done by the same `proxyLoader` as before - it just loads one template or the other depending on an individual page's path. - Doing this auto-wrapping gives us a chance to do one thing manual `withSentry` wrapping isn't able to do, which is set the [route config](https://nextjs.org/docs/api-routes/request-helpers) to use an external resolver, which will prevent next's dev server from throwing warnings about API routes not sending responses. (In other words, it should solve #3852.)
1 parent 036e2a0 commit 1d8370e

File tree

16 files changed

+260
-20
lines changed

16 files changed

+260
-20
lines changed

packages/nextjs/rollup.npm.config.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export default [
1414
),
1515
...makeNPMConfigVariants(
1616
makeBaseNPMConfig({
17-
entrypoints: ['src/config/templates/prefixLoaderTemplate.ts', 'src/config/templates/proxyLoaderTemplate.ts'],
17+
entrypoints: [
18+
'src/config/templates/prefixLoaderTemplate.ts',
19+
'src/config/templates/pageProxyLoaderTemplate.ts',
20+
'src/config/templates/apiProxyLoaderTemplate.ts',
21+
],
1822

1923
packageSpecificConfig: {
2024
plugins: [plugins.makeRemoveMultiLineCommentsPlugin()],

packages/nextjs/src/config/loaders/proxyLoader.ts

+4-7
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,6 @@ export default async function proxyLoader(this: LoaderThis<LoaderOptions>, userC
3333
// homepage), sub back in the root route
3434
.replace(/^$/, '/');
3535

36-
// TODO: For the moment we skip API routes. Those will need to be handled slightly differently because of the manual
37-
// wrapping we've already been having people do using `withSentry`.
38-
if (parameterizedRoute.startsWith('api')) {
39-
return userCode;
40-
}
41-
4236
// We don't want to wrap twice (or infinitely), so in the proxy we add this query string onto references to the
4337
// wrapped file, so that we know that it's already been processed. (Adding this query string is also necessary to
4438
// convince webpack that it's a different file than the one it's in the middle of loading now, so that the originals
@@ -47,7 +41,10 @@ export default async function proxyLoader(this: LoaderThis<LoaderOptions>, userC
4741
return userCode;
4842
}
4943

50-
const templatePath = path.resolve(__dirname, '../templates/proxyLoaderTemplate.js');
44+
const templateFile = parameterizedRoute.startsWith('/api')
45+
? 'apiProxyLoaderTemplate.js'
46+
: 'pageProxyLoaderTemplate.js';
47+
const templatePath = path.resolve(__dirname, `../templates/${templateFile}`);
5148
let templateCode = fs.readFileSync(templatePath).toString();
5249
// Make sure the template is included when runing `webpack watch`
5350
this.addDependency(templatePath);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* This file is a template for the code which will be substituted when our webpack loader handles API files in the
3+
* `pages/` directory.
4+
*
5+
* We use `__RESOURCE_PATH__` as a placeholder for the path to the file being wrapped. Because it's not a real package,
6+
* this causes both TS and ESLint to complain, hence the pragma comments below.
7+
*/
8+
9+
// @ts-ignore See above
10+
// eslint-disable-next-line import/no-unresolved
11+
import * as origModule from '__RESOURCE_PATH__';
12+
import * as Sentry from '@sentry/nextjs';
13+
import type { PageConfig } from 'next';
14+
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';
18+
19+
type NextApiModule = {
20+
default: NextApiHandler;
21+
config?: PageConfig;
22+
};
23+
24+
const userApiModule = origModule as NextApiModule;
25+
26+
const maybeWrappedHandler = userApiModule.default;
27+
const origConfig = userApiModule.config || {};
28+
29+
// Setting `externalResolver` to `true` prevents nextjs from throwing a warning in dev about API routes resolving
30+
// without sending a response. It's a false positive (a response is sent, but only after we flush our send queue), and
31+
// we throw a warning of our own to tell folks that, but it's better if we just don't have to deal with it in the first
32+
// place.
33+
export const config = {
34+
...origConfig,
35+
api: {
36+
...origConfig.api,
37+
externalResolver: true,
38+
},
39+
};
40+
41+
export default Sentry.withSentryAPI(maybeWrappedHandler, '__ROUTE__');
42+
43+
// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to
44+
// not include anything whose name matchs something we've explicitly exported above.
45+
// @ts-ignore See above
46+
// eslint-disable-next-line import/no-unresolved
47+
export * from '__RESOURCE_PATH__';

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

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { withSentryServerSideAppGetInitialProps } from './withSentryServerSideAp
44
export { withSentryServerSideDocumentGetInitialProps } from './withSentryServerSideDocumentGetInitialProps';
55
export { withSentryServerSideErrorGetInitialProps } from './withSentryServerSideErrorGetInitialProps';
66
export { withSentryGetServerSideProps } from './withSentryGetServerSideProps';
7+
export { withSentryAPI } from './withSentryAPI';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
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';
6+
7+
/**
8+
* Wrap the given API route handler for tracing and error capturing. Thin wrapper around `withSentry`, which only
9+
* applies it if it hasn't already been applied.
10+
*
11+
* @param maybeWrappedHandler The handler exported from the user's API page route file, which may or may not already be
12+
* wrapped with `withSentry`
13+
* @param parameterizedRoute The page's route, passed in via the proxy loader
14+
* @returns The wrapped handler
15+
*/
16+
export function withSentryAPI(
17+
maybeWrappedHandler: NextApiHandler | WrappedNextApiHandler,
18+
parameterizedRoute: string,
19+
): WrappedNextApiHandler {
20+
// Log a warning if the user is still manually wrapping their route in `withSentry`. Doesn't work in cases where
21+
// there's been an intermediate wrapper (like `withSentryAPI(someOtherWrapper(withSentry(handler)))`) but should catch
22+
// most cases. Only runs once per route. (Note: Such double-wrapping isn't harmful, but we'll eventually deprecate and remove `withSentry`, so
23+
// best to get people to stop using it.)
24+
if (maybeWrappedHandler.name === 'sentryWrappedHandler') {
25+
const [_sentryNextjs_, _autoWrapOption_, _withSentry_, _route_] = [
26+
'@sentry/nextjs',
27+
'autoInstrumentServerFunctions',
28+
'withSentry',
29+
parameterizedRoute,
30+
].map(phrase => formatAsCode(phrase));
31+
32+
nextLogger.info(
33+
`${_sentryNextjs_} is running with the ${_autoWrapOption_} flag set, which means API routes no longer need to ` +
34+
`be manually wrapped with ${_withSentry_}. Detected manual wrapping in ${_route_}.`,
35+
);
36+
}
37+
38+
return withSentry(maybeWrappedHandler, parameterizedRoute);
39+
}

packages/nextjs/src/index.server.ts

+1
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export {
134134
withSentryServerSideAppGetInitialProps,
135135
withSentryServerSideDocumentGetInitialProps,
136136
withSentryServerSideErrorGetInitialProps,
137+
withSentryAPI,
137138
} from './config/wrappers';
138139
export { withSentry } from './utils/withSentry';
139140

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* eslint-disable no-console */
2+
import * as chalk from 'chalk';
3+
4+
// This is nextjs's own logging formatting, vendored since it's not exported. See
5+
// https://github.com/vercel/next.js/blob/c3ceeb03abb1b262032bd96457e224497d3bbcef/packages/next/build/output/log.ts#L3-L11
6+
// and
7+
// https://github.com/vercel/next.js/blob/de7aa2d6e486c40b8be95a1327639cbed75a8782/packages/next/lib/eslint/runLintCheck.ts#L321-L323.
8+
9+
const prefixes = {
10+
wait: `${chalk.cyan('wait')} -`,
11+
error: `${chalk.red('error')} -`,
12+
warn: `${chalk.yellow('warn')} -`,
13+
ready: `${chalk.green('ready')} -`,
14+
info: `${chalk.cyan('info')} -`,
15+
event: `${chalk.magenta('event')} -`,
16+
trace: `${chalk.magenta('trace')} -`,
17+
};
18+
19+
export const formatAsCode = (str: string): string => chalk.bold.cyan(str);
20+
21+
export const nextLogger: {
22+
[key: string]: (...message: unknown[]) => void;
23+
} = {
24+
wait: (...message) => console.log(prefixes.wait, ...message),
25+
error: (...message) => console.error(prefixes.error, ...message),
26+
warn: (...message) => console.warn(prefixes.warn, ...message),
27+
ready: (...message) => console.log(prefixes.ready, ...message),
28+
info: (...message) => console.log(prefixes.info, ...message),
29+
event: (...message) => console.log(prefixes.event, ...message),
30+
trace: (...message) => console.log(prefixes.trace, ...message),
31+
};

packages/nextjs/src/utils/withSentry.ts

+33-12
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,32 @@ import { NextApiRequest, NextApiResponse } from 'next';
2828
// the test app would refer to the other version of the type (from the test app's `node_modules`). By using a custom
2929
// version of the type compatible with both the old and new official versions, we can use any Next version we want in
3030
// a test app without worrying about type errors.
31-
type NextApiHandler = (req: NextApiRequest, res: NextApiResponse) => void | Promise<void> | unknown | Promise<unknown>;
31+
export type NextApiHandler = (
32+
req: NextApiRequest,
33+
res: NextApiResponse,
34+
) => void | Promise<void> | unknown | Promise<unknown>;
3235
export type WrappedNextApiHandler = (req: NextApiRequest, res: NextApiResponse) => Promise<void> | Promise<unknown>;
3336

37+
type AugmentedNextApiRequest = NextApiRequest & {
38+
__withSentry_applied__?: boolean;
39+
};
40+
3441
export type AugmentedNextApiResponse = NextApiResponse & {
3542
__sentryTransaction?: Transaction;
3643
};
3744

3845
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
39-
export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler => {
46+
export const withSentry = (origHandler: NextApiHandler, parameterizedRoute?: string): WrappedNextApiHandler => {
4047
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
41-
return async (req, res) => {
48+
return async function sentryWrappedHandler(req: AugmentedNextApiRequest, res: NextApiResponse) {
49+
// We're now auto-wrapping API route handlers using `withSentryAPI` (which uses `withSentry` under the hood), but
50+
// users still may have their routes manually wrapped with `withSentry`. This check makes `sentryWrappedHandler`
51+
// idempotent so that those cases don't break anything.
52+
if (req.__withSentry_applied__) {
53+
return origHandler(req, res);
54+
}
55+
req.__withSentry_applied__ = true;
56+
4257
// first order of business: monkeypatch `res.end()` so that it will wait for us to send events to sentry before it
4358
// fires (if we don't do this, the lambda will close too early and events will be either delayed or lost)
4459
// eslint-disable-next-line @typescript-eslint/unbound-method
@@ -69,17 +84,23 @@ export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler =
6984
const baggageHeader = req.headers && req.headers.baggage;
7085
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(baggageHeader);
7186

72-
const url = `${req.url}`;
73-
// pull off query string, if any
74-
let reqPath = stripUrlQueryAndFragment(url);
75-
// Replace with placeholder
76-
if (req.query) {
77-
// TODO get this from next if possible, to avoid accidentally replacing non-dynamic parts of the path if
78-
// they happen to match the values of any of the dynamic parts
79-
for (const [key, value] of Object.entries(req.query)) {
80-
reqPath = reqPath.replace(`${value}`, `[${key}]`);
87+
// prefer the parameterized route, if we have it (which we will if we've auto-wrapped the route handler)
88+
let reqPath = parameterizedRoute;
89+
90+
// If not, fake it by just replacing parameter values with their names, hoping that none of them match either
91+
// each other or any hard-coded parts of the path
92+
if (!reqPath) {
93+
const url = `${req.url}`;
94+
// pull off query string, if any
95+
reqPath = stripUrlQueryAndFragment(url);
96+
// Replace with placeholder
97+
if (req.query) {
98+
for (const [key, value] of Object.entries(req.query)) {
99+
reqPath = reqPath.replace(`${value}`, `[${key}]`);
100+
}
81101
}
82102
}
103+
83104
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;
84105

85106
const transaction = startTransaction(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { NextApiRequest, NextApiResponse } from 'next';
2+
3+
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => {
4+
res.status(200).json({});
5+
};
6+
7+
export default handler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { NextApiRequest, NextApiResponse } from 'next';
2+
3+
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => {
4+
res.status(200).json({});
5+
};
6+
7+
export default handler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { NextApiRequest, NextApiResponse } from 'next';
2+
3+
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => {
4+
res.status(200).json({});
5+
};
6+
7+
export default handler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { withSentry } from '@sentry/nextjs';
2+
import { NextApiRequest, NextApiResponse } from 'next';
3+
4+
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => {
5+
res.status(200).json({});
6+
};
7+
8+
export default withSentry(handler);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { withSentry } from '@sentry/nextjs';
2+
import { NextApiRequest, NextApiResponse } from 'next';
3+
4+
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => {
5+
res.status(200).json({});
6+
};
7+
8+
export default withSentry(handler);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { withSentry } from '@sentry/nextjs';
2+
import { NextApiRequest, NextApiResponse } from 'next';
3+
4+
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => {
5+
res.status(200).json({});
6+
};
7+
8+
export default withSentry(handler);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const assert = require('assert');
2+
3+
const { sleep } = require('../utils/common');
4+
const { getAsync, interceptTracingRequest } = require('../utils/server');
5+
6+
module.exports = async ({ url: urlBase, argv }) => {
7+
const urls = {
8+
// testName: [url, route]
9+
unwrappedNoParamURL: [`/api/withSentryAPI/unwrapped/noParams`, '/api/withSentryAPI/unwrapped/noParams'],
10+
unwrappedDynamicURL: [`/api/withSentryAPI/unwrapped/dog`, '/api/withSentryAPI/unwrapped/[animal]'],
11+
unwrappedCatchAllURL: [`/api/withSentryAPI/unwrapped/dog/facts`, '/api/withSentryAPI/unwrapped/[...pathParts]'],
12+
wrappedNoParamURL: [`/api/withSentryAPI/wrapped/noParams`, '/api/withSentryAPI/wrapped/noParams'],
13+
wrappedDynamicURL: [`/api/withSentryAPI/wrapped/dog`, '/api/withSentryAPI/wrapped/[animal]'],
14+
wrappedCatchAllURL: [`/api/withSentryAPI/wrapped/dog/facts`, '/api/withSentryAPI/wrapped/[...pathParts]'],
15+
};
16+
17+
const interceptedRequests = {};
18+
19+
Object.entries(urls).forEach(([testName, [url, route]]) => {
20+
interceptedRequests[testName] = interceptTracingRequest(
21+
{
22+
contexts: {
23+
trace: {
24+
op: 'http.server',
25+
status: 'ok',
26+
tags: { 'http.status_code': '200' },
27+
},
28+
},
29+
transaction: `GET ${route}`,
30+
type: 'transaction',
31+
request: {
32+
url: `${urlBase}${url}`,
33+
},
34+
},
35+
argv,
36+
testName,
37+
);
38+
});
39+
40+
// Wait until all requests have completed
41+
await Promise.all(Object.values(urls).map(([url]) => getAsync(`${urlBase}${url}`)));
42+
43+
await sleep(250);
44+
45+
const failingTests = Object.entries(interceptedRequests).reduce(
46+
(failures, [testName, request]) => (!request.isDone() ? failures.concat(testName) : failures),
47+
[],
48+
);
49+
50+
assert.ok(
51+
failingTests.length === 0,
52+
`Did not intercept transaction request for the following tests: ${failingTests.join(', ')}.`,
53+
);
54+
};

0 commit comments

Comments
 (0)