Skip to content

Commit

Permalink
feat(nextjs): Frontend + withSentry Performance Monitoring (#3580)
Browse files Browse the repository at this point in the history
Co-authored-by: Abhijeet Prasad <[email protected]>
Co-authored-by: iker barriocanal <[email protected]>
Co-authored-by: Daniel Griesser <[email protected]>
  • Loading branch information
4 people authored May 31, 2021
1 parent 752856c commit f546d0f
Show file tree
Hide file tree
Showing 29 changed files with 992 additions and 95 deletions.
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@sentry/integrations": "6.4.1",
"@sentry/node": "6.4.1",
"@sentry/react": "6.4.1",
"@sentry/tracing": "6.4.1",
"@sentry/utils": "6.4.1",
"@sentry/webpack-plugin": "1.15.0",
"tslib": "^1.9.3"
Expand Down
33 changes: 32 additions & 1 deletion packages/nextjs/src/index.client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,48 @@
import { configureScope, init as reactInit } from '@sentry/react';
import { defaultRequestInstrumentationOptions, Integrations } from '@sentry/tracing';

import { nextRouterInstrumentation } from './performance/client';
import { MetadataBuilder } from './utils/metadataBuilder';
import { NextjsOptions } from './utils/nextjsOptions';
import { addIntegration, UserIntegrations } from './utils/userIntegrations';

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

const { BrowserTracing } = Integrations;

/** Inits the Sentry NextJS SDK on the browser with the React SDK. */
export function init(options: NextjsOptions): void {
const metadataBuilder = new MetadataBuilder(options, ['nextjs', 'react']);
metadataBuilder.addSdkMetadata();
options.environment = options.environment || process.env.NODE_ENV;
reactInit(options);

// Only add BrowserTracing if a tracesSampleRate or tracesSampler is set
const integrations =
options.tracesSampleRate === undefined && options.tracesSampler === undefined
? options.integrations
: createClientIntegrations(options.integrations);

reactInit({
...options,
integrations,
});
configureScope(scope => {
scope.setTag('runtime', 'browser');
});
}

const defaultBrowserTracingIntegration = new BrowserTracing({
tracingOrigins: [...defaultRequestInstrumentationOptions.tracingOrigins, /^(api\/)/],
routingInstrumentation: nextRouterInstrumentation,
});

function createClientIntegrations(integrations?: UserIntegrations): UserIntegrations {
if (integrations) {
return addIntegration(defaultBrowserTracingIntegration, integrations, {
BrowserTracing: { keyPath: 'options.routingInstrumentation', value: nextRouterInstrumentation },
});
} else {
return [defaultBrowserTracingIntegration];
}
}
3 changes: 2 additions & 1 deletion packages/nextjs/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function init(options: NextjsOptions): void {
const metadataBuilder = new MetadataBuilder(options, ['nextjs', 'node']);
metadataBuilder.addSdkMetadata();
options.environment = options.environment || process.env.NODE_ENV;
// TODO capture project root and store in an env var for RewriteFrames?
addServerIntegrations(options);
// Right now we only capture frontend sessions for Next.js
options.autoSessionTracking = false;
Expand Down Expand Up @@ -47,5 +48,5 @@ function addServerIntegrations(options: NextjsOptions): void {
export { withSentryConfig } from './utils/config';
export { withSentry } from './utils/handlers';

// TODO capture project root (which this returns) for RewriteFrames?
// wrap various server methods to enable error monitoring and tracing
instrumentServer();
109 changes: 109 additions & 0 deletions packages/nextjs/src/performance/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Primitive, Transaction, TransactionContext } from '@sentry/types';
import { fill, getGlobalObject, stripUrlQueryAndFragment } 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',
});

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 ? stripUrlQueryAndFragment(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 = stripUrlQueryAndFragment(url);
activeTransaction = startTransaction({
name: prevTransactionName,
op: 'navigation',
tags,
});
}
return originalChangeStateWrapper.call(this, method, url, as, options, ...args);
};
return wrapper;
}
Loading

0 comments on commit f546d0f

Please sign in to comment.