diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 05364f102731..83c93b280039 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -77,10 +77,5 @@ }, "volta": { "extends": "../../package.json" - }, - "sideEffects": [ - "./cjs/index.server.js", - "./esm/index.server.js", - "./src/index.server.ts" - ] + } } diff --git a/packages/nextjs/rollup.npm.config.js b/packages/nextjs/rollup.npm.config.js index 32100ef278be..f9bdfbf1bcbc 100644 --- a/packages/nextjs/rollup.npm.config.js +++ b/packages/nextjs/rollup.npm.config.js @@ -5,12 +5,7 @@ export default [ makeBaseNPMConfig({ // We need to include `instrumentServer.ts` separately because it's only conditionally required, and so rollup // doesn't automatically include it when calculating the module dependency tree. - entrypoints: [ - 'src/index.server.ts', - 'src/index.client.ts', - 'src/utils/instrumentServer.ts', - 'src/config/webpack.ts', - ], + entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/config/webpack.ts'], // prevent this internal nextjs code from ending up in our built package (this doesn't happen automatially because // the name doesn't match an SDK dependency) diff --git a/packages/nextjs/src/index.server.ts b/packages/nextjs/src/index.server.ts index ca36e41a0427..2db90df9964c 100644 --- a/packages/nextjs/src/index.server.ts +++ b/packages/nextjs/src/index.server.ts @@ -169,19 +169,3 @@ export { withSentryAPI, withSentry, } from './config/wrappers'; - -// Wrap various server methods to enable error monitoring and tracing. (Note: This only happens for non-Vercel -// deployments, because the current method of doing the wrapping a) crashes Next 12 apps deployed to Vercel and -// b) doesn't work on those apps anyway. We also don't do it during build, because there's no server running in that -// phase.) -if (!IS_BUILD && !IS_VERCEL) { - // Dynamically require the file because even importing from it causes Next 12 to crash on Vercel. - // In environments where the JS file doesn't exist, such as testing, import the TS file. - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { instrumentServer } = require('./utils/instrumentServer.js'); - instrumentServer(); - } catch (err) { - __DEBUG_BUILD__ && logger.warn(`Error: Unable to instrument server for tracing. Got ${err}.`); - } -} diff --git a/packages/nextjs/src/utils/instrumentServer.ts b/packages/nextjs/src/utils/instrumentServer.ts deleted file mode 100644 index 393c85fed014..000000000000 --- a/packages/nextjs/src/utils/instrumentServer.ts +++ /dev/null @@ -1,369 +0,0 @@ -/* eslint-disable max-lines */ -import { captureException, configureScope, deepReadDirSync, getCurrentHub, startTransaction } from '@sentry/node'; -import { extractTraceparentData, getActiveTransaction, hasTracingEnabled } from '@sentry/tracing'; -import { - addExceptionMechanism, - baggageHeaderToDynamicSamplingContext, - fill, - isString, - logger, - stripUrlQueryAndFragment, -} from '@sentry/utils'; -import * as domain from 'domain'; -import * as http from 'http'; -import { default as createNextServer } from 'next'; -import * as querystring from 'querystring'; -import * as url from 'url'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PlainObject = { [key: string]: T }; - -// class used by `next` as a proxy to the real server; see -// https://github.com/vercel/next.js/blob/4443d6f3d36b107e833376c2720c1e206eee720d/packages/next/server/next.ts#L32 -interface NextServer { - server: Server; - getServer: () => Promise; - createServer: (options: PlainObject) => Server; -} - -// `next`'s main server class; see -// https://github.com/vercel/next.js/blob/4443d6f3d36b107e833376c2720c1e206eee720d/packages/next/next-server/server/next-server.ts#L132 -interface Server { - dir: string; - publicDir: string; -} - -export type NextRequest = ( - | http.IncomingMessage // in nextjs versions < 12.0.9, `NextRequest` extends `http.IncomingMessage` - | { - _req: http.IncomingMessage; // in nextjs versions >= 12.0.9, `NextRequest` wraps `http.IncomingMessage` - } -) & { - cookies: Record; - url: string; - query: { [key: string]: string }; - headers: { [key: string]: string }; - body: string | { [key: string]: unknown }; - method: string; -}; - -type NextResponse = - // in nextjs versions < 12.0.9, `NextResponse` extends `http.ServerResponse` - | http.ServerResponse - // in nextjs versions >= 12.0.9, `NextResponse` wraps `http.ServerResponse` - | { - _res: http.ServerResponse; - }; - -// the methods we'll wrap -type HandlerGetter = () => Promise; -type ReqHandler = (nextReq: NextRequest, nextRes: NextResponse, parsedUrl?: url.UrlWithParsedQuery) => Promise; -type ErrorLogger = (err: Error) => void; -type ApiPageEnsurer = (path: string) => Promise; -type PageComponentFinder = ( - pathname: string, - query: querystring.ParsedUrlQuery, - params: { [key: string]: unknown } | null, -) => Promise<{ [key: string]: unknown } | null>; - -// these aliases are purely to make the function signatures more easily understandable -type WrappedHandlerGetter = HandlerGetter; -type WrappedErrorLogger = ErrorLogger; -type WrappedReqHandler = ReqHandler; -type WrappedApiPageEnsurer = ApiPageEnsurer; -type WrappedPageComponentFinder = PageComponentFinder; - -let liveServer: Server; -let sdkSetupComplete = false; - -const globalWithInjectedValues = global as typeof global & { - __sentryRewritesTunnelPath__?: string; -}; - -/** - * Do the monkeypatching and wrapping necessary to catch errors in page routes and record transactions for both page and - * API routes. - */ -export function instrumentServer(): void { - // The full implementation here involves a lot of indirection and multiple layers of callbacks and wrapping, and is - // therefore potentially a little hard to follow. Here's the overall idea: - - // Next.js uses two server classes, `NextServer` and `Server`, with the former proxying calls to the latter, which - // then does the all real work. The only access we have to either is through Next's default export, - // `createNextServer()`, which returns a `NextServer` instance. - - // At server startup: - // `next.config.js` imports SDK -> - // SDK's `index.ts` runs -> - // `instrumentServer()` (the function we're in right now) -> - // `createNextServer()` -> - // `NextServer` instance -> - // `NextServer` prototype -> - // Wrap `NextServer.getServerRequestHandler()`, purely to get us to the next step - - // At time of first request: - // Wrapped `getServerRequestHandler` runs for the first time -> - // Live `NextServer` instance(via`this`) -> - // Live `Server` instance (via `NextServer.server`) -> - // `Server` prototype -> - // Wrap `Server.logError`, `Server.handleRequest`, `Server.ensureApiPage`, and `Server.findPageComponents` methods, - // then fulfill original purpose of function by passing wrapped version of `handleRequest` to caller - - // Whenever caller of `NextServer.getServerRequestHandler` calls the wrapped `Server.handleRequest`: - // Trace request - - // Whenever something calls the wrapped `Server.logError`: - // Capture error - - // Whenever an API request is handled and the wrapped `Server.ensureApiPage` is called, or whenever a page request is - // handled and the wrapped `Server.findPageComponents` is called: - // Replace URL in transaction name with parameterized version - - const nextServerPrototype = Object.getPrototypeOf(createNextServer({})); - fill(nextServerPrototype, 'getServerRequestHandler', makeWrappedHandlerGetter); -} - -/** - * Create a wrapped version of Nextjs's `NextServer.getServerRequestHandler` method, as a way to access the running - * `Server` instance and monkeypatch its prototype. - * - * @param origHandlerGetter Nextjs's `NextServer.getServerRequestHandler` method - * @returns A wrapped version of the same method, to monkeypatch in at server startup - */ -function makeWrappedHandlerGetter(origHandlerGetter: HandlerGetter): WrappedHandlerGetter { - // We wrap this purely in order to be able to grab data and do further monkeypatching the first time it runs. - // Otherwise, it's just a pass-through to the original method. - const wrappedHandlerGetter = async function (this: NextServer): Promise { - if (!sdkSetupComplete) { - // stash this in the closure so that `makeWrappedReqHandler` can use it - liveServer = await this.getServer(); - const serverPrototype = Object.getPrototypeOf(liveServer); - - // Wrap for error capturing (`logError` gets called by `next` for all server-side errors) - fill(serverPrototype, 'logError', makeWrappedErrorLogger); - - // Wrap for request transaction creation (`handleRequest` is called for all incoming requests, and dispatches them - // to the appropriate handlers) - fill(serverPrototype, 'handleRequest', makeWrappedReqHandler); - - // Wrap as a way to grab the parameterized request URL to use as the transaction name for API requests and page - // requests, respectively. These methods are chosen because they're the first spot in the request-handling process - // where the parameterized path is provided as an argument, so it's easy to grab. - fill(serverPrototype, 'ensureApiPage', makeWrappedMethodForGettingParameterizedPath); - fill(serverPrototype, 'findPageComponents', makeWrappedMethodForGettingParameterizedPath); - - sdkSetupComplete = true; - } - - return origHandlerGetter.call(this); - }; - - return wrappedHandlerGetter; -} - -/** - * Wrap the error logger used by the server to capture exceptions which arise from functions like `getServerSideProps`. - * - * @param origErrorLogger The original logger from the `Server` class - * @returns A wrapped version of that logger - */ -function makeWrappedErrorLogger(origErrorLogger: ErrorLogger): WrappedErrorLogger { - return function (this: Server, err: Error): void { - // TODO add more context data here - - // We can use `configureScope` rather than `withScope` here because we're using domains to ensure that each request - // gets its own scope. (`configureScope` has the advantage of not creating a clone of the current scope before - // modifying it, which in this case is unnecessary.) - configureScope(scope => { - scope.addEventProcessor(event => { - addExceptionMechanism(event, { - type: 'instrument', - handled: true, - data: { - function: 'logError', - }, - }); - return event; - }); - }); - - captureException(err); - - return origErrorLogger.call(this, err); - }; -} - -// inspired by next's public file routing; see -// https://github.com/vercel/next.js/blob/4443d6f3d36b107e833376c2720c1e206eee720d/packages/next/next-server/server/next-server.ts#L1166 -function getPublicDirFiles(): Set { - try { - // we need the paths here to match the format of a request url, which means they must: - // - start with a slash - // - use forward slashes rather than backslashes - // - be URL-encoded - const dirContents = deepReadDirSync(liveServer.publicDir).map(filepath => - encodeURI(`/${filepath.replace(/\\/g, '/')}`), - ); - return new Set(dirContents); - } catch (_) { - return new Set(); - } -} - -/** - * Wrap the server's request handler to be able to create request transactions. - * - * @param origReqHandler The original request handler from the `Server` class - * @returns A wrapped version of that handler - */ -function makeWrappedReqHandler(origReqHandler: ReqHandler): WrappedReqHandler { - const publicDirFiles = getPublicDirFiles(); - // add transaction start and stop to the normal request handling - const wrappedReqHandler = async function ( - this: Server, - nextReq: NextRequest, - nextRes: NextResponse, - parsedUrl?: url.UrlWithParsedQuery, - ): Promise { - // Starting with version 12.0.9, nextjs wraps the incoming request in a `NodeNextRequest` object and the outgoing - // response in a `NodeNextResponse` object before passing them to the handler. (This is necessary here but not in - // `withSentry` because by the time nextjs passes them to an API handler, it's unwrapped them again.) - const req = '_req' in nextReq ? nextReq._req : nextReq; - const res = '_res' in nextRes ? nextRes._res : nextRes; - - // wrap everything in a domain in order to prevent scope bleed between requests - const local = domain.create(); - local.add(req); - local.add(res); - // TODO could this replace wrapping the error logger? - // local.on('error', Sentry.captureException); - - local.run(() => { - const currentScope = getCurrentHub().getScope(); - - if (currentScope) { - // Store the request on the scope so we can pull data from it and add it to the event - currentScope.setSDKProcessingMetadata({ request: req }); - - // We only want to record page and API requests - if (hasTracingEnabled() && shouldTraceRequest(nextReq.url, publicDirFiles)) { - // If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision) - let traceparentData; - if (nextReq.headers && isString(nextReq.headers['sentry-trace'])) { - traceparentData = extractTraceparentData(nextReq.headers['sentry-trace']); - __DEBUG_BUILD__ && logger.log(`[Tracing] Continuing trace ${traceparentData?.traceId}.`); - } - - const baggageHeader = nextReq.headers && nextReq.headers.baggage; - const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(baggageHeader); - - // pull off query string, if any - const reqPath = stripUrlQueryAndFragment(nextReq.url); - - // requests for pages will only ever be GET requests, so don't bother to include the method in the transaction - // name; requests to API routes could be GET, POST, PUT, etc, so do include it there - const namePrefix = nextReq.url.startsWith('/api') ? `${nextReq.method.toUpperCase()} ` : ''; - - const transaction = startTransaction( - { - name: `${namePrefix}${reqPath}`, - op: 'http.server', - metadata: { - dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, - requestPath: reqPath, - // TODO: Investigate if there's a way to tell if this is a dynamic route, so that we can make this more - // like `source: isDynamicRoute? 'url' : 'route'` - // TODO: What happens when `withSentry` is used also? Which values of `name` and `source` win? - source: 'url', - request: req, - }, - ...traceparentData, - }, - // Extra context passed to the `tracesSampler` (Note: We're combining `nextReq` and `req` this way in order - // to not break people's `tracesSampler` functions, even though the format of `nextReq` has changed (see - // note above re: nextjs 12.0.9). If `nextReq === req` (pre 12.0.9), then spreading `req` is a no-op - we're - // just spreading the same stuff twice. If `nextReq` contains `req` (12.0.9 and later), then spreading `req` - // mimics the old format by flattening the data.) - { request: { ...nextReq, ...req } }, - ); - - currentScope.setSpan(transaction); - - res.once('finish', () => { - const transaction = getActiveTransaction(); - if (transaction) { - transaction.setHttpStatus(res.statusCode); - - // Push `transaction.finish` to the next event loop so open spans have a chance to finish before the - // transaction closes - setImmediate(() => { - transaction.finish(); - }); - } - }); - } - } - - return origReqHandler.call(this, nextReq, nextRes, parsedUrl); - }); - }; - - return wrappedReqHandler; -} - -/** - * Wrap the given method in order to use the parameterized path passed to it in the transaction name. - * - * @param origMethod Either `ensureApiPage` (called for every API request) or `findPageComponents` (called for every - * page request), both from the `Server` class - * @returns A wrapped version of the given method - */ -function makeWrappedMethodForGettingParameterizedPath( - origMethod: ApiPageEnsurer | PageComponentFinder, -): WrappedApiPageEnsurer | WrappedPageComponentFinder { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const wrappedMethod = async function ( - this: Server, - parameterizedPath: - | string // `ensureAPIPage`, `findPageComponents` before nextjs 12.2.6 - | { pathname: string }, // `findPageComponents` from nextjs 12.2.6 onward - ...args: any[] - ): Promise { - const transaction = getActiveTransaction(); - - // replace specific URL with parameterized version - if (transaction && transaction.metadata.requestPath) { - const origPath = transaction.metadata.requestPath; - const newPath = typeof parameterizedPath === 'string' ? parameterizedPath : parameterizedPath.pathname; - if (newPath) { - const newName = transaction.name.replace(origPath, newPath); - transaction.setName(newName, 'route'); - } - } - - return origMethod.call(this, parameterizedPath, ...args); - }; - - return wrappedMethod; -} - -/** - * Determine if the request should be traced, by filtering out requests for internal next files and static resources. - * - * @param url The URL of the request - * @param publicDirFiles A set containing relative paths to all available static resources (note that this does not - * include static *pages*, but rather images and the like) - * @returns false if the URL is for an internal or static resource - */ -function shouldTraceRequest(url: string, publicDirFiles: Set): boolean { - // Don't trace tunneled sentry events - const tunnelPath = globalWithInjectedValues.__sentryRewritesTunnelPath__; - const pathname = new URL(url, 'http://example.com/').pathname; // `url` is relative so we need to define a base to be able to parse with URL - if (tunnelPath && pathname === tunnelPath) { - __DEBUG_BUILD__ && logger.log(`Tunneling Sentry event received on "${url}"`); - return false; - } - - // `static` is a deprecated but still-functional location for static resources - return !url.startsWith('/_next/') && !url.startsWith('/static/') && !publicDirFiles.has(url); -}