diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 5778b4f51fe6..82c310ddef4e 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -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 diff --git a/packages/core/test/lib/sdk.test.ts b/packages/core/test/lib/sdk.test.ts index 241994d7584f..6107d48a6345 100644 --- a/packages/core/test/lib/sdk.test.ts +++ b/packages/core/test/lib/sdk.test.ts @@ -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; diff --git a/packages/hub/src/hub.ts b/packages/hub/src/hub.ts index e3c9bd96d436..6ff2dbcc08f7 100644 --- a/packages/hub/src/hub.ts +++ b/packages/hub/src/hub.ts @@ -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 @@ -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__ || { diff --git a/packages/hub/src/interfaces.ts b/packages/hub/src/interfaces.ts index 9adbf4d5a8de..604526fa432f 100644 --- a/packages/hub/src/interfaces.ts +++ b/packages/hub/src/interfaces.ts @@ -1,4 +1,4 @@ -import { Client } from '@sentry/types'; +import { Client, Integration } from '@sentry/types'; import { Hub } from './hub'; import { Scope } from './scope'; @@ -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 diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index 13f46a4783a1..ccd40d9510ae 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -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; @@ -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); diff --git a/packages/node/test/index.test.ts b/packages/node/test/index.test.ts index 1f4ddff69268..341c3351bb9d 100644 --- a/packages/node/test/index.test.ts +++ b/packages/node/test/index.test.ts @@ -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 { @@ -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://53039209a22b4ec1bcc296a3c9fdecd6@sentry.io/4291'; // eslint-disable-next-line no-var @@ -26,6 +36,7 @@ describe('SentryNode', () => { }); beforeEach(() => { + jest.clearAllMocks(); getCurrentHub().pushScope(); }); @@ -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 }); @@ -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([]); + }); + }); + }); }); diff --git a/packages/tracing/src/hubextensions.ts b/packages/tracing/src/hubextensions.ts index e041a9e33b3b..0238cde6422e 100644 --- a/packages/tracing/src/hubextensions.ts +++ b/packages/tracing/src/hubextensions.ts @@ -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'; @@ -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 Integration> = { + mongodb() { + const integration = dynamicRequire(module, './integrations/mongo') as { Mongo: IntegrationClass }; + return new integration.Mongo(); + }, + mongoose() { + const integration = dynamicRequire(module, './integrations/mongo') as { Mongo: IntegrationClass }; + return new integration.Mongo({ mongoose: true }); + }, + mysql() { + const integration = dynamicRequire(module, './integrations/mysql') as { Mysql: IntegrationClass }; + return new integration.Mysql(); + }, + pg() { + const integration = dynamicRequire(module, './integrations/postgres') as { + Postgres: IntegrationClass; + }; + 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]; } } @@ -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(); } diff --git a/packages/tracing/src/integrations/mongo.ts b/packages/tracing/src/integrations/mongo.ts index 38a3bcaf23fd..f19ae2a10429 100644 --- a/packages/tracing/src/integrations/mongo.ts +++ b/packages/tracing/src/integrations/mongo.ts @@ -1,6 +1,6 @@ import { Hub } from '@sentry/hub'; import { EventProcessor, Integration, SpanContext } from '@sentry/types'; -import { dynamicRequire, fill, isThenable, logger } from '@sentry/utils'; +import { fill, isThenable, loadModule, logger } from '@sentry/utils'; // This allows us to use the same array for both defaults options and the type itself. // (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 { * @inheritDoc */ public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - let collection: MongoCollection; const moduleName = this._useMongoose ? 'mongoose' : 'mongodb'; - try { - const mongodbModule = dynamicRequire(module, moduleName) as { Collection: MongoCollection }; - collection = mongodbModule.Collection; - } catch (e) { + const pkg = loadModule<{ Collection: MongoCollection }>(moduleName); + + if (!pkg) { logger.error(`Mongo Integration was unable to require \`${moduleName}\` package.`); return; } - this._instrumentOperations(collection, this._operations, getCurrentHub); + this._instrumentOperations(pkg.Collection, this._operations, getCurrentHub); } /** diff --git a/packages/tracing/src/integrations/mysql.ts b/packages/tracing/src/integrations/mysql.ts index 146504ee1c28..2d53f7dfc61a 100644 --- a/packages/tracing/src/integrations/mysql.ts +++ b/packages/tracing/src/integrations/mysql.ts @@ -1,6 +1,6 @@ import { Hub } from '@sentry/hub'; import { EventProcessor, Integration } from '@sentry/types'; -import { dynamicRequire, fill, logger } from '@sentry/utils'; +import { fill, loadModule, logger } from '@sentry/utils'; interface MysqlConnection { createQuery: () => void; @@ -22,12 +22,9 @@ export class Mysql implements Integration { * @inheritDoc */ public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - let connection: MysqlConnection; + const pkg = loadModule('mysql/lib/Connection.js'); - try { - // Unfortunatelly mysql is using some custom loading system and `Connection` is not exported directly. - connection = dynamicRequire(module, 'mysql/lib/Connection.js'); - } catch (e) { + if (!pkg) { logger.error('Mysql Integration was unable to require `mysql` package.'); return; } @@ -36,7 +33,7 @@ export class Mysql implements Integration { // function (callback) => void // function (options, callback) => void // function (options, values, callback) => void - fill(connection, 'createQuery', function(orig: () => void) { + fill(pkg, 'createQuery', function(orig: () => void) { return function(this: unknown, options: unknown, values: unknown, callback: unknown) { const scope = getCurrentHub().getScope(); const parentSpan = scope?.getSpan(); diff --git a/packages/tracing/src/integrations/postgres.ts b/packages/tracing/src/integrations/postgres.ts index f78f9d26121b..1645b3065090 100644 --- a/packages/tracing/src/integrations/postgres.ts +++ b/packages/tracing/src/integrations/postgres.ts @@ -1,6 +1,6 @@ import { Hub } from '@sentry/hub'; import { EventProcessor, Integration } from '@sentry/types'; -import { dynamicRequire, fill, logger } from '@sentry/utils'; +import { fill, loadModule, logger } from '@sentry/utils'; interface PgClient { prototype: { @@ -24,12 +24,9 @@ export class Postgres implements Integration { * @inheritDoc */ public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - let client: PgClient; + const pkg = loadModule<{ Client: PgClient }>('pg'); - try { - const pgModule = dynamicRequire(module, 'pg') as { Client: PgClient }; - client = pgModule.Client; - } catch (e) { + if (!pkg) { logger.error('Postgres Integration was unable to require `pg` package.'); return; } @@ -40,7 +37,7 @@ export class Postgres implements Integration { * function (query) => Promise * function (query, params) => Promise */ - fill(client.prototype, 'query', function(orig: () => void | Promise) { + fill(pkg.Client.prototype, 'query', function(orig: () => void | Promise) { return function(this: unknown, config: unknown, values: unknown, callback: unknown) { const scope = getCurrentHub().getScope(); const parentSpan = scope?.getSpan(); diff --git a/packages/tracing/test/integrations/mongo.test.ts b/packages/tracing/test/integrations/mongo.test.ts index f65df207f5ba..460e2c7efab6 100644 --- a/packages/tracing/test/integrations/mongo.test.ts +++ b/packages/tracing/test/integrations/mongo.test.ts @@ -27,7 +27,7 @@ jest.mock('@sentry/utils', () => { const actual = jest.requireActual('@sentry/utils'); return { ...actual, - dynamicRequire() { + loadModule() { return { Collection, }; diff --git a/packages/utils/src/node.ts b/packages/utils/src/node.ts index 790725b69ca1..1b37e0867c07 100644 --- a/packages/utils/src/node.ts +++ b/packages/utils/src/node.ts @@ -17,3 +17,35 @@ export function dynamicRequire(mod: any, request: string): any { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return mod.require(request); } + +/** + * Helper for dynamically loading module that should work with linked dependencies. + * The problem is that we _should_ be using `require(require.resolve(moduleName, { paths: [cwd()] }))` + * However it's _not possible_ to do that with Webpack, as it has to know all the dependencies during + * build time. `require.resolve` is also not available in any other way, so we cannot create, + * a fake helper like we do with `dynamicRequire`. + * + * We always prefer to use local package, thus the value is not returned early from each `try/catch` block. + * That is to mimic the behavior of `require.resolve` exactly. + * + * @param moduleName module name to require + * @returns possibly required module + */ +export function loadModule(moduleName: string): T | undefined { + let mod: T | undefined; + + try { + mod = dynamicRequire(module, moduleName); + } catch (e) { + // no-empty + } + + try { + const { cwd } = dynamicRequire(module, 'process'); + mod = dynamicRequire(module, `${cwd()}/node_modules/${moduleName}`) as T; + } catch (e) { + // no-empty + } + + return mod; +}