Skip to content
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): Create spans and route parameterization in server-side getInitialProps #5587

Merged
merged 6 commits into from
Aug 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 37 additions & 21 deletions packages/nextjs/src/config/loaders/dataFetchersLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ 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;

// Get the parameterized route name from this page's filepath
const parameterizedRouteName = path
Copy link
Member

@Lms24 Lms24 Aug 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice chaining approach! I like it! (I wish it'd work as nicely in all our SDKs)
(EDIT, I'm referring to the lines below 115, I just botched the line selection in the review :D)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Props go to Katie.

// 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
// string.
Expand All @@ -136,13 +151,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 (parameterizedRouteName === '/_app') {
// getInitialProps signature is a bit different in _app.js so we need a different wrapper
// Currently a no-op
} else if (parameterizedRouteName === '/_error') {
// getInitialProps behaviour is a bit different in _error.js so we probably want different wrapper
// Currently a no-op
} else if (parameterizedRouteName === '/_document') {
// 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 +201,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
35 changes: 0 additions & 35 deletions packages/nextjs/src/config/wrappers/types.ts

This file was deleted.

24 changes: 18 additions & 6 deletions packages/nextjs/src/config/wrappers/withSentryGetInitialProps.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
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 origGIPropsHost: The user's object on which `getInitialProps` lives (used for `this`)
* @param origGetInitialProps The user's `getInitialProps` function
* @param parameterizedRoute The page's parameterized route
* @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,
parameterizedRoute: string,
): GetInitialProps {
return async function (
...getInitialPropsArguments: Parameters<GetInitialProps>
): Promise<ReturnType<GetInitialProps>> {
return callDataFetcherTraced(origGetInitialProps, getInitialPropsArguments, {
parameterizedRoute,
dataFetchingMethodName: 'getInitialProps',
});
};
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
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
*
* @param origGetServerSideProps: The user's `getServerSideProps` function
* @param route: The page's parameterized route
* @param origGetServerSideProps The user's `getServerSideProps` function
* @param parameterizedRoute 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,
parameterizedRoute: string,
): GetServerSideProps {
return async function (
...getServerSidePropsArguments: Parameters<GetServerSideProps>
): ReturnType<GetServerSideProps> {
return callDataFetcherTraced(origGetServerSideProps, getServerSidePropsArguments, {
parameterizedRoute,
dataFetchingMethodName: 'getServerSideProps',
});
};
}
25 changes: 18 additions & 7 deletions packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
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
*
* @param origGetStaticProps: The user's `getStaticProps` function
* @param route: The page's parameterized route
* @param origGetStaticProps The user's `getStaticProps` function
* @param parameterizedRoute 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>,
parameterizedRoute: string,
): GetStaticProps<Props> {
return async function (
...getStaticPropsArguments: Parameters<GetStaticProps<Props>>
): ReturnType<GetStaticProps<Props>> {
return callDataFetcherTraced(origGetStaticProps, getStaticPropsArguments, {
parameterizedRoute,
dataFetchingMethodName: 'getStaticProps',
});
};
}
73 changes: 30 additions & 43 deletions packages/nextjs/src/config/wrappers/wrapperUtils.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,45 @@
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';
export async function callDataFetcherTraced<F extends (...args: any[]) => Promise<any> | any>(
origFunction: F,
origFunctionArgs: Parameters<F>,
options: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you think about making this the SpanContext instead of options? we would make route -> description.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is blocked on #5564. I have to see what we're gonna do with op and description there before I make any more changes here.

Honestly, I wouldn't read too much into this PR. We're likely gonna scrap this wrapper anyways because it's too generic to be used across different data fetching methods.

parameterizedRoute: string;
dataFetchingMethodName: string;
},
): Promise<ReturnType<F>> {
const { parameterizedRoute, dataFetchingMethodName } = options;

// 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);

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);
}
// 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.
Comment on lines +29 to +30
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for me to get this right: If we already have a transaction, this means that we started it beforehand in a previous data fetcher function (iirc you said that getInitialProps can e.g. be executed before getServerSideProps), right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now it's started in instrumentServer.ts - but actually, that's not working so there never is a transaction. Soooo.... this PR currently doesn't do anything lol.

Copy link
Member

@lobsterkatie lobsterkatie Aug 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, something is weird, because there IS in fact a transaction in our integration tests, otherwise they wouldn't pass. And there's a transaction if you run the server inside of the debugger. But seemingly not if you just run the server straight. It's very mysterious.

transaction.name = parameterizedRoute;
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: `${dataFetchingMethodName} (${parameterizedRoute})`,
});

/** 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);
return await origFunction(...origFunctionArgs);
} catch (err) {
if (span) {
span.finish();
Expand Down