Skip to content

Commit 50c84c6

Browse files
authored
ref(nextjs): Simplify adding default integrations when initializing SDK (#5702)
While working on adding a `RequestData` integration to the nextjs SDK, I found that the functions we use to set default integrations in the SDK were a little hard to parse, primarily because of a docstring that didn't make any sense and vague and/or slightly inaccurate function and variable names. We also have been making things more complicated than necessary, in that a) The existing functions in `utils/userIntegrations.ts` use the given default integration instance if no user instance is found. Meanwhile, the two index files do the same in the case that no user integrations are provided at all. But those two conditions aren't logically independent, so by simply providing a default (empty) value for the second case, we can rely on the first check to ensure the default instance is used. (See the now-removed `if (options.integrations)` check in both of the index files.) b) The options we want to force onto any user instance we find have been living in a nested object which really doesn’t need to be nested. (See the `Options` - now `ForcedIntegrationOptions` - type in `utils/userIntegrations.ts`.) c) We've essentially been manually implementing `Array.find`, which there's no reason for us to do. (See the `for`-loop in `addIntegrationToArray` - now `addOrUpdateIntegrationInArray` - in `utils/userIntegrations.ts`.) This fixes those problems, rewrites the docstring, and renames a bunch of variables. It also - Reorganizes the tests testing the adding of default integrations and refactors them to stop depending on the order or size of the eventual `integrations` array when testing an individual integration type. - Fixes a small regex bug in `setNestedKey`.
1 parent 249e64d commit 50c84c6

File tree

6 files changed

+222
-195
lines changed

6 files changed

+222
-195
lines changed

packages/nextjs/src/index.client.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { EventProcessor } from '@sentry/types';
55
import { nextRouterInstrumentation } from './performance/client';
66
import { buildMetadata } from './utils/metadata';
77
import { NextjsOptions } from './utils/nextjsOptions';
8-
import { addIntegration, UserIntegrations } from './utils/userIntegrations';
8+
import { addOrUpdateIntegration, UserIntegrations } from './utils/userIntegrations';
99

1010
export * from '@sentry/react';
1111
export { nextRouterInstrumentation } from './performance/client';
@@ -57,17 +57,13 @@ export function init(options: NextjsOptions): void {
5757
});
5858
}
5959

60-
function createClientIntegrations(integrations?: UserIntegrations): UserIntegrations {
60+
function createClientIntegrations(userIntegrations: UserIntegrations = []): UserIntegrations {
6161
const defaultBrowserTracingIntegration = new BrowserTracing({
6262
tracingOrigins: [...defaultRequestInstrumentationOptions.tracingOrigins, /^(api\/)/],
6363
routingInstrumentation: nextRouterInstrumentation,
6464
});
6565

66-
if (integrations) {
67-
return addIntegration(defaultBrowserTracingIntegration, integrations, {
68-
BrowserTracing: { keyPath: 'options.routingInstrumentation', value: nextRouterInstrumentation },
69-
});
70-
} else {
71-
return [defaultBrowserTracingIntegration];
72-
}
66+
return addOrUpdateIntegration(defaultBrowserTracingIntegration, userIntegrations, {
67+
'options.routingInstrumentation': nextRouterInstrumentation,
68+
});
7369
}

packages/nextjs/src/index.server.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as path from 'path';
1010
import { isBuild } from './utils/isBuild';
1111
import { buildMetadata } from './utils/metadata';
1212
import { NextjsOptions } from './utils/nextjsOptions';
13-
import { addIntegration } from './utils/userIntegrations';
13+
import { addOrUpdateIntegration } from './utils/userIntegrations';
1414

1515
export * from '@sentry/node';
1616
export { captureUnderscoreErrorException } from './utils/_error';
@@ -93,6 +93,8 @@ function sdkAlreadyInitialized(): boolean {
9393
}
9494

9595
function addServerIntegrations(options: NextjsOptions): void {
96+
let integrations = options.integrations || [];
97+
9698
// This value is injected at build time, based on the output directory specified in the build config. Though a default
9799
// is set there, we set it here as well, just in case something has gone wrong with the injection.
98100
const distDirName = (global as GlobalWithDistDir).__rewriteFramesDistDir__ || '.next';
@@ -107,19 +109,16 @@ function addServerIntegrations(options: NextjsOptions): void {
107109
return frame;
108110
},
109111
});
110-
111-
if (options.integrations) {
112-
options.integrations = addIntegration(defaultRewriteFramesIntegration, options.integrations);
113-
} else {
114-
options.integrations = [defaultRewriteFramesIntegration];
115-
}
112+
integrations = addOrUpdateIntegration(defaultRewriteFramesIntegration, integrations);
116113

117114
if (hasTracingEnabled(options)) {
118115
const defaultHttpTracingIntegration = new Integrations.Http({ tracing: true });
119-
options.integrations = addIntegration(defaultHttpTracingIntegration, options.integrations, {
120-
Http: { keyPath: '_tracing', value: true },
116+
integrations = addOrUpdateIntegration(defaultHttpTracingIntegration, integrations, {
117+
_tracing: true,
121118
});
122119
}
120+
121+
options.integrations = integrations;
123122
}
124123

125124
export type { SentryWebpackPluginOptions } from './config/types';
Lines changed: 39 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import { Integration } from '@sentry/types';
22

3-
export type UserFunctionIntegrations = (integrations: Integration[]) => Integration[];
4-
export type UserIntegrations = Integration[] | UserFunctionIntegrations;
3+
export type UserIntegrationsFunction = (integrations: Integration[]) => Integration[];
4+
export type UserIntegrations = Integration[] | UserIntegrationsFunction;
55

6-
type Options = {
7-
[integrationName: string]:
8-
| {
9-
keyPath: string;
10-
value: unknown;
11-
}
12-
| undefined;
6+
type ForcedIntegrationOptions = {
7+
[keyPath: string]: unknown;
138
};
149

1510
/**
@@ -24,70 +19,66 @@ type Options = {
2419
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2520
function setNestedKey(obj: Record<string, any>, keyPath: string, value: unknown): void {
2621
// Ex. foo.bar.zoop will extract foo and bar.zoop
27-
const match = keyPath.match(/([a-z]+)\.(.*)/i);
22+
const match = keyPath.match(/([a-z_]+)\.(.*)/i);
23+
// The match will be null when there's no more recursing to do, i.e., when we've reached the right level of the object
2824
if (match === null) {
2925
obj[keyPath] = value;
3026
} else {
31-
setNestedKey(obj[match[1]], match[2], value);
27+
// `match[1]` is the initial segment of the path, and `match[2]` is the remainder of the path
28+
const innerObj = obj[match[1]];
29+
setNestedKey(innerObj, match[2], value);
3230
}
3331
}
3432

3533
/**
36-
* Retrieves the patched integrations with the provided integration.
34+
* Enforces inclusion of a given integration with specified options in an integration array originally determined by the
35+
* user, by either including the given default instance or by patching an existing user instance with the given options.
3736
*
38-
* The integration must be present in the final user integrations, and they are compared
39-
* by integration name. If the user has defined one, there's nothing to patch; if not,
40-
* the provided integration is added.
37+
* Ideally this would happen when integrations are set up, but there isn't currently a mechanism there for merging
38+
* options from a default integration instance with those from a user-provided instance of the same integration, only
39+
* for allowing the user to override a default instance entirely. (TODO: Fix that.)
4140
*
42-
* @param integration The integration to patch, if necessary.
41+
* @param defaultIntegrationInstance An instance of the integration with the correct options already set
4342
* @param userIntegrations Integrations defined by the user.
44-
* @param options options to update for a particular integration
45-
* @returns Final integrations, patched if necessary.
43+
* @param forcedOptions Options with which to patch an existing user-derived instance on the integration.
44+
* @returns A final integrations array.
4645
*/
47-
export function addIntegration(
48-
integration: Integration,
46+
export function addOrUpdateIntegration(
47+
defaultIntegrationInstance: Integration,
4948
userIntegrations: UserIntegrations,
50-
options: Options = {},
49+
forcedOptions: ForcedIntegrationOptions = {},
5150
): UserIntegrations {
52-
if (Array.isArray(userIntegrations)) {
53-
return addIntegrationToArray(integration, userIntegrations, options);
54-
} else {
55-
return addIntegrationToFunction(integration, userIntegrations, options);
56-
}
51+
return Array.isArray(userIntegrations)
52+
? addOrUpdateIntegrationInArray(defaultIntegrationInstance, userIntegrations, forcedOptions)
53+
: addOrUpdateIntegrationInFunction(defaultIntegrationInstance, userIntegrations, forcedOptions);
5754
}
5855

59-
function addIntegrationToArray(
60-
integration: Integration,
56+
function addOrUpdateIntegrationInArray(
57+
defaultIntegrationInstance: Integration,
6158
userIntegrations: Integration[],
62-
options: Options,
59+
forcedOptions: ForcedIntegrationOptions,
6360
): 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-
}
61+
const userInstance = userIntegrations.find(integration => integration.name === defaultIntegrationInstance.name);
7062

71-
const op = options[userIntegrations[x].name];
72-
if (op) {
73-
setNestedKey(userIntegrations[x], op.keyPath, op.value);
63+
if (userInstance) {
64+
for (const [keyPath, value] of Object.entries(forcedOptions)) {
65+
setNestedKey(userInstance, keyPath, value);
7466
}
75-
}
7667

77-
if (includesName) {
7868
return userIntegrations;
7969
}
80-
return [...userIntegrations, integration];
70+
71+
return [...userIntegrations, defaultIntegrationInstance];
8172
}
8273

83-
function addIntegrationToFunction(
84-
integration: Integration,
85-
userIntegrationsFunc: UserFunctionIntegrations,
86-
options: Options,
87-
): UserFunctionIntegrations {
88-
const wrapper: UserFunctionIntegrations = defaultIntegrations => {
74+
function addOrUpdateIntegrationInFunction(
75+
defaultIntegrationInstance: Integration,
76+
userIntegrationsFunc: UserIntegrationsFunction,
77+
forcedOptions: ForcedIntegrationOptions,
78+
): UserIntegrationsFunction {
79+
const wrapper: UserIntegrationsFunction = defaultIntegrations => {
8980
const userFinalIntegrations = userIntegrationsFunc(defaultIntegrations);
90-
return addIntegrationToArray(integration, userFinalIntegrations, options);
81+
return addOrUpdateIntegrationInArray(defaultIntegrationInstance, userFinalIntegrations, forcedOptions);
9182
};
9283
return wrapper;
9384
}

packages/nextjs/test/index.client.test.ts

Lines changed: 86 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getGlobalObject, logger } from '@sentry/utils';
77
import { JSDOM } from 'jsdom';
88

99
import { init, Integrations, nextRouterInstrumentation } from '../src/index.client';
10-
import { NextjsOptions } from '../src/utils/nextjsOptions';
10+
import { UserIntegrationsFunction } from '../src/utils/userIntegrations';
1111

1212
const { BrowserTracing } = TracingIntegrations;
1313

@@ -32,6 +32,10 @@ afterAll(() => {
3232
Object.defineProperty(global, 'location', { value: originalGlobalLocation });
3333
});
3434

35+
function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined {
36+
return integrations.find(integration => integration.name === name);
37+
}
38+
3539
describe('Client init()', () => {
3640
afterEach(() => {
3741
jest.clearAllMocks();
@@ -95,84 +99,101 @@ describe('Client init()', () => {
9599
});
96100

97101
describe('integrations', () => {
98-
it('does not add BrowserTracing integration by default if tracesSampleRate is not set', () => {
99-
init({});
100-
101-
const reactInitOptions: NextjsOptions = reactInit.mock.calls[0][0];
102-
expect(reactInitOptions.integrations).toBeUndefined();
103-
});
102+
// Options passed by `@sentry/nextjs`'s `init` to `@sentry/react`'s `init` after modifying them
103+
type ModifiedInitOptionsIntegrationArray = { integrations: Integration[] };
104+
type ModifiedInitOptionsIntegrationFunction = { integrations: UserIntegrationsFunction };
104105

105-
it('adds BrowserTracing integration by default if tracesSampleRate is set', () => {
106-
init({ tracesSampleRate: 1.0 });
106+
it('supports passing unrelated integrations through options', () => {
107+
init({ integrations: [new Integrations.Breadcrumbs({ console: false })] });
107108

108-
const reactInitOptions: NextjsOptions = reactInit.mock.calls[0][0];
109-
expect(reactInitOptions.integrations).toHaveLength(1);
109+
const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray;
110+
const breadcrumbsIntegration = findIntegrationByName(reactInitOptions.integrations, 'Breadcrumbs');
110111

111-
const integrations = reactInitOptions.integrations as Integration[];
112-
expect(integrations[0]).toEqual(expect.any(BrowserTracing));
113-
// eslint-disable-next-line @typescript-eslint/unbound-method
114-
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options.routingInstrumentation).toEqual(
115-
nextRouterInstrumentation,
116-
);
112+
expect(breadcrumbsIntegration).toBeDefined();
117113
});
118114

119-
it('adds BrowserTracing integration by default if tracesSampler is set', () => {
120-
init({ tracesSampler: () => true });
115+
describe('`BrowserTracing` integration', () => {
116+
it('adds `BrowserTracing` integration if `tracesSampleRate` is set', () => {
117+
init({ tracesSampleRate: 1.0 });
118+
119+
const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray;
120+
const browserTracingIntegration = findIntegrationByName(reactInitOptions.integrations, 'BrowserTracing');
121+
122+
expect(browserTracingIntegration).toBeDefined();
123+
expect(browserTracingIntegration).toEqual(
124+
expect.objectContaining({
125+
options: expect.objectContaining({
126+
routingInstrumentation: nextRouterInstrumentation,
127+
}),
128+
}),
129+
);
130+
});
121131

122-
const reactInitOptions: NextjsOptions = reactInit.mock.calls[0][0];
123-
expect(reactInitOptions.integrations).toHaveLength(1);
132+
it('adds `BrowserTracing` integration if `tracesSampler` is set', () => {
133+
init({ tracesSampler: () => true });
124134

125-
const integrations = reactInitOptions.integrations as Integration[];
126-
expect(integrations[0]).toEqual(expect.any(BrowserTracing));
127-
// eslint-disable-next-line @typescript-eslint/unbound-method
128-
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options.routingInstrumentation).toEqual(
129-
nextRouterInstrumentation,
130-
);
131-
});
135+
const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray;
136+
const browserTracingIntegration = findIntegrationByName(reactInitOptions.integrations, 'BrowserTracing');
132137

133-
it('supports passing integration through options', () => {
134-
init({ tracesSampleRate: 1.0, integrations: [new Integrations.Breadcrumbs({ console: false })] });
135-
const reactInitOptions: NextjsOptions = reactInit.mock.calls[0][0];
136-
expect(reactInitOptions.integrations).toHaveLength(2);
138+
expect(browserTracingIntegration).toBeDefined();
139+
expect(browserTracingIntegration).toEqual(
140+
expect.objectContaining({
141+
options: expect.objectContaining({
142+
routingInstrumentation: nextRouterInstrumentation,
143+
}),
144+
}),
145+
);
146+
});
137147

138-
const integrations = reactInitOptions.integrations as Integration[];
139-
expect(integrations).toEqual([expect.any(Integrations.Breadcrumbs), expect.any(BrowserTracing)]);
140-
});
148+
it('does not add `BrowserTracing` integration if tracing not enabled in SDK', () => {
149+
init({});
141150

142-
it('uses custom BrowserTracing with array option with nextRouterInstrumentation', () => {
143-
init({
144-
tracesSampleRate: 1.0,
145-
integrations: [new BrowserTracing({ idleTimeout: 5000, startTransactionOnLocationChange: false })],
146-
});
151+
const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray;
152+
const browserTracingIntegration = findIntegrationByName(reactInitOptions.integrations, 'BrowserTracing');
147153

148-
const reactInitOptions: NextjsOptions = reactInit.mock.calls[0][0];
149-
expect(reactInitOptions.integrations).toHaveLength(1);
150-
const integrations = reactInitOptions.integrations as Integration[];
151-
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options).toEqual(
152-
expect.objectContaining({
153-
idleTimeout: 5000,
154-
startTransactionOnLocationChange: false,
155-
routingInstrumentation: nextRouterInstrumentation,
156-
}),
157-
);
158-
});
154+
expect(browserTracingIntegration).toBeUndefined();
155+
});
159156

160-
it('uses custom BrowserTracing with function option with nextRouterInstrumentation', () => {
161-
init({
162-
tracesSampleRate: 1.0,
163-
integrations: () => [new BrowserTracing({ idleTimeout: 5000, startTransactionOnLocationChange: false })],
157+
it('forces correct router instrumentation if user provides `BrowserTracing` in an array', () => {
158+
init({
159+
tracesSampleRate: 1.0,
160+
integrations: [new BrowserTracing({ startTransactionOnLocationChange: false })],
161+
});
162+
163+
const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray;
164+
const browserTracingIntegration = findIntegrationByName(reactInitOptions.integrations, 'BrowserTracing');
165+
166+
expect(browserTracingIntegration).toEqual(
167+
expect.objectContaining({
168+
options: expect.objectContaining({
169+
routingInstrumentation: nextRouterInstrumentation,
170+
// This proves it's still the user's copy
171+
startTransactionOnLocationChange: false,
172+
}),
173+
}),
174+
);
164175
});
165176

166-
const reactInitOptions: NextjsOptions = reactInit.mock.calls[0][0];
167-
const integrationFunc = reactInitOptions.integrations as () => Integration[];
168-
const integrations = integrationFunc();
169-
expect((integrations[0] as InstanceType<typeof BrowserTracing>).options).toEqual(
170-
expect.objectContaining({
171-
idleTimeout: 5000,
172-
startTransactionOnLocationChange: false,
173-
routingInstrumentation: nextRouterInstrumentation,
174-
}),
175-
);
177+
it('forces correct router instrumentation if user provides `BrowserTracing` in a function', () => {
178+
init({
179+
tracesSampleRate: 1.0,
180+
integrations: defaults => [...defaults, new BrowserTracing({ startTransactionOnLocationChange: false })],
181+
});
182+
183+
const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationFunction;
184+
const materializedIntegrations = reactInitOptions.integrations(SentryReact.defaultIntegrations);
185+
const browserTracingIntegration = findIntegrationByName(materializedIntegrations, 'BrowserTracing');
186+
187+
expect(browserTracingIntegration).toEqual(
188+
expect.objectContaining({
189+
options: expect.objectContaining({
190+
routingInstrumentation: nextRouterInstrumentation,
191+
// This proves it's still the user's copy
192+
startTransactionOnLocationChange: false,
193+
}),
194+
}),
195+
);
196+
});
176197
});
177198
});
178199
});

0 commit comments

Comments
 (0)