Skip to content

Commit fe09ce2

Browse files
committed
feat: Autoload Database Integrations in Node environment
1 parent 726b4aa commit fe09ce2

File tree

10 files changed

+200
-73
lines changed

10 files changed

+200
-73
lines changed

packages/core/src/integration.ts

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addGlobalEventProcessor, getCurrentHub } from '@sentry/hub';
1+
import { addGlobalEventProcessor, getCurrentHub, getMainCarrier } from '@sentry/hub';
22
import { Integration, Options } from '@sentry/types';
33
import { logger } from '@sentry/utils';
44

@@ -9,38 +9,39 @@ export interface IntegrationIndex {
99
[key: string]: Integration;
1010
}
1111

12+
/**
13+
* @private
14+
*/
15+
function filterDuplicates(integrations: Integration[]): Integration[] {
16+
return integrations.reduce((acc, integrations) => {
17+
if (acc.every(accIntegration => integrations.name !== accIntegration.name)) {
18+
acc.push(integrations);
19+
}
20+
return acc;
21+
}, [] as Integration[]);
22+
}
23+
1224
/** Gets integration to install */
1325
export function getIntegrationsToSetup(options: Options): Integration[] {
26+
const carrier = getMainCarrier();
27+
const autoloadedIntegrations = carrier.__SENTRY__?.integrations || [];
1428
const defaultIntegrations = (options.defaultIntegrations && [...options.defaultIntegrations]) || [];
1529
const userIntegrations = options.integrations;
16-
let integrations: Integration[] = [];
17-
if (Array.isArray(userIntegrations)) {
18-
const userIntegrationsNames = userIntegrations.map(i => i.name);
19-
const pickedIntegrationsNames: string[] = [];
2030

21-
// Leave only unique default integrations, that were not overridden with provided user integrations
22-
defaultIntegrations.forEach(defaultIntegration => {
23-
if (
24-
userIntegrationsNames.indexOf(defaultIntegration.name) === -1 &&
25-
pickedIntegrationsNames.indexOf(defaultIntegration.name) === -1
26-
) {
27-
integrations.push(defaultIntegration);
28-
pickedIntegrationsNames.push(defaultIntegration.name);
29-
}
30-
});
31+
let integrations: Integration[] = [...filterDuplicates(defaultIntegrations), ...autoloadedIntegrations];
3132

32-
// Don't add same user integration twice
33-
userIntegrations.forEach(userIntegration => {
34-
if (pickedIntegrationsNames.indexOf(userIntegration.name) === -1) {
35-
integrations.push(userIntegration);
36-
pickedIntegrationsNames.push(userIntegration.name);
37-
}
38-
});
33+
if (Array.isArray(userIntegrations)) {
34+
// Filter out integrations that are also included in user options
35+
integrations = [
36+
...integrations.filter(integrations =>
37+
userIntegrations.every(userIntegration => userIntegration.name !== integrations.name),
38+
),
39+
// And filter out duplicated user options integrations
40+
...filterDuplicates(userIntegrations),
41+
];
3942
} else if (typeof userIntegrations === 'function') {
40-
integrations = userIntegrations(defaultIntegrations);
43+
integrations = userIntegrations(integrations);
4144
integrations = Array.isArray(integrations) ? integrations : [integrations];
42-
} else {
43-
integrations = [...defaultIntegrations];
4445
}
4546

4647
// Make sure that if present, `Debug` integration will always run last

packages/core/test/lib/integration.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ class MockIntegration implements Integration {
1515
}
1616
}
1717

18+
function withAutoloadedIntegrations(integrations: Integration[], callback: () => void) {
19+
(global as any).__SENTRY__ = { integrations };
20+
callback();
21+
delete (global as any).__SENTRY__;
22+
}
23+
1824
describe('getIntegrationsToSetup', () => {
1925
it('works with empty array', () => {
2026
const integrations = getIntegrationsToSetup({
@@ -124,6 +130,40 @@ describe('getIntegrationsToSetup', () => {
124130
expect((integrations[1] as any).order).toEqual('secondUser');
125131
});
126132

133+
it('work with single autoloaded integration', () => {
134+
withAutoloadedIntegrations([new MockIntegration('foo')], () => {
135+
const integrations = getIntegrationsToSetup({});
136+
expect(integrations.map(i => i.name)).toEqual(['foo']);
137+
});
138+
});
139+
140+
it('work with multiple autoloaded integrations', () => {
141+
withAutoloadedIntegrations([new MockIntegration('foo'), new MockIntegration('bar')], () => {
142+
const integrations = getIntegrationsToSetup({});
143+
expect(integrations.map(i => i.name)).toEqual(['foo', 'bar']);
144+
});
145+
});
146+
147+
it('user integrations override autoloaded', () => {
148+
const firstAutoloaded = new MockIntegration('foo');
149+
(firstAutoloaded as any).order = 'firstAutoloaded';
150+
const secondAutoloaded = new MockIntegration('bar');
151+
(secondAutoloaded as any).order = 'secondAutoloaded';
152+
const firstUser = new MockIntegration('foo');
153+
(firstUser as any).order = 'firstUser';
154+
const secondUser = new MockIntegration('bar');
155+
(secondUser as any).order = 'secondUser';
156+
157+
withAutoloadedIntegrations([firstAutoloaded, secondAutoloaded], () => {
158+
const integrations = getIntegrationsToSetup({
159+
integrations: [firstUser, secondUser],
160+
});
161+
expect(integrations.map(i => i.name)).toEqual(['foo', 'bar']);
162+
expect((integrations[0] as any).order).toEqual('firstUser');
163+
expect((integrations[1] as any).order).toEqual('secondUser');
164+
});
165+
});
166+
127167
it('always moves Debug integration to the end of the list', () => {
128168
let integrations = getIntegrationsToSetup({
129169
defaultIntegrations: [new MockIntegration('Debug'), new MockIntegration('foo')],

packages/core/test/lib/sdk.test.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,26 @@ declare var global: any;
99

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

12-
jest.mock('@sentry/hub', () => ({
13-
getCurrentHub(): {
14-
bindClient(client: Client): boolean;
15-
getClient(): boolean;
16-
} {
17-
return {
18-
getClient(): boolean {
19-
return false;
20-
},
21-
bindClient(client: Client): boolean {
22-
client.setupIntegrations();
23-
return true;
24-
},
25-
};
26-
},
27-
}));
12+
jest.mock('@sentry/hub', () => {
13+
const original = jest.requireActual('@sentry/hub');
14+
return {
15+
...original,
16+
getCurrentHub(): {
17+
bindClient(client: Client): boolean;
18+
getClient(): boolean;
19+
} {
20+
return {
21+
getClient(): boolean {
22+
return false;
23+
},
24+
bindClient(client: Client): boolean {
25+
client.setupIntegrations();
26+
return true;
27+
},
28+
};
29+
},
30+
};
31+
});
2832

2933
class MockIntegration implements Integration {
3034
public name: string;

packages/hub/src/interfaces.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Client } from '@sentry/types';
1+
import { Client, Integration } from '@sentry/types';
22

33
import { Hub } from './hub';
44
import { Scope } from './scope';
@@ -22,6 +22,7 @@ export interface Carrier {
2222
/**
2323
* Extra Hub properties injected by various SDKs
2424
*/
25+
integrations?: Integration[];
2526
extensions?: {
2627
/** Hack to prevent bundlers from breaking our usage of the domain package in the cross-platform Hub package */
2728
// eslint-disable-next-line @typescript-eslint/no-explicit-any

packages/tracing/src/hubextensions.ts

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { getMainCarrier, Hub } from '@sentry/hub';
22
import {
33
CustomSamplingContext,
4+
Integration,
5+
IntegrationClass,
46
Options,
57
SamplingContext,
68
TransactionContext,
79
TransactionSamplingMethod,
810
} from '@sentry/types';
9-
import { logger } from '@sentry/utils';
11+
import { dynamicRequire, isNodeEnv, loadModule, logger } from '@sentry/utils';
1012

1113
import { registerErrorInstrumentation } from './errors';
1214
import { IdleTransaction } from './idletransaction';
@@ -206,15 +208,67 @@ export function startIdleTransaction(
206208
* @private
207209
*/
208210
export function _addTracingExtensions(): void {
211+
// FIXME: This is problematic, because `getMainCarrier` always return a valid Carrier, but because
212+
// of how we set the types, we need to perform an unnecessary check here.
209213
const carrier = getMainCarrier();
210-
if (carrier.__SENTRY__) {
211-
carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {};
212-
if (!carrier.__SENTRY__.extensions.startTransaction) {
213-
carrier.__SENTRY__.extensions.startTransaction = _startTransaction;
214-
}
215-
if (!carrier.__SENTRY__.extensions.traceHeaders) {
216-
carrier.__SENTRY__.extensions.traceHeaders = traceHeaders;
217-
}
214+
if (!carrier.__SENTRY__) {
215+
return;
216+
}
217+
carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {};
218+
if (!carrier.__SENTRY__.extensions.startTransaction) {
219+
carrier.__SENTRY__.extensions.startTransaction = _startTransaction;
220+
}
221+
if (!carrier.__SENTRY__.extensions.traceHeaders) {
222+
carrier.__SENTRY__.extensions.traceHeaders = traceHeaders;
223+
}
224+
}
225+
226+
/**
227+
* @private
228+
*/
229+
function _autoloadDatabaseIntegrations(): void {
230+
const carrier = getMainCarrier();
231+
if (!carrier.__SENTRY__) {
232+
return;
233+
}
234+
235+
const supportedPackages = ['mongodb', 'mongoose', 'pg', 'mysql'];
236+
237+
const packageToIntegrationMapping: Record<string, () => Integration> = {
238+
mongodb() {
239+
const integration = dynamicRequire(module, './integrations/mongo') as { Mongo: IntegrationClass<Integration> };
240+
return new integration.Mongo();
241+
},
242+
mongoose() {
243+
const integration = dynamicRequire(module, './integrations/mongo') as { Mongo: IntegrationClass<Integration> };
244+
return new integration.Mongo({ mongoose: true });
245+
},
246+
mysql() {
247+
const integration = dynamicRequire(module, './integrations/mysql') as { Mysql: IntegrationClass<Integration> };
248+
return new integration.Mysql();
249+
},
250+
pg() {
251+
const integration = dynamicRequire(module, './integrations/postgres') as {
252+
Postgres: IntegrationClass<Integration>;
253+
};
254+
return new integration.Postgres();
255+
},
256+
};
257+
258+
const mappedPackages = supportedPackages
259+
.filter(moduleName => !!loadModule(moduleName))
260+
.map(pkg => {
261+
try {
262+
return packageToIntegrationMapping[pkg]();
263+
} catch (e) {
264+
return undefined;
265+
}
266+
})
267+
.filter(p => p) as Integration[];
268+
269+
if (mappedPackages.length > 0) {
270+
carrier.__SENTRY__.integrations = carrier.__SENTRY__.integrations || [];
271+
carrier.__SENTRY__.integrations.concat(mappedPackages);
218272
}
219273
}
220274

@@ -224,6 +278,12 @@ export function _addTracingExtensions(): void {
224278
export function addExtensionMethods(): void {
225279
_addTracingExtensions();
226280

281+
// TODO: Option to disable this (but how, if it's a side-effect and there's no access to client options?)
282+
// Detect and automatically load specified integrations.
283+
if (isNodeEnv()) {
284+
_autoloadDatabaseIntegrations();
285+
}
286+
227287
// If an error happens globally, we should make sure transaction status is set to error.
228288
registerErrorInstrumentation();
229289
}

packages/tracing/src/integrations/mongo.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Hub } from '@sentry/hub';
22
import { EventProcessor, Integration, SpanContext } from '@sentry/types';
3-
import { dynamicRequire, fill, isThenable, logger } from '@sentry/utils';
3+
import { fill, isThenable, loadModule, logger } from '@sentry/utils';
44

55
// This allows us to use the same array for both defaults options and the type itself.
66
// (note `as const` at the end to make it a union of string literal types (i.e. "a" | "b" | ... )
@@ -119,17 +119,15 @@ export class Mongo implements Integration {
119119
* @inheritDoc
120120
*/
121121
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
122-
let collection: MongoCollection;
123122
const moduleName = this._useMongoose ? 'mongoose' : 'mongodb';
124-
try {
125-
const mongodbModule = dynamicRequire(module, moduleName) as { Collection: MongoCollection };
126-
collection = mongodbModule.Collection;
127-
} catch (e) {
123+
const pkg = loadModule<{ Collection: MongoCollection }>(moduleName);
124+
125+
if (!pkg) {
128126
logger.error(`Mongo Integration was unable to require \`${moduleName}\` package.`);
129127
return;
130128
}
131129

132-
this._instrumentOperations(collection, this._operations, getCurrentHub);
130+
this._instrumentOperations(pkg.Collection, this._operations, getCurrentHub);
133131
}
134132

135133
/**

packages/tracing/src/integrations/mysql.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Hub } from '@sentry/hub';
22
import { EventProcessor, Integration } from '@sentry/types';
3-
import { dynamicRequire, fill, logger } from '@sentry/utils';
3+
import { fill, loadModule, logger } from '@sentry/utils';
44

55
interface MysqlConnection {
66
createQuery: () => void;
@@ -22,12 +22,9 @@ export class Mysql implements Integration {
2222
* @inheritDoc
2323
*/
2424
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
25-
let connection: MysqlConnection;
25+
const pkg = loadModule<MysqlConnection>('mysql/lib/Connection.js');
2626

27-
try {
28-
// Unfortunatelly mysql is using some custom loading system and `Connection` is not exported directly.
29-
connection = dynamicRequire(module, 'mysql/lib/Connection.js');
30-
} catch (e) {
27+
if (!pkg) {
3128
logger.error('Mysql Integration was unable to require `mysql` package.');
3229
return;
3330
}
@@ -36,7 +33,7 @@ export class Mysql implements Integration {
3633
// function (callback) => void
3734
// function (options, callback) => void
3835
// function (options, values, callback) => void
39-
fill(connection, 'createQuery', function(orig: () => void) {
36+
fill(pkg, 'createQuery', function(orig: () => void) {
4037
return function(this: unknown, options: unknown, values: unknown, callback: unknown) {
4138
const scope = getCurrentHub().getScope();
4239
const parentSpan = scope?.getSpan();

packages/tracing/src/integrations/postgres.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Hub } from '@sentry/hub';
22
import { EventProcessor, Integration } from '@sentry/types';
3-
import { dynamicRequire, fill, logger } from '@sentry/utils';
3+
import { fill, loadModule, logger } from '@sentry/utils';
44

55
interface PgClient {
66
prototype: {
@@ -24,12 +24,9 @@ export class Postgres implements Integration {
2424
* @inheritDoc
2525
*/
2626
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
27-
let client: PgClient;
27+
const pkg = loadModule<{ Client: PgClient }>('pg');
2828

29-
try {
30-
const pgModule = dynamicRequire(module, 'pg') as { Client: PgClient };
31-
client = pgModule.Client;
32-
} catch (e) {
29+
if (!pkg) {
3330
logger.error('Postgres Integration was unable to require `pg` package.');
3431
return;
3532
}
@@ -40,7 +37,7 @@ export class Postgres implements Integration {
4037
* function (query) => Promise
4138
* function (query, params) => Promise
4239
*/
43-
fill(client.prototype, 'query', function(orig: () => void | Promise<unknown>) {
40+
fill(pkg.Client.prototype, 'query', function(orig: () => void | Promise<unknown>) {
4441
return function(this: unknown, config: unknown, values: unknown, callback: unknown) {
4542
const scope = getCurrentHub().getScope();
4643
const parentSpan = scope?.getSpan();

packages/tracing/test/integrations/mongo.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jest.mock('@sentry/utils', () => {
2727
const actual = jest.requireActual('@sentry/utils');
2828
return {
2929
...actual,
30-
dynamicRequire() {
30+
loadModule() {
3131
return {
3232
Collection,
3333
};

0 commit comments

Comments
 (0)