diff --git a/.circleci/config.yml b/.circleci/config.yml index 931821b9300..e616306f6d9 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 @@ -68,18 +74,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-node-tracer/src/NodeTracer.ts b/packages/opentelemetry-node-tracer/src/NodeTracer.ts index ea138470e5c..2befdf8ea67 100644 --- a/packages/opentelemetry-node-tracer/src/NodeTracer.ts +++ b/packages/opentelemetry-node-tracer/src/NodeTracer.ts @@ -14,8 +14,45 @@ * limitations under the License. */ -import { BasicTracer, BasicTracerConfig } from '@opentelemetry/basic-tracer'; +import { BasicTracer } from '@opentelemetry/basic-tracer'; import { AsyncHooksScopeManager } from '@opentelemetry/scope-async-hooks'; +import { ScopeManager } from '@opentelemetry/scope-base'; +import { + Attributes, + BinaryFormat, + HttpTextFormat, + Logger, + Sampler, +} from '@opentelemetry/types'; + +// @todo: Find a way to re-use BasicTracerConfig here +export declare interface NodeTracerConfig { + /** + * Binary formatter which can serialize/deserialize Spans. + */ + binaryFormat?: BinaryFormat; + /** + * Attributed that will be applied on every span created by Tracer. + * Useful to add infrastructure and environment information to your spans. + */ + defaultAttributes?: Attributes; + /** + * HTTP text formatter which can inject/extract Spans. + */ + httpTextFormat?: HttpTextFormat; + /** + * User provided logger. + */ + logger?: Logger; + /** + * Sampler determines if a span should be recorded or should be a NoopSpan. + */ + sampler?: Sampler; + /** + * Scope manager keeps context across in-process operations. + */ + scopeManager?: ScopeManager; +} /** * This class represents a node tracer with `async_hooks` module. @@ -24,7 +61,7 @@ export class NodeTracer extends BasicTracer { /** * Constructs a new Tracer instance. */ - constructor(config: BasicTracerConfig) { + constructor(config: NodeTracerConfig) { super( Object.assign({}, { scopeManager: new AsyncHooksScopeManager() }, config) ); diff --git a/packages/opentelemetry-node-tracer/src/index.ts b/packages/opentelemetry-node-tracer/src/index.ts index 62fb100a201..eaad585d142 100644 --- a/packages/opentelemetry-node-tracer/src/index.ts +++ b/packages/opentelemetry-node-tracer/src/index.ts @@ -13,3 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export * from './NodeTracer'; +export { Span } from '@opentelemetry/basic-tracer'; diff --git a/packages/opentelemetry-plugin-mongodb/package.json b/packages/opentelemetry-plugin-mongodb/package.json index 03b5cc104aa..d8bcc033eb3 100644 --- a/packages/opentelemetry-plugin-mongodb/package.json +++ b/packages/opentelemetry-plugin-mongodb/package.json @@ -6,7 +6,7 @@ "types": "build/src/index.d.ts", "repository": "open-telemetry/opentelemetry-js", "scripts": { - "test": "nyc ts-mocha -p tsconfig.json test/**/*.ts", + "test": "nyc ts-mocha -p tsconfig.json test/*.ts", "tdd": "yarn test -- --watch-extensions ts --watch", "clean": "rimraf build/*", "check": "gts check", @@ -45,6 +45,7 @@ "codecov": "^3.5.0", "gts": "^1.1.0", "mocha": "^6.2.0", + "mongodb": "^3.3.0", "nyc": "^14.1.1", "ts-mocha": "^6.0.0", "ts-node": "^8.3.0", diff --git a/packages/opentelemetry-plugin-mongodb/src/index.ts b/packages/opentelemetry-plugin-mongodb/src/index.ts index 62fb100a201..734114e7779 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..df8e436e171 100644 --- a/packages/opentelemetry-plugin-mongodb/src/mongodb.ts +++ b/packages/opentelemetry-plugin-mongodb/src/mongodb.ts @@ -1,5 +1,5 @@ /** - * 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. @@ -37,10 +37,10 @@ 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']; + private readonly _SERVER_METHODS = ['insert', 'update', 'remove', 'command']; + private readonly _CURSOR_METHODS = ['_find', '_getmore']; protected _logger!: Logger; protected readonly _tracer!: Tracer; @@ -58,35 +58,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 +89,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,40 +107,39 @@ 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[]); - } 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 === true) { type = 'createIndexes'; - } else if (command.findandmodify) { + } else if (command.findandmodify === true) { type = 'findAndModify'; - } else if (command.ismaster) { + } else if (command.ismaster === true) { type = 'isMaster'; - } else if (command.count) { + } else if (command.count === true) { 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) + plugin._patchEnd(span, options as Func) ); } else { return original.call( @@ -155,51 +147,7 @@ export class MongoDBPlugin extends BasePlugin { 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) - ); - } else { - return original.call( - this, - ns, - command, - options, - plugin.patchEnd(span, callback) + plugin._patchEnd(span, callback) ); } }; @@ -207,26 +155,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)); + span.setAttribute('db', this.ns); + return original.call(this, plugin._patchEnd(span, resultHandler)); }; }; } @@ -236,7 +182,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 +197,4 @@ export class MongoDBPlugin extends BasePlugin { } } -const plugin = new MongoDBPlugin('mongodb'); -export { plugin }; +export const plugin = new MongoDBPlugin('mongodb'); 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..37fe4e6ecd9 --- /dev/null +++ b/packages/opentelemetry-plugin-mongodb/test/mongodb.test.ts @@ -0,0 +1,272 @@ +/** + * 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 { Span } from '@opentelemetry/node-tracer'; +import * as assert from 'assert'; +import * as mongodb from 'mongodb'; +import { plugin } from '../src'; +import { ProxyTracer } from './utils/ProxyTracer'; +import { SpanKind } from '@opentelemetry/types'; + +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 assertSpan( + spans: Span[], + expectedName: string, + expectedKind: SpanKind +) { + assert.strictEqual(spans.length, 2); + spans.forEach(span => { + assert.deepStrictEqual(typeof span.endTime, 'number'); + }); + assert.strictEqual(spans[1].name, expectedName); + assert.strictEqual(spans[1].kind, expectedKind); +} + +describe('MongoDBPlugin', () => { + // For these tests, mongo must be running. Add OPENTELEMETRY_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://localhost:27017'; + const DB_NAME = 'opentelemetry-tests'; + const COLLECTION_NAME = 'test'; + + const tracer = new ProxyTracer({}); + let client: mongodb.MongoClient; + let collection: mongodb.Collection; + + before(done => { + plugin.enable(mongodb, tracer); + 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(); + } + tracer.spans = []; + // 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`); + collection.insertMany(insertData, (err, result) => { + span.end(); + assert.ifError(err); + + assert.strictEqual(tracer.spans.length, 2); + assertSpan( + tracer.spans, + `${DB_NAME}.${COLLECTION_NAME}.query`, + SpanKind.CLIENT + ); + done(); + }); + }); + + it('should create a child span for update', done => { + const span = tracer.startSpan('updateRootSpan'); + collection.updateOne({ a: 2 }, { $set: { b: 1 } }, (err, result) => { + span.end(); + assert.ifError(err); + + assert.strictEqual(tracer.spans.length, 2); + assertSpan( + tracer.spans, + `${DB_NAME}.${COLLECTION_NAME}.query`, + SpanKind.CLIENT + ); + done(); + }); + }); + + it('should create a child span for remove', done => { + const span = tracer.startSpan('removeRootSpan'); + collection.deleteOne({ a: 3 }, (err, result) => { + span.end(); + assert.ifError(err); + + assert.strictEqual(tracer.spans.length, 2); + assertSpan( + tracer.spans, + `${DB_NAME}.${COLLECTION_NAME}.query`, + SpanKind.CLIENT + ); + done(); + }); + }); + }); + + /** Should intercept cursor */ + describe('Instrumenting cursor operations', () => { + it('should create a child span for find', done => { + const span = tracer.startSpan('findRootSpan'); + collection.find({}).toArray((err, result) => { + span.end(); + assert.ifError(err); + + assert.strictEqual(tracer.spans.length, 2); + assertSpan( + tracer.spans, + `${DB_NAME}.${COLLECTION_NAME}.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'); + collection.createIndex({ a: 1 }, (err, result) => { + span.end(); + assert.ifError(err); + + assert.strictEqual(tracer.spans.length, 2); + assertSpan(tracer.spans, `${DB_NAME}.createIndexes`, SpanKind.CLIENT); + done(); + }); + }); + + it('should create a child span for count', done => { + const span = tracer.startSpan('countRootSpan'); + collection.count({ a: 1 }, (err, result) => { + span.end(); + assert.ifError(err); + + assert.strictEqual(tracer.spans.length, 2); + assertSpan(tracer.spans, `${DB_NAME}.count`, 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(tracer.spans.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(tracer.spans.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(tracer.spans.length, 1); + done(); + }); + }); + }); +}); diff --git a/packages/opentelemetry-plugin-mongodb/test/utils/ProxyTracer.ts b/packages/opentelemetry-plugin-mongodb/test/utils/ProxyTracer.ts new file mode 100644 index 00000000000..3cc36a02bc3 --- /dev/null +++ b/packages/opentelemetry-plugin-mongodb/test/utils/ProxyTracer.ts @@ -0,0 +1,28 @@ +/** + * 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. + */ + +import { SpanOptions } from '@opentelemetry/types'; +import { Span, NodeTracer } from '@opentelemetry/node-tracer'; + +export class ProxyTracer extends NodeTracer { + spans: Span[] = []; + + startSpan(name: string, options?: SpanOptions): Span { + const span = super.startSpan(name, options) as Span; + this.spans.push(span); + return span; + } +}