Skip to content

Commit 5d4dcab

Browse files
feat(nextjs): Client performance monitoring (#3552)
Adds front-end performance monitoring in the Next.js SDK, by providing a router instrumentation.
1 parent 0e46ae4 commit 5d4dcab

File tree

3 files changed

+233
-0
lines changed

3 files changed

+233
-0
lines changed

packages/nextjs/src/index.client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MetadataBuilder } from './utils/metadataBuilder';
44
import { NextjsOptions } from './utils/nextjsOptions';
55

66
export * from '@sentry/react';
7+
export { nextRouterInstrumentation } from './performance/client';
78

89
/** Inits the Sentry NextJS SDK on the browser with the React SDK. */
910
export function init(options: NextjsOptions): void {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { Primitive, Transaction, TransactionContext } from '@sentry/types';
2+
import { fill, getGlobalObject } from '@sentry/utils';
3+
import { default as Router } from 'next/router';
4+
5+
const global = getGlobalObject<Window>();
6+
7+
type StartTransactionCb = (context: TransactionContext) => Transaction | undefined;
8+
9+
const DEFAULT_TAGS = Object.freeze({
10+
'routing.instrumentation': 'next-router',
11+
});
12+
13+
const QUERY_PARAM_REGEX = /\?(.*)/;
14+
15+
let activeTransaction: Transaction | undefined = undefined;
16+
let prevTransactionName: string | undefined = undefined;
17+
let startTransaction: StartTransactionCb | undefined = undefined;
18+
19+
/**
20+
* Creates routing instrumention for Next Router. Only supported for
21+
* client side routing. Works for Next >= 10.
22+
*
23+
* Leverages the SingletonRouter from the `next/router` to
24+
* generate pageload/navigation transactions and parameterize
25+
* transaction names.
26+
*/
27+
export function nextRouterInstrumentation(
28+
startTransactionCb: StartTransactionCb,
29+
startTransactionOnPageLoad: boolean = true,
30+
startTransactionOnLocationChange: boolean = true,
31+
): void {
32+
startTransaction = startTransactionCb;
33+
Router.ready(() => {
34+
// We can only start the pageload transaction when we have access to the parameterized
35+
// route name. Setting the transaction name after the transaction is started could lead
36+
// to possible race conditions with the router, so this approach was taken.
37+
if (startTransactionOnPageLoad) {
38+
prevTransactionName = Router.route !== null ? removeQueryParams(Router.route) : global.location.pathname;
39+
activeTransaction = startTransactionCb({
40+
name: prevTransactionName,
41+
op: 'pageload',
42+
tags: DEFAULT_TAGS,
43+
});
44+
}
45+
46+
// Spans that aren't attached to any transaction are lost; so if transactions aren't
47+
// created (besides potentially the onpageload transaction), no need to wrap the router.
48+
if (!startTransactionOnLocationChange) return;
49+
50+
// `withRouter` uses `useRouter` underneath:
51+
// https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/with-router.tsx#L21
52+
// Router events also use the router:
53+
// https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/router.ts#L92
54+
// `Router.changeState` handles the router state changes, so it may be enough to only wrap it
55+
// (instead of wrapping all of the Router's functions).
56+
const routerPrototype = Object.getPrototypeOf(Router.router);
57+
fill(routerPrototype, 'changeState', changeStateWrapper);
58+
});
59+
}
60+
61+
type RouterChangeState = (
62+
method: string,
63+
url: string,
64+
as: string,
65+
options: Record<string, any>,
66+
...args: any[]
67+
) => void;
68+
type WrappedRouterChangeState = RouterChangeState;
69+
70+
/**
71+
* Wraps Router.changeState()
72+
* https://github.com/vercel/next.js/blob/da97a18dafc7799e63aa7985adc95f213c2bf5f3/packages/next/next-server/lib/router/router.ts#L1204
73+
* Start a navigation transaction every time the router changes state.
74+
*/
75+
function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): WrappedRouterChangeState {
76+
const wrapper = function(
77+
this: any,
78+
method: string,
79+
// The parameterized url, ex. posts/[id]/[comment]
80+
url: string,
81+
// The actual url, ex. posts/85/my-comment
82+
as: string,
83+
options: Record<string, any>,
84+
// At the moment there are no additional arguments (meaning the rest parameter is empty).
85+
// This is meant to protect from future additions to Next.js API, especially since this is an
86+
// internal API.
87+
...args: any[]
88+
): Promise<boolean> {
89+
if (startTransaction !== undefined) {
90+
if (activeTransaction) {
91+
activeTransaction.finish();
92+
}
93+
const tags: Record<string, Primitive> = {
94+
...DEFAULT_TAGS,
95+
method,
96+
...options,
97+
};
98+
if (prevTransactionName) {
99+
tags.from = prevTransactionName;
100+
}
101+
prevTransactionName = removeQueryParams(url);
102+
activeTransaction = startTransaction({
103+
name: prevTransactionName,
104+
op: 'navigation',
105+
tags,
106+
});
107+
}
108+
return originalChangeStateWrapper.call(this, method, url, as, options, ...args);
109+
};
110+
return wrapper;
111+
}
112+
113+
export function removeQueryParams(route: string): string {
114+
return route.replace(QUERY_PARAM_REGEX, '');
115+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { default as Router } from 'next/router';
2+
3+
import { nextRouterInstrumentation, removeQueryParams } from '../../src/performance/client';
4+
5+
let readyCalled = false;
6+
jest.mock('next/router', () => {
7+
const router = {};
8+
Object.setPrototypeOf(router, { changeState: () => undefined });
9+
return {
10+
default: {
11+
router,
12+
route: '/[user]/posts/[id]',
13+
readyCallbacks: [],
14+
ready(cb: () => void) {
15+
readyCalled = true;
16+
return cb();
17+
},
18+
},
19+
};
20+
});
21+
22+
type Table<I = string, O = string> = Array<{ in: I; out: O }>;
23+
24+
describe('client', () => {
25+
describe('nextRouterInstrumentation', () => {
26+
it('waits for Router.ready()', () => {
27+
const mockStartTransaction = jest.fn();
28+
expect(readyCalled).toBe(false);
29+
nextRouterInstrumentation(mockStartTransaction);
30+
expect(readyCalled).toBe(true);
31+
});
32+
33+
it('creates a pageload transaction', () => {
34+
const mockStartTransaction = jest.fn();
35+
nextRouterInstrumentation(mockStartTransaction);
36+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
37+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
38+
name: '/[user]/posts/[id]',
39+
op: 'pageload',
40+
tags: {
41+
'routing.instrumentation': 'next-router',
42+
},
43+
});
44+
});
45+
46+
it('does not create a pageload transaction if option not given', () => {
47+
const mockStartTransaction = jest.fn();
48+
nextRouterInstrumentation(mockStartTransaction, false);
49+
expect(mockStartTransaction).toHaveBeenCalledTimes(0);
50+
});
51+
52+
it('creates navigation transactions', () => {
53+
const mockStartTransaction = jest.fn();
54+
nextRouterInstrumentation(mockStartTransaction, false);
55+
expect(mockStartTransaction).toHaveBeenCalledTimes(0);
56+
57+
const table: Table<Array<string | unknown>, Record<string, unknown>> = [
58+
{
59+
in: ['pushState', '/posts/[id]', '/posts/32', {}],
60+
out: {
61+
name: '/posts/[id]',
62+
op: 'navigation',
63+
tags: {
64+
from: '/posts/[id]',
65+
method: 'pushState',
66+
'routing.instrumentation': 'next-router',
67+
},
68+
},
69+
},
70+
{
71+
in: ['replaceState', '/posts/[id]?name=cat', '/posts/32?name=cat', {}],
72+
out: {
73+
name: '/posts/[id]',
74+
op: 'navigation',
75+
tags: {
76+
from: '/posts/[id]',
77+
method: 'replaceState',
78+
'routing.instrumentation': 'next-router',
79+
},
80+
},
81+
},
82+
{
83+
in: ['pushState', '/about', '/about', {}],
84+
out: {
85+
name: '/about',
86+
op: 'navigation',
87+
tags: {
88+
from: '/about',
89+
method: 'pushState',
90+
'routing.instrumentation': 'next-router',
91+
},
92+
},
93+
},
94+
];
95+
96+
table.forEach(test => {
97+
// @ts-ignore changeState can be called with array spread
98+
Router.router?.changeState(...test.in);
99+
expect(mockStartTransaction).toHaveBeenLastCalledWith(test.out);
100+
});
101+
});
102+
});
103+
104+
describe('removeQueryParams()', () => {
105+
it('removes query params from an url', () => {
106+
const table: Table = [
107+
{ in: '/posts/[id]/[comment]?name=ferret&color=purple', out: '/posts/[id]/[comment]' },
108+
{ in: '/posts/[id]/[comment]?', out: '/posts/[id]/[comment]' },
109+
{ in: '/about?', out: '/about' },
110+
];
111+
112+
table.forEach(test => {
113+
expect(removeQueryParams(test.in)).toEqual(test.out);
114+
});
115+
});
116+
});
117+
});

0 commit comments

Comments
 (0)