-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(nextjs): Auto-wrap API routes #5778
Changes from all commits
100edb7
fd3f109
bdafcab
fd3d544
8faeba6
e11aed4
4902789
b615b32
72704e0
8ed7ea5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
/** | ||
* This file is a template for the code which will be substituted when our webpack loader handles API files in the | ||
* `pages/` directory. | ||
* | ||
* We use `__RESOURCE_PATH__` as a placeholder for the path to the file being wrapped. Because it's not a real package, | ||
* this causes both TS and ESLint to complain, hence the pragma comments below. | ||
*/ | ||
|
||
// @ts-ignore See above | ||
// eslint-disable-next-line import/no-unresolved | ||
import * as origModule from '__RESOURCE_PATH__'; | ||
import * as Sentry from '@sentry/nextjs'; | ||
import type { PageConfig } from 'next'; | ||
|
||
// We import this from `withSentry` rather than directly from `next` because our version can work simultaneously with | ||
// multiple versions of next. See note in `withSentry` for more. | ||
import type { NextApiHandler } from '../../utils/withSentry'; | ||
|
||
type NextApiModule = { | ||
default: NextApiHandler; | ||
config?: PageConfig; | ||
}; | ||
|
||
const userApiModule = origModule as NextApiModule; | ||
|
||
const maybeWrappedHandler = userApiModule.default; | ||
const origConfig = userApiModule.config || {}; | ||
|
||
// Setting `externalResolver` to `true` prevents nextjs from throwing a warning in dev about API routes resolving | ||
// without sending a response. It's a false positive (a response is sent, but only after we flush our send queue), and | ||
// 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 | ||
// place. | ||
export const config = { | ||
...origConfig, | ||
api: { | ||
...origConfig.api, | ||
externalResolver: true, | ||
}, | ||
}; | ||
|
||
export default Sentry.withSentryAPI(maybeWrappedHandler, '__ROUTE__'); | ||
|
||
// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to | ||
// not include anything whose name matchs something we've explicitly exported above. | ||
// @ts-ignore See above | ||
// eslint-disable-next-line import/no-unresolved | ||
export * from '__RESOURCE_PATH__'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import { formatAsCode, nextLogger } from '../../utils/nextLogger'; | ||
// We import these types from `withSentry` rather than directly from `next` because our version can work simultaneously | ||
// with multiple versions of next. See note in `withSentry` for more. | ||
import type { NextApiHandler, WrappedNextApiHandler } from '../../utils/withSentry'; | ||
import { withSentry } from '../../utils/withSentry'; | ||
|
||
/** | ||
* Wrap the given API route handler for tracing and error capturing. Thin wrapper around `withSentry`, which only | ||
* applies it if it hasn't already been applied. | ||
* | ||
* @param maybeWrappedHandler The handler exported from the user's API page route file, which may or may not already be | ||
* wrapped with `withSentry` | ||
* @param parameterizedRoute The page's route, passed in via the proxy loader | ||
* @returns The wrapped handler | ||
*/ | ||
export function withSentryAPI( | ||
maybeWrappedHandler: NextApiHandler | WrappedNextApiHandler, | ||
parameterizedRoute: string, | ||
): WrappedNextApiHandler { | ||
// Log a warning if the user is still manually wrapping their route in `withSentry`. Doesn't work in cases where | ||
// there's been an intermediate wrapper (like `withSentryAPI(someOtherWrapper(withSentry(handler)))`) but should catch | ||
// most cases. Only runs once per route. (Note: Such double-wrapping isn't harmful, but we'll eventually deprecate and remove `withSentry`, so | ||
// best to get people to stop using it.) | ||
if (maybeWrappedHandler.name === 'sentryWrappedHandler') { | ||
const [_sentryNextjs_, _autoWrapOption_, _withSentry_, _route_] = [ | ||
'@sentry/nextjs', | ||
'autoInstrumentServerFunctions', | ||
'withSentry', | ||
parameterizedRoute, | ||
].map(phrase => formatAsCode(phrase)); | ||
|
||
nextLogger.info( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I find this message as a good line in our As a user, I would find it annoying to see this message. I don't care if the route is wrapped by Sentry automatically or by me. All I care is that it works. |
||
`${_sentryNextjs_} is running with the ${_autoWrapOption_} flag set, which means API routes no longer need to ` + | ||
`be manually wrapped with ${_withSentry_}. Detected manual wrapping in ${_route_}.`, | ||
); | ||
} | ||
Comment on lines
+32
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am on the fence about whether I find this message useful or unnecessary. Can't hurt I guess, so it's definitely not blocking (or worth a discussion at this point in time). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My thinking is that eventually, we'll deprecate (Bonus of eventually removing it: We can then merge |
||
|
||
return withSentry(maybeWrappedHandler, parameterizedRoute); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
/* eslint-disable no-console */ | ||
import * as chalk from 'chalk'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the purpose of this logger? Yes, I checked the implementation on the nextjs repo that you referenced and I don't udnerstand the "why" behind it. :) Also, I believe that the comment attached is more appropriate as a GH comment then part of the codebase |
||
|
||
// This is nextjs's own logging formatting, vendored since it's not exported. See | ||
// https://github.com/vercel/next.js/blob/c3ceeb03abb1b262032bd96457e224497d3bbcef/packages/next/build/output/log.ts#L3-L11 | ||
// and | ||
// https://github.com/vercel/next.js/blob/de7aa2d6e486c40b8be95a1327639cbed75a8782/packages/next/lib/eslint/runLintCheck.ts#L321-L323. | ||
|
||
const prefixes = { | ||
wait: `${chalk.cyan('wait')} -`, | ||
error: `${chalk.red('error')} -`, | ||
warn: `${chalk.yellow('warn')} -`, | ||
ready: `${chalk.green('ready')} -`, | ||
info: `${chalk.cyan('info')} -`, | ||
event: `${chalk.magenta('event')} -`, | ||
trace: `${chalk.magenta('trace')} -`, | ||
}; | ||
|
||
export const formatAsCode = (str: string): string => chalk.bold.cyan(str); | ||
|
||
export const nextLogger: { | ||
[key: string]: (...message: unknown[]) => void; | ||
} = { | ||
wait: (...message) => console.log(prefixes.wait, ...message), | ||
error: (...message) => console.error(prefixes.error, ...message), | ||
warn: (...message) => console.warn(prefixes.warn, ...message), | ||
ready: (...message) => console.log(prefixes.ready, ...message), | ||
info: (...message) => console.log(prefixes.info, ...message), | ||
event: (...message) => console.log(prefixes.event, ...message), | ||
trace: (...message) => console.log(prefixes.trace, ...message), | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { NextApiRequest, NextApiResponse } from 'next'; | ||
|
||
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => { | ||
res.status(200).json({}); | ||
}; | ||
|
||
export default handler; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { NextApiRequest, NextApiResponse } from 'next'; | ||
|
||
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => { | ||
res.status(200).json({}); | ||
}; | ||
|
||
export default handler; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { NextApiRequest, NextApiResponse } from 'next'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recommend we make a test checking for nested routes like Docs https://nextjs.org/docs/routing/introduction#dynamic-route-segments There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
|
||
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => { | ||
res.status(200).json({}); | ||
}; | ||
|
||
export default handler; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import { withSentry } from '@sentry/nextjs'; | ||
import { NextApiRequest, NextApiResponse } from 'next'; | ||
|
||
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => { | ||
res.status(200).json({}); | ||
}; | ||
|
||
export default withSentry(handler); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import { withSentry } from '@sentry/nextjs'; | ||
import { NextApiRequest, NextApiResponse } from 'next'; | ||
|
||
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => { | ||
res.status(200).json({}); | ||
}; | ||
|
||
export default withSentry(handler); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import { withSentry } from '@sentry/nextjs'; | ||
import { NextApiRequest, NextApiResponse } from 'next'; | ||
|
||
const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise<void> => { | ||
res.status(200).json({}); | ||
}; | ||
|
||
export default withSentry(handler); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
const assert = require('assert'); | ||
|
||
const { sleep } = require('../utils/common'); | ||
const { getAsync, interceptTracingRequest } = require('../utils/server'); | ||
|
||
module.exports = async ({ url: urlBase, argv }) => { | ||
const urls = { | ||
// testName: [url, route] | ||
unwrappedNoParamURL: [`/api/withSentryAPI/unwrapped/noParams`, '/api/withSentryAPI/unwrapped/noParams'], | ||
unwrappedDynamicURL: [`/api/withSentryAPI/unwrapped/dog`, '/api/withSentryAPI/unwrapped/[animal]'], | ||
unwrappedCatchAllURL: [`/api/withSentryAPI/unwrapped/dog/facts`, '/api/withSentryAPI/unwrapped/[...pathParts]'], | ||
wrappedNoParamURL: [`/api/withSentryAPI/wrapped/noParams`, '/api/withSentryAPI/wrapped/noParams'], | ||
wrappedDynamicURL: [`/api/withSentryAPI/wrapped/dog`, '/api/withSentryAPI/wrapped/[animal]'], | ||
wrappedCatchAllURL: [`/api/withSentryAPI/wrapped/dog/facts`, '/api/withSentryAPI/wrapped/[...pathParts]'], | ||
}; | ||
|
||
const interceptedRequests = {}; | ||
|
||
Object.entries(urls).forEach(([testName, [url, route]]) => { | ||
interceptedRequests[testName] = interceptTracingRequest( | ||
{ | ||
contexts: { | ||
trace: { | ||
op: 'http.server', | ||
status: 'ok', | ||
tags: { 'http.status_code': '200' }, | ||
}, | ||
}, | ||
transaction: `GET ${route}`, | ||
type: 'transaction', | ||
request: { | ||
url: `${urlBase}${url}`, | ||
}, | ||
}, | ||
argv, | ||
testName, | ||
); | ||
}); | ||
|
||
// Wait until all requests have completed | ||
await Promise.all(Object.values(urls).map(([url]) => getAsync(`${urlBase}${url}`))); | ||
|
||
await sleep(250); | ||
|
||
const failingTests = Object.entries(interceptedRequests).reduce( | ||
(failures, [testName, request]) => (!request.isDone() ? failures.concat(testName) : failures), | ||
[], | ||
); | ||
|
||
assert.ok( | ||
failingTests.length === 0, | ||
`Did not intercept transaction request for the following tests: ${failingTests.join(', ')}.`, | ||
); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we have access to
API_ROUTE
from next.js?If so, I would recommend us using that instead when referring to
/api
folderThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't - it's not exported - but it's the just the regex version of this same check. Is your concern that they might change the value? (Feels like that'd be a pretty big breaking change on their part...)