Skip to content

Commit ba8deb5

Browse files
author
Luca Forstner
committed
feat(nextjs): Create spans in serverside getInitialProps
1 parent 99b1f78 commit ba8deb5

File tree

7 files changed

+134
-108
lines changed

7 files changed

+134
-108
lines changed

packages/nextjs/src/config/loaders/dataFetchersLoader.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const DATA_FETCHING_FUNCTIONS = {
3838
type LoaderOptions = {
3939
projectDir: string;
4040
pagesDir: string;
41+
underscoreAppRegex: RegExp;
42+
underscoreErrorRegex: RegExp;
43+
underscoreDocumentRegex: RegExp;
4144
};
4245

4346
/**
@@ -109,7 +112,23 @@ export default function wrapDataFetchersLoader(this: LoaderThis<LoaderOptions>,
109112
}
110113

111114
// We know one or the other will be defined, depending on the version of webpack being used
112-
const { projectDir, pagesDir } = 'getOptions' in this ? this.getOptions() : this.query;
115+
const { projectDir, pagesDir, underscoreAppRegex, underscoreDocumentRegex, underscoreErrorRegex } =
116+
'getOptions' in this ? this.getOptions() : this.query;
117+
118+
// Get the parameterized route name from this page's filepath
119+
const parameterizedRouteName = path
120+
// Get the path of the file insde of the pages directory
121+
.relative(pagesDir, this.resourcePath)
122+
// Add a slash at the beginning
123+
.replace(/(.*)/, '/$1')
124+
// Pull off the file extension
125+
.replace(/\.(jsx?|tsx?)/, '')
126+
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
127+
// just `/xyz`
128+
.replace(/\/index$/, '')
129+
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
130+
// homepage), sub back in the root route
131+
.replace(/^$/, '/');
113132

114133
// In the following branch we will proxy the user's file. This means we return code (basically an entirely new file)
115134
// that re - exports all the user file's originial export, but with a "sentry-proxy-loader" query in the module
@@ -136,13 +155,26 @@ export default function wrapDataFetchersLoader(this: LoaderThis<LoaderOptions>,
136155
if (hasDefaultExport(ast)) {
137156
outputFileContent += `
138157
import { default as _sentry_default } from "${this.resourcePath}?sentry-proxy-loader";
139-
import { withSentryGetInitialProps } from "@sentry/nextjs";
140-
141-
if (typeof _sentry_default.getInitialProps === 'function') {
142-
_sentry_default.getInitialProps = withSentryGetInitialProps(_sentry_default.getInitialProps);
143-
}
144-
145-
export default _sentry_default;`;
158+
import { withSentryGetInitialProps } from "@sentry/nextjs";`;
159+
160+
if (this.resourcePath.match(underscoreAppRegex)) {
161+
// getInitialProps signature is a bit different in _app.js so we need a different wrapper
162+
// Currently a no-op
163+
} else if (this.resourcePath.match(underscoreErrorRegex)) {
164+
// getInitialProps behaviour is a bit different in _error.js so we probably want different wrapper
165+
// Currently a no-op
166+
} else if (this.resourcePath.match(underscoreDocumentRegex)) {
167+
// getInitialProps signature is a bit different in _document.js so we need a different wrapper
168+
// Currently a no-op
169+
} else {
170+
// We enter this branch for any "normal" Next.js page
171+
outputFileContent += `
172+
if (typeof _sentry_default.getInitialProps === 'function') {
173+
_sentry_default.getInitialProps = withSentryGetInitialProps(_sentry_default.getInitialProps, '${parameterizedRouteName}');
174+
}`;
175+
}
176+
177+
outputFileContent += 'export default _sentry_default;';
146178
}
147179

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

174206
// Fill in template placeholders
175207
let injectedCode = modifiedTemplateCode;
176-
const route = path
177-
// Get the path of the file insde of the pages directory
178-
.relative(pagesDir, this.resourcePath)
179-
// Add a slash at the beginning
180-
.replace(/(.*)/, '/$1')
181-
// Pull off the file extension
182-
.replace(/\.(jsx?|tsx?)/, '')
183-
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
184-
// just `/xyz`
185-
.replace(/\/index$/, '')
186-
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
187-
// homepage), sub back in the root route
188-
.replace(/^$/, '/');
189-
injectedCode = injectedCode.replace('__FILEPATH__', route);
208+
209+
injectedCode = injectedCode.replace('__FILEPATH__', parameterizedRouteName);
190210
for (const { placeholder, alias } of Object.values(DATA_FETCHING_FUNCTIONS)) {
191211
injectedCode = injectedCode.replace(new RegExp(placeholder, 'g'), alias);
192212
}

packages/nextjs/src/config/webpack.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ export function constructWebpackConfigFunction(
7878
],
7979
};
8080

81+
const underscoreAppRegex = new RegExp(`${escapeStringForRegex(projectDir)}(/src)?/pages/_app\\.(jsx?|tsx?)`);
82+
const underscoreErrorRegex = new RegExp(`${escapeStringForRegex(projectDir)}(/src)?/pages/_error\\.(jsx?|tsx?)`);
83+
const underscoreDocumentRegex = new RegExp(
84+
`${escapeStringForRegex(projectDir)}(/src)?/pages/_document\\.(jsx?|tsx?)`,
85+
);
86+
8187
if (userSentryOptions.experiments?.autoWrapDataFetchers) {
8288
const pagesDir = newConfig.resolve?.alias?.['private-next-pages'] as string;
8389

@@ -87,7 +93,13 @@ export function constructWebpackConfigFunction(
8793
use: [
8894
{
8995
loader: path.resolve(__dirname, 'loaders/dataFetchersLoader.js'),
90-
options: { projectDir, pagesDir },
96+
options: {
97+
projectDir,
98+
pagesDir,
99+
underscoreAppRegex,
100+
underscoreErrorRegex,
101+
underscoreDocumentRegex,
102+
},
91103
},
92104
],
93105
});

packages/nextjs/src/config/wrappers/types.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import { GIProps } from './types';
1+
import { NextPage } from 'next';
2+
3+
import { callDataFetcherTraced } from './wrapperUtils';
4+
5+
type GetInitialProps = Required<NextPage<unknown>>['getInitialProps'];
26

37
/**
48
* Create a wrapped version of the user's exported `getInitialProps` function
59
*
6-
* @param origGIProps: The user's `getInitialProps` function
10+
* @param origGetInitialProps: The user's `getInitialProps` function
711
* @param origGIPropsHost: The user's object on which `getInitialProps` lives (used for `this`)
812
* @returns A wrapped version of the function
913
*/
10-
export function withSentryGetInitialProps(origGIProps: GIProps['fn']): GIProps['wrappedFn'] {
11-
return async function (this: unknown, ...args: Parameters<GIProps['fn']>) {
12-
return await origGIProps.call(this, ...args);
14+
export function withSentryGetInitialProps(origGetInitialProps: GetInitialProps, route: string): GetInitialProps {
15+
return function (...getInitialPropsArguments: Parameters<GetInitialProps>): ReturnType<GetInitialProps> {
16+
return callDataFetcherTraced(origGetInitialProps, getInitialPropsArguments, { route, op: 'getInitialProps' });
1317
};
1418
}
Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { GSSP } from './types';
2-
import { wrapperCore } from './wrapperUtils';
1+
import { GetServerSideProps } from 'next';
2+
3+
import { callDataFetcherTraced } from './wrapperUtils';
34

45
/**
56
* Create a wrapped version of the user's exported `getServerSideProps` function
@@ -8,8 +9,14 @@ import { wrapperCore } from './wrapperUtils';
89
* @param route: The page's parameterized route
910
* @returns A wrapped version of the function
1011
*/
11-
export function withSentryGetServerSideProps(origGetServerSideProps: GSSP['fn'], route: string): GSSP['wrappedFn'] {
12-
return async function (context: GSSP['context']): Promise<GSSP['result']> {
13-
return wrapperCore<GSSP>({ origFunction: origGetServerSideProps, context, route, op: 'getServerSideProps' });
12+
export function withSentryGetServerSideProps(
13+
origGetServerSideProps: GetServerSideProps,
14+
route: string,
15+
): GetServerSideProps {
16+
return function (...getServerSidePropsArguments: Parameters<GetServerSideProps>): ReturnType<GetServerSideProps> {
17+
return callDataFetcherTraced(origGetServerSideProps, getServerSidePropsArguments, {
18+
route,
19+
op: 'getServerSideProps',
20+
});
1421
};
1522
}
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { GSProps } from './types';
2-
import { wrapperCore } from './wrapperUtils';
1+
import { GetStaticProps } from 'next';
2+
3+
import { callDataFetcherTraced } from './wrapperUtils';
4+
5+
type Props = { [key: string]: unknown };
36

47
/**
58
* Create a wrapped version of the user's exported `getStaticProps` function
@@ -8,8 +11,11 @@ import { wrapperCore } from './wrapperUtils';
811
* @param route: The page's parameterized route
912
* @returns A wrapped version of the function
1013
*/
11-
export function withSentryGetStaticProps(origGetStaticProps: GSProps['fn'], route: string): GSProps['wrappedFn'] {
12-
return async function (context: GSProps['context']): Promise<GSProps['result']> {
13-
return wrapperCore<GSProps>({ origFunction: origGetStaticProps, context, route, op: 'getStaticProps' });
14+
export function withSentryGetStaticProps(
15+
origGetStaticProps: GetStaticProps<Props>,
16+
route: string,
17+
): GetStaticProps<Props> {
18+
return function (...getStaticPropsArguments: Parameters<GetStaticProps<Props>>): ReturnType<GetStaticProps<Props>> {
19+
return callDataFetcherTraced(origGetStaticProps, getStaticPropsArguments, { route, op: 'getStaticProps' });
1420
};
1521
}
Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,58 @@
11
import { getActiveTransaction } from '@sentry/tracing';
22

3-
import { DataFetchingFunction } from './types';
4-
53
/**
6-
* Create a span to track the wrapped function and update transaction name with parameterized route.
4+
* Call a data fetcher and trace it. Only traces the function if there is an active transaction on the scope.
75
*
8-
* @template T Types for `getInitialProps`, `getStaticProps`, and `getServerSideProps`
9-
* @param origFunction The user's exported `getInitialProps`, `getStaticProps`, or `getServerSideProps` function
10-
* @param context The context object passed by nextjs to the function
11-
* @returns The result of calling the user's function
6+
* We only do the following until we move transaction creation into this function: When called, the wrapped function
7+
* will also update the name of the active transaction with a parameterized route provided via the `options` argument.
128
*/
13-
export async function wrapperCore<T extends DataFetchingFunction>(options: {
14-
origFunction: T['fn'];
15-
context: T['context'];
16-
route: string;
17-
op: string;
18-
}): Promise<T['result']> {
19-
const { origFunction, context, route, op } = options;
9+
export function callDataFetcherTraced<F extends (...args: any[]) => Promise<any> | any>(
10+
origFunction: F,
11+
origFunctionArgs: Parameters<F>,
12+
options: {
13+
route: string;
14+
op: string;
15+
},
16+
): ReturnType<F> {
17+
const { route, op } = options;
2018

2119
const transaction = getActiveTransaction();
2220

23-
if (transaction) {
24-
// TODO: Make sure that the given route matches the name of the active transaction (to prevent background data
25-
// fetching from switching the name to a completely other route)
26-
transaction.name = route;
27-
transaction.metadata.source = 'route';
28-
29-
// Capture the route, since pre-loading, revalidation, etc might mean that this span may happen during another
30-
// route's transaction
31-
const span = transaction.startChild({ op, data: { route } });
32-
33-
// TODO: Can't figure out how to tell TS that the types are correlated - that a `GSPropsFunction` will only get passed
34-
// `GSPropsContext` and never, say, `GSSPContext`. That's what wrapping everything in objects and using the generic
35-
// and pulling the types from the generic rather than specifying them directly was supposed to do, but... no luck.
36-
// eslint-disable-next-line prefer-const, @typescript-eslint/no-explicit-any
37-
const props = await (origFunction as any)(context);
38-
39-
span.finish();
40-
41-
return props;
21+
if (!transaction) {
22+
return origFunction(...origFunctionArgs);
4223
}
4324

44-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45-
return (origFunction as any)(context);
25+
// TODO: Make sure that the given route matches the name of the active transaction (to prevent background data
26+
// fetching from switching the name to a completely other route) -- We'll probably switch to creating a transaction
27+
// right here so making that check will probabably not even be necessary.
28+
// Logic will be: If there is no active transaction, start one with correct name and source. If there is an active
29+
// transaction, create a child span with correct name and source.
30+
// We will probably need to put
31+
transaction.name = route;
32+
transaction.metadata.source = 'route';
33+
34+
// Capture the route, since pre-loading, revalidation, etc might mean that this span may happen during another
35+
// route's transaction
36+
const span = transaction.startChild({ op, data: { route } });
37+
38+
const result = origFunction(...origFunctionArgs);
39+
40+
// We do the following instead of `await`-ing the return value of `origFunction`, because that would require us to
41+
// make this function async which might in turn create a mismatch of function signatures between the original
42+
// function and the wrapped one.
43+
// This wraps `result`, which is potentially a Promise, into a Promise.
44+
// If `result` is a non-Promise, the callback of `then` is immediately called and the span is finished.
45+
// If `result` is a Promise, the callback of `then` is only called when `result` resolves
46+
void Promise.resolve(result).then(
47+
() => {
48+
span.finish();
49+
},
50+
err => {
51+
// TODO: Can we somehow associate the error with the span?
52+
span.finish();
53+
throw err;
54+
},
55+
);
56+
57+
return result;
4658
}

0 commit comments

Comments
 (0)