Skip to content

Commit 4d37cd1

Browse files
feat(nextjs): Automatic performance monitoring (#3586)
Automatically enable `BrowserTracing` if a traceSampler or tracesSample Rate is set. Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent 65aea8a commit 4d37cd1

File tree

4 files changed

+227
-9
lines changed

4 files changed

+227
-9
lines changed
Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,47 @@
11
import { configureScope, init as reactInit } from '@sentry/react';
2+
import { Integrations } from '@sentry/tracing';
23

4+
import { nextRouterInstrumentation } from './performance/client';
35
import { MetadataBuilder } from './utils/metadataBuilder';
46
import { NextjsOptions } from './utils/nextjsOptions';
7+
import { addIntegration, UserIntegrations } from './utils/userIntegrations';
58

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

12+
const { BrowserTracing } = Integrations;
13+
914
/** Inits the Sentry NextJS SDK on the browser with the React SDK. */
1015
export function init(options: NextjsOptions): void {
1116
const metadataBuilder = new MetadataBuilder(options, ['nextjs', 'react']);
1217
metadataBuilder.addSdkMetadata();
1318
options.environment = options.environment || process.env.NODE_ENV;
14-
reactInit(options);
19+
20+
// Only add BrowserTracing if a tracesSampleRate or tracesSampler is set
21+
const integrations =
22+
options.tracesSampleRate === undefined && options.tracesSampler === undefined
23+
? options.integrations
24+
: createClientIntegrations(options.integrations);
25+
26+
reactInit({
27+
...options,
28+
integrations,
29+
});
1530
configureScope(scope => {
1631
scope.setTag('runtime', 'browser');
1732
});
1833
}
34+
35+
const defaultBrowserTracingIntegration = new BrowserTracing({
36+
routingInstrumentation: nextRouterInstrumentation,
37+
});
38+
39+
function createClientIntegrations(integrations?: UserIntegrations): UserIntegrations {
40+
if (integrations) {
41+
return addIntegration(defaultBrowserTracingIntegration, integrations, {
42+
BrowserTracing: { keyPath: 'options.routingInstrumentation', value: nextRouterInstrumentation },
43+
});
44+
} else {
45+
return [defaultBrowserTracingIntegration];
46+
}
47+
}
Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
11
import { Integration } from '@sentry/types';
22

33
export type UserFunctionIntegrations = (integrations: Integration[]) => Integration[];
4-
type UserIntegrations = Integration[] | UserFunctionIntegrations;
4+
export type UserIntegrations = Integration[] | UserFunctionIntegrations;
5+
6+
type Options = {
7+
[integrationName: string]:
8+
| {
9+
keyPath: string;
10+
value: unknown;
11+
}
12+
| undefined;
13+
};
14+
15+
/**
16+
* Recursively traverses an object to update an existing nested key.
17+
* Note: The provided key path must include existing properties,
18+
* the function will not create objects while traversing.
19+
*
20+
* @param obj An object to update
21+
* @param value The value to update the nested key with
22+
* @param keyPath The path to the key to update ex. fizz.buzz.foo
23+
*/
24+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
25+
function setNestedKey(obj: Record<string, any>, keyPath: string, value: unknown): void {
26+
// Ex. foo.bar.zoop will extract foo and bar.zoop
27+
const match = keyPath.match(/([a-z]+)\.(.*)/i);
28+
if (match === null) {
29+
obj[keyPath] = value;
30+
} else {
31+
setNestedKey(obj[match[1]], match[2], value);
32+
}
33+
}
534

635
/**
736
* Retrieves the patched integrations with the provided integration.
@@ -12,18 +41,40 @@ type UserIntegrations = Integration[] | UserFunctionIntegrations;
1241
*
1342
* @param integration The integration to patch, if necessary.
1443
* @param userIntegrations Integrations defined by the user.
44+
* @param options options to update for a particular integration
1545
* @returns Final integrations, patched if necessary.
1646
*/
17-
export function addIntegration(integration: Integration, userIntegrations: UserIntegrations): UserIntegrations {
47+
export function addIntegration(
48+
integration: Integration,
49+
userIntegrations: UserIntegrations,
50+
options: Options = {},
51+
): UserIntegrations {
1852
if (Array.isArray(userIntegrations)) {
19-
return addIntegrationToArray(integration, userIntegrations);
53+
return addIntegrationToArray(integration, userIntegrations, options);
2054
} else {
21-
return addIntegrationToFunction(integration, userIntegrations);
55+
return addIntegrationToFunction(integration, userIntegrations, options);
2256
}
2357
}
2458

25-
function addIntegrationToArray(integration: Integration, userIntegrations: Integration[]): Integration[] {
26-
if (userIntegrations.map(int => int.name).includes(integration.name)) {
59+
function addIntegrationToArray(
60+
integration: Integration,
61+
userIntegrations: Integration[],
62+
options: Options,
63+
): Integration[] {
64+
let includesName = false;
65+
// eslint-disable-next-line @typescript-eslint/prefer-for-of
66+
for (let x = 0; x < userIntegrations.length; x++) {
67+
if (userIntegrations[x].name === integration.name) {
68+
includesName = true;
69+
}
70+
71+
const op = options[userIntegrations[x].name];
72+
if (op) {
73+
setNestedKey(userIntegrations[x], op.keyPath, op.value);
74+
}
75+
}
76+
77+
if (includesName) {
2778
return userIntegrations;
2879
}
2980
return [...userIntegrations, integration];
@@ -32,10 +83,11 @@ function addIntegrationToArray(integration: Integration, userIntegrations: Integ
3283
function addIntegrationToFunction(
3384
integration: Integration,
3485
userIntegrationsFunc: UserFunctionIntegrations,
86+
options: Options,
3587
): UserFunctionIntegrations {
3688
const wrapper: UserFunctionIntegrations = defaultIntegrations => {
3789
const userFinalIntegrations = userIntegrationsFunc(defaultIntegrations);
38-
return addIntegrationToArray(integration, userFinalIntegrations);
90+
return addIntegrationToArray(integration, userFinalIntegrations, options);
3991
};
4092
return wrapper;
4193
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Integrations as TracingIntegrations } from '@sentry/tracing';
2+
import { Integration } from '@sentry/types';
3+
4+
import { init, Integrations, nextRouterInstrumentation, Scope } from '../src/index.client';
5+
import { NextjsOptions } from '../src/utils/nextjsOptions';
6+
7+
const { BrowserTracing } = TracingIntegrations;
8+
9+
const mockInit = jest.fn();
10+
let configureScopeCallback: (scope: Scope) => void = () => undefined;
11+
12+
jest.mock('@sentry/react', () => {
13+
const actual = jest.requireActual('@sentry/react');
14+
return {
15+
...actual,
16+
init: (options: NextjsOptions) => {
17+
mockInit(options);
18+
},
19+
configureScope: (callback: (scope: Scope) => void) => {
20+
configureScopeCallback = callback;
21+
},
22+
};
23+
});
24+
25+
describe('Client init()', () => {
26+
afterEach(() => {
27+
mockInit.mockClear();
28+
configureScopeCallback = () => undefined;
29+
});
30+
31+
it('inits the React SDK', () => {
32+
expect(mockInit).toHaveBeenCalledTimes(0);
33+
init({});
34+
expect(mockInit).toHaveBeenCalledTimes(1);
35+
expect(mockInit).toHaveBeenLastCalledWith({
36+
_metadata: {
37+
sdk: {
38+
name: 'sentry.javascript.nextjs',
39+
version: expect.any(String),
40+
packages: expect.any(Array),
41+
},
42+
},
43+
environment: 'test',
44+
integrations: undefined,
45+
});
46+
});
47+
48+
it('sets runtime on scope', () => {
49+
const mockScope = new Scope();
50+
init({});
51+
configureScopeCallback(mockScope);
52+
// @ts-ignore need access to protected _tags attribute
53+
expect(mockScope._tags).toEqual({ runtime: 'browser' });
54+
});
55+
56+
describe('integrations', () => {
57+
it('does not add BrowserTracing integration by default if tracesSampleRate is not set', () => {
58+
init({});
59+
60+
const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
61+
expect(reactInitOptions.integrations).toBeUndefined();
62+
});
63+
64+
it('adds BrowserTracing integration by default if tracesSampleRate is set', () => {
65+
init({ tracesSampleRate: 1.0 });
66+
67+
const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
68+
expect(reactInitOptions.integrations).toHaveLength(1);
69+
70+
const integrations = reactInitOptions.integrations as Integration[];
71+
expect(integrations[0]).toEqual(expect.any(BrowserTracing));
72+
// eslint-disable-next-line @typescript-eslint/unbound-method
73+
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options.routingInstrumentation).toEqual(
74+
nextRouterInstrumentation,
75+
);
76+
});
77+
78+
it('adds BrowserTracing integration by default if tracesSampler is set', () => {
79+
init({ tracesSampler: () => true });
80+
81+
const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
82+
expect(reactInitOptions.integrations).toHaveLength(1);
83+
84+
const integrations = reactInitOptions.integrations as Integration[];
85+
expect(integrations[0]).toEqual(expect.any(BrowserTracing));
86+
// eslint-disable-next-line @typescript-eslint/unbound-method
87+
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options.routingInstrumentation).toEqual(
88+
nextRouterInstrumentation,
89+
);
90+
});
91+
92+
it('supports passing integration through options', () => {
93+
init({ tracesSampleRate: 1.0, integrations: [new Integrations.Breadcrumbs({ console: false })] });
94+
const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
95+
expect(reactInitOptions.integrations).toHaveLength(2);
96+
97+
const integrations = reactInitOptions.integrations as Integration[];
98+
expect(integrations).toEqual([expect.any(Integrations.Breadcrumbs), expect.any(BrowserTracing)]);
99+
});
100+
101+
it('uses custom BrowserTracing with array option with nextRouterInstrumentation', () => {
102+
init({
103+
tracesSampleRate: 1.0,
104+
integrations: [new BrowserTracing({ idleTimeout: 5000, startTransactionOnLocationChange: false })],
105+
});
106+
107+
const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
108+
expect(reactInitOptions.integrations).toHaveLength(1);
109+
const integrations = reactInitOptions.integrations as Integration[];
110+
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options).toEqual(
111+
expect.objectContaining({
112+
idleTimeout: 5000,
113+
startTransactionOnLocationChange: false,
114+
routingInstrumentation: nextRouterInstrumentation,
115+
}),
116+
);
117+
});
118+
119+
it('uses custom BrowserTracing with function option with nextRouterInstrumentation', () => {
120+
init({
121+
tracesSampleRate: 1.0,
122+
integrations: () => [new BrowserTracing({ idleTimeout: 5000, startTransactionOnLocationChange: false })],
123+
});
124+
125+
const reactInitOptions: NextjsOptions = mockInit.mock.calls[0][0];
126+
const integrationFunc = reactInitOptions.integrations as () => Integration[];
127+
const integrations = integrationFunc();
128+
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options).toEqual(
129+
expect.objectContaining({
130+
idleTimeout: 5000,
131+
startTransactionOnLocationChange: false,
132+
routingInstrumentation: nextRouterInstrumentation,
133+
}),
134+
);
135+
});
136+
});
137+
});

packages/nextjs/test/userIntegrations.test.ts renamed to packages/nextjs/test/utils/userIntegrations.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { RewriteFrames } from '@sentry/integrations';
22
import { Integration } from '@sentry/types';
33

4-
import { addIntegration, UserFunctionIntegrations } from '../src/utils/userIntegrations';
4+
import { addIntegration, UserFunctionIntegrations } from '../../src/utils/userIntegrations';
55

66
const testIntegration = new RewriteFrames();
77

0 commit comments

Comments
 (0)