Skip to content

Commit 58c067e

Browse files
authored
feat(nextjs): Trace navigation transactions (#5676)
1 parent 952464e commit 58c067e

File tree

5 files changed

+318
-244
lines changed

5 files changed

+318
-244
lines changed
+111-85
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
31
import { getCurrentHub } from '@sentry/hub';
4-
import { Primitive, TraceparentData, Transaction, TransactionContext } from '@sentry/types';
2+
import { Primitive, TraceparentData, Transaction, TransactionContext, TransactionSource } from '@sentry/types';
53
import {
64
extractTraceparentData,
7-
fill,
85
getGlobalObject,
96
logger,
107
parseBaggageHeader,
@@ -14,7 +11,13 @@ import type { NEXT_DATA as NextData } from 'next/dist/next-server/lib/utils';
1411
import { default as Router } from 'next/router';
1512
import type { ParsedUrlQuery } from 'querystring';
1613

17-
const global = getGlobalObject<Window>();
14+
const global = getGlobalObject<
15+
Window & {
16+
__BUILD_MANIFEST?: {
17+
sortedPages?: string[];
18+
};
19+
}
20+
>();
1821

1922
type StartTransactionCb = (context: TransactionContext) => Transaction | undefined;
2023

@@ -76,6 +79,8 @@ function extractNextDataTagInformation(): NextDataTagInfo {
7679
// `nextData.page` always contains the parameterized route - except for when an error occurs in a data fetching
7780
// function, then it is "/_error", but that isn't a problem since users know which route threw by looking at the
7881
// parent transaction
82+
// TODO: Actually this is a problem (even though it is not that big), because the DSC and the transaction payload will contain
83+
// a different transaction name. Maybe we can fix this. Idea: Also send transaction name via pageProps when available.
7984
nextDataTagInfo.route = page;
8085
nextDataTagInfo.params = query;
8186

@@ -96,20 +101,12 @@ const DEFAULT_TAGS = {
96101
'routing.instrumentation': 'next-router',
97102
} as const;
98103

104+
// We keep track of the active transaction so we can finish it when we start a navigation transaction.
99105
let activeTransaction: Transaction | undefined = undefined;
100-
let startTransaction: StartTransactionCb | undefined = undefined;
101106

102-
// We keep track of the previous page location so we can avoid creating transactions when navigating to the same page.
103-
// This variable should always contain a pathname. (without query string or fragment)
104-
// We are making a tradeoff by not starting transactions when just the query string changes. One could argue that we
105-
// should in fact start transactions when the query changes, however, in some cases (for example when typing in a search
106-
// box) the query might change multiple times a second, resulting in way too many transactions.
107-
// Because we currently don't have a real way of preventing transactions to be created in this case (except for the
108-
// shotgun approach `startTransactionOnLocationChange: false`), we won't start transactions when *just* the query changes.
109-
let previousLocation: string | undefined = undefined;
110-
111-
// We keep track of the previous transaction name so we can set the `from` field on navigation transactions.
112-
let prevTransactionName: string | undefined = undefined;
107+
// We keep track of the previous location name so we can set the `from` field on navigation transactions.
108+
// This is either a route or a pathname.
109+
let prevLocationName: string | undefined = undefined;
113110

114111
const client = getCurrentHub().getClient();
115112

@@ -126,18 +123,14 @@ export function nextRouterInstrumentation(
126123
startTransactionOnPageLoad: boolean = true,
127124
startTransactionOnLocationChange: boolean = true,
128125
): void {
129-
startTransaction = startTransactionCb;
126+
const { route, traceParentData, baggage, params } = extractNextDataTagInformation();
127+
prevLocationName = route || global.location.pathname;
130128

131129
if (startTransactionOnPageLoad) {
132-
const { route, traceParentData, baggage, params } = extractNextDataTagInformation();
133-
134-
prevTransactionName = route || global.location.pathname;
135-
previousLocation = global.location.pathname;
136-
137130
const source = route ? 'route' : 'url';
138131

139132
activeTransaction = startTransactionCb({
140-
name: prevTransactionName,
133+
name: prevLocationName,
141134
op: 'pageload',
142135
tags: DEFAULT_TAGS,
143136
...(params && client && client.getOptions().sendDefaultPii && { data: params }),
@@ -149,78 +142,111 @@ export function nextRouterInstrumentation(
149142
});
150143
}
151144

152-
Router.ready(() => {
153-
// Spans that aren't attached to any transaction are lost; so if transactions aren't
154-
// created (besides potentially the onpageload transaction), no need to wrap the router.
155-
if (!startTransactionOnLocationChange) return;
156-
157-
// `withRouter` uses `useRouter` underneath:
158-
// https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/with-router.tsx#L21
159-
// Router events also use the router:
160-
// https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/router.ts#L92
161-
// `Router.changeState` handles the router state changes, so it may be enough to only wrap it
162-
// (instead of wrapping all of the Router's functions).
163-
const routerPrototype = Object.getPrototypeOf(Router.router);
164-
fill(routerPrototype, 'changeState', changeStateWrapper);
165-
});
166-
}
145+
if (startTransactionOnLocationChange) {
146+
Router.events.on('routeChangeStart', (navigationTarget: string) => {
147+
const matchedRoute = getNextRouteFromPathname(stripUrlQueryAndFragment(navigationTarget));
167148

168-
type RouterChangeState = (
169-
method: string,
170-
url: string,
171-
as: string,
172-
options: Record<string, any>,
173-
...args: any[]
174-
) => void;
175-
type WrappedRouterChangeState = RouterChangeState;
149+
let transactionName: string;
150+
let transactionSource: TransactionSource;
176151

177-
/**
178-
* Wraps Router.changeState()
179-
* https://github.com/vercel/next.js/blob/da97a18dafc7799e63aa7985adc95f213c2bf5f3/packages/next/next-server/lib/router/router.ts#L1204
180-
* Start a navigation transaction every time the router changes state.
181-
*/
182-
function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): WrappedRouterChangeState {
183-
return function wrapper(
184-
this: any,
185-
method: string,
186-
// The parameterized url, ex. posts/[id]/[comment]
187-
url: string,
188-
// The actual url, ex. posts/85/my-comment
189-
as: string,
190-
options: Record<string, any>,
191-
// At the moment there are no additional arguments (meaning the rest parameter is empty).
192-
// This is meant to protect from future additions to Next.js API, especially since this is an
193-
// internal API.
194-
...args: any[]
195-
): Promise<boolean> {
196-
const newTransactionName = stripUrlQueryAndFragment(url);
197-
198-
// do not start a transaction if it's from the same page
199-
if (startTransaction !== undefined && previousLocation !== as) {
200-
previousLocation = as;
201-
202-
if (activeTransaction) {
203-
activeTransaction.finish();
152+
if (matchedRoute) {
153+
transactionName = matchedRoute;
154+
transactionSource = 'route';
155+
} else {
156+
transactionName = navigationTarget;
157+
transactionSource = 'url';
204158
}
205159

206160
const tags: Record<string, Primitive> = {
207161
...DEFAULT_TAGS,
208-
method,
209-
...options,
162+
from: prevLocationName,
210163
};
211164

212-
if (prevTransactionName) {
213-
tags.from = prevTransactionName;
165+
prevLocationName = transactionName;
166+
167+
if (activeTransaction) {
168+
activeTransaction.finish();
214169
}
215170

216-
prevTransactionName = newTransactionName;
217-
activeTransaction = startTransaction({
218-
name: prevTransactionName,
171+
const navigationTransaction = startTransactionCb({
172+
name: transactionName,
219173
op: 'navigation',
220174
tags,
221-
metadata: { source: 'route' },
175+
metadata: { source: transactionSource },
222176
});
223-
}
224-
return originalChangeStateWrapper.call(this, method, url, as, options, ...args);
225-
};
177+
178+
if (navigationTransaction) {
179+
// In addition to the navigation transaction we're also starting a span to mark Next.js's `routeChangeStart`
180+
// and `routeChangeComplete` events.
181+
// We don't want to finish the navigation transaction on `routeChangeComplete`, since users might want to attach
182+
// spans to that transaction even after `routeChangeComplete` is fired (eg. HTTP requests in some useEffect
183+
// hooks). Instead, we'll simply let the navigation transaction finish itself (it's an `IdleTransaction`).
184+
const nextRouteChangeSpan = navigationTransaction.startChild({
185+
op: 'ui.nextjs.route-change',
186+
description: 'Next.js Route Change',
187+
});
188+
189+
const finishRouteChangeSpan = (): void => {
190+
nextRouteChangeSpan.finish();
191+
Router.events.off('routeChangeComplete', finishRouteChangeSpan);
192+
};
193+
194+
Router.events.on('routeChangeComplete', finishRouteChangeSpan);
195+
}
196+
});
197+
}
198+
}
199+
200+
function getNextRouteFromPathname(pathname: string): string | undefined {
201+
const pageRoutes = (global.__BUILD_MANIFEST || {}).sortedPages;
202+
203+
// Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here
204+
if (!pageRoutes) {
205+
return;
206+
}
207+
208+
return pageRoutes.find(route => {
209+
const routeRegExp = convertNextRouteToRegExp(route);
210+
return pathname.match(routeRegExp);
211+
});
212+
}
213+
214+
/**
215+
* Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments).
216+
*
217+
* In general this involves replacing any instances of square brackets in a route with a wildcard:
218+
* e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/
219+
*
220+
* Some additional edgecases need to be considered:
221+
* - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or
222+
* "/users/[id]/info/" - both will be resolved to "/users/[id]/info".
223+
* - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]").
224+
* - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]").
225+
*
226+
* @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages`
227+
*/
228+
function convertNextRouteToRegExp(route: string): RegExp {
229+
// We can assume a route is at least "/".
230+
const routeParts = route.split('/');
231+
232+
let optionalCatchallWildcardRegex = '';
233+
if (routeParts[routeParts.length - 1].match(/^\[\[\.\.\..+\]\]$/)) {
234+
// If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing
235+
// slash that would come before it if we didn't pop it.
236+
routeParts.pop();
237+
optionalCatchallWildcardRegex = '(?:/(.+?))?';
238+
}
239+
240+
const rejoinedRouteParts = routeParts
241+
.map(
242+
routePart =>
243+
routePart
244+
.replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard
245+
.replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards
246+
)
247+
.join('/');
248+
249+
return new RegExp(
250+
`^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end
251+
);
226252
}

packages/nextjs/test/integration/pages/[id]/withInitialProps.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
const WithInitialPropsPage = ({ data }: { data: string }) => <h1>WithInitialPropsPage {data}</h1>;
1+
import Link from 'next/link';
2+
3+
const WithInitialPropsPage = ({ data }: { data: string }) => (
4+
<>
5+
<h1>WithInitialPropsPage {data}</h1>
6+
<Link href="/1337/withServerSideProps">
7+
<a id="server-side-props-page">Go to withServerSideProps</a>
8+
</Link>
9+
</>
10+
);
211

312
WithInitialPropsPage.getInitialProps = () => {
413
return { data: '[some getInitialProps data]' };

packages/nextjs/test/integration/pages/[id]/withServerSideProps.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
const WithServerSidePropsPage = ({ data }: { data: string }) => <h1>WithServerSidePropsPage {data}</h1>;
1+
import Link from 'next/link';
2+
3+
const WithServerSidePropsPage = ({ data }: { data: string }) => (
4+
<>
5+
<h1>WithServerSidePropsPage {data}</h1>
6+
<Link href="/3c2e87573d/withInitialProps">
7+
<a id="initial-props-page">Go to withInitialProps</a>
8+
</Link>
9+
</>
10+
);
211

312
export async function getServerSideProps() {
413
return { props: { data: '[some getServerSideProps data]' } };

packages/nextjs/test/integration/test/client/tracingNavigate.js

+8-8
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ const { sleep } = require('../utils/common');
22
const { expectRequestCount, isTransactionRequest, expectTransaction } = require('../utils/client');
33

44
module.exports = async ({ page, url, requests }) => {
5-
await page.goto(`${url}/healthy`);
5+
await page.goto(`${url}/42/withInitialProps/`);
66
await page.waitForRequest(isTransactionRequest);
77

88
expectTransaction(requests.transactions[0], {
9-
transaction: '/healthy',
9+
transaction: '/[id]/withInitialProps',
1010
type: 'transaction',
1111
contexts: {
1212
trace: {
@@ -17,35 +17,35 @@ module.exports = async ({ page, url, requests }) => {
1717

1818
await sleep(250);
1919

20-
await page.click('a#alsoHealthy');
20+
await page.click('a#server-side-props-page');
2121
await page.waitForRequest(isTransactionRequest);
2222

2323
expectTransaction(requests.transactions[1], {
24-
transaction: '/alsoHealthy',
24+
transaction: '/[id]/withServerSideProps',
2525
type: 'transaction',
2626
contexts: {
2727
trace: {
2828
op: 'navigation',
2929
tags: {
30-
from: '/healthy',
30+
from: '/[id]/withInitialProps',
3131
},
3232
},
3333
},
3434
});
3535

3636
await sleep(250);
3737

38-
await page.click('a#healthy');
38+
await page.click('a#initial-props-page');
3939
await page.waitForRequest(isTransactionRequest);
4040

4141
expectTransaction(requests.transactions[2], {
42-
transaction: '/healthy',
42+
transaction: '/[id]/withInitialProps',
4343
type: 'transaction',
4444
contexts: {
4545
trace: {
4646
op: 'navigation',
4747
tags: {
48-
from: '/alsoHealthy',
48+
from: '/[id]/withServerSideProps',
4949
},
5050
},
5151
},

0 commit comments

Comments
 (0)