Skip to content
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
1 change: 1 addition & 0 deletions packages/nextjs/src/index.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MetadataBuilder } from './utils/metadataBuilder';
import { NextjsOptions } from './utils/nextjsOptions';

export * from '@sentry/react';
export { nextRouterInstrumentation } from './performance/client';

/** Inits the Sentry NextJS SDK on the browser with the React SDK. */
export function init(options: NextjsOptions): void {
Expand Down
115 changes: 115 additions & 0 deletions packages/nextjs/src/performance/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Primitive, Transaction, TransactionContext } from '@sentry/types';
import { fill, getGlobalObject } from '@sentry/utils';
import { default as Router } from 'next/router';

const global = getGlobalObject<Window>();

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

const DEFAULT_TAGS = Object.freeze({
'routing.instrumentation': 'next-router',
});

const QUERY_PARAM_REGEX = /\?(.*)/;

let activeTransaction: Transaction | undefined = undefined;
let prevTransactionName: string | undefined = undefined;
let startTransaction: StartTransactionCb | undefined = undefined;

/**
* Creates routing instrumention for Next Router. Only supported for
* client side routing. Works for Next >= 10.
*
* Leverages the SingletonRouter from the `next/router` to
* generate pageload/navigation transactions and parameterize
* transaction names.
*/
export function nextRouterInstrumentation(
startTransactionCb: StartTransactionCb,
startTransactionOnPageLoad: boolean = true,
startTransactionOnLocationChange: boolean = true,
): void {
startTransaction = startTransactionCb;
Router.ready(() => {
// We can only start the pageload transaction when we have access to the parameterized
// route name. Setting the transaction name after the transaction is started could lead
// to possible race conditions with the router, so this approach was taken.
if (startTransactionOnPageLoad) {
prevTransactionName = Router.route !== null ? removeQueryParams(Router.route) : global.location.pathname;
activeTransaction = startTransactionCb({
name: prevTransactionName,
op: 'pageload',
tags: DEFAULT_TAGS,
});
}

// Spans that aren't attached to any transaction are lost; so if transactions aren't
// created (besides potentially the onpageload transaction), no need to wrap the router.
if (!startTransactionOnLocationChange) return;

// `withRouter` uses `useRouter` underneath:
// https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/with-router.tsx#L21
// Router events also use the router:
// https://github.com/vercel/next.js/blob/de42719619ae69fbd88e445100f15701f6e1e100/packages/next/client/router.ts#L92
// `Router.changeState` handles the router state changes, so it may be enough to only wrap it
// (instead of wrapping all of the Router's functions).
const routerPrototype = Object.getPrototypeOf(Router.router);
fill(routerPrototype, 'changeState', changeStateWrapper);
});
}

type RouterChangeState = (
method: string,
url: string,
as: string,
options: Record<string, any>,
...args: any[]
) => void;
type WrappedRouterChangeState = RouterChangeState;

/**
* Wraps Router.changeState()
* https://github.com/vercel/next.js/blob/da97a18dafc7799e63aa7985adc95f213c2bf5f3/packages/next/next-server/lib/router/router.ts#L1204
* Start a navigation transaction every time the router changes state.
*/
function changeStateWrapper(originalChangeStateWrapper: RouterChangeState): WrappedRouterChangeState {
const wrapper = function(
this: any,
method: string,
// The parameterized url, ex. posts/[id]/[comment]
url: string,
// The actual url, ex. posts/85/my-comment
as: string,
options: Record<string, any>,
// At the moment there are no additional arguments (meaning the rest parameter is empty).
// This is meant to protect from future additions to Next.js API, especially since this is an
// internal API.
...args: any[]
): Promise<boolean> {
if (startTransaction !== undefined) {
if (activeTransaction) {
activeTransaction.finish();
}
const tags: Record<string, Primitive> = {
...DEFAULT_TAGS,
method,
...options,
};
if (prevTransactionName) {
tags.from = prevTransactionName;
}
prevTransactionName = removeQueryParams(url);
activeTransaction = startTransaction({
name: prevTransactionName,
op: 'navigation',
tags,
});
}
return originalChangeStateWrapper.call(this, method, url, as, options, ...args);
};
return wrapper;
}

export function removeQueryParams(route: string): string {
return route.replace(QUERY_PARAM_REGEX, '');
}
117 changes: 117 additions & 0 deletions packages/nextjs/test/performance/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { default as Router } from 'next/router';

import { nextRouterInstrumentation, removeQueryParams } from '../../src/performance/client';

let readyCalled = false;
jest.mock('next/router', () => {
const router = {};
Object.setPrototypeOf(router, { changeState: () => undefined });
return {
default: {
router,
route: '/[user]/posts/[id]',
readyCallbacks: [],
ready(cb: () => void) {
readyCalled = true;
return cb();
},
},
};
});

type Table<I = string, O = string> = Array<{ in: I; out: O }>;

describe('client', () => {
describe('nextRouterInstrumentation', () => {
it('waits for Router.ready()', () => {
const mockStartTransaction = jest.fn();
expect(readyCalled).toBe(false);
nextRouterInstrumentation(mockStartTransaction);
expect(readyCalled).toBe(true);
});

it('creates a pageload transaction', () => {
const mockStartTransaction = jest.fn();
nextRouterInstrumentation(mockStartTransaction);
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
expect(mockStartTransaction).toHaveBeenLastCalledWith({
name: '/[user]/posts/[id]',
op: 'pageload',
tags: {
'routing.instrumentation': 'next-router',
},
});
});

it('does not create a pageload transaction if option not given', () => {
const mockStartTransaction = jest.fn();
nextRouterInstrumentation(mockStartTransaction, false);
expect(mockStartTransaction).toHaveBeenCalledTimes(0);
});

it('creates navigation transactions', () => {
const mockStartTransaction = jest.fn();
nextRouterInstrumentation(mockStartTransaction, false);
expect(mockStartTransaction).toHaveBeenCalledTimes(0);

const table: Table<Array<string | unknown>, Record<string, unknown>> = [
{
in: ['pushState', '/posts/[id]', '/posts/32', {}],
out: {
name: '/posts/[id]',
op: 'navigation',
tags: {
from: '/posts/[id]',
method: 'pushState',
'routing.instrumentation': 'next-router',
},
},
},
{
in: ['replaceState', '/posts/[id]?name=cat', '/posts/32?name=cat', {}],
out: {
name: '/posts/[id]',
op: 'navigation',
tags: {
from: '/posts/[id]',
method: 'replaceState',
'routing.instrumentation': 'next-router',
},
},
},
{
in: ['pushState', '/about', '/about', {}],
out: {
name: '/about',
op: 'navigation',
tags: {
from: '/about',
method: 'pushState',
'routing.instrumentation': 'next-router',
},
},
},
];

table.forEach(test => {
// @ts-ignore changeState can be called with array spread
Router.router?.changeState(...test.in);
expect(mockStartTransaction).toHaveBeenLastCalledWith(test.out);
});
});
});

describe('removeQueryParams()', () => {
it('removes query params from an url', () => {
const table: Table = [
{ in: '/posts/[id]/[comment]?name=ferret&color=purple', out: '/posts/[id]/[comment]' },
{ in: '/posts/[id]/[comment]?', out: '/posts/[id]/[comment]' },
{ in: '/about?', out: '/about' },
];

table.forEach(test => {
expect(removeQueryParams(test.in)).toEqual(test.out);
});
});
});
});