Skip to content

feat: Autoload Database Integrations in Node environment #3483

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 7 commits into from
May 10, 2021
47 changes: 23 additions & 24 deletions packages/core/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,37 @@ export interface IntegrationIndex {
[key: string]: Integration;
}

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

/** Gets integration to install */
export function getIntegrationsToSetup(options: Options): Integration[] {
const defaultIntegrations = (options.defaultIntegrations && [...options.defaultIntegrations]) || [];
const userIntegrations = options.integrations;
let integrations: Integration[] = [];
if (Array.isArray(userIntegrations)) {
const userIntegrationsNames = userIntegrations.map(i => i.name);
const pickedIntegrationsNames: string[] = [];

// Leave only unique default integrations, that were not overridden with provided user integrations
defaultIntegrations.forEach(defaultIntegration => {
if (
userIntegrationsNames.indexOf(defaultIntegration.name) === -1 &&
pickedIntegrationsNames.indexOf(defaultIntegration.name) === -1
) {
integrations.push(defaultIntegration);
pickedIntegrationsNames.push(defaultIntegration.name);
}
});
let integrations: Integration[] = [...filterDuplicates(defaultIntegrations)];

// Don't add same user integration twice
userIntegrations.forEach(userIntegration => {
if (pickedIntegrationsNames.indexOf(userIntegration.name) === -1) {
integrations.push(userIntegration);
pickedIntegrationsNames.push(userIntegration.name);
}
});
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),
];
} else if (typeof userIntegrations === 'function') {
integrations = userIntegrations(defaultIntegrations);
integrations = userIntegrations(integrations);
integrations = Array.isArray(integrations) ? integrations : [integrations];
} else {
integrations = [...defaultIntegrations];
}

// Make sure that if present, `Debug` integration will always run last
Expand Down
36 changes: 20 additions & 16 deletions packages/core/test/lib/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,26 @@ declare var global: any;

const PUBLIC_DSN = 'https://username@domain/123';

jest.mock('@sentry/hub', () => ({
getCurrentHub(): {
bindClient(client: Client): boolean;
getClient(): boolean;
} {
return {
getClient(): boolean {
return false;
},
bindClient(client: Client): boolean {
client.setupIntegrations();
return true;
},
};
},
}));
jest.mock('@sentry/hub', () => {
const original = jest.requireActual('@sentry/hub');
return {
...original,
getCurrentHub(): {
bindClient(client: Client): boolean;
getClient(): boolean;
} {
return {
getClient(): boolean {
return false;
},
bindClient(client: Client): boolean {
client.setupIntegrations();
return true;
},
};
},
};
});

class MockIntegration implements Integration {
public name: string;
Expand Down
10 changes: 8 additions & 2 deletions packages/hub/src/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { Session } from './session';
*
* @hidden
*/
export const API_VERSION = 3;
export const API_VERSION = 4;

/**
* Default maximum number of breadcrumbs added to an event. Can be overwritten
Expand Down Expand Up @@ -457,7 +457,13 @@ export class Hub implements HubInterface {
}
}

/** Returns the global shim registry. */
/**
* Returns the global shim registry.
*
* FIXME: This function is problematic, because despite always returning a valid Carrier,
* it has an optional `__SENTRY__` property, which then in turn requires us to always perform an unnecessary check
* at the call-site. We always access the carrier through this function, so we can guarantee that `__SENTRY__` is there.
**/
export function getMainCarrier(): Carrier {
const carrier = getGlobalObject();
carrier.__SENTRY__ = carrier.__SENTRY__ || {
Expand Down
3 changes: 2 additions & 1 deletion packages/hub/src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Client } from '@sentry/types';
import { Client, Integration } from '@sentry/types';

import { Hub } from './hub';
import { Scope } from './scope';
Expand All @@ -22,6 +22,7 @@ export interface Carrier {
/**
* Extra Hub properties injected by various SDKs
*/
integrations?: Integration[];
extensions?: {
/** Hack to prevent bundlers from breaking our usage of the domain package in the cross-platform Hub package */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
15 changes: 11 additions & 4 deletions packages/node/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,16 @@ export const defaultIntegrations = [
* @see {@link NodeOptions} for documentation on configuration options.
*/
export function init(options: NodeOptions = {}): void {
if (options.defaultIntegrations === undefined) {
options.defaultIntegrations = defaultIntegrations;
}
const carrier = getMainCarrier();
const autoloadedIntegrations = carrier.__SENTRY__?.integrations || [];

options.defaultIntegrations =
options.defaultIntegrations === false
? []
: [
...(Array.isArray(options.defaultIntegrations) ? options.defaultIntegrations : defaultIntegrations),
...autoloadedIntegrations,
];

if (options.dsn === undefined && process.env.SENTRY_DSN) {
options.dsn = process.env.SENTRY_DSN;
Expand Down Expand Up @@ -113,7 +120,7 @@ export function init(options: NodeOptions = {}): void {

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
if ((domain as any).active) {
setHubOnCarrier(getMainCarrier(), getCurrentHub());
setHubOnCarrier(carrier, getCurrentHub());
}

initAndBind(NodeClient, options);
Expand Down
70 changes: 69 additions & 1 deletion packages/node/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { SDK_VERSION } from '@sentry/core';
import { initAndBind, SDK_VERSION } from '@sentry/core';
import { getMainCarrier } from '@sentry/hub';
import { Integration } from '@sentry/types';
import * as domain from 'domain';

import {
Expand All @@ -15,6 +17,14 @@ import {
} from '../src';
import { NodeBackend } from '../src/backend';

jest.mock('@sentry/core', () => {
const original = jest.requireActual('@sentry/core');
return {
...original,
initAndBind: jest.fn().mockImplementation(original.initAndBind),
};
});

const dsn = 'https://[email protected]/4291';

// eslint-disable-next-line no-var
Expand All @@ -26,6 +36,7 @@ describe('SentryNode', () => {
});

beforeEach(() => {
jest.clearAllMocks();
getCurrentHub().pushScope();
});

Expand Down Expand Up @@ -270,7 +281,32 @@ describe('SentryNode', () => {
});
});

function withAutoloadedIntegrations(integrations: Integration[], callback: () => void) {
const carrier = getMainCarrier();
carrier.__SENTRY__!.integrations = integrations;
callback();
carrier.__SENTRY__!.integrations = undefined;
delete carrier.__SENTRY__!.integrations;
}

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

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

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

describe('SentryNode initialization', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('global.SENTRY_RELEASE is used to set release on initialization if available', () => {
global.SENTRY_RELEASE = { id: 'foobar' };
init({ dsn });
Expand Down Expand Up @@ -333,4 +369,36 @@ describe('SentryNode initialization', () => {
expect(sdkData.version).toEqual(SDK_VERSION);
});
});

describe('autoloaded integrations', () => {
it('should attach single integration to default integrations', () => {
withAutoloadedIntegrations([new MockIntegration('foo')], () => {
init({
defaultIntegrations: [new MockIntegration('bar')],
});
const integrations = (initAndBind as jest.Mock).mock.calls[0][1].defaultIntegrations;
expect(integrations.map(i => i.name)).toEqual(['bar', 'foo']);
});
});

it('should attach multiple integrations to default integrations', () => {
withAutoloadedIntegrations([new MockIntegration('foo'), new MockIntegration('bar')], () => {
init({
defaultIntegrations: [new MockIntegration('baz'), new MockIntegration('qux')],
});
const integrations = (initAndBind as jest.Mock).mock.calls[0][1].defaultIntegrations;
expect(integrations.map(i => i.name)).toEqual(['baz', 'qux', 'foo', 'bar']);
});
});

it('should ignore autoloaded integrations when defaultIntegrations:false', () => {
withAutoloadedIntegrations([new MockIntegration('foo')], () => {
init({
defaultIntegrations: false,
});
const integrations = (initAndBind as jest.Mock).mock.calls[0][1].defaultIntegrations;
expect(integrations).toEqual([]);
});
});
});
});
72 changes: 63 additions & 9 deletions packages/tracing/src/hubextensions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { getMainCarrier, Hub } from '@sentry/hub';
import {
CustomSamplingContext,
Integration,
IntegrationClass,
Options,
SamplingContext,
TransactionContext,
TransactionSamplingMethod,
} from '@sentry/types';
import { logger } from '@sentry/utils';
import { dynamicRequire, isNodeEnv, loadModule, logger } from '@sentry/utils';

import { registerErrorInstrumentation } from './errors';
import { IdleTransaction } from './idletransaction';
Expand Down Expand Up @@ -207,14 +209,61 @@ export function startIdleTransaction(
*/
export function _addTracingExtensions(): void {
const carrier = getMainCarrier();
if (carrier.__SENTRY__) {
carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {};
if (!carrier.__SENTRY__.extensions.startTransaction) {
carrier.__SENTRY__.extensions.startTransaction = _startTransaction;
}
if (!carrier.__SENTRY__.extensions.traceHeaders) {
carrier.__SENTRY__.extensions.traceHeaders = traceHeaders;
}
if (!carrier.__SENTRY__) {
return;
}
carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {};
if (!carrier.__SENTRY__.extensions.startTransaction) {
carrier.__SENTRY__.extensions.startTransaction = _startTransaction;
}
if (!carrier.__SENTRY__.extensions.traceHeaders) {
carrier.__SENTRY__.extensions.traceHeaders = traceHeaders;
}
}

/**
* @private
*/
function _autoloadDatabaseIntegrations(): void {
const carrier = getMainCarrier();
if (!carrier.__SENTRY__) {
return;
}

const packageToIntegrationMapping: Record<string, () => Integration> = {
mongodb() {
const integration = dynamicRequire(module, './integrations/mongo') as { Mongo: IntegrationClass<Integration> };
return new integration.Mongo();
},
mongoose() {
const integration = dynamicRequire(module, './integrations/mongo') as { Mongo: IntegrationClass<Integration> };
return new integration.Mongo({ mongoose: true });
},
mysql() {
const integration = dynamicRequire(module, './integrations/mysql') as { Mysql: IntegrationClass<Integration> };
return new integration.Mysql();
},
pg() {
const integration = dynamicRequire(module, './integrations/postgres') as {
Postgres: IntegrationClass<Integration>;
};
return new integration.Postgres();
},
};

const mappedPackages = Object.keys(packageToIntegrationMapping)
.filter(moduleName => !!loadModule(moduleName))
.map(pkg => {
try {
return packageToIntegrationMapping[pkg]();
} catch (e) {
return undefined;
}
})
.filter(p => p) as Integration[];

if (mappedPackages.length > 0) {
carrier.__SENTRY__.integrations = [...(carrier.__SENTRY__.integrations || []), ...mappedPackages];
}
}

Expand All @@ -224,6 +273,11 @@ export function _addTracingExtensions(): void {
export function addExtensionMethods(): void {
_addTracingExtensions();

// Detect and automatically load specified integrations.
if (isNodeEnv()) {
_autoloadDatabaseIntegrations();
}

// If an error happens globally, we should make sure transaction status is set to error.
registerErrorInstrumentation();
}
Loading