Skip to content

Commit

Permalink
Add Plugin loader (open-telemetry#126)
Browse files Browse the repository at this point in the history
* Add Plugin Loader

* Load simple-module

* gts fix

* Use type variable T instead of any

* Log instead of throwing and remove redundant Plugins suffix

* Add package specific .gitignore

* Add @todo

* Change DEFAULT_PLUGIN_PACKAGE_NAME_PREFIX to plugin

* fix: build pipeline

* fix: remove test functions out of the class

* fix: use private members in test without exposing it

* feat: add PluginConfig during load

* fix: make comment more descriptive

* fix: move the any to the Plugin type

* fix: remove constants container
  • Loading branch information
mayurkale22 authored Aug 5, 2019
1 parent e32796a commit 20c9a49
Show file tree
Hide file tree
Showing 14 changed files with 510 additions and 2 deletions.
2 changes: 2 additions & 0 deletions packages/opentelemetry-node-tracer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Dependency directories
!test/instrumentation/node_modules
7 changes: 6 additions & 1 deletion packages/opentelemetry-node-tracer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
}
}
144 changes: 144 additions & 0 deletions packages/opentelemetry-node-tracer/src/instrumentation/PluginLoader.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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 = <T>(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;
}
69 changes: 69 additions & 0 deletions packages/opentelemetry-node-tracer/src/instrumentation/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit 20c9a49

Please sign in to comment.