Skip to content

Commit

Permalink
feat(instrumentation): add support for esm module
Browse files Browse the repository at this point in the history
  • Loading branch information
vmarchaud committed Oct 30, 2022
1 parent a3e40da commit 4256872
Show file tree
Hide file tree
Showing 9 changed files with 102 additions and 28 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ All notable changes to this project will be documented in this file.

* feat(sdk-trace): re-export sdk-trace-base in sdk-trace-node and web [#3319](https://github.com/open-telemetry/opentelemetry-js/pull/3319) @legendecas
* feat: enable tree shaking [#3329](https://github.com/open-telemetry/opentelemetry-js/pull/3329) @pkanal
* feat(instrumentation): add support for esm module [#2846](https://github.com/open-telemetry/opentelemetry-js/pull/2846) @vmarchaud


### :bug: (Bug Fix)

Expand Down
4 changes: 4 additions & 0 deletions experimental/packages/opentelemetry-instrumentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ If nothing is specified the global registered provider is used. Usually this is
There might be usecase where someone has the need for more providers within an application. Please note that special care must be takes in such setups
to avoid leaking information from one provider to the other because there are a lot places where e.g. the global `ContextManager` or `Propagator` is used.

## ESM within Node.JS

As the module loading mechanism for ESM is different than CJS, you need to select a custom loader so instrumentation can load hook on the esm module it want to patch, to do so you need to provide `--loader=import-in-the-middle/hook.mjs` to the `node` binary. This only works for Node.JS > 12.

## License

Apache 2.0 - See [LICENSE][license-url] for more information.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
"tdd": "npm run tdd:node",
"tdd:node": "npm run test -- --watch-extensions ts --watch",
"tdd:browser": "karma start",
"test": "nyc ts-mocha -p tsconfig.json 'test/**/*.test.ts' --exclude 'test/browser/**/*.ts'",
"test:cjs": "nyc ts-mocha -p tsconfig.json 'test/**/*.test.ts' --exclude 'test/browser/**/*.ts'",
"test:esm": "nyc node --loader=import-in-the-middle/hook.mjs node_modules/.bin/_mocha test/node/*.test.mjs",
"test": "npm run test:cjs && npm run test:esm",
"test:browser": "nyc karma start --single-run",
"version": "node ../../../scripts/version-update.js",
"watch": "tsc --build --watch tsconfig.all.json",
Expand All @@ -69,6 +71,7 @@
},
"dependencies": {
"@opentelemetry/api-metrics": "0.33.0",
"import-in-the-middle": "^1.3.4",
"require-in-the-middle": "^5.0.3",
"semver": "^7.3.2",
"shimmer": "^1.2.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import * as RequireInTheMiddle from 'require-in-the-middle';
import * as ImportInTheMiddle from 'import-in-the-middle';
import * as path from 'path';
import { ModuleNameTrie, ModuleNameSeparator } from './ModuleNameTrie';

Expand All @@ -23,6 +24,12 @@ export type Hooked = {
onRequire: RequireInTheMiddle.OnRequireFn
};

/**
* We are forced to re-type there because ImportInTheMiddle is exported as normal CJS
* in the JS files but transpiled ESM (with a default export) in its typing.
*/
const ESMHook = ImportInTheMiddle as unknown as typeof ImportInTheMiddle.default;

/**
* Whether Mocha is running in this process
* Inspired by https://github.com/AndreasPizsa/detect-mocha
Expand All @@ -35,12 +42,12 @@ const isMocha = ['afterEach','after','beforeEach','before','describe','it'].ever
});

/**
* Singleton class for `require-in-the-middle`
* Singleton class for `require-in-the-middle` and `import-in-the-middle`
* Allows instrumentation plugins to patch modules with only a single `require` patch
* WARNING: Because this class will create its own `require-in-the-middle` (RITM) instance,
* WARNING: Because this class will create its own RITM and IITM instance,
* we should minimize the number of new instances of this class.
* Multiple instances of `@opentelemetry/instrumentation` (e.g. multiple versions) in a single process
* will result in multiple instances of RITM, which will have an impact
* will result in multiple instances of RITM/ITTM, which will have an impact
* on the performance of instrumentation hooks being applied.
*/
export class RequireInTheMiddleSingleton {
Expand All @@ -52,23 +59,23 @@ export class RequireInTheMiddleSingleton {
}

private _initialize() {
RequireInTheMiddle(
// Intercept all `require` calls; we will filter the matching ones below
null,
{ internals: true },
(exports, name, basedir) => {
// For internal files on Windows, `name` will use backslash as the path separator
const normalizedModuleName = normalizePathSeparators(name);
//
const onHook = (exports: any, name: string, basedir: string | undefined | void) => {
// For internal files on Windows, `name` will use backslash as the path separator
const normalizedModuleName = normalizePathSeparators(name);

const matches = this._moduleNameTrie.search(normalizedModuleName, { maintainInsertionOrder: true });
const matches = this._moduleNameTrie.search(normalizedModuleName, { maintainInsertionOrder: true });

for (const { onRequire } of matches) {
exports = onRequire(exports, name, basedir);
}

return exports;
for (const { onRequire } of matches) {
exports = onRequire(exports, name, basedir ? basedir : undefined);
}
);

return exports;
}
// Intercept all `require` calls; we will filter the matching ones below
RequireInTheMiddle(null, { internals: true }, onHook);
// We can give no module to patch but this signature isn't exposed in typings
new ESMHook(null as any, { internals: true }, onHook)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,14 @@ export abstract class InstrumentationBase<T = any>
});
}

private _extractPackageVersion(baseDir: string): string | undefined {
private _extractPackage(baseDir: string): { name?: string, version?: string, main?: string } {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const version = require(path.join(baseDir, 'package.json')).version;
return typeof version === 'string' ? version : undefined;
return require(path.join(baseDir, 'package.json'));
} catch (error) {
diag.warn('Failed extracting version', baseDir);
}

return undefined;
return {};
}

private _onRequire<T>(
Expand All @@ -104,12 +102,13 @@ export abstract class InstrumentationBase<T = any>
return exports;
}

const version = this._extractPackageVersion(baseDir);
module.moduleVersion = version;
if (module.name === name) {
const pkg = this._extractPackage(baseDir);
module.moduleVersion = pkg.version;
// if the targeted module is an esm, the name will be the name of its entrypoint
if (module.name === name || (pkg.main && path.normalize(`${pkg.name}/${pkg.main}`) === name)) {
// main module
if (
isSupported(module.supportedVersions, version, module.includePrerelease)
isSupported(module.supportedVersions, pkg.version, module.includePrerelease)
) {
if (typeof module.patch === 'function') {
module.moduleExports = exports;
Expand All @@ -124,7 +123,7 @@ export abstract class InstrumentationBase<T = any>
const files = module.files ?? [];
const supportedFileInstrumentations = files
.filter(f => f.name === name)
.filter(f => isSupported(f.supportedVersions, version, module.includePrerelease));
.filter(f => isSupported(f.supportedVersions, pkg.version, module.includePrerelease));
return supportedFileInstrumentations.reduce<T>(
(patchedExports, file) => {
file.moduleExports = patchedExports;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright The 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 * as assert from 'assert'
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '../../build/src/index.js';

describe('when loading esm module', () => {
it('should patch module file', async () => {
class TestInstrumentation extends InstrumentationBase {
constructor(onPatch, onUnpatch) {
super('my-esm-instrumentation', '0.1.0');
}

init() {
return [
new InstrumentationNodeModuleDefinition(
'my-esm-module',
['*'],
(exports, version) => {
exports.myConstant = 43;
exports.myFunction = () => 'another';
}
)
];
}
}

const instrumentation = new TestInstrumentation();
instrumentation.enable();
const exported = await import('my-esm-module');
assert.deepEqual(exported.myConstant, 43);
assert.deepEqual(exported.myFunction(), 'another');
});
});
Empty file.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 4256872

Please sign in to comment.