-
Notifications
You must be signed in to change notification settings - Fork 807
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(instrumentation): implement
require-in-the-middle
singleton
- Loading branch information
Showing
4 changed files
with
241 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
...l/packages/opentelemetry-instrumentation/src/platform/node/requireInTheMiddleSingleton.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); |
141 changes: 141 additions & 0 deletions
141
...ntal/packages/opentelemetry-instrumentation/test/node/requireInTheMiddleSingleton.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); | ||
}); | ||
}); |