diff --git a/packages/opentelemetry-node-tracer/.gitignore b/packages/opentelemetry-node-tracer/.gitignore new file mode 100644 index 00000000000..ba80b4026b2 --- /dev/null +++ b/packages/opentelemetry-node-tracer/.gitignore @@ -0,0 +1,2 @@ +# Dependency directories +!test/instrumentation/node_modules diff --git a/packages/opentelemetry-node-tracer/package.json b/packages/opentelemetry-node-tracer/package.json index d36c87c0c06..e226af85240 100644 --- a/packages/opentelemetry-node-tracer/package.json +++ b/packages/opentelemetry-node-tracer/package.json @@ -40,10 +40,13 @@ "devDependencies": { "@types/mocha": "^5.2.5", "@types/node": "^12.6.8", + "@types/shimmer": "^1.0.1", + "@types/semver": "^6.0.1", "codecov": "^3.1.0", "gts": "^1.0.0", "mocha": "^6.1.0", "nyc": "^14.1.1", + "shimmer": "^1.2.0", "ts-mocha": "^6.0.0", "ts-node": "^8.0.0", "typescript": "^3.4.5" @@ -53,6 +56,8 @@ "@opentelemetry/core": "^0.0.1", "@opentelemetry/scope-async-hooks": "^0.0.1", "@opentelemetry/scope-base": "^0.0.1", - "@opentelemetry/types": "^0.0.1" + "@opentelemetry/types": "^0.0.1", + "require-in-the-middle": "^4.0.0", + "semver": "^6.2.0" } } diff --git a/packages/opentelemetry-node-tracer/src/instrumentation/PluginLoader.ts b/packages/opentelemetry-node-tracer/src/instrumentation/PluginLoader.ts new file mode 100644 index 00000000000..69978b302f6 --- /dev/null +++ b/packages/opentelemetry-node-tracer/src/instrumentation/PluginLoader.ts @@ -0,0 +1,144 @@ +/** + * 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 { Logger, Plugin, Tracer } from '@opentelemetry/types'; +import * as hook from 'require-in-the-middle'; +import * as utils from './utils'; + +// States for the Plugin Loader +export enum HookState { + UNINITIALIZED, + ENABLED, + DISABLED, +} + +interface PluginNames { + [pluginName: string]: string; +} + +interface PluginConfig { + // TODO: Consider to add configuration options + [pluginName: string]: boolean; +} + +/** + * The PluginLoader class can load instrumentation plugins that use a patch + * mechanism to enable automatic tracing for specific target modules. + */ +export class PluginLoader { + /** A list of loaded plugins. */ + private _plugins: Plugin[] = []; + /** + * A field that tracks whether the require-in-the-middle hook has been loaded + * for the first time, as well as whether the hook body is activated or not. + */ + private _hookState = HookState.UNINITIALIZED; + + /** Constructs a new PluginLoader instance. */ + constructor(readonly tracer: Tracer, readonly logger: Logger) {} + + /** + * Loads a list of plugins. Each plugin module should implement the core + * {@link Plugin} interface and export an instance named as 'plugin'. This + * function will attach a hook to be called the first time the module is + * loaded. + * @param pluginConfig an object whose keys are plugin names and whose + * boolean values indicate whether to enable the plugin. + */ + load(pluginConfig: PluginConfig): PluginLoader { + if (this._hookState === HookState.UNINITIALIZED) { + const plugins = Object.keys(pluginConfig).reduce( + (plugins: PluginNames, moduleName: string) => { + if (pluginConfig[moduleName]) { + plugins[moduleName] = utils.defaultPackageName(moduleName); + } + return plugins; + }, + {} as PluginNames + ); + const modulesToHook = Object.keys(plugins); + // Do not hook require when no module is provided. In this case it is + // not necessary. With skipping this step we lower our footprint in + // customer applications and require-in-the-middle won't show up in CPU + // frames. + if (modulesToHook.length === 0) { + this._hookState = HookState.DISABLED; + return this; + } + + // Enable the require hook. + hook(modulesToHook, (exports, name, baseDir) => { + if (this._hookState !== HookState.ENABLED) return exports; + + const moduleName = plugins[name]; + // Get the module version. + const version = utils.getPackageVersion(this.logger, baseDir as string); + this.logger.info( + `PluginLoader#load: trying loading ${name}.${version}` + ); + + // @todo (issues/132): Check if version and supportedVersions are + // satisfied + if (!version) return exports; + + this.logger.debug( + `PluginLoader#load: applying patch to ${name}@${version} using ${moduleName} module` + ); + + // Expecting a plugin from module; + try { + const plugin: Plugin = require(moduleName).plugin; + this._plugins.push(plugin); + // Enable each supported plugin. + return plugin.enable(exports, this.tracer); + } catch (e) { + this.logger.error( + `PluginLoader#load: could not load plugin ${moduleName} of module ${name}. Error: ${e.message}` + ); + return exports; + } + }); + this._hookState = HookState.ENABLED; + } else if (this._hookState === HookState.DISABLED) { + this.logger.error( + 'PluginLoader#load: Currently cannot re-enable plugin loader.' + ); + } else { + this.logger.error('PluginLoader#load: Plugin loader already enabled.'); + } + return this; + } + + /** Unloads plugins. */ + unload(): PluginLoader { + if (this._hookState === HookState.ENABLED) { + for (const plugin of this._plugins) { + plugin.disable(); + } + this._plugins = []; + this._hookState = HookState.DISABLED; + } + return this; + } +} + +/** + * Adds a search path for plugin modules. Intended for testing purposes only. + * @param searchPath The path to add. + */ +export function searchPathForTest(searchPath: string) { + module.paths.push(searchPath); +} diff --git a/packages/opentelemetry-node-tracer/src/instrumentation/constants.ts b/packages/opentelemetry-node-tracer/src/instrumentation/constants.ts new file mode 100644 index 00000000000..8e2040af73f --- /dev/null +++ b/packages/opentelemetry-node-tracer/src/instrumentation/constants.ts @@ -0,0 +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. + */ + +/** opentelemetry scope */ +export const OPENTELEMETRY_SCOPE = '@opentelemetry'; + +/** Default prefix for instrumentation modules */ +export const DEFAULT_PLUGIN_PACKAGE_NAME_PREFIX = 'plugin'; diff --git a/packages/opentelemetry-node-tracer/src/instrumentation/ext-types.d.ts b/packages/opentelemetry-node-tracer/src/instrumentation/ext-types.d.ts new file mode 100644 index 00000000000..e1d889ac7a2 --- /dev/null +++ b/packages/opentelemetry-node-tracer/src/instrumentation/ext-types.d.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. + */ + +declare module 'require-in-the-middle' { + namespace hook { + type Options = { + internals?: boolean; + }; + type OnRequireFn = (exports: T, name: string, basedir?: string) => T; + } + function hook(modules: string[]|null, options: hook.Options|null, onRequire: hook.OnRequireFn): void; + function hook(modules: string[]|null, onRequire: hook.OnRequireFn): void; + function hook(onRequire: hook.OnRequireFn): void; + export = hook; + } diff --git a/packages/opentelemetry-node-tracer/src/instrumentation/utils.ts b/packages/opentelemetry-node-tracer/src/instrumentation/utils.ts new file mode 100644 index 00000000000..de9648e3024 --- /dev/null +++ b/packages/opentelemetry-node-tracer/src/instrumentation/utils.ts @@ -0,0 +1,69 @@ +/** + * 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 { Logger } from '@opentelemetry/types'; +import * as path from 'path'; +import * as semver from 'semver'; +import * as constants from './constants'; + +/** + * Gets the default package name for a target module. The default package + * name uses the default scope and a default prefix. + * @param moduleName The module name. + * @returns The default name for the package. + */ +export function defaultPackageName(moduleName: string): string { + return `${constants.OPENTELEMETRY_SCOPE}/${constants.DEFAULT_PLUGIN_PACKAGE_NAME_PREFIX}-${moduleName}`; +} + +/** + * Gets the package version. + * @param logger The logger to use. + * @param [basedir] The base directory. + */ +export function getPackageVersion( + logger: Logger, + basedir?: string +): string | null { + if (!basedir) return null; + + const pjsonPath = path.join(basedir, 'package.json'); + try { + const version = require(pjsonPath).version; + // Attempt to parse a string as a semantic version, returning either a + // SemVer object or null. + if (!semver.parse(version)) { + logger.error( + `getPackageVersion: [${pjsonPath}|${version}] Version string could not be parsed.` + ); + return null; + } + return version; + } catch (e) { + logger.error( + `getPackageVersion: [${pjsonPath}] An error occurred while retrieving version string. ${e.message}` + ); + return null; + } +} + +/** + * Adds a search path for plugin modules. Intended for testing purposes only. + * @param searchPath The path to add. + */ +export function searchPathForTest(searchPath: string) { + module.paths.push(searchPath); +} diff --git a/packages/opentelemetry-node-tracer/test/instrumentation/PluginLoader.test.ts b/packages/opentelemetry-node-tracer/test/instrumentation/PluginLoader.test.ts new file mode 100644 index 00000000000..fc10b9ea578 --- /dev/null +++ b/packages/opentelemetry-node-tracer/test/instrumentation/PluginLoader.test.ts @@ -0,0 +1,126 @@ +/** + * 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 { NoopLogger, NoopTracer } from '@opentelemetry/core'; +import * as assert from 'assert'; +import * as path from 'path'; +import { + HookState, + PluginLoader, + searchPathForTest, +} from '../../src/instrumentation/PluginLoader'; + +const INSTALLED_PLUGINS_PATH = path.join(__dirname, 'node_modules'); + +describe('PluginLoader', () => { + const tracer = new NoopTracer(); + const logger = new NoopLogger(); + + before(() => { + module.paths.push(INSTALLED_PLUGINS_PATH); + searchPathForTest(INSTALLED_PLUGINS_PATH); + }); + + afterEach(() => { + // clear require cache + Object.keys(require.cache).forEach(key => delete require.cache[key]); + }); + + describe('state()', () => { + it('returns UNINITIALIZED when first called', () => { + const pluginLoader = new PluginLoader(tracer, logger); + assert.strictEqual(pluginLoader['_hookState'], HookState.UNINITIALIZED); + }); + + it('transitions from UNINITIALIZED to ENABLED', () => { + const pluginLoader = new PluginLoader(tracer, logger); + pluginLoader.load({ 'simple-module': true }); + assert.strictEqual(pluginLoader['_hookState'], HookState.ENABLED); + pluginLoader.unload(); + }); + + it('transitions from ENABLED to DISABLED', () => { + const pluginLoader = new PluginLoader(tracer, logger); + pluginLoader.load({ 'simple-module': true }).unload(); + assert.strictEqual(pluginLoader['_hookState'], HookState.DISABLED); + }); + }); + + describe('load()', () => { + it('sanity check', () => { + // Ensure that module fixtures contain values that we expect. + const simpleModule = require('simple-module'); + assert.strictEqual(simpleModule.name(), 'simple-module'); + assert.strictEqual(simpleModule.value(), 0); + assert.throws(() => require('nonexistent-module')); + }); + + it('should load a plugin and patch the target modules', () => { + const pluginLoader = new PluginLoader(tracer, logger); + assert.strictEqual(pluginLoader['_plugins'].length, 0); + pluginLoader.load({ 'simple-module': true }); + // The hook is only called the first time the module is loaded. + const simpleModule = require('simple-module'); + assert.strictEqual(pluginLoader['_plugins'].length, 1); + assert.strictEqual(simpleModule.value(), 1); + assert.strictEqual(simpleModule.name(), 'patched-simple-module'); + pluginLoader.unload(); + }); + + it('should not load a plugin when value is false', () => { + const pluginLoader = new PluginLoader(tracer, logger); + assert.strictEqual(pluginLoader['_plugins'].length, 0); + pluginLoader.load({ 'simple-module': false }); + const simpleModule = require('simple-module'); + assert.strictEqual(pluginLoader['_plugins'].length, 0); + assert.strictEqual(simpleModule.value(), 0); + assert.strictEqual(simpleModule.name(), 'simple-module'); + pluginLoader.unload(); + }); + + it('should not load a non existing plugin', () => { + const pluginLoader = new PluginLoader(tracer, logger); + assert.strictEqual(pluginLoader['_plugins'].length, 0); + pluginLoader.load({ 'nonexistent-module': true }); + assert.strictEqual(pluginLoader['_plugins'].length, 0); + pluginLoader.unload(); + }); + + it(`doesn't patch modules for which plugins aren't specified`, () => { + const pluginLoader = new PluginLoader(tracer, logger); + pluginLoader.load({}); + assert.strictEqual(require('simple-module').value(), 0); + pluginLoader.unload(); + }); + }); + + describe('unload()', () => { + it('should unload the plugins and unpatch the target module when unloads', () => { + const pluginLoader = new PluginLoader(tracer, logger); + assert.strictEqual(pluginLoader['_plugins'].length, 0); + pluginLoader.load({ 'simple-module': true }); + // The hook is only called the first time the module is loaded. + const simpleModule = require('simple-module'); + assert.strictEqual(pluginLoader['_plugins'].length, 1); + assert.strictEqual(simpleModule.value(), 1); + assert.strictEqual(simpleModule.name(), 'patched-simple-module'); + pluginLoader.unload(); + assert.strictEqual(pluginLoader['_plugins'].length, 0); + assert.strictEqual(simpleModule.name(), 'simple-module'); + assert.strictEqual(simpleModule.value(), 0); + }); + }); +}); diff --git a/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/index.js b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/index.js new file mode 100644 index 00000000000..1b22b5ce904 --- /dev/null +++ b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/index.js @@ -0,0 +1,5 @@ +function __export(m) { + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +Object.defineProperty(exports, "__esModule", { value: true }); +__export(require("./simple-module")); diff --git a/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/package.json b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/package.json new file mode 100644 index 00000000000..59d87df3500 --- /dev/null +++ b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/package.json @@ -0,0 +1,4 @@ +{ + "name": "@opentelemetry/plugin-simple-module", + "version": "0.0.1" +} diff --git a/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/simple-module.js b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/simple-module.js new file mode 100644 index 00000000000..142b861d222 --- /dev/null +++ b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/@opentelemetry/plugin-simple-module/simple-module.js @@ -0,0 +1,23 @@ +Object.defineProperty(exports, "__esModule", { value: true }); +const core_1 = require("@opentelemetry/core"); +const shimmer = require("shimmer"); + +class SimpleModulePlugin extends core_1.BasePlugin { + constructor() { + super(); + } + + patch() { + shimmer.wrap(this._moduleExports, 'name', orig => () => 'patched-' + orig.apply()); + shimmer.wrap(this._moduleExports, 'value', orig => () => orig.apply() + 1); + return this._moduleExports; + } + + unpatch() { + shimmer.unwrap(this._moduleExports, 'name'); + shimmer.unwrap(this._moduleExports, 'value'); + } +} +exports.SimpleModulePlugin = SimpleModulePlugin; +const plugin = new SimpleModulePlugin(); +exports.plugin = plugin; diff --git a/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/simple-module/index.js b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/simple-module/index.js new file mode 100644 index 00000000000..8ec2e77ffd6 --- /dev/null +++ b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/simple-module/index.js @@ -0,0 +1,4 @@ +module.exports = { + name: () => 'simple-module', + value: () => 0, +}; diff --git a/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/simple-module/package.json b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/simple-module/package.json new file mode 100644 index 00000000000..8661e6e52f1 --- /dev/null +++ b/packages/opentelemetry-node-tracer/test/instrumentation/node_modules/simple-module/package.json @@ -0,0 +1,4 @@ +{ + "name": "simple-module", + "version": "0.0.1" +} diff --git a/packages/opentelemetry-node-tracer/test/instrumentation/utils.test.ts b/packages/opentelemetry-node-tracer/test/instrumentation/utils.test.ts new file mode 100644 index 00000000000..66d25390c59 --- /dev/null +++ b/packages/opentelemetry-node-tracer/test/instrumentation/utils.test.ts @@ -0,0 +1,72 @@ +/** + * 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 { NoopLogger } from '@opentelemetry/core'; +import * as assert from 'assert'; +import * as path from 'path'; +import * as utils from '../../src/instrumentation/utils'; + +const INSTALLED_PLUGINS_PATH = path.join(__dirname, 'node_modules'); +const TEST_MODULES: Array<{ name: string; version: string | null }> = [ + { + name: 'simple-module', + version: '0.0.1', + }, + { + name: 'nonexistent-module', + version: null, + }, + { + name: 'http', + version: null, + }, +]; + +describe('Instrumentation#utils', () => { + const logger = new NoopLogger(); + + before(() => { + utils.searchPathForTest(INSTALLED_PLUGINS_PATH); + }); + + describe('defaultPackageName', () => { + it('should return package name with default scope and a prefix', () => { + assert.strictEqual( + utils.defaultPackageName('http'), + '@opentelemetry/plugin-http' + ); + assert.strictEqual( + utils.defaultPackageName('simple-module'), + '@opentelemetry/plugin-simple-module' + ); + }); + }); + + describe('getPackageVersion', () => { + it('should handle when undefined basedir', () => { + assert.strictEqual(utils.getPackageVersion(logger), null); + }); + + TEST_MODULES.forEach(testCase => { + it(`should return ${testCase.version} for ${testCase.name}`, () => { + assert.strictEqual( + utils.getPackageVersion(logger, testCase.name), + testCase.version + ); + }); + }); + }); +}); diff --git a/packages/opentelemetry-types/src/trace/instrumentation/Plugin.ts b/packages/opentelemetry-types/src/trace/instrumentation/Plugin.ts index 663e998f98b..1233996a566 100644 --- a/packages/opentelemetry-types/src/trace/instrumentation/Plugin.ts +++ b/packages/opentelemetry-types/src/trace/instrumentation/Plugin.ts @@ -17,7 +17,8 @@ import { Tracer } from '../tracer'; /** Interface Plugin to apply patch. */ -export interface Plugin { +// tslint:disable-next-line:no-any +export interface Plugin { /** * Method that enables the instrumentation patch. * @param moduleExports The value of the `module.exports` property that would