Skip to content

fix(core): Fix integration deduping #5696

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Sep 7, 2022
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
74 changes: 49 additions & 25 deletions packages/core/src/integration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { addGlobalEventProcessor, getCurrentHub } from '@sentry/hub';
import { Integration, Options } from '@sentry/types';
import { logger } from '@sentry/utils';
import { arrayify, logger } from '@sentry/utils';

declare module '@sentry/types' {
interface Integration {
isDefaultInstance?: boolean;
}
}

export const installedIntegrations: string[] = [];

Expand All @@ -10,46 +16,64 @@ export type IntegrationIndex = {
};

/**
* Remove duplicates from the given array, preferring the last instance of any duplicate. Not guaranteed to
* preseve the order of integrations in the array.
Comment on lines +19 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Remove duplicates from the given array, preferring the last instance of any duplicate. Not guaranteed to
* preseve the order of integrations in the array.
* Remove duplicates from the given array, preferring the last instance of duplicate instances, and user instances over
* default instances. Not guaranteed to preseve the order of integrations in the array.

Copy link
Member Author

@lobsterkatie lobsterkatie Sep 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically? Yes. But I'd argue that

a) it falls under the heading of "fixing bugs is always technically breaking", and

b) coming back to the examples from the description, we have

  • [UserProvidedCopyofIntegration, DefaultCopyOfSameIntegration] - the user's copy should win, and already does (no behavior change here)
  • [DefaultCopyofIntegration, UserProvidedCopyOfSameIntegration] - the user's copy should win, but currently doesn't (this seems wrong on its face and also, why would anyone rely on us ignoring their copy?)
  • [DefaultCopyAofIntegration, DefaultCopyBofIntegration] - copy B should win, but currently doesn't (we have total control here, though I don't think it actually comes up anywhere)
  • [UserCopyAofIntegration, UserCopyBofIntegration] - copy B should win, but currently doesn't (the one breaking, not-currently-quite-as-obviously-wrong change, but I'm wagering that the number of people actively providing two copies of the same integration is vanishingly small)

I honestly have no idea why we were preferring first to begin with (the PR which introduced both the logic and the test unfortunately says absolutely nothing about it), and I can't find any place where we rely on that behavior, so I feel okay changing it.

WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol at the xkcd

I am totally fine with the changes. If people report in that they see problems, we can tell them to change the order to match the fixed behavior!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops - completely forgot to include your good suggestion above before I merged. I'll throw it into the pile for the next comments-fix PR.

*
* @private
*/
function filterDuplicates(integrations: Integration[]): Integration[] {
return integrations.reduce((acc, integrations) => {
if (acc.every(accIntegration => integrations.name !== accIntegration.name)) {
acc.push(integrations);
const integrationsByName: { [key: string]: Integration } = {};

integrations.forEach(currentInstance => {
const { name } = currentInstance;

const existingInstance = integrationsByName[name];

// We want integrations later in the array to overwrite earlier ones of the same type, except that we never want a
// default instance to overwrite an existing user instance
if (existingInstance && !existingInstance.isDefaultInstance && currentInstance.isDefaultInstance) {
return;
}
return acc;
}, [] as Integration[]);

integrationsByName[name] = currentInstance;
});

return Object.values(integrationsByName);
}

/** Gets integration to install */
/** Gets integrations to install */
export function getIntegrationsToSetup(options: Options): Integration[] {
const defaultIntegrations = (options.defaultIntegrations && [...options.defaultIntegrations]) || [];
const defaultIntegrations = options.defaultIntegrations || [];
const userIntegrations = options.integrations;

let integrations: Integration[] = [...filterDuplicates(defaultIntegrations)];
// We flag default instances, so that later we can tell them apart from any user-created instances of the same class
defaultIntegrations.forEach(integration => {
integration.isDefaultInstance = true;
});

let integrations: Integration[];

if (Array.isArray(userIntegrations)) {
// Filter out integrations that are also included in user options
integrations = [
...integrations.filter(integrations =>
userIntegrations.every(userIntegration => userIntegration.name !== integrations.name),
),
// And filter out duplicated user options integrations
...filterDuplicates(userIntegrations),
];
integrations = [...defaultIntegrations, ...userIntegrations];
} else if (typeof userIntegrations === 'function') {
integrations = userIntegrations(integrations);
integrations = Array.isArray(integrations) ? integrations : [integrations];
integrations = arrayify(userIntegrations(defaultIntegrations));
} else {
integrations = defaultIntegrations;
}

// Make sure that if present, `Debug` integration will always run last
const integrationsNames = integrations.map(i => i.name);
const alwaysLastToRun = 'Debug';
if (integrationsNames.indexOf(alwaysLastToRun) !== -1) {
integrations.push(...integrations.splice(integrationsNames.indexOf(alwaysLastToRun), 1));
const finalIntegrations = filterDuplicates(integrations);

// The `Debug` integration prints copies of the `event` and `hint` which will be passed to `beforeSend`. It therefore
// has to run after all other integrations, so that the changes of all event processors will be reflected in the
// printed values. For lack of a more elegant way to guarantee that, we therefore locate it and, assuming it exists,
// pop it out of its current spot and shove it onto the end of the array.
const debugIndex = finalIntegrations.findIndex(integration => integration.name === 'Debug');
if (debugIndex !== -1) {
const [debugInstance] = finalIntegrations.splice(debugIndex, 1);
finalIntegrations.push(debugInstance);
}

return integrations;
return finalIntegrations;
}

/**
Expand Down
202 changes: 186 additions & 16 deletions packages/core/test/lib/integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,205 @@
import { Integration } from '@sentry/types';
import { Integration, Options } from '@sentry/types';

import { getIntegrationsToSetup } from '../../src/integration';

/** JSDoc */
class MockIntegration implements Integration {
public name: string;

public constructor(name: string) {
// Only for testing - tag to keep separate instances straight when testing deduplication
public tag?: string;

public constructor(name: string, tag?: string) {
this.name = name;
this.tag = tag;
}

public setupOnce(): void {
// noop
}
}

type TestCase = [
string, // test name
Options['defaultIntegrations'], // default integrations
Options['integrations'], // user-provided integrations
Array<string | string[]>, // expected resulst
];

describe('getIntegrationsToSetup', () => {
describe('no duplicate integrations', () => {
const defaultIntegrations = [new MockIntegration('ChaseSquirrels')];
const userIntegrationsArray = [new MockIntegration('CatchTreats')];
const userIntegrationsFunction = (defaults: Integration[]) => [...defaults, ...userIntegrationsArray];

const testCases: TestCase[] = [
// each test case is [testName, defaultIntegrations, userIntegrations, expectedResult]
['no default integrations, no user integrations provided', false, undefined, []],
['no default integrations, empty user-provided array', false, [], []],
['no default integrations, user-provided array', false, userIntegrationsArray, ['CatchTreats']],
['no default integrations, user-provided function', false, userIntegrationsFunction, ['CatchTreats']],
['with default integrations, no user integrations provided', defaultIntegrations, undefined, ['ChaseSquirrels']],
['with default integrations, empty user-provided array', defaultIntegrations, [], ['ChaseSquirrels']],
[
'with default integrations, user-provided array',
defaultIntegrations,
userIntegrationsArray,
['ChaseSquirrels', 'CatchTreats'],
],
[
'with default integrations, user-provided function',
defaultIntegrations,
userIntegrationsFunction,
['ChaseSquirrels', 'CatchTreats'],
],
];

test.each(testCases)('%s', (_, defaultIntegrations, userIntegrations, expected) => {
const integrations = getIntegrationsToSetup({
defaultIntegrations,
integrations: userIntegrations,
});
expect(integrations.map(i => i.name)).toEqual(expected);
});
});

describe('deduping', () => {
// No duplicates
const defaultIntegrations = [new MockIntegration('ChaseSquirrels', 'defaultA')];
const userIntegrationsArray = [new MockIntegration('CatchTreats', 'userA')];

// Duplicates within either default or user integrations, but no overlap between them (to show that last one wins)
const duplicateDefaultIntegrations = [
new MockIntegration('ChaseSquirrels', 'defaultA'),
new MockIntegration('ChaseSquirrels', 'defaultB'),
];
const duplicateUserIntegrationsArray = [
new MockIntegration('CatchTreats', 'userA'),
new MockIntegration('CatchTreats', 'userB'),
];
const duplicateUserIntegrationsFunctionDefaultsFirst = (defaults: Integration[]) => [
...defaults,
...duplicateUserIntegrationsArray,
];
const duplicateUserIntegrationsFunctionDefaultsSecond = (defaults: Integration[]) => [
...duplicateUserIntegrationsArray,
...defaults,
];

// User integrations containing new instances of default integrations (to show that user integration wins)
const userIntegrationsMatchingDefaultsArray = [
new MockIntegration('ChaseSquirrels', 'userA'),
new MockIntegration('CatchTreats', 'userA'),
];
const userIntegrationsMatchingDefaultsFunctionDefaultsFirst = (defaults: Integration[]) => [
...defaults,
...userIntegrationsMatchingDefaultsArray,
];
const userIntegrationsMatchingDefaultsFunctionDefaultsSecond = (defaults: Integration[]) => [
...userIntegrationsMatchingDefaultsArray,
...defaults,
];

const testCases: TestCase[] = [
// each test case is [testName, defaultIntegrations, userIntegrations, expectedResult]
[
'duplicate default integrations',
duplicateDefaultIntegrations,
userIntegrationsArray,
[
['ChaseSquirrels', 'defaultB'],
['CatchTreats', 'userA'],
],
],
[
'duplicate user integrations, user-provided array',
defaultIntegrations,
duplicateUserIntegrationsArray,
[
['ChaseSquirrels', 'defaultA'],
['CatchTreats', 'userB'],
],
],
[
'duplicate user integrations, user-provided function with defaults first',
defaultIntegrations,
duplicateUserIntegrationsFunctionDefaultsFirst,
[
['ChaseSquirrels', 'defaultA'],
['CatchTreats', 'userB'],
],
],
[
'duplicate user integrations, user-provided function with defaults second',
defaultIntegrations,
duplicateUserIntegrationsFunctionDefaultsSecond,
[
['CatchTreats', 'userB'],
['ChaseSquirrels', 'defaultA'],
],
],
[
'same integration in default and user integrations, user-provided array',
defaultIntegrations,
userIntegrationsMatchingDefaultsArray,
[
['ChaseSquirrels', 'userA'],
['CatchTreats', 'userA'],
],
],
[
'same integration in default and user integrations, user-provided function with defaults first',
defaultIntegrations,
userIntegrationsMatchingDefaultsFunctionDefaultsFirst,
[
['ChaseSquirrels', 'userA'],
['CatchTreats', 'userA'],
],
],
[
'same integration in default and user integrations, user-provided function with defaults second',
defaultIntegrations,
userIntegrationsMatchingDefaultsFunctionDefaultsSecond,
[
['ChaseSquirrels', 'userA'],
['CatchTreats', 'userA'],
],
],
];

test.each(testCases)('%s', (_, defaultIntegrations, userIntegrations, expected) => {
const integrations = getIntegrationsToSetup({
defaultIntegrations: defaultIntegrations,
integrations: userIntegrations,
}) as MockIntegration[];

expect(integrations.map(i => [i.name, i.tag])).toEqual(expected);
});
});

describe('puts `Debug` integration last', () => {
// No variations here (default vs user, duplicates, user array vs user function, etc) because by the time we're
// dealing with the `Debug` integration, all of the combining and deduping has already been done
const noDebug = [new MockIntegration('ChaseSquirrels')];
const debugNotLast = [new MockIntegration('Debug'), new MockIntegration('CatchTreats')];
const debugAlreadyLast = [new MockIntegration('ChaseSquirrels'), new MockIntegration('Debug')];

const testCases: TestCase[] = [
// each test case is [testName, defaultIntegrations, userIntegrations, expectedResult]
['`Debug` not present', false, noDebug, ['ChaseSquirrels']],
['`Debug` not originally last', false, debugNotLast, ['CatchTreats', 'Debug']],
['`Debug` already last', false, debugAlreadyLast, ['ChaseSquirrels', 'Debug']],
];

test.each(testCases)('%s', (_, defaultIntegrations, userIntegrations, expected) => {
const integrations = getIntegrationsToSetup({
defaultIntegrations,
integrations: userIntegrations,
});
expect(integrations.map(i => i.name)).toEqual(expected);
});
});

it('works with empty array', () => {
const integrations = getIntegrationsToSetup({
integrations: [],
Expand Down Expand Up @@ -48,20 +232,6 @@ describe('getIntegrationsToSetup', () => {
expect(integrations.map(i => i.name)).toEqual(['foo', 'bar']);
});

it('filter duplicated items and always let first win', () => {
const first = new MockIntegration('foo');
(first as any).order = 'first';
const second = new MockIntegration('foo');
(second as any).order = 'second';

const integrations = getIntegrationsToSetup({
integrations: [first, second, new MockIntegration('bar')],
});

expect(integrations.map(i => i.name)).toEqual(['foo', 'bar']);
expect((integrations[0] as any).order).toEqual('first');
});

it('work with empty defaults', () => {
const integrations = getIntegrationsToSetup({
defaultIntegrations: [],
Expand Down