Skip to content

Commit

Permalink
feat(nextjs): Create spans in serverside getInitialProps
Browse files Browse the repository at this point in the history
  • Loading branch information
lforst committed Aug 17, 2022
1 parent e861cc4 commit 927746c
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 124 deletions.
64 changes: 42 additions & 22 deletions packages/nextjs/src/config/loaders/dataFetchersLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const DATA_FETCHING_FUNCTIONS = {
type LoaderOptions = {
projectDir: string;
pagesDir: string;
underscoreAppRegex: RegExp;
underscoreErrorRegex: RegExp;
underscoreDocumentRegex: RegExp;
};

/**
Expand Down Expand Up @@ -109,7 +112,23 @@ export default function wrapDataFetchersLoader(this: LoaderThis<LoaderOptions>,
}

// We know one or the other will be defined, depending on the version of webpack being used
const { projectDir, pagesDir } = 'getOptions' in this ? this.getOptions() : this.query;
const { projectDir, pagesDir, underscoreAppRegex, underscoreDocumentRegex, underscoreErrorRegex } =
'getOptions' in this ? this.getOptions() : this.query;

// Get the parameterized route name from this page's filepath
const parameterizedRouteName = path
// Get the path of the file insde of the pages directory
.relative(pagesDir, this.resourcePath)
// Add a slash at the beginning
.replace(/(.*)/, '/$1')
// Pull off the file extension
.replace(/\.(jsx?|tsx?)/, '')
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
// just `/xyz`
.replace(/\/index$/, '')
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
// homepage), sub back in the root route
.replace(/^$/, '/');

// In the following branch we will proxy the user's file. This means we return code (basically an entirely new file)
// that re - exports all the user file's originial export, but with a "sentry-proxy-loader" query in the module
Expand All @@ -136,13 +155,26 @@ export default function wrapDataFetchersLoader(this: LoaderThis<LoaderOptions>,
if (hasDefaultExport(ast)) {
outputFileContent += `
import { default as _sentry_default } from "${this.resourcePath}?sentry-proxy-loader";
import { withSentryGetInitialProps } from "@sentry/nextjs";
if (typeof _sentry_default.getInitialProps === 'function') {
_sentry_default.getInitialProps = withSentryGetInitialProps(_sentry_default.getInitialProps);
}
export default _sentry_default;`;
import { withSentryGetInitialProps } from "@sentry/nextjs";`;

if (this.resourcePath.match(underscoreAppRegex)) {
// getInitialProps signature is a bit different in _app.js so we need a different wrapper
// Currently a no-op
} else if (this.resourcePath.match(underscoreErrorRegex)) {
// getInitialProps behaviour is a bit different in _error.js so we probably want different wrapper
// Currently a no-op
} else if (this.resourcePath.match(underscoreDocumentRegex)) {
// getInitialProps signature is a bit different in _document.js so we need a different wrapper
// Currently a no-op
} else {
// We enter this branch for any "normal" Next.js page
outputFileContent += `
if (typeof _sentry_default.getInitialProps === 'function') {
_sentry_default.getInitialProps = withSentryGetInitialProps(_sentry_default.getInitialProps, '${parameterizedRouteName}');
}`;
}

outputFileContent += 'export default _sentry_default;';
}

return outputFileContent;
Expand Down Expand Up @@ -173,20 +205,8 @@ export default function wrapDataFetchersLoader(this: LoaderThis<LoaderOptions>,

// Fill in template placeholders
let injectedCode = modifiedTemplateCode;
const route = path
// Get the path of the file insde of the pages directory
.relative(pagesDir, this.resourcePath)
// Add a slash at the beginning
.replace(/(.*)/, '/$1')
// Pull off the file extension
.replace(/\.(jsx?|tsx?)/, '')
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
// just `/xyz`
.replace(/\/index$/, '')
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
// homepage), sub back in the root route
.replace(/^$/, '/');
injectedCode = injectedCode.replace('__FILEPATH__', route);

injectedCode = injectedCode.replace('__FILEPATH__', parameterizedRouteName);
for (const { placeholder, alias } of Object.values(DATA_FETCHING_FUNCTIONS)) {
injectedCode = injectedCode.replace(new RegExp(placeholder, 'g'), alias);
}
Expand Down
14 changes: 13 additions & 1 deletion packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export function constructWebpackConfigFunction(
],
};

const underscoreAppRegex = new RegExp(`${escapeStringForRegex(projectDir)}(/src)?/pages/_app\\.(jsx?|tsx?)`);
const underscoreErrorRegex = new RegExp(`${escapeStringForRegex(projectDir)}(/src)?/pages/_error\\.(jsx?|tsx?)`);
const underscoreDocumentRegex = new RegExp(
`${escapeStringForRegex(projectDir)}(/src)?/pages/_document\\.(jsx?|tsx?)`,
);

if (userSentryOptions.experiments?.autoWrapDataFetchers) {
const pagesDir = newConfig.resolve?.alias?.['private-next-pages'] as string;

Expand All @@ -87,7 +93,13 @@ export function constructWebpackConfigFunction(
use: [
{
loader: path.resolve(__dirname, 'loaders/dataFetchersLoader.js'),
options: { projectDir, pagesDir },
options: {
projectDir,
pagesDir,
underscoreAppRegex,
underscoreErrorRegex,
underscoreDocumentRegex,
},
},
],
});
Expand Down
35 changes: 0 additions & 35 deletions packages/nextjs/src/config/wrappers/types.ts

This file was deleted.

14 changes: 9 additions & 5 deletions packages/nextjs/src/config/wrappers/withSentryGetInitialProps.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { GIProps } from './types';
import { NextPage } from 'next';

import { callDataFetcherTraced } from './wrapperUtils';

type GetInitialProps = Required<NextPage<unknown>>['getInitialProps'];

/**
* Create a wrapped version of the user's exported `getInitialProps` function
*
* @param origGIProps: The user's `getInitialProps` function
* @param origGetInitialProps: The user's `getInitialProps` function
* @param origGIPropsHost: The user's object on which `getInitialProps` lives (used for `this`)
* @returns A wrapped version of the function
*/
export function withSentryGetInitialProps(origGIProps: GIProps['fn']): GIProps['wrappedFn'] {
return async function (this: unknown, ...args: Parameters<GIProps['fn']>) {
return await origGIProps.call(this, ...args);
export function withSentryGetInitialProps(origGetInitialProps: GetInitialProps, route: string): GetInitialProps {
return function (...getInitialPropsArguments: Parameters<GetInitialProps>): ReturnType<GetInitialProps> {
return callDataFetcherTraced(origGetInitialProps, getInitialPropsArguments, { route, op: 'getInitialProps' });
};
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GSSP } from './types';
import { wrapperCore } from './wrapperUtils';
import { GetServerSideProps } from 'next';

import { callDataFetcherTraced } from './wrapperUtils';

/**
* Create a wrapped version of the user's exported `getServerSideProps` function
Expand All @@ -8,8 +9,14 @@ import { wrapperCore } from './wrapperUtils';
* @param route: The page's parameterized route
* @returns A wrapped version of the function
*/
export function withSentryGetServerSideProps(origGetServerSideProps: GSSP['fn'], route: string): GSSP['wrappedFn'] {
return async function (context: GSSP['context']): Promise<GSSP['result']> {
return wrapperCore<GSSP>(origGetServerSideProps, context, route);
export function withSentryGetServerSideProps(
origGetServerSideProps: GetServerSideProps,
route: string,
): GetServerSideProps {
return function (...getServerSidePropsArguments: Parameters<GetServerSideProps>): ReturnType<GetServerSideProps> {
return callDataFetcherTraced(origGetServerSideProps, getServerSidePropsArguments, {
route,
op: 'getServerSideProps',
});
};
}
16 changes: 11 additions & 5 deletions packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { GSProps } from './types';
import { wrapperCore } from './wrapperUtils';
import { GetStaticProps } from 'next';

import { callDataFetcherTraced } from './wrapperUtils';

type Props = { [key: string]: unknown };

/**
* Create a wrapped version of the user's exported `getStaticProps` function
Expand All @@ -8,8 +11,11 @@ import { wrapperCore } from './wrapperUtils';
* @param route: The page's parameterized route
* @returns A wrapped version of the function
*/
export function withSentryGetStaticProps(origGetStaticProps: GSProps['fn'], route: string): GSProps['wrappedFn'] {
return async function (context: GSProps['context']): Promise<GSProps['result']> {
return wrapperCore<GSProps>(origGetStaticProps, context, route);
export function withSentryGetStaticProps(
origGetStaticProps: GetStaticProps<Props>,
route: string,
): GetStaticProps<Props> {
return function (...getStaticPropsArguments: Parameters<GetStaticProps<Props>>): ReturnType<GetStaticProps<Props>> {
return callDataFetcherTraced(origGetStaticProps, getStaticPropsArguments, { route, op: 'getStaticProps' });
};
}
100 changes: 49 additions & 51 deletions packages/nextjs/src/config/wrappers/wrapperUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,62 @@ import { captureException } from '@sentry/core';
import { getActiveTransaction } from '@sentry/tracing';
import { Span } from '@sentry/types';

import { DataFetchingFunction } from './types';

/**
* Create a span to track the wrapped function and update transaction name with parameterized route.
* Call a data fetcher and trace it. Only traces the function if there is an active transaction on the scope.
*
* @template T Types for `getInitialProps`, `getStaticProps`, and `getServerSideProps`
* @param origFunction The user's exported `getInitialProps`, `getStaticProps`, or `getServerSideProps` function
* @param context The context object passed by nextjs to the function
* @param route The route currently being served
* @returns The result of calling the user's function
* We only do the following until we move transaction creation into this function: When called, the wrapped function
* will also update the name of the active transaction with a parameterized route provided via the `options` argument.
*/
export async function wrapperCore<T extends DataFetchingFunction>(
origFunction: T['fn'],
context: T['context'],
route: string,
): Promise<T['result']> {
const transaction = getActiveTransaction();

if (transaction) {
// Pull off any leading underscores we've added in the process of wrapping the function
const wrappedFunctionName = origFunction.name.replace(/^_*/, '');

// TODO: Make sure that the given route matches the name of the active transaction (to prevent background data
// fetching from switching the name to a completely other route)
transaction.name = route;
transaction.metadata.source = 'route';

// Capture the route, since pre-loading, revalidation, etc might mean that this span may happen during another
// route's transaction
const span = transaction.startChild({ op: 'nextjs.data', description: `${wrappedFunctionName} (${route})` });

const props = await callOriginal(origFunction, context, span);
export function callDataFetcherTraced<F extends (...args: any[]) => Promise<any> | any>(
origFunction: F,
origFunctionArgs: Parameters<F>,
options: {
route: string;
op: string;
},
): ReturnType<F> {
const { route, op } = options;

span.finish();
const transaction = getActiveTransaction();

return props;
if (!transaction) {
return origFunction(...origFunctionArgs);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return callOriginal(origFunction, context);
}

/** Call the original function, capturing any errors and finishing the span (if any) in case of error */
async function callOriginal<T extends DataFetchingFunction>(
origFunction: T['fn'],
context: T['context'],
span?: Span,
): Promise<T['result']> {
try {
// eslint-disable-next-line prefer-const, @typescript-eslint/no-explicit-any
return (origFunction as any)(context);
} catch (err) {
if (span) {
// Pull off any leading underscores we've added in the process of wrapping the function
const wrappedFunctionName = origFunction.name.replace(/^_*/, '');

// TODO: Make sure that the given route matches the name of the active transaction (to prevent background data
// fetching from switching the name to a completely other route) -- We'll probably switch to creating a transaction
// right here so making that check will probabably not even be necessary.
// Logic will be: If there is no active transaction, start one with correct name and source. If there is an active
// transaction, create a child span with correct name and source.
// We will probably need to put
transaction.name = route;
transaction.metadata.source = 'route';

// Capture the route, since pre-loading, revalidation, etc might mean that this span may happen during another
// route's transaction
const span = transaction.startChild({ op: 'nextjs.data', description: `${wrappedFunctionName} (${route})` });

const result = origFunction(...origFunctionArgs);

// We do the following instead of `await`-ing the return value of `origFunction`, because that would require us to
// make this function async which might in turn create a mismatch of function signatures between the original
// function and the wrapped one.
// This wraps `result`, which is potentially a Promise, into a Promise.
// If `result` is a non-Promise, the callback of `then` is immediately called and the span is finished.
// If `result` is a Promise, the callback of `then` is only called when `result` resolves
void Promise.resolve(result).then(
() => {
span.finish();
}
},
err => {
// TODO: Can we somehow associate the error with the span?
span.finish();
throw err;
},
);

// TODO Copy more robust error handling over from `withSentry`
captureException(err);
throw err;
}
return result;
}

0 comments on commit 927746c

Please sign in to comment.