diff --git a/.circleci/config.yml b/.circleci/config.yml index 208c5f711d0..80a8b6784a6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,5 +1,11 @@ version: 2 +test_env: &test_env + RUN_MONGODB_TESTS: 1 + +mongo_service: &mongo_service + image: mongo + node_unit_tests: &node_unit_tests steps: - checkout @@ -71,18 +77,26 @@ jobs: node8: docker: - image: node:8 + environment: *test_env + - *mongo_service <<: *node_unit_tests node10: docker: - image: node:10 + environment: *test_env + - *mongo_service <<: *node_unit_tests node11: docker: - image: node:11 + environment: *test_env + - *mongo_service <<: *node_unit_tests node12: docker: - image: node:12 + environment: *test_env + - *mongo_service <<: *node_unit_tests node12-browsers: docker: diff --git a/packages/opentelemetry-plugin-mongodb/package.json b/packages/opentelemetry-plugin-mongodb/package.json index bfc411b2dc5..1fc35c32739 100644 --- a/packages/opentelemetry-plugin-mongodb/package.json +++ b/packages/opentelemetry-plugin-mongodb/package.json @@ -45,6 +45,7 @@ "codecov": "^3.5.0", "gts": "^1.1.0", "mocha": "^6.2.0", + "mongodb": "^3.3.0", "nyc": "^14.1.1", "rimraf": "^3.0.0", "tslint-microsoft-contrib": "^6.2.0", @@ -56,7 +57,7 @@ "dependencies": { "@opentelemetry/core": "^0.1.0", "@opentelemetry/types": "^0.1.0", - "@opentelemetry/node-tracer": "^0.0.1", + "@opentelemetry/node": "^0.1.0", "shimmer": "^1.2.1" } } \ No newline at end of file diff --git a/packages/opentelemetry-plugin-mongodb/src/index.ts b/packages/opentelemetry-plugin-mongodb/src/index.ts index ae225f6b521..21a9d57333a 100644 --- a/packages/opentelemetry-plugin-mongodb/src/index.ts +++ b/packages/opentelemetry-plugin-mongodb/src/index.ts @@ -13,3 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export * from './mongodb'; diff --git a/packages/opentelemetry-plugin-mongodb/src/mongodb.ts b/packages/opentelemetry-plugin-mongodb/src/mongodb.ts index 276fa9a6987..985568dcb69 100644 --- a/packages/opentelemetry-plugin-mongodb/src/mongodb.ts +++ b/packages/opentelemetry-plugin-mongodb/src/mongodb.ts @@ -1,5 +1,21 @@ +/*! + * Copyright 2019, 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. + */ + /** - * Copyright 2018, OpenCensus Authors + * Copyright 2019, OpenTelemetry Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,14 +33,8 @@ // mongodb.Server type is deprecated so every use trigger a lint error /* tslint:disable:deprecation */ -import { BasePlugin, NoopLogger } from '@opentelemetry/core'; -import { - Span, - SpanKind, - Logger, - Tracer, - CanonicalCode, -} from '@opentelemetry/types'; +import { BasePlugin } from '@opentelemetry/core'; +import { Span, SpanKind, CanonicalCode } from '@opentelemetry/types'; import * as mongodb from 'mongodb'; import * as shimmer from 'shimmer'; @@ -37,18 +47,15 @@ interface MongoInternalCommand { ismaster: boolean; } -/** MongoDB instrumentation plugin for Opencensus */ +/** MongoDB instrumentation plugin for OpenTelemetry */ export class MongoDBPlugin extends BasePlugin { - private readonly SERVER_FNS = ['insert', 'update', 'remove', 'auth']; - private readonly CURSOR_FNS_FIRST = ['_find', '_getmore']; - protected _logger!: Logger; - protected readonly _tracer!: Tracer; + private readonly _SERVER_METHODS = ['insert', 'update', 'remove', 'command']; + private readonly _CURSOR_METHODS = ['_next', 'next']; + + protected readonly _supportedVersions = ['>=2 <3']; - constructor(public moduleName: string) { + constructor(readonly moduleName: string) { super(); - // TODO: remove this once a logger will be passed - // https://github.com/open-telemetry/opentelemetry-js/issues/193 - this._logger = new NoopLogger(); } /** @@ -58,35 +65,29 @@ export class MongoDBPlugin extends BasePlugin { this._logger.debug('Patched MongoDB'); if (this._moduleExports.Server) { - this._logger.debug('patching mongodb-core.Server.prototype.command'); - shimmer.wrap( - this._moduleExports.Server.prototype, - 'command' as never, - // tslint:disable-next-line:no-any - this.getPatchCommand() as any - ); - this._logger.debug( - 'patching mongodb-core.Server.prototype functions:', - this.SERVER_FNS - ); - shimmer.massWrap( - [this._moduleExports.Server.prototype], - this.SERVER_FNS as never[], - // tslint:disable-next-line:no-any - this.getPatchQuery() as any - ); + for (const fn of this._SERVER_METHODS) { + this._logger.debug(`patching mongodb-core.Server.prototype.${fn}`); + shimmer.wrap( + this._moduleExports.Server.prototype, + // Forced to ignore due to incomplete typings + // tslint:disable-next-line:ban-ts-ignore + // @ts-ignore + fn, + this._getPatchCommand(fn) + ); + } } if (this._moduleExports.Cursor) { this._logger.debug( 'patching mongodb-core.Cursor.prototype functions:', - this.CURSOR_FNS_FIRST + this._CURSOR_METHODS ); shimmer.massWrap( [this._moduleExports.Cursor.prototype], - this.CURSOR_FNS_FIRST as never[], + this._CURSOR_METHODS as never[], // tslint:disable-next-line:no-any - this.getPatchCursor() as any + this._getPatchCursor() as any ); } @@ -95,18 +96,17 @@ export class MongoDBPlugin extends BasePlugin { /** Unpatches all MongoDB patched functions. */ unpatch(): void { - shimmer.unwrap(this._moduleExports.Server.prototype, 'command' as never); shimmer.massUnwrap([this._moduleExports.Server.prototype], this - .SERVER_FNS as never[]); + ._SERVER_METHODS as never[]); shimmer.massUnwrap([this._moduleExports.Cursor.prototype], this - .CURSOR_FNS_FIRST as never[]); + ._CURSOR_METHODS as never[]); } /** Creates spans for Command operations */ - private getPatchCommand() { + private _getPatchCommand(operationName: string) { const plugin = this; return (original: Func) => { - return function( + return function patchedServerCommand( this: mongodb.Server, ns: string, command: MongoInternalCommand, @@ -114,84 +114,40 @@ export class MongoDBPlugin extends BasePlugin { callback: Func ): mongodb.Server { const currentSpan = plugin._tracer.getCurrentSpan(); - if (currentSpan === null) { - return original.apply(this, (arguments as unknown) as unknown[]); - } + // @ts-ignore const resultHandler = typeof options === 'function' ? options : callback; - if (typeof resultHandler !== 'function') { - return original.apply(this, (arguments as unknown) as unknown[]); - } - if (typeof command !== 'object') { + if ( + currentSpan === null || + typeof resultHandler !== 'function' || + typeof command !== 'object' + ) { return original.apply(this, (arguments as unknown) as unknown[]); } let type: string; - if (command.createIndexes) { + if (command.createIndexes !== undefined) { type = 'createIndexes'; - } else if (command.findandmodify) { + } else if (command.findandmodify !== undefined) { type = 'findAndModify'; - } else if (command.ismaster) { + } else if (command.ismaster !== undefined) { type = 'isMaster'; - } else if (command.count) { + } else if (command.count !== undefined) { type = 'count'; } else { - type = 'command'; + type = operationName; } - const span = plugin._tracer.startSpan(`${ns}.${type}`, { + const span = plugin._tracer.startSpan(`mongodb.${type}`, { parent: currentSpan, kind: SpanKind.CLIENT, }); + span.setAttribute('db', ns); if (typeof options === 'function') { return original.call( this, ns, command, - plugin.patchEnd(span, options as Func) - ); - } else { - return original.call( - this, - ns, - command, - options, - plugin.patchEnd(span, callback) - ); - } - }; - }; - } - - /** Creates spans for Query operations */ - private getPatchQuery() { - const plugin = this; - return (original: Func) => { - return function( - this: mongodb.Server, - ns: string, - command: MongoInternalCommand, - options: {}, - callback: Func - ): mongodb.Server { - const currentSpan = plugin._tracer.getCurrentSpan(); - if (currentSpan === null) { - return original.apply(this, (arguments as unknown) as unknown[]); - } - const resultHandler = - typeof options === 'function' ? options : callback; - if (typeof resultHandler !== 'function') { - return original.apply(this, (arguments as unknown) as unknown[]); - } - const span = plugin._tracer.startSpan(`${ns}.query`, { - kind: SpanKind.CLIENT, - parent: currentSpan, - }); - if (typeof options === 'function') { - return original.call( - this, - ns, - command, - plugin.patchEnd(span, options as Func) + plugin._patchEnd(span, options as Func) ); } else { return original.call( @@ -199,7 +155,7 @@ export class MongoDBPlugin extends BasePlugin { ns, command, options, - plugin.patchEnd(span, callback) + plugin._patchEnd(span, callback) ); } }; @@ -207,26 +163,24 @@ export class MongoDBPlugin extends BasePlugin { } /** Creates spans for Cursor operations */ - private getPatchCursor() { + private _getPatchCursor() { const plugin = this; return (original: Func) => { - return function( + return function patchedCursorCommand( this: { ns: string }, ...args: unknown[] ): mongodb.Cursor { const currentSpan = plugin._tracer.getCurrentSpan(); - if (currentSpan === null) { - return original.apply(this, (arguments as unknown) as unknown[]); - } const resultHandler = args[0] as Func | undefined; - if (resultHandler === undefined) { + if (currentSpan === null || resultHandler === undefined) { return original.apply(this, (arguments as unknown) as unknown[]); } - const span = plugin._tracer.startSpan(`${this.ns}.cursor`, { + const span = plugin._tracer.startSpan(`mongodb.cursor`, { parent: currentSpan, kind: SpanKind.CLIENT, }); - return original.call(this, plugin.patchEnd(span, resultHandler)); + + return original.call(this, plugin._patchEnd(span, resultHandler)); }; }; } @@ -236,7 +190,7 @@ export class MongoDBPlugin extends BasePlugin { * @param span The created span to end. * @param resultHandler A callback function. */ - patchEnd(span: Span, resultHandler: Func): Function { + private _patchEnd(span: Span, resultHandler: Func): Function { return function patchedEnd(this: {}, ...args: unknown[]) { const error = args[0]; if (error instanceof Error) { @@ -251,5 +205,4 @@ export class MongoDBPlugin extends BasePlugin { } } -const plugin = new MongoDBPlugin('mongodb'); -export { plugin }; +export const plugin = new MongoDBPlugin('mongodb-core'); diff --git a/packages/opentelemetry-plugin-mongodb/test/mongodb.test.ts b/packages/opentelemetry-plugin-mongodb/test/mongodb.test.ts new file mode 100644 index 00000000000..68a97a280ac --- /dev/null +++ b/packages/opentelemetry-plugin-mongodb/test/mongodb.test.ts @@ -0,0 +1,290 @@ +/*! + * Copyright 2019, 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. + */ + +/** + * Copyright 2019, 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 + * + * http://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. + */ + +import { NodeTracer } from '@opentelemetry/node'; +import * as assert from 'assert'; +import * as mongodb from 'mongodb'; +import { plugin } from '../src'; +import { SpanKind } from '@opentelemetry/types'; +import { NoopLogger } from '@opentelemetry/core'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, + ReadableSpan, +} from '../../opentelemetry-basic-tracer/build/src'; + +export interface MongoDBAccess { + client: mongodb.MongoClient; + collection: mongodb.Collection; +} + +/** + * Access the mongodb collection. + * @param url The mongodb URL to access. + * @param dbName The mongodb database name. + * @param collectionName The mongodb collection name. + */ +function accessCollection( + url: string, + dbName: string, + collectionName: string +): Promise { + return new Promise((resolve, reject) => { + mongodb.MongoClient.connect(url, function connectedClient(err, client) { + if (err) { + reject(err); + return; + } + const db = client.db(dbName); + const collection = db.collection(collectionName); + resolve({ client, collection }); + }); + }); +} + +/** + * Asserts root spans attributes. + * @param rootSpanVerifier An instance of rootSpanVerifier to analyse RootSpan + * instances from. + * @param expectedName The expected name of the first root span. + * @param expectedKind The expected kind of the first root span. + */ +function assertSpans( + spans: ReadableSpan[], + expectedName: string, + expectedKind: SpanKind +) { + assert.strictEqual(spans.length, 2); + spans.forEach(span => { + assert(span.endTime instanceof Array); + assert(span.endTime.length === 2); + }); + assert.strictEqual(spans[0].name, expectedName); + assert.strictEqual(spans[0].kind, expectedKind); +} + +describe('MongoDBPlugin', () => { + // For these tests, mongo must be running. Add OPENTELEMETRY_MONGODB_TESTS to run + // these tests. + const OPENTELEMETRY_MONGODB_TESTS = process.env + .OPENTELEMETRY_MONGODB_TESTS as string; + let shouldTest = true; + if (!OPENTELEMETRY_MONGODB_TESTS) { + console.log('Skipping test-mongodb. Run MongoDB to test'); + shouldTest = false; + } + + const URL = 'mongodb://localhost:27017'; + const DB_NAME = 'opentelemetry-tests'; + const COLLECTION_NAME = 'test'; + + let client: mongodb.MongoClient; + let collection: mongodb.Collection; + const logger = new NoopLogger(); + const tracer = new NodeTracer(); + const memoryExporter = new InMemorySpanExporter(); + tracer.addSpanProcessor(new SimpleSpanProcessor(memoryExporter)); + + before(done => { + plugin.enable(mongodb, tracer, logger); + 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) { + // Skiping all tests in beforeEach() is a workarround. Mocha does not work + // properly when skiping tests in before() on nested describe() calls. + // https://github.com/mochajs/mocha/issues/2819 + if (!shouldTest) { + this.skip(); + } + memoryExporter.reset(); + // Non traced insertion of basic data to perform tests + const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; + collection.insertMany(insertData, (err, result) => { + done(); + }); + }); + + afterEach(done => { + collection.deleteOne({}, 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 = tracer.startSpan(`insertRootSpan`); + tracer.withSpan(span, () => { + collection.insertMany(insertData, (err, result) => { + span.end(); + assert.ifError(err); + assertSpans( + memoryExporter.getFinishedSpans(), + `mongodb.insert`, + SpanKind.CLIENT + ); + done(); + }); + }); + }); + + it('should create a child span for update', done => { + const span = tracer.startSpan('updateRootSpan'); + tracer.withSpan(span, () => { + collection.updateOne({ a: 2 }, { $set: { b: 1 } }, (err, result) => { + span.end(); + assert.ifError(err); + assertSpans( + memoryExporter.getFinishedSpans(), + `mongodb.update`, + SpanKind.CLIENT + ); + done(); + }); + }); + }); + + it('should create a child span for remove', done => { + const span = tracer.startSpan('removeRootSpan'); + tracer.withSpan(span, () => { + collection.deleteOne({ a: 3 }, (err, result) => { + span.end(); + assert.ifError(err); + assertSpans( + memoryExporter.getFinishedSpans(), + `mongodb.remove`, + SpanKind.CLIENT + ); + done(); + }); + }); + }); + }); + + /** Should intercept cursor */ + describe('Instrumenting cursor operations', () => { + it('should create a child span for find', done => { + const span = tracer.startSpan('findRootSpan'); + tracer.withSpan(span, () => { + collection.find({}).toArray((err, result) => { + span.end(); + assert.ifError(err); + assertSpans( + memoryExporter.getFinishedSpans(), + `mongodb.cursor`, + SpanKind.CLIENT + ); + done(); + }); + }); + }); + }); + + /** Should intercept command */ + describe('Instrumenting command operations', () => { + it('should create a child span for create index', done => { + const span = tracer.startSpan('indexRootSpan'); + tracer.withSpan(span, () => { + collection.createIndex({ a: 1 }, (err, result) => { + span.end(); + assert.ifError(err); + assertSpans( + memoryExporter.getFinishedSpans(), + `mongodb.createIndexes`, + SpanKind.CLIENT + ); + done(); + }); + }); + }); + }); + + /** Should intercept command */ + describe('Removing Instrumentation', () => { + it('should unpatch plugin', () => { + assert.doesNotThrow(() => { + plugin.unpatch(); + }); + }); + + it('should not create a child span for query', done => { + const insertData = [{ a: 1 }, { a: 2 }, { a: 3 }]; + + const span = tracer.startSpan('insertRootSpan'); + collection.insertMany(insertData, (err, result) => { + span.end(); + assert.ifError(err); + assert.strictEqual(memoryExporter.getFinishedSpans().length, 1); + done(); + }); + }); + + it('should not create a child span for cursor', done => { + const span = tracer.startSpan('findRootSpan'); + collection.find({}).toArray((err, result) => { + span.end(); + assert.ifError(err); + assert.strictEqual(memoryExporter.getFinishedSpans().length, 1); + done(); + }); + }); + + it('should not create a child span for command', done => { + const span = tracer.startSpan('indexRootSpan'); + collection.createIndex({ a: 1 }, (err, result) => { + span.end(); + assert.ifError(err); + assert.strictEqual(memoryExporter.getFinishedSpans().length, 1); + done(); + }); + }); + }); +});