diff --git a/packages/opentelemetry-context-async-hooks/.npmignore b/packages/opentelemetry-scope-async-hooks/.npmignore similarity index 100% rename from packages/opentelemetry-context-async-hooks/.npmignore rename to packages/opentelemetry-scope-async-hooks/.npmignore diff --git a/packages/opentelemetry-context-async-hooks/LICENSE b/packages/opentelemetry-scope-async-hooks/LICENSE similarity index 100% rename from packages/opentelemetry-context-async-hooks/LICENSE rename to packages/opentelemetry-scope-async-hooks/LICENSE diff --git a/packages/opentelemetry-context-async-hooks/README.md b/packages/opentelemetry-scope-async-hooks/README.md similarity index 100% rename from packages/opentelemetry-context-async-hooks/README.md rename to packages/opentelemetry-scope-async-hooks/README.md diff --git a/packages/opentelemetry-context-async-hooks/package.json b/packages/opentelemetry-scope-async-hooks/package.json similarity index 76% rename from packages/opentelemetry-context-async-hooks/package.json rename to packages/opentelemetry-scope-async-hooks/package.json index 38e35613eaa..10db66a317d 100644 --- a/packages/opentelemetry-context-async-hooks/package.json +++ b/packages/opentelemetry-scope-async-hooks/package.json @@ -1,13 +1,14 @@ { - "name": "@opentelemetry/context-async-hooks", + "name": "@opentelemetry/scope-async-hooks", "version": "0.0.1", - "description": "OpenTelemetry AsyncHooks-based Context Manager", + "description": "OpenTelemetry AsyncHooks-based Scope Manager", "main": "build/src/index.js", "types": "build/src/index.d.ts", "repository": "open-telemetry/opentelemetry-js", "scripts": { "test": "c8 ts-mocha -p tsconfig.json test/**/*.ts", "tdd": "yarn test -- --watch-extensions ts --watch", + "codecov": "c8 report --reporter=json && codecov -f coverage/*.json -p ../../", "clean": "rimraf build/*", "check": "gts check", "compile": "tsc -p .", @@ -40,6 +41,7 @@ "devDependencies": { "@types/mocha": "^5.2.5", "@types/node": "^12.0.10", + "@types/shimmer": "^1.0.1", "c8": "^5.0.1", "codecov": "^3.1.0", "gts": "^1.0.0", @@ -48,5 +50,8 @@ "ts-node": "^8.0.0", "typescript": "^3.4.5" }, - "dependencies": {} + "dependencies": { + "@opentelemetry/scope-base": "^0.0.1", + "shimmer": "^1.2.1" + } } diff --git a/packages/opentelemetry-scope-async-hooks/src/AsyncHooksScopeManager.ts b/packages/opentelemetry-scope-async-hooks/src/AsyncHooksScopeManager.ts new file mode 100644 index 00000000000..63e5dab5e82 --- /dev/null +++ b/packages/opentelemetry-scope-async-hooks/src/AsyncHooksScopeManager.ts @@ -0,0 +1,158 @@ +/** + * 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 { ScopeManager } from '@opentelemetry/scope-base'; +import * as asyncHooks from 'async_hooks'; +import { EventEmitter } from 'events'; +import * as shimmer from 'shimmer'; + +const EVENT_EMITTER_METHODS: Array = [ + 'addListener', + 'on', + 'once', + 'prependListener', + 'prependOnceListener', +]; + +export class AsyncHooksScopeManager implements ScopeManager { + private asyncHook: asyncHooks.AsyncHook; + private scopes: { [uid: number]: unknown } = {}; + private current: unknown = null; + + constructor() { + this.asyncHook = asyncHooks.createHook({ + init: this.init.bind(this), + before: this.before.bind(this), + destroy: this.destroy.bind(this), + }); + } + + active(): unknown { + return this.current; + } + + with ReturnType>( + scope: unknown, + fn: T + ): ReturnType { + const oldCurrent = this.current; + this.current = scope; + const res = fn(); + this.current = oldCurrent; + return res; + } + + bind(target: T, scope?: unknown): T { + // if no specific scope to propagate is give, we use the current one + if (!scope) { + scope = this.current; + } + if (target instanceof EventEmitter) { + return this.bindEventEmitter(target, scope); + } else if (typeof target === 'function') { + return this.bindFunction(target, scope); + } + return target; + } + + enable(): this { + this.asyncHook.enable(); + return this; + } + + disable(): this { + this.asyncHook.disable(); + this.scopes = {}; + this.current = null; + return this; + } + + private bindFunction(target: T, scope?: unknown): T { + const manager = this; + const boundContext = scope; + const contextWrapper = function(this: {}) { + const oldContext = manager.current; + manager.current = boundContext; + const res = target.apply(this, arguments); + manager.current = oldContext; + return res; + }; + Object.defineProperty(contextWrapper, 'length', { + enumerable: false, + configurable: true, + writable: false, + value: target.length, + }); + /** + * It isnt possible to tell Typescript that contextWrapper is the same as T + * so we forced to cast as any here. + */ + // tslint:disable-next-line:no-any + return contextWrapper as any; + } + + /** + * By default, EventEmitter call their callback with their scope, which we do + * not want, instead we will bind a specific scope to all callbacks that + * go through it. + * @param target EventEmitter a instance of EventEmitter to patch + * @param scope the scope we want to bind + */ + private bindEventEmitter( + target: T, + scope?: unknown + ): T { + const scopeManager = this; + EVENT_EMITTER_METHODS.forEach(methodName => { + if (target[methodName] === undefined) return; + shimmer.wrap(target as EventEmitter, methodName, (original: Function) => { + return function(this: {}, event: string, cb: Function) { + return original.call(this, event, scopeManager.bind(cb, scope)); + }; + }); + }); + return target; + } + + /** + * Init hook will be called when userland create a async scope, setting the + * scope as the current one if it exist. + * @param uid id of the async scope + */ + private init(uid: number, type: string) { + this.scopes[uid] = this.current; + } + + /** + * Before hook will be called just before calling a callback on a ressource + * so we restore the correct scope. + * @param uid id of the async scope + */ + private before(uid: number) { + if (this.scopes[uid]) { + this.current = this.scopes[uid]; + } + } + + /** + * Destroy hook will be called when a given scope is no longer used so we can + * remove its attached scope. + * @param uid uid of the async scope + */ + private destroy(uid: number) { + delete this.scopes[uid]; + } +} diff --git a/packages/opentelemetry-context-async-hooks/src/index.ts b/packages/opentelemetry-scope-async-hooks/src/index.ts similarity index 93% rename from packages/opentelemetry-context-async-hooks/src/index.ts rename to packages/opentelemetry-scope-async-hooks/src/index.ts index 6acf3afa4e0..692c99d325b 100644 --- a/packages/opentelemetry-context-async-hooks/src/index.ts +++ b/packages/opentelemetry-scope-async-hooks/src/index.ts @@ -13,3 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export * from './AsyncHooksScopeManager'; diff --git a/packages/opentelemetry-scope-async-hooks/test/asynchooks/AsyncHooksScopeManager.test.ts b/packages/opentelemetry-scope-async-hooks/test/asynchooks/AsyncHooksScopeManager.test.ts new file mode 100644 index 00000000000..e74a24b9fe0 --- /dev/null +++ b/packages/opentelemetry-scope-async-hooks/test/asynchooks/AsyncHooksScopeManager.test.ts @@ -0,0 +1,127 @@ +/** + * 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 * as assert from 'assert'; +import { AsyncHooksScopeManager } from '../../src'; + +describe('AsyncHooksScopeManager', () => { + let scopeManager: AsyncHooksScopeManager; + + describe('.enable()', () => { + it('should work', () => { + assert.doesNotThrow(() => { + scopeManager = new AsyncHooksScopeManager(); + assert(scopeManager.enable() === scopeManager, 'should return this'); + }); + }); + }); + + describe('.disable()', () => { + it('should work', () => { + assert.doesNotThrow(() => { + assert(scopeManager.disable() === scopeManager, 'should return this'); + }); + scopeManager.enable(); + }); + }); + + describe('.with()', () => { + it('should run the callback (null as target)', done => { + scopeManager.with(null, done); + }); + + it('should run the callback (object as target)', done => { + const test = { a: 1 }; + scopeManager.with(test, () => { + assert.strictEqual(scopeManager.active(), test, 'should have scope'); + return done(); + }); + }); + + it('should run the callback (when disabled)', done => { + scopeManager.disable(); + scopeManager.with(null, () => { + scopeManager.enable(); + return done(); + }); + }); + }); + + describe('.bind()', () => { + it('should return the same target (when enabled)', () => { + const test = { a: 1 }; + assert.deepStrictEqual(scopeManager.bind(test), test); + }); + + it('should return the same target (when disabled)', () => { + scopeManager.disable(); + const test = { a: 1 }; + assert.deepStrictEqual(scopeManager.bind(test), test); + scopeManager.enable(); + }); + + it('should return current scope (when enabled)', done => { + const scope = { a: 1 }; + const fn = scopeManager.bind(() => { + assert.strictEqual(scopeManager.active(), scope, 'should have scope'); + return done(); + }, scope); + fn(); + }); + + /** + * Even if asynchooks is disabled, the scope propagation will + * still works but it might be lost after any async op. + */ + it('should return current scope (when disabled)', done => { + scopeManager.disable(); + const scope = { a: 1 }; + const fn = scopeManager.bind(() => { + assert.strictEqual(scopeManager.active(), scope, 'should have scope'); + return done(); + }, scope); + fn(); + }); + + it('should fail to return current scope (when disabled + async op)', done => { + scopeManager.disable(); + const scope = { a: 1 }; + const fn = scopeManager.bind(() => { + setTimeout(() => { + assert.strictEqual( + scopeManager.active(), + null, + 'should have no scope' + ); + return done(); + }, 100); + }, scope); + fn(); + }); + + it('should return current scope (when re-enabled + async op)', done => { + scopeManager.enable(); + const scope = { a: 1 }; + const fn = scopeManager.bind(() => { + setTimeout(() => { + assert.strictEqual(scopeManager.active(), scope, 'should have scope'); + return done(); + }, 100); + }, scope); + fn(); + }); + }); +}); diff --git a/packages/opentelemetry-context-async-hooks/tsconfig.json b/packages/opentelemetry-scope-async-hooks/tsconfig.json similarity index 100% rename from packages/opentelemetry-context-async-hooks/tsconfig.json rename to packages/opentelemetry-scope-async-hooks/tsconfig.json