diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 95b8d6e82b..b3260fc765 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -91,7 +91,7 @@ jobs: RUN_REDIS_TESTS: 1 CASSANDRA_HOST: localhost MONGODB_DB: opentelemetry-tests - MONGODB_HOST: localhost + MONGODB_HOST: 127.0.0.1 MONGODB_PORT: 27017 MSSQL_PASSWORD: mssql_passw0rd MYSQL_DATABASE: circle_database diff --git a/plugins/node/opentelemetry-instrumentation-mongodb/.tav.yml b/plugins/node/opentelemetry-instrumentation-mongodb/.tav.yml index d5c0521307..00de6af24f 100644 --- a/plugins/node/opentelemetry-instrumentation-mongodb/.tav.yml +++ b/plugins/node/opentelemetry-instrumentation-mongodb/.tav.yml @@ -1,6 +1,9 @@ mongodb: - versions: ">=3.3 <4" - commands: npm run test + jobs: + - versions: ">=3.3 <4" + commands: npm run test + - versions: ">=4" + commands: npm run test-new-versions # Fix missing `contrib-test-utils` package pretest: npm run --prefix ../../../ lerna:link diff --git a/plugins/node/opentelemetry-instrumentation-mongodb/README.md b/plugins/node/opentelemetry-instrumentation-mongodb/README.md index 0ad995c89d..317e5a5c1c 100644 --- a/plugins/node/opentelemetry-instrumentation-mongodb/README.md +++ b/plugins/node/opentelemetry-instrumentation-mongodb/README.md @@ -20,7 +20,7 @@ npm install --save @opentelemetry/instrumentation-mongodb ### Supported Versions -- `>=3.3 <4` +- `>=3.3 <5` ## Usage diff --git a/plugins/node/opentelemetry-instrumentation-mongodb/package.json b/plugins/node/opentelemetry-instrumentation-mongodb/package.json index 79f0e0dc4a..8e564040f0 100644 --- a/plugins/node/opentelemetry-instrumentation-mongodb/package.json +++ b/plugins/node/opentelemetry-instrumentation-mongodb/package.json @@ -6,8 +6,9 @@ "types": "build/src/index.d.ts", "repository": "open-telemetry/opentelemetry-js-contrib", "scripts": { - "docker:start": "docker run -e MONGODB_DB=opentelemetry-tests -e MONGODB_PORT=27017 -e MONGODB_HOST=localhost -p 27017:27017 --rm mongo", - "test": "nyc ts-mocha -p tsconfig.json --require '@opentelemetry/contrib-test-utils' 'test/**/*.test.ts'", + "docker:start": "docker run -e MONGODB_DB=opentelemetry-tests -e MONGODB_PORT=27017 -e MONGODB_HOST=127.0.0.1 -p 27017:27017 --rm mongo", + "test": "nyc ts-mocha -p tsconfig.json --require '@opentelemetry/contrib-test-utils' 'test/**/mongodb-v3.test.ts'", + "test-new-versions": "nyc ts-mocha -p tsconfig.json --require '@opentelemetry/contrib-test-utils' 'test/**mongodb-v4.test.ts'", "test-all-versions": "tav", "codecov": "nyc report --reporter=json && codecov -f coverage/*.json -p ../../", "tdd": "npm run test -- --watch-extensions ts --watch", diff --git a/plugins/node/opentelemetry-instrumentation-mongodb/src/instrumentation.ts b/plugins/node/opentelemetry-instrumentation-mongodb/src/instrumentation.ts index eeb01820ea..cc539d9406 100644 --- a/plugins/node/opentelemetry-instrumentation-mongodb/src/instrumentation.ts +++ b/plugins/node/opentelemetry-instrumentation-mongodb/src/instrumentation.ts @@ -39,11 +39,10 @@ import { MongoInternalTopology, WireProtocolInternal, CommandResult, + V4Connection, } from './types'; import { VERSION } from './version'; -const supportedVersions = ['>=3.3 <4']; - /** mongodb instrumentation plugin for OpenTelemetry */ export class MongoDBInstrumentation extends InstrumentationBase< typeof mongodb @@ -53,62 +52,90 @@ export class MongoDBInstrumentation extends InstrumentationBase< } init() { - const { patch, unpatch } = this._getPatches(); + const { v3Patch, v3Unpatch } = this._getV3Patches(); + const { v4Patch, v4Unpatch } = this._getV4Patches(); + return [ new InstrumentationNodeModuleDefinition( 'mongodb', - supportedVersions, + ['>=3.3 <4'], undefined, undefined, [ new InstrumentationNodeModuleFile( 'mongodb/lib/core/wireprotocol/index.js', - supportedVersions, - patch, - unpatch + ['>=3.3 <4'], + v3Patch, + v3Unpatch + ), + ] + ), + new InstrumentationNodeModuleDefinition( + 'mongodb', + ['4.*'], + undefined, + undefined, + [ + new InstrumentationNodeModuleFile( + 'mongodb/lib/cmap/connection.js', + ['4.*'], + v4Patch, + v4Unpatch ), ] ), ]; } - private _getPatches() { + private _getV3Patches() { return { - patch: (moduleExports: T, moduleVersion?: string) => { + v3Patch: (moduleExports: T, moduleVersion?: string) => { diag.debug(`Applying patch for mongodb@${moduleVersion}`); // patch insert operation if (isWrapped(moduleExports.insert)) { this._unwrap(moduleExports, 'insert'); } - this._wrap(moduleExports, 'insert', this._getPatchOperation('insert')); + this._wrap( + moduleExports, + 'insert', + this._getV3PatchOperation('insert') + ); // patch remove operation if (isWrapped(moduleExports.remove)) { this._unwrap(moduleExports, 'remove'); } - this._wrap(moduleExports, 'remove', this._getPatchOperation('remove')); + this._wrap( + moduleExports, + 'remove', + this._getV3PatchOperation('remove') + ); // patch update operation if (isWrapped(moduleExports.update)) { this._unwrap(moduleExports, 'update'); } - this._wrap(moduleExports, 'update', this._getPatchOperation('update')); + this._wrap( + moduleExports, + 'update', + this._getV3PatchOperation('update') + ); // patch other command if (isWrapped(moduleExports.command)) { this._unwrap(moduleExports, 'command'); } - this._wrap(moduleExports, 'command', this._getPatchCommand()); + this._wrap(moduleExports, 'command', this._getV3PatchCommand()); // patch query if (isWrapped(moduleExports.query)) { this._unwrap(moduleExports, 'query'); } - this._wrap(moduleExports, 'query', this._getPatchFind()); + this._wrap(moduleExports, 'query', this._getV3PatchFind()); // patch get more operation on cursor if (isWrapped(moduleExports.getMore)) { this._unwrap(moduleExports, 'getMore'); } - this._wrap(moduleExports, 'getMore', this._getPatchCursor()); + this._wrap(moduleExports, 'getMore', this._getV3PatchCursor()); return moduleExports; }, - unpatch: (moduleExports?: T, moduleVersion?: string) => { + v3Unpatch: (moduleExports?: T, moduleVersion?: string) => { if (moduleExports === undefined) return; diag.debug(`Removing internal patch for mongodb@${moduleVersion}`); this._unwrap(moduleExports, 'insert'); @@ -121,8 +148,33 @@ export class MongoDBInstrumentation extends InstrumentationBase< }; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private _getV4Patches() { + return { + v4Patch: (moduleExports: any, moduleVersion?: string) => { + diag.debug(`Applying patch for mongodb@${moduleVersion}`); + // patch insert operation + if (isWrapped(moduleExports.Connection.prototype.command)) { + this._unwrap(moduleExports, 'command'); + } + + this._wrap( + moduleExports.Connection.prototype, + 'command', + this._getV4PatchCommand() + ); + return moduleExports; + }, + v4Unpatch: (moduleExports?: any, moduleVersion?: string) => { + if (moduleExports === undefined) return; + diag.debug(`Removing internal patch for mongodb@${moduleVersion}`); + this._unwrap(moduleExports.Connection.prototype, 'command'); + }, + }; + } + /** Creates spans for common operations */ - private _getPatchOperation(operationName: 'insert' | 'update' | 'remove') { + private _getV3PatchOperation(operationName: 'insert' | 'update' | 'remove') { const instrumentation = this; return (original: WireProtocolInternal[typeof operationName]) => { return function patchedServerCommand( @@ -154,7 +206,7 @@ export class MongoDBInstrumentation extends InstrumentationBase< } ); - instrumentation._populateAttributes( + instrumentation._populateV3Attributes( span, ns, server, @@ -173,7 +225,7 @@ export class MongoDBInstrumentation extends InstrumentationBase< } /** Creates spans for command operation */ - private _getPatchCommand() { + private _getV3PatchCommand() { const instrumentation = this; return (original: WireProtocolInternal['command']) => { return function patchedServerCommand( @@ -198,13 +250,13 @@ export class MongoDBInstrumentation extends InstrumentationBase< return original.call(this, server, ns, cmd, options, callback); } } - const commandType = instrumentation._getCommandType(cmd); + const commandType = MongoDBInstrumentation._getCommandType(cmd); const type = commandType === MongodbCommandType.UNKNOWN ? 'command' : commandType; const span = instrumentation.tracer.startSpan(`mongodb.${type}`, { kind: SpanKind.CLIENT, }); - instrumentation._populateAttributes(span, ns, server, cmd); + instrumentation._populateV3Attributes(span, ns, server, cmd); const patchedCallback = instrumentation._patchEnd(span, resultHandler); // handle when options is the callback to send the correct number of args if (typeof options === 'function') { @@ -216,8 +268,44 @@ export class MongoDBInstrumentation extends InstrumentationBase< }; } + /** Creates spans for command operation */ + private _getV4PatchCommand() { + const instrumentation = this; + return (original: V4Connection['command']) => { + return function patchedV4ServerCommand( + this: unknown, + ns: any, + cmd: any, + options: undefined | unknown, + callback: any + ) { + const currentSpan = trace.getSpan(context.active()); + const resultHandler = callback; + if ( + !currentSpan || + typeof resultHandler !== 'function' || + typeof cmd !== 'object' || + cmd.ismaster || + cmd.hello + ) { + return original.call(this, ns, cmd, options, callback); + } + const commandType = Object.keys(cmd)[0]; + const span = instrumentation.tracer.startSpan( + `mongodb.${commandType}`, + { + kind: SpanKind.CLIENT, + } + ); + instrumentation._populateV4Attributes(span, this, ns, cmd); + const patchedCallback = instrumentation._patchEnd(span, resultHandler); + return original.call(this, ns, cmd, options, patchedCallback); + }; + }; + } + /** Creates spans for find operation */ - private _getPatchFind() { + private _getV3PatchFind() { const instrumentation = this; return (original: WireProtocolInternal['query']) => { return function patchedServerCommand( @@ -254,7 +342,7 @@ export class MongoDBInstrumentation extends InstrumentationBase< const span = instrumentation.tracer.startSpan('mongodb.find', { kind: SpanKind.CLIENT, }); - instrumentation._populateAttributes(span, ns, server, cmd); + instrumentation._populateV3Attributes(span, ns, server, cmd); const patchedCallback = instrumentation._patchEnd(span, resultHandler); // handle when options is the callback to send the correct number of args if (typeof options === 'function') { @@ -282,7 +370,7 @@ export class MongoDBInstrumentation extends InstrumentationBase< } /** Creates spans for find operation */ - private _getPatchCursor() { + private _getV3PatchCursor() { const instrumentation = this; return (original: WireProtocolInternal['getMore']) => { return function patchedServerCommand( @@ -322,7 +410,12 @@ export class MongoDBInstrumentation extends InstrumentationBase< const span = instrumentation.tracer.startSpan('mongodb.getMore', { kind: SpanKind.CLIENT, }); - instrumentation._populateAttributes(span, ns, server, cursorState.cmd); + instrumentation._populateV3Attributes( + span, + ns, + server, + cursorState.cmd + ); const patchedCallback = instrumentation._patchEnd(span, resultHandler); // handle when options is the callback to send the correct number of args if (typeof options === 'function') { @@ -353,7 +446,9 @@ export class MongoDBInstrumentation extends InstrumentationBase< * Get the mongodb command type from the object. * @param command Internal mongodb command object */ - private _getCommandType(command: MongoInternalCommand): MongodbCommandType { + private static _getCommandType( + command: MongoInternalCommand + ): MongodbCommandType { if (command.createIndexes !== undefined) { return MongodbCommandType.CREATE_INDEXES; } else if (command.findandmodify !== undefined) { @@ -370,22 +465,66 @@ export class MongoDBInstrumentation extends InstrumentationBase< /** * Populate span's attributes by fetching related metadata from the context * @param span span to add attributes to + * @param connectionCtx mongodb internal connection context * @param ns mongodb namespace * @param command mongodb internal representation of a command + */ + private _populateV4Attributes( + span: Span, + connectionCtx: any, + ns: any, + command?: any + ) { + let host, port: undefined | string; + if (connectionCtx) { + const hostParts = + typeof connectionCtx.address === 'string' + ? connectionCtx.address.split(':') + : ''; + if (hostParts.length === 2) { + host = hostParts[0]; + port = hostParts[1]; + } + } + // capture parameters within the query as well if enhancedDatabaseReporting is enabled. + let commandObj: Record; + if (command?.documents && command.documents[0]) { + commandObj = command.documents[0]; + } else if (command?.cursors) { + commandObj = command.cursors; + } else { + commandObj = command; + } + + this._addAllSpanAttributes( + span, + ns.db, + ns.collection, + host, + port, + commandObj + ); + } + + /** + * Populate span's attributes by fetching related metadata from the context + * @param span span to add attributes to + * @param ns mongodb namespace * @param topology mongodb internal representation of the network topology + * @param command mongodb internal representation of a command */ - private _populateAttributes( + private _populateV3Attributes( span: Span, ns: string, topology: MongoInternalTopology, command?: MongoInternalCommand ) { // add network attributes to determine the remote server + let host: undefined | string; + let port: undefined | string; if (topology && topology.s) { - let host = topology.s.options?.host ?? topology.s.host; - let port: string | undefined = ( - topology.s.options?.port ?? topology.s.port - )?.toString(); + host = topology.s.options?.host ?? topology.s.host; + port = (topology.s.options?.port ?? topology.s.port)?.toString(); if (host == null || port == null) { const address = topology.description?.address; if (address) { @@ -394,12 +533,6 @@ export class MongoDBInstrumentation extends InstrumentationBase< port = addressSegments[1]; } } - if (host?.length && port?.length) { - span.setAttributes({ - [SemanticAttributes.NET_HOST_NAME]: host, - [SemanticAttributes.NET_HOST_PORT]: port, - }); - } } // The namespace is a combination of the database name and the name of the @@ -407,7 +540,27 @@ export class MongoDBInstrumentation extends InstrumentationBase< // It could be a string or an instance of MongoDBNamespace, as such we // always coerce to a string to extract db and collection. const [dbName, dbCollection] = ns.toString().split('.'); + // capture parameters within the query as well if enhancedDatabaseReporting is enabled. + const commandObj = command?.query ?? command?.q ?? command; + this._addAllSpanAttributes( + span, + dbName, + dbCollection, + host, + port, + commandObj + ); + } + + private _addAllSpanAttributes( + span: Span, + dbName?: string, + dbCollection?: string, + host?: undefined | string, + port?: undefined | string, + commandObj?: any + ) { // add database related attributes span.setAttributes({ [SemanticAttributes.DB_SYSTEM]: 'mongodb', @@ -415,10 +568,13 @@ export class MongoDBInstrumentation extends InstrumentationBase< [SemanticAttributes.DB_MONGODB_COLLECTION]: dbCollection, }); - if (command === undefined) return; - - // capture parameters within the query as well if enhancedDatabaseReporting is enabled. - const commandObj = command.query ?? command.q ?? command; + if (host && port) { + span.setAttributes({ + [SemanticAttributes.NET_HOST_NAME]: host, + [SemanticAttributes.NET_HOST_PORT]: port, + }); + } + if (!commandObj) return; const dbStatementSerializer = typeof this._config.dbStatementSerializer === 'function' ? this._config.dbStatementSerializer @@ -452,7 +608,6 @@ export class MongoDBInstrumentation extends InstrumentationBase< /** * Triggers the response hook in case it is defined. * @param span The span to add the results to. - * @param config The MongoDB instrumentation config object * @param result The command result */ private _handleExecutionResult(span: Span, result: CommandResult) { diff --git a/plugins/node/opentelemetry-instrumentation-mongodb/src/types.ts b/plugins/node/opentelemetry-instrumentation-mongodb/src/types.ts index 0292ecb62c..d81c97b08d 100644 --- a/plugins/node/opentelemetry-instrumentation-mongodb/src/types.ts +++ b/plugins/node/opentelemetry-instrumentation-mongodb/src/types.ts @@ -161,3 +161,13 @@ export enum MongodbCommandType { COUNT = 'count', UNKNOWN = 'unknown', } + +// https://github.com/mongodb/node-mongodb-native/blob/v4.2.2/src/cmap/connection.ts +export type V4Connection = { + command( + ns: any, + cmd: Document, + options: undefined | unknown, + callback: any + ): void; +}; diff --git a/plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb.test.ts b/plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb-v3.test.ts similarity index 65% rename from plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb.test.ts rename to plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb-v3.test.ts index c7c0a916c3..45229d8789 100644 --- a/plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb.test.ts +++ b/plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb-v3.test.ts @@ -19,7 +19,7 @@ import { context, trace, SpanKind, Span } from '@opentelemetry/api'; import * as assert from 'assert'; import { MongoDBInstrumentation, MongoDBInstrumentationConfig } from '../src'; -import { MongoResponseHookInformation } from '../src/types'; +import { MongoResponseHookInformation } from '../src'; import { registerInstrumentationTesting, getTestSpans, @@ -47,7 +47,6 @@ describe('MongoDBInstrumentation', () => { console.log('Skipping test-mongodb. Run MongoDB to test'); shouldTest = false; } - // shouldTest = true const URL = `mongodb://${process.env.MONGODB_HOST || DEFAULT_MONGO_HOST}:${ process.env.MONGODB_PORT || '27017' @@ -109,37 +108,48 @@ describe('MongoDBInstrumentation', () => { const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; const span = trace.getTracer('default').startSpan('insertRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.insertMany(insertData, (err, result) => { - span.end(); - assert.ifError(err); - assertSpans(getTestSpans(), 'mongodb.insert', SpanKind.CLIENT); - done(); - }); + collection + .insertMany(insertData) + .then(() => { + span.end(); + assertSpans(getTestSpans(), 'mongodb.insert', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); }); }); it('should create a child span for update', done => { const span = trace.getTracer('default').startSpan('updateRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.updateOne({ a: 2 }, { $set: { b: 1 } }, (err, result) => { - span.end(); - console.log(getTestSpans()); - assert.ifError(err); - assertSpans(getTestSpans(), 'mongodb.update', SpanKind.CLIENT); - done(); - }); + collection + .updateOne({ a: 2 }, { $set: { b: 1 } }) + .then(() => { + span.end(); + assertSpans(getTestSpans(), 'mongodb.update', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); }); }); it('should create a child span for remove', done => { const span = trace.getTracer('default').startSpan('removeRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.deleteOne({ a: 3 }, (err, result) => { - span.end(); - assert.ifError(err); - assertSpans(getTestSpans(), 'mongodb.remove', SpanKind.CLIENT); - done(); - }); + collection + .deleteOne({ a: 3 }) + .then(() => { + span.end(); + assertSpans(getTestSpans(), 'mongodb.remove', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); }); }); }); @@ -149,41 +159,52 @@ describe('MongoDBInstrumentation', () => { it('should create a child span for find', done => { const span = trace.getTracer('default').startSpan('findRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.find({ a: 1 }).toArray((err, result) => { - span.end(); - assert.ifError(err); - assertSpans(getTestSpans(), 'mongodb.find', SpanKind.CLIENT); - done(); - }); + collection + .find({ a: 1 }) + .toArray() + .then(() => { + span.end(); + assertSpans(getTestSpans(), 'mongodb.find', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); }); }); + it('should create a child span for cursor operations', done => { const span = trace.getTracer('default').startSpan('findRootSpan'); context.with(trace.setSpan(context.active(), span), () => { const cursor = collection.find().batchSize(1); cursor.next().then(firstElement => { assert(firstElement !== null); - cursor.next().then(secondElement => { - span.end(); - assert(secondElement !== null); - // assert that we correctly got the first as a find - assertSpans( - getTestSpans().filter( - span => span.name.includes('mongodb.getMore') === false - ), - 'mongodb.find', - SpanKind.CLIENT - ); - // assert that we correctly got the first as a find - assertSpans( - getTestSpans().filter( - span => span.name.includes('mongodb.find') === false - ), - 'mongodb.getMore', - SpanKind.CLIENT - ); - done(); - }); + cursor + .next() + .then(secondElement => { + span.end(); + assert(secondElement !== null); + // assert that we correctly got the first as a find + assertSpans( + getTestSpans().filter( + span => !span.name.includes('mongodb.getMore') + ), + 'mongodb.find', + SpanKind.CLIENT + ); + // assert that we correctly got the first as a find + assertSpans( + getTestSpans().filter( + span => !span.name.includes('mongodb.find') + ), + 'mongodb.getMore', + SpanKind.CLIENT + ); + done(); + }) + .catch(err => { + done(err); + }); }); }); }); @@ -194,12 +215,20 @@ describe('MongoDBInstrumentation', () => { it('should create a child span for create index', done => { const span = trace.getTracer('default').startSpan('indexRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.createIndex({ a: 1 }, (err, result) => { - span.end(); - assert.ifError(err); - assertSpans(getTestSpans(), 'mongodb.createIndexes', SpanKind.CLIENT); - done(); - }); + collection + .createIndex({ a: 1 }) + .then(() => { + span.end(); + assertSpans( + getTestSpans(), + 'mongodb.createIndexes', + SpanKind.CLIENT + ); + done(); + }) + .catch(err => { + done(err); + }); }); }); }); @@ -218,18 +247,23 @@ describe('MongoDBInstrumentation', () => { it('should properly collect db statement (hide attribute values)', done => { const span = trace.getTracer('default').startSpan('insertRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.insertOne(object).then(() => { - span.end(); - const spans = getTestSpans(); - const operationName = 'mongodb.insert'; - assertSpans(spans, operationName, SpanKind.CLIENT, false, false); - const mongoSpan = spans.find(s => s.name === operationName); - const dbStatement = JSON.parse( - mongoSpan!.attributes[SemanticAttributes.DB_STATEMENT] as string - ); - assert.strictEqual(dbStatement[key], '?'); - done(); - }); + collection + .insertOne(object) + .then(() => { + span.end(); + const spans = getTestSpans(); + const operationName = 'mongodb.insert'; + assertSpans(spans, operationName, SpanKind.CLIENT, false, false); + const mongoSpan = spans.find(s => s.name === operationName); + const dbStatement = JSON.parse( + mongoSpan!.attributes[SemanticAttributes.DB_STATEMENT] as string + ); + assert.strictEqual(dbStatement[key], '?'); + done(); + }) + .catch(err => { + done(err); + }); }); }); }); @@ -251,18 +285,23 @@ describe('MongoDBInstrumentation', () => { it('should properly collect db statement', done => { const span = trace.getTracer('default').startSpan('insertRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.insertOne(object).then(() => { - span.end(); - const spans = getTestSpans(); - const operationName = 'mongodb.insert'; - assertSpans(spans, operationName, SpanKind.CLIENT, false, true); - const mongoSpan = spans.find(s => s.name === operationName); - const dbStatement = JSON.parse( - mongoSpan!.attributes[SemanticAttributes.DB_STATEMENT] as string - ); - assert.strictEqual(dbStatement[key], value); - done(); - }); + collection + .insertOne(object) + .then(() => { + span.end(); + const spans = getTestSpans(); + const operationName = 'mongodb.insert'; + assertSpans(spans, operationName, SpanKind.CLIENT, false, true); + const mongoSpan = spans.find(s => s.name === operationName); + const dbStatement = JSON.parse( + mongoSpan!.attributes[SemanticAttributes.DB_STATEMENT] as string + ); + assert.strictEqual(dbStatement[key], value); + done(); + }) + .catch(err => { + done(err); + }); }); }); }); @@ -280,12 +319,17 @@ describe('MongoDBInstrumentation', () => { it('should not do any harm when throwing an exception', done => { const span = trace.getTracer('default').startSpan('insertRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.insertOne(object).then(() => { - span.end(); - const spans = getTestSpans(); - assertSpans(spans, 'mongodb.insert', SpanKind.CLIENT); - done(); - }); + collection + .insertOne(object) + .then(() => { + span.end(); + const spans = getTestSpans(); + assertSpans(spans, 'mongodb.insert', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); }); }); }); @@ -293,7 +337,6 @@ describe('MongoDBInstrumentation', () => { describe('when specifying a responseHook configuration', () => { const dataAttributeName = 'mongodb_data'; - describe('with a valid function', () => { beforeEach(() => { create({ @@ -310,41 +353,53 @@ describe('MongoDBInstrumentation', () => { const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; const span = trace.getTracer('default').startSpan('insertRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.insertMany(insertData, (err, result) => { - span.end(); - assert.ifError(err); - const spans = getTestSpans(); - const insertSpan = spans[0]; - - assert.deepStrictEqual( - JSON.parse(insertSpan.attributes[dataAttributeName] as string), - result.result - ); + collection + .insertMany(insertData) + .then(results => { + span.end(); + const spans = getTestSpans(); + const insertSpan = spans[0]; + + assert.deepStrictEqual( + JSON.parse(insertSpan.attributes[dataAttributeName] as string), + (results)?.result + ); - done(); - }); + done(); + }) + .catch(err => { + done(err); + }); }); }); it('should attach response hook data to the resulting span for find function', done => { const span = trace.getTracer('default').startSpan('findRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.find({ a: 1 }).toArray((err, results) => { - span.end(); - assert.ifError(err); - const spans = getTestSpans(); - const findSpan = spans[0]; - const hookAttributeValue = JSON.parse( - findSpan.attributes[dataAttributeName] as string - ); - - assert.strictEqual( - hookAttributeValue?.cursor?.firstBatch[0]._id, - results[0]._id.toString() - ); + collection + .find({ a: 1 }) + .toArray() + .then(results => { + span.end(); + const spans = getTestSpans(); + const findSpan = spans[0]; + const hookAttributeValue = JSON.parse( + findSpan.attributes[dataAttributeName] as string + ); - done(); - }); + if (results) { + assert.strictEqual( + hookAttributeValue?.cursor?.firstBatch[0]._id, + results[0]._id.toString() + ); + } else { + throw new Error('Got an unexpected Results: ' + results); + } + done(); + }) + .catch(err => { + done(err); + }); }); }); }); @@ -357,19 +412,21 @@ describe('MongoDBInstrumentation', () => { }, }); }); - it('should not do any harm when throwing an exception', done => { const span = trace.getTracer('default').startSpan('findRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.find({ a: 1 }).toArray((err, results) => { - span.end(); - const spans = getTestSpans(); - - assert.ifError(err); - assertSpans(spans, 'mongodb.find', SpanKind.CLIENT); - - done(); - }); + collection + .find({ a: 1 }) + .toArray() + .then(() => { + span.end(); + const spans = getTestSpans(); + assertSpans(spans, 'mongodb.find', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); }); }); }); @@ -380,27 +437,36 @@ describe('MongoDBInstrumentation', () => { const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; const span = trace.getTracer('default').startSpan('insertRootSpan'); context.with(trace.setSpan(context.active(), span), () => { - collection.insertMany(insertData, (err, result) => { - span.end(); - assert.ifError(err); - const spans = getTestSpans(); - const mainSpan = spans[spans.length - 1]; - assertSpans(spans, 'mongodb.insert', SpanKind.CLIENT); - resetMemoryExporter(); - - collection.find({ a: 1 }).toArray((err, result) => { - const spans2 = getTestSpans(); - spans2.push(mainSpan); - - assert.ifError(err); - assertSpans(spans2, 'mongodb.find', SpanKind.CLIENT); - assert.strictEqual( - mainSpan.spanContext().spanId, - spans2[0].parentSpanId - ); - done(); + collection + .insertMany(insertData) + .then(() => { + span.end(); + const spans = getTestSpans(); + const mainSpan = spans[spans.length - 1]; + assertSpans(spans, 'mongodb.insert', SpanKind.CLIENT); + resetMemoryExporter(); + + collection + .find({ a: 1 }) + .toArray() + .then(() => { + const spans2 = getTestSpans(); + spans2.push(mainSpan); + + assertSpans(spans2, 'mongodb.find', SpanKind.CLIENT); + assert.strictEqual( + mainSpan.spanContext().spanId, + spans2[0].parentSpanId + ); + done(); + }) + .catch(err => { + done(err); + }); + }) + .catch(err => { + done(err); }); - }); }); }); }); @@ -410,6 +476,8 @@ describe('MongoDBInstrumentation', () => { let collection: mongodb.Collection; before(done => { accessCollection(URL, DB_NAME, COLLECTION_NAME, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore useUnifiedTopology: true, }) .then(result => { @@ -468,32 +536,46 @@ describe('MongoDBInstrumentation', () => { it('should not create a child span for query', done => { const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; const span = trace.getTracer('default').startSpan('insertRootSpan'); - collection.insertMany(insertData, (err, result) => { - span.end(); - assert.ifError(err); - assert.strictEqual(getTestSpans().length, 1); - done(); - }); + collection + .insertMany(insertData) + .then(() => { + span.end(); + assert.strictEqual(getTestSpans().length, 1); + done(); + }) + .catch(err => { + done(err); + }); }); it('should not create a child span for cursor', done => { const span = trace.getTracer('default').startSpan('findRootSpan'); - collection.find({}).toArray((err, result) => { - span.end(); - assert.ifError(err); - assert.strictEqual(getTestSpans().length, 1); - done(); - }); + collection + .find({}) + .toArray() + .then(() => { + span.end(); + assert.strictEqual(getTestSpans().length, 1); + done(); + }) + .catch(err => { + assert.ifError(err); + done(err); + }); }); it('should not create a child span for command', done => { const span = trace.getTracer('default').startSpan('indexRootSpan'); - collection.createIndex({ a: 1 }, (err, result) => { - span.end(); - assert.ifError(err); - assert.strictEqual(getTestSpans().length, 1); - done(); - }); + collection + .createIndex({ a: 1 }) + .then(() => { + span.end(); + assert.strictEqual(getTestSpans().length, 1); + done(); + }) + .catch(err => { + done(err); + }); }); }); }); diff --git a/plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb-v4.test.ts b/plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb-v4.test.ts new file mode 100644 index 0000000000..f42be4b03b --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-mongodb/test/mongodb-v4.test.ts @@ -0,0 +1,522 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// for testing locally "npm run docker:start" + +import { context, trace, SpanKind, Span } from '@opentelemetry/api'; +import * as assert from 'assert'; +import { MongoDBInstrumentation, MongoDBInstrumentationConfig } from '../src'; +import { MongoResponseHookInformation } from '../src'; +import { + registerInstrumentationTesting, + getTestSpans, + resetMemoryExporter, +} from '@opentelemetry/contrib-test-utils'; + +const instrumentation = registerInstrumentationTesting( + new MongoDBInstrumentation() +); + +import * as mongodb from 'mongodb'; +import { assertSpans, accessCollection, DEFAULT_MONGO_HOST } from './utils'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; + +describe('MongoDBInstrumentation', () => { + function create(config: MongoDBInstrumentationConfig = {}) { + instrumentation.setConfig(config); + } + // For these tests, mongo must be running. Add RUN_MONGODB_TESTS to run + // these tests. + const RUN_MONGODB_TESTS = process.env.RUN_MONGODB_TESTS as string; + let shouldTest = true; + if (!RUN_MONGODB_TESTS) { + console.log('Skipping test-mongodb. Run MongoDB to test'); + shouldTest = false; + } + + const URL = `mongodb://${process.env.MONGODB_HOST || DEFAULT_MONGO_HOST}:${ + process.env.MONGODB_PORT || '27017' + }`; + const DB_NAME = process.env.MONGODB_DB || 'opentelemetry-tests'; + const COLLECTION_NAME = 'test'; + + let client: mongodb.MongoClient; + let collection: mongodb.Collection; + + before(done => { + shouldTest = true; + accessCollection(URL, DB_NAME, COLLECTION_NAME) + .then(result => { + client = result.client; + collection = result.collection; + done(); + }) + .catch((err: Error) => { + console.log( + 'Skipping test-mongodb. Could not connect. Run MongoDB to test' + ); + shouldTest = false; + done(); + }); + }); + + beforeEach(function mongoBeforeEach(done) { + // Skipping all tests in beforeEach() is a workaround. Mocha does not work + // properly when skipping tests in before() on nested describe() calls. + // https://github.com/mochajs/mocha/issues/2819 + if (!shouldTest) { + this.skip(); + } + // Non traced insertion of basic data to perform tests + const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; + collection.insertMany(insertData, (err, result) => { + resetMemoryExporter(); + done(); + }); + }); + + afterEach(done => { + if (shouldTest) { + return collection.deleteMany({}, done); + } + done(); + }); + + after(() => { + if (client) { + client.close(); + } + }); + + /** Should intercept query */ + describe('Instrumenting query operations', () => { + it('should create a child span for insert', done => { + const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; + const span = trace.getTracer('default').startSpan('insertRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .insertMany(insertData) + .then(() => { + span.end(); + assertSpans(getTestSpans(), 'mongodb.insert', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + + it('should create a child span for update', done => { + const span = trace.getTracer('default').startSpan('updateRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .updateOne({ a: 2 }, { $set: { b: 1 } }) + .then(() => { + span.end(); + assertSpans(getTestSpans(), 'mongodb.update', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + + it('should create a child span for remove', done => { + const span = trace.getTracer('default').startSpan('removeRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .deleteOne({ a: 3 }) + .then(() => { + span.end(); + assertSpans(getTestSpans(), 'mongodb.delete', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + + /** Should intercept cursor */ + describe('Instrumenting cursor operations', () => { + it('should create a child span for find', done => { + const span = trace.getTracer('default').startSpan('findRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .find({ a: 1 }) + .toArray() + .then(() => { + span.end(); + assertSpans(getTestSpans(), 'mongodb.find', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + + it('should create a child span for cursor operations', done => { + const span = trace.getTracer('default').startSpan('findRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + const cursor = collection.find().batchSize(1); + cursor.next().then(firstElement => { + assert(firstElement !== null); + cursor + .next() + .then(secondElement => { + span.end(); + assert(secondElement !== null); + // assert that we correctly got the first as a find + assertSpans( + getTestSpans().filter( + span => !span.name.includes('mongodb.getMore') + ), + 'mongodb.find', + SpanKind.CLIENT + ); + // assert that we correctly got the first as a find + assertSpans( + getTestSpans().filter( + span => !span.name.includes('mongodb.find') + ), + 'mongodb.getMore', + SpanKind.CLIENT + ); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + }); + + /** Should intercept command */ + describe('Instrumenting command operations', () => { + it('should create a child span for create index', done => { + const span = trace.getTracer('default').startSpan('indexRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .createIndex({ a: 1 }) + .then(() => { + span.end(); + assertSpans( + getTestSpans(), + 'mongodb.createIndexes', + SpanKind.CLIENT + ); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + + describe('when using enhanced database reporting without db statementSerializer', () => { + const key = 'key'; + const value = 'value'; + const object = { [key]: value }; + + beforeEach(() => { + create({ + enhancedDatabaseReporting: false, + }); + }); + + it('should properly collect db statement (hide attribute values)', done => { + const span = trace.getTracer('default').startSpan('insertRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .insertOne(object) + .then(() => { + span.end(); + const spans = getTestSpans(); + const operationName = 'mongodb.insert'; + assertSpans(spans, operationName, SpanKind.CLIENT, false, false); + const mongoSpan = spans.find(s => s.name === operationName); + const dbStatement = JSON.parse( + mongoSpan!.attributes[SemanticAttributes.DB_STATEMENT] as string + ); + assert.strictEqual(dbStatement[key], '?'); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + + describe('when specifying a dbStatementSerializer configuration', () => { + const key = 'key'; + const value = 'value'; + const object = { [key]: value }; + + describe('with a valid function', () => { + beforeEach(() => { + create({ + dbStatementSerializer: (commandObj: Record) => { + return JSON.stringify(commandObj); + }, + }); + }); + + it('should properly collect db statement', done => { + const span = trace.getTracer('default').startSpan('insertRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .insertOne(object) + .then(() => { + span.end(); + const spans = getTestSpans(); + const operationName = 'mongodb.insert'; + assertSpans(spans, operationName, SpanKind.CLIENT, false, true); + const mongoSpan = spans.find(s => s.name === operationName); + const dbStatement = JSON.parse( + mongoSpan!.attributes[SemanticAttributes.DB_STATEMENT] as string + ); + assert.strictEqual(dbStatement[key], value); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + + describe('with an invalid function', () => { + beforeEach(() => { + create({ + enhancedDatabaseReporting: true, + dbStatementSerializer: (_commandObj: Record) => { + throw new Error('something went wrong!'); + }, + }); + }); + + it('should not do any harm when throwing an exception', done => { + const span = trace.getTracer('default').startSpan('insertRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .insertOne(object) + .then(() => { + span.end(); + const spans = getTestSpans(); + assertSpans(spans, 'mongodb.insert', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + }); + + describe('when specifying a responseHook configuration', () => { + const dataAttributeName = 'mongodb_data'; + describe('with a valid function', () => { + beforeEach(() => { + create({ + responseHook: (span: Span, result: MongoResponseHookInformation) => { + span.setAttribute(dataAttributeName, JSON.stringify(result.data)); + }, + }); + }); + + it('should attach response hook data to the resulting span for insert function', done => { + const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; + const span = trace.getTracer('default').startSpan('insertRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .insertMany(insertData) + .then(results => { + span.end(); + const spans = getTestSpans(); + const insertSpan = spans[0]; + assert.deepStrictEqual( + JSON.parse(insertSpan.attributes[dataAttributeName] as string) + .n, + results?.insertedCount + ); + + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + + it('should attach response hook data to the resulting span for find function', done => { + const span = trace.getTracer('default').startSpan('findRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .find({ a: 1 }) + .toArray() + .then(results => { + span.end(); + const spans = getTestSpans(); + const findSpan = spans[0]; + const hookAttributeValue = JSON.parse( + findSpan.attributes[dataAttributeName] as string + ); + + if (results) { + assert.strictEqual( + hookAttributeValue?.cursor?.firstBatch[0]._id, + results[0]._id.toString() + ); + } else { + throw new Error('Got an unexpected Results: ' + results); + } + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + + describe('with an invalid function', () => { + beforeEach(() => { + create({ + responseHook: (span: Span, result: MongoResponseHookInformation) => { + throw 'some error'; + }, + }); + }); + it('should not do any harm when throwing an exception', done => { + const span = trace.getTracer('default').startSpan('findRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .find({ a: 1 }) + .toArray() + .then(() => { + span.end(); + const spans = getTestSpans(); + assertSpans(spans, 'mongodb.find', SpanKind.CLIENT); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + }); + + describe('Mixed operations with callback', () => { + it('should create a span for find after callback insert', done => { + const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; + const span = trace.getTracer('default').startSpan('insertRootSpan'); + context.with(trace.setSpan(context.active(), span), () => { + collection + .insertMany(insertData) + .then(() => { + span.end(); + const spans = getTestSpans(); + const mainSpan = spans[spans.length - 1]; + assertSpans(spans, 'mongodb.insert', SpanKind.CLIENT); + resetMemoryExporter(); + + collection + .find({ a: 1 }) + .toArray() + .then(() => { + const spans2 = getTestSpans(); + spans2.push(mainSpan); + assertSpans(spans2, 'mongodb.find', SpanKind.CLIENT); + assert.strictEqual( + mainSpan.spanContext().spanId, + spans2[0].parentSpanId + ); + done(); + }) + .catch(err => { + done(err); + }); + }) + .catch(err => { + done(err); + }); + }); + }); + }); + + /** Should intercept command */ + describe('Removing Instrumentation', () => { + it('should unpatch plugin', () => { + assert.doesNotThrow(() => { + instrumentation.disable(); + }); + }); + + it('should not create a child span for query', done => { + const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; + const span = trace.getTracer('default').startSpan('insertRootSpan'); + collection + .insertMany(insertData) + .then(() => { + span.end(); + assert.strictEqual(getTestSpans().length, 1); + done(); + }) + .catch(err => { + done(err); + }); + }); + + it('should not create a child span for cursor', done => { + const span = trace.getTracer('default').startSpan('findRootSpan'); + collection + .find({}) + .toArray() + .then(() => { + span.end(); + assert.strictEqual(getTestSpans().length, 1); + done(); + }) + .catch(err => { + assert.ifError(err); + done(err); + }); + }); + + it('should not create a child span for command', done => { + const span = trace.getTracer('default').startSpan('indexRootSpan'); + collection + .createIndex({ a: 1 }) + .then(() => { + span.end(); + assert.strictEqual(getTestSpans().length, 1); + done(); + }) + .catch(err => { + done(err); + }); + }); + }); +}); diff --git a/plugins/node/opentelemetry-instrumentation-mongodb/test/utils.ts b/plugins/node/opentelemetry-instrumentation-mongodb/test/utils.ts index df7dfc0801..b63d60e57c 100644 --- a/plugins/node/opentelemetry-instrumentation-mongodb/test/utils.ts +++ b/plugins/node/opentelemetry-instrumentation-mongodb/test/utils.ts @@ -32,6 +32,7 @@ export interface MongoDBAccess { * @param url The mongodb URL to access. * @param dbName The mongodb database name. * @param collectionName The mongodb collection name. + * @param options The mongodb client config options. */ export function accessCollection( url: string, @@ -40,15 +41,15 @@ export function accessCollection( options: mongodb.MongoClientOptions = {} ): Promise { return new Promise((resolve, reject) => { - mongodb.MongoClient.connect(url, options, (err, client) => { - if (err) { - reject(err); - return; - } - const db = client.db(dbName); - const collection = db.collection(collectionName); - resolve({ client, collection }); - }); + mongodb.MongoClient.connect(url, { serverSelectionTimeoutMS: 1000 }) + .then(client => { + const db = client.db(dbName); + const collection = db.collection(collectionName); + resolve({ client, collection }); + }) + .catch(reason => { + reject(reason); + }); }); } @@ -57,8 +58,8 @@ export function accessCollection( * @param spans Readable spans that we need to assert. * @param expectedName The expected name of the first root span. * @param expectedKind The expected kind of the first root span. - * @param log - * @param isEnhancedDatabaseReportingEnabled Is enhanced database reporting enabled: boolean + * @param log Whether should debug print the expected spans. + * @param isEnhancedDatabaseReportingEnabled Is enhanced database reporting enabled: boolean. */ export function assertSpans( spans: ReadableSpan[],