Skip to content

Commit b428d14

Browse files
authored
fix(nextjs): Allow onUncaughtException integration to remain excluded (#6148)
In the nextjs SDK, the `addOrUpdateIntegration` function exists to force the inclusion of certain integrations with certain options set. If such an integration is included in the underlying SDK's default integrations, however, (in other words, if it's included in the defaults set by `@sentry/browser` or `@sentry/node`), it's possible for the user to have actively chosen to exclude it, which we would then be overriding. This PR adds to the `addOrUpdateIntegration` logic to provide the ability to respect that choice. The only way for a user to choose to filter out a default integration is by providing a function as their `integrations` option in `Sentry.init()`. Therefore, when handling the function case, we can check if a given integration is included in the return value, and if it's not, not add the default instance we otherwise would. This is controlled by a flag on that default instance named `allowExclusionByUser`. If it's set to `true`, we'll perform the check and respect the user's choice. If it's set to `false` or not set at all, we'll continue to behave as we have, forcing the inclusion of the given integration. The inspiration for this change is our recent inclusion of the `onUncaughtException` integration in the nextjs defaults. This PR therefore also applies the above change to that default instance. Finally, the test suite for `addOrUpdateIntegration` has been entirely reworked, to ensure that it covers all possible cases.
1 parent f214732 commit b428d14

File tree

3 files changed

+251
-53
lines changed

3 files changed

+251
-53
lines changed

packages/nextjs/src/index.server.ts

Lines changed: 6 additions & 3 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 { addOrUpdateIntegration } from './utils/userIntegrations';
13+
import { addOrUpdateIntegration, IntegrationWithExclusionOption } from './utils/userIntegrations';
1414

1515
export * from '@sentry/node';
1616
export { captureUnderscoreErrorException } from './utils/_error';
@@ -118,8 +118,11 @@ function addServerIntegrations(options: NextjsOptions): void {
118118
});
119119
integrations = addOrUpdateIntegration(defaultRewriteFramesIntegration, integrations);
120120

121-
const nativeBehaviourOnUncaughtException = new Integrations.OnUncaughtException();
122-
integrations = addOrUpdateIntegration(nativeBehaviourOnUncaughtException, integrations, {
121+
const defaultOnUncaughtExceptionIntegration: IntegrationWithExclusionOption = new Integrations.OnUncaughtException({
122+
exitEvenIfOtherHandlersAreRegistered: false,
123+
});
124+
defaultOnUncaughtExceptionIntegration.allowExclusionByUser = true;
125+
integrations = addOrUpdateIntegration(defaultOnUncaughtExceptionIntegration, integrations, {
123126
_options: { exitEvenIfOtherHandlersAreRegistered: false },
124127
});
125128

packages/nextjs/src/utils/userIntegrations.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ type ForcedIntegrationOptions = {
77
[keyPath: string]: unknown;
88
};
99

10+
export type IntegrationWithExclusionOption = Integration & {
11+
/**
12+
* Allow the user to exclude this integration by not returning it from a function provided as the `integrations` option
13+
* in `Sentry.init()`. Meant to be used with default integrations, the idea being that if a user has actively filtered
14+
* an integration out, we should be able to respect that choice if we wish.
15+
*/
16+
allowExclusionByUser?: boolean;
17+
};
18+
1019
/**
1120
* Recursively traverses an object to update an existing nested key.
1221
* Note: The provided key path must include existing properties,
@@ -43,14 +52,21 @@ function setNestedKey(obj: Record<string, any>, keyPath: string, value: unknown)
4352
* @param forcedOptions Options with which to patch an existing user-derived instance on the integration.
4453
* @returns A final integrations array.
4554
*/
46-
export function addOrUpdateIntegration(
55+
export function addOrUpdateIntegration<T extends UserIntegrations>(
4756
defaultIntegrationInstance: Integration,
48-
userIntegrations: UserIntegrations,
57+
userIntegrations: T,
4958
forcedOptions: ForcedIntegrationOptions = {},
50-
): UserIntegrations {
51-
return Array.isArray(userIntegrations)
52-
? addOrUpdateIntegrationInArray(defaultIntegrationInstance, userIntegrations, forcedOptions)
53-
: addOrUpdateIntegrationInFunction(defaultIntegrationInstance, userIntegrations, forcedOptions);
59+
): T {
60+
return (
61+
Array.isArray(userIntegrations)
62+
? addOrUpdateIntegrationInArray(defaultIntegrationInstance, userIntegrations, forcedOptions)
63+
: addOrUpdateIntegrationInFunction(
64+
defaultIntegrationInstance,
65+
// Somehow TS can't figure out that not being an array makes this necessarily a function
66+
userIntegrations as UserIntegrationsFunction,
67+
forcedOptions,
68+
)
69+
) as T;
5470
}
5571

5672
function addOrUpdateIntegrationInArray(
@@ -72,13 +88,27 @@ function addOrUpdateIntegrationInArray(
7288
}
7389

7490
function addOrUpdateIntegrationInFunction(
75-
defaultIntegrationInstance: Integration,
91+
defaultIntegrationInstance: IntegrationWithExclusionOption,
7692
userIntegrationsFunc: UserIntegrationsFunction,
7793
forcedOptions: ForcedIntegrationOptions,
7894
): UserIntegrationsFunction {
7995
const wrapper: UserIntegrationsFunction = defaultIntegrations => {
8096
const userFinalIntegrations = userIntegrationsFunc(defaultIntegrations);
97+
98+
// There are instances where we want the user to be able to prevent an integration from appearing at all, which they
99+
// would do by providing a function which filters out the integration in question. If that's happened in one of
100+
// those cases, don't add our default back in.
101+
if (defaultIntegrationInstance.allowExclusionByUser) {
102+
const userFinalInstance = userFinalIntegrations.find(
103+
integration => integration.name === defaultIntegrationInstance.name,
104+
);
105+
if (!userFinalInstance) {
106+
return userFinalIntegrations;
107+
}
108+
}
109+
81110
return addOrUpdateIntegrationInArray(defaultIntegrationInstance, userFinalIntegrations, forcedOptions);
82111
};
112+
83113
return wrapper;
84114
}
Lines changed: 208 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,215 @@
1-
import { RewriteFrames } from '@sentry/integrations';
2-
import { Integration } from '@sentry/types';
3-
4-
import { addOrUpdateIntegration, UserIntegrationsFunction } from '../../src/utils/userIntegrations';
5-
6-
const testIntegration = new RewriteFrames();
7-
8-
describe('user integrations without any integrations', () => {
9-
test('as an array', () => {
10-
const userIntegrations: Integration[] = [];
11-
// Should get a single integration
12-
let finalIntegrations = addOrUpdateIntegration(testIntegration, userIntegrations);
13-
expect(finalIntegrations).toBeInstanceOf(Array);
14-
finalIntegrations = finalIntegrations as Integration[];
15-
expect(finalIntegrations).toHaveLength(1);
16-
expect(finalIntegrations[0]).toMatchObject(testIntegration);
17-
});
1+
import type { IntegrationWithExclusionOption as Integration } from '../../src/utils/userIntegrations';
2+
import { addOrUpdateIntegration, UserIntegrations } from '../../src/utils/userIntegrations';
3+
4+
type MockIntegrationOptions = {
5+
name: string;
6+
descriptor: string;
7+
age?: number;
8+
};
9+
10+
class DogIntegration implements Integration {
11+
public static id: string = 'Dog';
12+
public name: string = DogIntegration.id;
13+
14+
public dogName: string;
15+
public descriptor: string;
16+
public age?: number;
17+
18+
public allowExclusionByUser?: boolean;
19+
20+
constructor(options: MockIntegrationOptions) {
21+
this.dogName = options.name;
22+
this.descriptor = options.descriptor;
23+
this.age = options.age;
24+
}
25+
26+
setupOnce() {
27+
return undefined;
28+
}
29+
}
30+
31+
class CatIntegration implements Integration {
32+
public static id: string = 'Cat';
33+
public name: string = CatIntegration.id;
34+
35+
public catName: string;
36+
public descriptor: string;
37+
public age?: number;
38+
39+
constructor(options: MockIntegrationOptions) {
40+
this.catName = options.name;
41+
this.descriptor = options.descriptor;
42+
this.age = options.age;
43+
}
44+
45+
setupOnce() {
46+
return undefined;
47+
}
48+
}
49+
50+
const defaultDogIntegration = new DogIntegration({ name: 'Maisey', descriptor: 'silly' });
51+
const defaultCatIntegration = new CatIntegration({ name: 'Piper', descriptor: 'fluffy' });
52+
const forcedDogIntegration = new DogIntegration({ name: 'Charlie', descriptor: 'goofy' });
53+
const forcedDogIntegrationProperties = { dogName: 'Charlie', descriptor: 'goofy' };
54+
55+
// Note: This is essentially the implementation of a `test.each()` test. Making it a separate function called in
56+
// individual tests instead allows the various `describe` clauses to be nested, which is helpful here given how many
57+
// different combinations of factors come into play.
58+
function runTest(testOptions: {
59+
userIntegrations: UserIntegrations;
60+
forcedDogIntegrationInstance: DogIntegration;
61+
underlyingDefaultIntegrations?: Integration[];
62+
allowIntegrationExclusion?: boolean;
63+
expectedDogIntegrationProperties: Partial<DogIntegration> | undefined;
64+
}): void {
65+
const {
66+
userIntegrations,
67+
forcedDogIntegrationInstance,
68+
underlyingDefaultIntegrations = [],
69+
allowIntegrationExclusion = false,
70+
expectedDogIntegrationProperties,
71+
} = testOptions;
72+
73+
if (allowIntegrationExclusion) {
74+
forcedDogIntegrationInstance.allowExclusionByUser = true;
75+
}
76+
77+
let integrations;
78+
if (typeof userIntegrations === 'function') {
79+
const wrappedUserIntegrationsFunction = addOrUpdateIntegration(forcedDogIntegrationInstance, userIntegrations, {
80+
dogName: 'Charlie',
81+
descriptor: 'goofy',
82+
});
83+
integrations = wrappedUserIntegrationsFunction(underlyingDefaultIntegrations);
84+
} else {
85+
integrations = addOrUpdateIntegration(
86+
forcedDogIntegrationInstance,
87+
userIntegrations,
88+
forcedDogIntegrationProperties,
89+
);
90+
}
91+
92+
const finalDogIntegrationInstance = integrations.find(integration => integration.name === 'Dog') as DogIntegration;
93+
94+
if (expectedDogIntegrationProperties) {
95+
expect(finalDogIntegrationInstance).toMatchObject(expectedDogIntegrationProperties);
96+
} else {
97+
expect(finalDogIntegrationInstance).toBeUndefined();
98+
}
1899

19-
test('as a function', () => {
20-
const userIntegrationFnc: UserIntegrationsFunction = (): Integration[] => [];
21-
// Should get a single integration
22-
const integrationWrapper = addOrUpdateIntegration(testIntegration, userIntegrationFnc);
23-
expect(integrationWrapper).toBeInstanceOf(Function);
24-
const finalIntegrations = (integrationWrapper as UserIntegrationsFunction)([]);
25-
expect(finalIntegrations).toHaveLength(1);
26-
expect(finalIntegrations[0]).toMatchObject(testIntegration);
100+
delete forcedDogIntegrationInstance.allowExclusionByUser;
101+
}
102+
103+
describe('addOrUpdateIntegration()', () => {
104+
describe('user provides no `integrations` option', () => {
105+
it('adds forced integration instance', () => {
106+
expect.assertions(1);
107+
108+
runTest({
109+
userIntegrations: [], // default if no option is provided
110+
forcedDogIntegrationInstance: forcedDogIntegration,
111+
expectedDogIntegrationProperties: forcedDogIntegrationProperties,
112+
});
113+
});
27114
});
28-
});
29115

30-
describe('user integrations with integrations', () => {
31-
test('as an array', () => {
32-
const userIntegrations = [new RewriteFrames()];
33-
// Should get the same array (with no patches)
34-
const finalIntegrations = addOrUpdateIntegration(testIntegration, userIntegrations);
35-
expect(finalIntegrations).toMatchObject(userIntegrations);
116+
describe('user provides `integrations` array', () => {
117+
describe('array contains forced integration type', () => {
118+
it('updates user instance with forced options', () => {
119+
expect.assertions(1);
120+
121+
runTest({
122+
userIntegrations: [{ ...defaultDogIntegration, age: 9 } as unknown as Integration],
123+
forcedDogIntegrationInstance: forcedDogIntegration,
124+
expectedDogIntegrationProperties: { ...forcedDogIntegrationProperties, age: 9 },
125+
});
126+
});
127+
});
128+
129+
describe('array does not contain forced integration type', () => {
130+
it('adds forced integration instance', () => {
131+
expect.assertions(1);
132+
133+
runTest({
134+
userIntegrations: [defaultCatIntegration],
135+
forcedDogIntegrationInstance: forcedDogIntegration,
136+
expectedDogIntegrationProperties: forcedDogIntegrationProperties,
137+
});
138+
});
139+
});
36140
});
37141

38-
test('as a function', () => {
39-
const userIntegrations = [new RewriteFrames()];
40-
const integrationsFnc: UserIntegrationsFunction = (_integrations: Integration[]): Integration[] => {
41-
return userIntegrations;
42-
};
43-
// Should get a function that returns the test integration
44-
let finalIntegrations = addOrUpdateIntegration(testIntegration, integrationsFnc);
45-
expect(typeof finalIntegrations === 'function').toBe(true);
46-
expect(finalIntegrations).toBeInstanceOf(Function);
47-
finalIntegrations = finalIntegrations as UserIntegrationsFunction;
48-
expect(finalIntegrations([])).toMatchObject(userIntegrations);
142+
describe('user provides `integrations` function', () => {
143+
describe('forced integration in `defaultIntegrations`', () => {
144+
const underlyingDefaultIntegrations = [defaultDogIntegration, defaultCatIntegration];
145+
146+
describe('function filters out forced integration type', () => {
147+
it('adds forced integration instance by default', () => {
148+
expect.assertions(1);
149+
150+
runTest({
151+
userIntegrations: _defaults => [defaultCatIntegration],
152+
forcedDogIntegrationInstance: forcedDogIntegration,
153+
underlyingDefaultIntegrations,
154+
expectedDogIntegrationProperties: forcedDogIntegrationProperties,
155+
});
156+
});
157+
158+
it('does not add forced integration instance if integration exclusion is allowed', () => {
159+
expect.assertions(1);
160+
161+
runTest({
162+
userIntegrations: _defaults => [defaultCatIntegration],
163+
forcedDogIntegrationInstance: forcedDogIntegration,
164+
underlyingDefaultIntegrations,
165+
allowIntegrationExclusion: true,
166+
expectedDogIntegrationProperties: undefined, // this means no instance was found
167+
});
168+
});
169+
});
170+
171+
describe("function doesn't filter out forced integration type", () => {
172+
it('updates user instance with forced options', () => {
173+
expect.assertions(1);
174+
175+
runTest({
176+
userIntegrations: _defaults => [{ ...defaultDogIntegration, age: 9 } as unknown as Integration],
177+
forcedDogIntegrationInstance: forcedDogIntegration,
178+
underlyingDefaultIntegrations,
179+
expectedDogIntegrationProperties: { ...forcedDogIntegrationProperties, age: 9 },
180+
});
181+
});
182+
});
183+
});
184+
185+
describe('forced integration not in `defaultIntegrations`', () => {
186+
const underlyingDefaultIntegrations = [defaultCatIntegration];
187+
188+
describe('function returns forced integration type', () => {
189+
it('updates user instance with forced options', () => {
190+
expect.assertions(1);
191+
192+
runTest({
193+
userIntegrations: _defaults => [{ ...defaultDogIntegration, age: 9 } as unknown as Integration],
194+
forcedDogIntegrationInstance: forcedDogIntegration,
195+
underlyingDefaultIntegrations,
196+
expectedDogIntegrationProperties: { ...forcedDogIntegrationProperties, age: 9 },
197+
});
198+
});
199+
});
200+
201+
describe("function doesn't return forced integration type", () => {
202+
it('adds forced integration instance', () => {
203+
expect.assertions(1);
204+
205+
runTest({
206+
userIntegrations: _defaults => [{ ...defaultCatIntegration, age: 1 } as unknown as Integration],
207+
forcedDogIntegrationInstance: forcedDogIntegration,
208+
underlyingDefaultIntegrations,
209+
expectedDogIntegrationProperties: forcedDogIntegrationProperties,
210+
});
211+
});
212+
});
213+
});
49214
});
50215
});

0 commit comments

Comments
 (0)