diff --git a/packages/nextjs/src/config/templates/proxyLoaderTemplate.ts b/packages/nextjs/src/config/templates/proxyLoaderTemplate.ts index e303ebc24ea0..43630f52daae 100644 --- a/packages/nextjs/src/config/templates/proxyLoaderTemplate.ts +++ b/packages/nextjs/src/config/templates/proxyLoaderTemplate.ts @@ -27,7 +27,7 @@ const origGetStaticProps = userPageModule.getStaticProps; const origGetServerSideProps = userPageModule.getServerSideProps; if (typeof origGetInitialProps === 'function') { - pageComponent.getInitialProps = Sentry.withSentryGetInitialProps( + pageComponent.getInitialProps = Sentry.withSentryServerSideGetInitialProps( origGetInitialProps, '__ROUTE__', ) as NextPageComponent['getInitialProps']; diff --git a/packages/nextjs/src/config/wrappers/index.ts b/packages/nextjs/src/config/wrappers/index.ts index 347f8c99f875..bd47765ae04f 100644 --- a/packages/nextjs/src/config/wrappers/index.ts +++ b/packages/nextjs/src/config/wrappers/index.ts @@ -1,3 +1,3 @@ export { withSentryGetStaticProps } from './withSentryGetStaticProps'; -export { withSentryGetInitialProps } from './withSentryGetInitialProps'; export { withSentryGetServerSideProps } from './withSentryGetServerSideProps'; +export { withSentryServerSideGetInitialProps } from './withSentryServerSideGetInitialProps'; diff --git a/packages/nextjs/src/config/wrappers/withSentryGetInitialProps.ts b/packages/nextjs/src/config/wrappers/withSentryGetInitialProps.ts deleted file mode 100644 index 9abf2c7a7f0a..000000000000 --- a/packages/nextjs/src/config/wrappers/withSentryGetInitialProps.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextPage } from 'next'; - -import { callDataFetcherTraced } from './wrapperUtils'; - -type GetInitialProps = Required>['getInitialProps']; - -/** - * Create a wrapped version of the user's exported `getInitialProps` function - * - * @param origGetInitialProps The user's `getInitialProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export function withSentryGetInitialProps( - origGetInitialProps: GetInitialProps, - parameterizedRoute: string, -): GetInitialProps { - return async function ( - ...getInitialPropsArguments: Parameters - ): Promise> { - return callDataFetcherTraced(origGetInitialProps, getInitialPropsArguments, { - parameterizedRoute, - dataFetchingMethodName: 'getInitialProps', - }); - }; -} diff --git a/packages/nextjs/src/config/wrappers/withSentryGetServerSideProps.ts b/packages/nextjs/src/config/wrappers/withSentryGetServerSideProps.ts index dce840606e39..a9d06f17cf4e 100644 --- a/packages/nextjs/src/config/wrappers/withSentryGetServerSideProps.ts +++ b/packages/nextjs/src/config/wrappers/withSentryGetServerSideProps.ts @@ -1,6 +1,8 @@ +import { hasTracingEnabled } from '@sentry/tracing'; import { GetServerSideProps } from 'next'; -import { callDataFetcherTraced } from './wrapperUtils'; +import { isBuild } from '../../utils/isBuild'; +import { callTracedServerSideDataFetcher, withErrorInstrumentation } from './wrapperUtils'; /** * Create a wrapped version of the user's exported `getServerSideProps` function @@ -16,9 +18,22 @@ export function withSentryGetServerSideProps( return async function ( ...getServerSidePropsArguments: Parameters ): ReturnType { - return callDataFetcherTraced(origGetServerSideProps, getServerSidePropsArguments, { - parameterizedRoute, - dataFetchingMethodName: 'getServerSideProps', - }); + if (isBuild()) { + return origGetServerSideProps(...getServerSidePropsArguments); + } + + const [context] = getServerSidePropsArguments; + const { req, res } = context; + + const errorWrappedGetServerSideProps = withErrorInstrumentation(origGetServerSideProps); + + if (hasTracingEnabled()) { + return callTracedServerSideDataFetcher(errorWrappedGetServerSideProps, getServerSidePropsArguments, req, res, { + parameterizedRoute, + functionName: 'getServerSideProps', + }); + } else { + return errorWrappedGetServerSideProps(...getServerSidePropsArguments); + } }; } diff --git a/packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts b/packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts index 0d2cc5e161dd..396c57c0652c 100644 --- a/packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts +++ b/packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts @@ -1,6 +1,7 @@ import { GetStaticProps } from 'next'; -import { callDataFetcherTraced } from './wrapperUtils'; +import { isBuild } from '../../utils/isBuild'; +import { callDataFetcherTraced, withErrorInstrumentation } from './wrapperUtils'; type Props = { [key: string]: unknown }; @@ -18,7 +19,13 @@ export function withSentryGetStaticProps( return async function ( ...getStaticPropsArguments: Parameters> ): ReturnType> { - return callDataFetcherTraced(origGetStaticProps, getStaticPropsArguments, { + if (isBuild()) { + return origGetStaticProps(...getStaticPropsArguments); + } + + const errorWrappedGetStaticProps = withErrorInstrumentation(origGetStaticProps); + + return callDataFetcherTraced(errorWrappedGetStaticProps, getStaticPropsArguments, { parameterizedRoute, dataFetchingMethodName: 'getStaticProps', }); diff --git a/packages/nextjs/src/config/wrappers/withSentryServerSideGetInitialProps.ts b/packages/nextjs/src/config/wrappers/withSentryServerSideGetInitialProps.ts new file mode 100644 index 000000000000..19be4b8e9a6f --- /dev/null +++ b/packages/nextjs/src/config/wrappers/withSentryServerSideGetInitialProps.ts @@ -0,0 +1,44 @@ +import { hasTracingEnabled } from '@sentry/tracing'; +import { NextPage } from 'next'; + +import { isBuild } from '../../utils/isBuild'; +import { callTracedServerSideDataFetcher, withErrorInstrumentation } from './wrapperUtils'; + +type GetInitialProps = Required['getInitialProps']; + +/** + * Create a wrapped version of the user's exported `getInitialProps` function + * + * @param origGetInitialProps The user's `getInitialProps` function + * @param parameterizedRoute The page's parameterized route + * @returns A wrapped version of the function + */ +export function withSentryServerSideGetInitialProps( + origGetInitialProps: GetInitialProps, + parameterizedRoute: string, +): GetInitialProps { + return async function ( + ...getInitialPropsArguments: Parameters + ): Promise> { + if (isBuild()) { + return origGetInitialProps(...getInitialPropsArguments); + } + + const [context] = getInitialPropsArguments; + const { req, res } = context; + + const errorWrappedGetInitialProps = withErrorInstrumentation(origGetInitialProps); + + if (hasTracingEnabled()) { + // Since this wrapper is only applied to `getInitialProps` running on the server, we can assert that `req` and + // `res` are always defined: https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return callTracedServerSideDataFetcher(errorWrappedGetInitialProps, getInitialPropsArguments, req!, res!, { + parameterizedRoute, + functionName: 'getInitialProps', + }); + } else { + return errorWrappedGetInitialProps(...getInitialPropsArguments); + } + }; +} diff --git a/packages/nextjs/src/config/wrappers/wrapperUtils.ts b/packages/nextjs/src/config/wrappers/wrapperUtils.ts index ab216f34aeaf..115d663350cc 100644 --- a/packages/nextjs/src/config/wrappers/wrapperUtils.ts +++ b/packages/nextjs/src/config/wrappers/wrapperUtils.ts @@ -1,5 +1,129 @@ -import { captureException } from '@sentry/core'; +import { captureException, getCurrentHub, startTransaction } from '@sentry/core'; +import { addRequestDataToEvent } from '@sentry/node'; import { getActiveTransaction } from '@sentry/tracing'; +import { Transaction } from '@sentry/types'; +import { fill } from '@sentry/utils'; +import * as domain from 'domain'; +import { IncomingMessage, ServerResponse } from 'http'; + +declare module 'http' { + interface IncomingMessage { + _sentryTransaction?: Transaction; + } +} + +function getTransactionFromRequest(req: IncomingMessage): Transaction | undefined { + return req._sentryTransaction; +} + +function setTransactionOnRequest(transaction: Transaction, req: IncomingMessage): void { + req._sentryTransaction = transaction; +} + +function autoEndTransactionOnResponseEnd(transaction: Transaction, res: ServerResponse): void { + fill(res, 'end', (originalEnd: ServerResponse['end']) => { + return function (this: unknown, ...endArguments: Parameters) { + transaction.finish(); + return originalEnd.call(this, ...endArguments); + }; + }); +} + +/** + * Wraps a function that potentially throws. If it does, the error is passed to `captureException` and rethrown. + */ +export function withErrorInstrumentation any>( + origFunction: F, +): (...params: Parameters) => ReturnType { + return function (this: unknown, ...origFunctionArguments: Parameters): ReturnType { + try { + const potentialPromiseResult = origFunction.call(this, ...origFunctionArguments); + // First of all, we need to capture promise rejections so we have the following check, as well as the try-catch block. + // Additionally, we do the following instead of `await`-ing so we do not change the method signature of the passed function from `() => unknown` to `() => Promise. + Promise.resolve(potentialPromiseResult).catch(err => { + // TODO: Extract error logic from `withSentry` in here or create a new wrapper with said logic or something like that. + captureException(err); + }); + return potentialPromiseResult; + } catch (e) { + // TODO: Extract error logic from `withSentry` in here or create a new wrapper with said logic or something like that. + captureException(e); + throw e; + } + }; +} + +/** + * Calls a server-side data fetching function (that takes a `req` and `res` object in its context) with tracing + * instrumentation. A transaction will be created for the incoming request (if it doesn't already exist) in addition to + * a span for the wrapped data fetching function. + * + * All of the above happens in an isolated domain, meaning all thrown errors will be associated with the correct span. + * + * @param origFunction The data fetching method to call. + * @param origFunctionArguments The arguments to call the data fetching method with. + * @param req The data fetching function's request object. + * @param res The data fetching function's response object. + * @param options Options providing details for the created transaction and span. + * @returns what the data fetching method call returned. + */ +export function callTracedServerSideDataFetcher Promise | any>( + origFunction: F, + origFunctionArguments: Parameters, + req: IncomingMessage, + res: ServerResponse, + options: { + parameterizedRoute: string; + functionName: string; + }, +): Promise> { + return domain.create().bind(async () => { + let requestTransaction: Transaction | undefined = getTransactionFromRequest(req); + + if (requestTransaction === undefined) { + // TODO: Extract trace data from `req` object (trace and baggage headers) and attach it to transaction + + const newTransaction = startTransaction({ + op: 'nextjs.data', + name: options.parameterizedRoute, + metadata: { + source: 'route', + }, + }); + + requestTransaction = newTransaction; + autoEndTransactionOnResponseEnd(newTransaction, res); + setTransactionOnRequest(newTransaction, req); + } + + const dataFetcherSpan = requestTransaction.startChild({ + op: 'nextjs.data', + description: `${options.functionName} (${options.parameterizedRoute})`, + }); + + const currentScope = getCurrentHub().getScope(); + if (currentScope) { + currentScope.setSpan(dataFetcherSpan); + currentScope.addEventProcessor(event => + addRequestDataToEvent(event, req, { + include: { + // When the `transaction` option is set to true, it tries to extract a transaction name from the request + // object. We don't want this since we already have a high-quality transaction name with a parameterized + // route. Setting `transaction` to `true` will clobber that transaction name. + transaction: false, + }, + }), + ); + } + + try { + // TODO: Inject trace data into returned props + return await origFunction(...origFunctionArguments); + } finally { + dataFetcherSpan.finish(); + } + })(); +} /** * Call a data fetcher and trace it. Only traces the function if there is an active transaction on the scope. diff --git a/packages/nextjs/src/index.client.ts b/packages/nextjs/src/index.client.ts index 1ff546c5248d..a7bfca1f2660 100644 --- a/packages/nextjs/src/index.client.ts +++ b/packages/nextjs/src/index.client.ts @@ -11,8 +11,6 @@ export * from '@sentry/react'; export { nextRouterInstrumentation } from './performance/client'; export { captureUnderscoreErrorException } from './utils/_error'; -export { withSentryGetInitialProps } from './config/wrappers'; - export { Integrations }; // Previously we expected users to import `BrowserTracing` like this: diff --git a/packages/nextjs/src/index.server.ts b/packages/nextjs/src/index.server.ts index 0c1935b84d83..4fbe95bb8ee6 100644 --- a/packages/nextjs/src/index.server.ts +++ b/packages/nextjs/src/index.server.ts @@ -125,7 +125,11 @@ function addServerIntegrations(options: NextjsOptions): void { export type { SentryWebpackPluginOptions } from './config/types'; export { withSentryConfig } from './config'; export { isBuild } from './utils/isBuild'; -export { withSentryGetServerSideProps, withSentryGetStaticProps, withSentryGetInitialProps } from './config/wrappers'; +export { + withSentryGetServerSideProps, + withSentryGetStaticProps, + withSentryServerSideGetInitialProps, +} from './config/wrappers'; export { withSentry } from './utils/withSentry'; // Wrap various server methods to enable error monitoring and tracing. (Note: This only happens for non-Vercel