Skip to content

Commit

Permalink
feat(scope-manager): Add AsyncHooks implementations of ScopeManager
Browse files Browse the repository at this point in the history
  • Loading branch information
vmarchaud committed Jul 23, 2019
1 parent 39ca469 commit 83ad44f
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -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 .",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
}
}
153 changes: 153 additions & 0 deletions packages/opentelemetry-scope-async-hooks/src/AsyncHooksScopeManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* 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<keyof EventEmitter> = [
'addListener',
'on',
'once',
'prependListener',
'prependOnceListener',
];

export class AsyncHooksScopeManager implements ScopeManager {
private asyncHook: asyncHooks.AsyncHook;
private scopes: { [uid: number]: unknown } = Object.create(null);
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<T extends (...args: unknown[]) => ReturnType<T>>(
scope: unknown,
fn: T
): ReturnType<T> {
const oldCurrent = this.current;
this.current = scope;
const res = fn();
this.current = oldCurrent;
return res;
}

bind<T>(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<T extends Function>(target: T, scope?: unknown): T {
const manager = this;
const contextWrapper = function(this: {}) {
return manager.with(scope, () => target.apply(this, arguments))
};
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<T extends EventEmitter>(
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) {
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];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export * from './AsyncHooksScopeManager';
Original file line number Diff line number Diff line change
@@ -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();
});
});
});

0 comments on commit 83ad44f

Please sign in to comment.