diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 9c3307b0b4..05ff1ed4a9 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -43,6 +43,7 @@ All notable changes to experimental packages in this project will be documented * Add `resourceDetectors` option to `NodeSDK` [#3210](https://github.com/open-telemetry/opentelemetry-js/issues/3210) * feat: add Logs API @mkuba [#3117](https://github.com/open-telemetry/opentelemetry-js/pull/3117) +* feat(instrumentation): implement `require-in-the-middle` singleton [#3161](https://github.com/open-telemetry/opentelemetry-js/pull/3161) @mhassan1 ### :bug: (Bug Fix) diff --git a/experimental/packages/opentelemetry-instrumentation/src/platform/node/instrumentation.ts b/experimental/packages/opentelemetry-instrumentation/src/platform/node/instrumentation.ts index 70dac85332..9b50da51fe 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/platform/node/instrumentation.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/platform/node/instrumentation.ts @@ -16,9 +16,9 @@ import * as types from '../../types'; import * as path from 'path'; -import * as RequireInTheMiddle from 'require-in-the-middle'; import { satisfies } from 'semver'; import { InstrumentationAbstract } from '../../instrumentation'; +import { requireInTheMiddleSingleton, Hooked } from './requireInTheMiddleSingleton'; import { InstrumentationModuleDefinition } from './types'; import { diag } from '@opentelemetry/api'; @@ -29,7 +29,7 @@ export abstract class InstrumentationBase extends InstrumentationAbstract implements types.Instrumentation { private _modules: InstrumentationModuleDefinition[]; - private _hooks: RequireInTheMiddle.Hooked[] = []; + private _hooks: Hooked[] = []; private _enabled = false; constructor( @@ -159,9 +159,8 @@ export abstract class InstrumentationBase this._warnOnPreloadedModules(); for (const module of this._modules) { this._hooks.push( - RequireInTheMiddle( - [module.name], - { internals: true }, + requireInTheMiddleSingleton.register( + module.name, (exports, name, baseDir) => { return this._onRequire( (module as unknown) as InstrumentationModuleDefinition< diff --git a/experimental/packages/opentelemetry-instrumentation/src/platform/node/requireInTheMiddleSingleton.ts b/experimental/packages/opentelemetry-instrumentation/src/platform/node/requireInTheMiddleSingleton.ts new file mode 100644 index 0000000000..9375f7da8b --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation/src/platform/node/requireInTheMiddleSingleton.ts @@ -0,0 +1,95 @@ +/* + * 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 RequireInTheMiddle from 'require-in-the-middle'; +import * as path from 'path'; + +export type Hooked = { + moduleName: string + onRequire: RequireInTheMiddle.OnRequireFn +}; + +const RITM_SINGLETON_SYM = Symbol.for('OpenTelemetry.js.sdk.require-in-the-middle'); + +/** + * Singleton class for `require-in-the-middle` + * Allows instrumentation plugins to patch modules with only a single `require` patch + */ +class RequireInTheMiddleSingleton { + private _modulesToHook: Hooked[] = []; + + constructor() { + this._initialize(); + } + + 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 matches = this._modulesToHook.filter(({ moduleName: hookedModuleName }) => { + return shouldHook(hookedModuleName, normalizedModuleName); + }); + + for (const { onRequire } of matches) { + exports = onRequire(exports, name, basedir); + } + + return exports; + } + ); + } + + register(moduleName: string, onRequire: RequireInTheMiddle.OnRequireFn): Hooked { + const hooked = { moduleName, onRequire }; + this._modulesToHook.push(hooked); + return hooked; + } + + static getGlobalInstance(): RequireInTheMiddleSingleton { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (global as any)[RITM_SINGLETON_SYM] = (global as any)[RITM_SINGLETON_SYM] ?? new RequireInTheMiddleSingleton(); + } +} + +/** + * Determine whether a `require`d module should be hooked + * + * @param {string} hookedModuleName Hooked module name + * @param {string} requiredModuleName Required module name + * @returns {boolean} Whether to hook the required module + * @private + */ +export function shouldHook(hookedModuleName: string, requiredModuleName: string): boolean { + return requiredModuleName === hookedModuleName || requiredModuleName.startsWith(hookedModuleName + '/'); +} + +/** + * Normalize the path separators to forward slash in a module name or path + * + * @param {string} moduleNameOrPath Module name or path + * @returns {string} Normalized module name or path + */ +function normalizePathSeparators(moduleNameOrPath: string): string { + return path.sep !== '/' + ? moduleNameOrPath.split(path.sep).join('/') + : moduleNameOrPath; +} + +export const requireInTheMiddleSingleton = RequireInTheMiddleSingleton.getGlobalInstance(); diff --git a/experimental/packages/opentelemetry-instrumentation/test/node/requireInTheMiddleSingleton.test.ts b/experimental/packages/opentelemetry-instrumentation/test/node/requireInTheMiddleSingleton.test.ts new file mode 100644 index 0000000000..eecb05a5b8 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation/test/node/requireInTheMiddleSingleton.test.ts @@ -0,0 +1,141 @@ +/* + * 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 * as sinon from 'sinon'; +import * as path from 'path'; +import * as RequireInTheMiddle from 'require-in-the-middle'; +import { requireInTheMiddleSingleton, shouldHook } from '../../src/platform/node/requireInTheMiddleSingleton'; + +type AugmentedExports = { + __ritmOnRequires?: string[] +}; + +const makeOnRequiresStub = (label: string): sinon.SinonStub => sinon.stub().callsFake(((exports: AugmentedExports) => { + exports.__ritmOnRequires ??= []; + exports.__ritmOnRequires.push(label); + return exports; +}) as RequireInTheMiddle.OnRequireFn); + +describe('requireInTheMiddleSingleton', () => { + describe('register', () => { + const onRequireFsStub = makeOnRequiresStub('fs'); + const onRequireFsPromisesStub = makeOnRequiresStub('fs-promises'); + const onRequireCodecovStub = makeOnRequiresStub('codecov'); + const onRequireCodecovLibStub = makeOnRequiresStub('codecov-lib'); + const onRequireCpxStub = makeOnRequiresStub('cpx'); + const onRequireCpxLibStub = makeOnRequiresStub('cpx-lib'); + + before(() => { + requireInTheMiddleSingleton.register('fs', onRequireFsStub); + requireInTheMiddleSingleton.register('fs/promises', onRequireFsPromisesStub); + requireInTheMiddleSingleton.register('codecov', onRequireCodecovStub); + requireInTheMiddleSingleton.register('codecov/lib/codecov.js', onRequireCodecovLibStub); + requireInTheMiddleSingleton.register('cpx', onRequireCpxStub); + requireInTheMiddleSingleton.register('cpx/lib/copy-sync.js', onRequireCpxLibStub); + }); + + beforeEach(() => { + onRequireFsStub.resetHistory(); + onRequireFsPromisesStub.resetHistory(); + onRequireCodecovStub.resetHistory(); + onRequireCodecovLibStub.resetHistory(); + onRequireCpxStub.resetHistory(); + onRequireCpxLibStub.resetHistory(); + }); + + it('should return a hooked object', () => { + const moduleName = 'm'; + const onRequire = makeOnRequiresStub('m'); + const hooked = requireInTheMiddleSingleton.register(moduleName, onRequire); + assert.deepStrictEqual(hooked, { moduleName, onRequire }); + }); + + describe('core module', () => { + describe('AND module name matches', () => { + it('should call `onRequire`', () => { + const exports = require('fs'); + assert.deepStrictEqual(exports.__ritmOnRequires, ['fs']); + sinon.assert.calledOnceWithExactly(onRequireFsStub, exports, 'fs', undefined); + sinon.assert.notCalled(onRequireFsPromisesStub); + }); + }); + describe('AND module name does not match', () => { + it('should not call `onRequire`', () => { + const exports = require('crypto'); + assert.equal(exports.__ritmOnRequires, undefined); + sinon.assert.notCalled(onRequireFsStub); + }); + }); + }); + + describe('core module with sub-path', () => { + describe('AND module name matches', () => { + it('should call `onRequire`', () => { + const exports = require('fs/promises'); + assert.deepStrictEqual(exports.__ritmOnRequires, ['fs', 'fs-promises']); + sinon.assert.calledOnceWithExactly(onRequireFsPromisesStub, exports, 'fs/promises', undefined); + sinon.assert.calledOnceWithMatch(onRequireFsStub, { __ritmOnRequires: ['fs', 'fs-promises'] }, 'fs/promises', undefined); + }); + }); + }); + + describe('non-core module', () => { + describe('AND module name matches', () => { + const baseDir = path.dirname(require.resolve('codecov')); + const modulePath = path.join('codecov', 'lib', 'codecov.js'); + it('should call `onRequire`', () => { + const exports = require('codecov'); + assert.deepStrictEqual(exports.__ritmOnRequires, ['codecov']); + sinon.assert.calledWithExactly(onRequireCodecovStub, exports, 'codecov', baseDir); + sinon.assert.calledWithMatch(onRequireCodecovStub, { __ritmOnRequires: ['codecov', 'codecov-lib'] }, modulePath, baseDir); + sinon.assert.calledWithMatch(onRequireCodecovLibStub, { __ritmOnRequires: ['codecov', 'codecov-lib'] }, modulePath, baseDir); + }); + }); + }); + + describe('non-core module with sub-path', () => { + describe('AND module name matches', () => { + const baseDir = path.resolve(path.dirname(require.resolve('cpx')), '..'); + const modulePath = path.join('cpx', 'lib', 'copy-sync.js'); + it('should call `onRequire`', () => { + const exports = require('cpx/lib/copy-sync'); + assert.deepStrictEqual(exports.__ritmOnRequires, ['cpx', 'cpx-lib']); + sinon.assert.calledWithMatch(onRequireCpxStub, { __ritmOnRequires: ['cpx', 'cpx-lib'] }, modulePath, baseDir); + sinon.assert.calledWithExactly(onRequireCpxStub, exports, modulePath, baseDir); + sinon.assert.calledWithExactly(onRequireCpxLibStub, exports, modulePath, baseDir); + }); + }); + }); + }); + + describe('shouldHook', () => { + describe('module that matches', () => { + it('should be hooked', () => { + assert.equal(shouldHook('c', 'c'), true); + assert.equal(shouldHook('c', 'c/d'), true); + assert.equal(shouldHook('c.js', 'c.js'), true); + }); + }); + describe('module that does not match', () => { + it('should not be hooked', () => { + assert.equal(shouldHook('c', 'c.js'), false); + assert.equal(shouldHook('c', 'e'), false); + assert.equal(shouldHook('c.js', 'c'), false); + }); + }); + }); +});