Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(instrumentation): implement require-in-the-middle singleton #3161

Merged
merged 17 commits into from
Oct 20, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions experimental/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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
mhassan1 marked this conversation as resolved.
Show resolved Hide resolved

### :books: (Refine Doc)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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 type { Hooked } from './RequireInTheMiddleSingleton';

export const ModuleNameSeparator = '/';

/**
* Node in a `ModuleNameTrie`
*/
class ModuleNameTrieNode {
hooks: Array<{ hook: Hooked, insertedId: number }> = [];
children: Map<string, ModuleNameTrieNode> = new Map();
}

/**
* Trie containing nodes that represent a part of a module name (i.e. the parts separated by forward slash)
*/
export class ModuleNameTrie {
private _trie: ModuleNameTrieNode = new ModuleNameTrieNode();
private _counter: number = 0;

/**
* Insert a module hook into the trie
*
* @param {Hooked} hook Hook
*/
insert(hook: Hooked) {
let trieNode = this._trie;

for (const moduleNamePart of hook.moduleName.split(ModuleNameSeparator)) {
let nextNode = trieNode.children.get(moduleNamePart);
if (!nextNode) {
nextNode = new ModuleNameTrieNode();
trieNode.children.set(moduleNamePart, nextNode);
}
trieNode = nextNode;
}
trieNode.hooks.push({ hook, insertedId: this._counter++ });
}

/**
* Search for matching hooks in the trie
*
* @param {string} moduleName Module name
* @param {boolean} maintainInsertionOrder Whether to return the results in insertion order
* @returns {Hooked[]} Matching hooks
*/
search(moduleName: string, { maintainInsertionOrder }: { maintainInsertionOrder?: boolean } = {}): Hooked[] {
mhassan1 marked this conversation as resolved.
Show resolved Hide resolved
let trieNode = this._trie;
const results: ModuleNameTrieNode['hooks'] = [];

for (const moduleNamePart of moduleName.split(ModuleNameSeparator)) {
const nextNode = trieNode.children.get(moduleNamePart);
if (!nextNode) {
break;
}
results.push(...nextNode.hooks);
trieNode = nextNode;
}

if (results.length === 0) {
return [];
}
if (results.length === 1) {
return [results[0].hook];
}
if (maintainInsertionOrder) {
results.sort((a, b) => a.insertedId - b.insertedId);
}
return results.map(({ hook }) => hook);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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';
import { ModuleNameTrie, ModuleNameSeparator } from './ModuleNameTrie';

export type Hooked = {
moduleName: string
onRequire: RequireInTheMiddle.OnRequireFn
};

/**
* Whether Mocha is running in this process
* Inspired by https://github.com/AndreasPizsa/detect-mocha
*
* @type {boolean}
*/
const isMocha = ['afterEach','after','beforeEach','before','describe','it'].every(fn => {
// @ts-expect-error TS7053: Element implicitly has an 'any' type
return typeof global[fn] === 'function';
});

/**
* Singleton class for `require-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,
* 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
* on the performance of instrumentation hooks being applied.
*/
export class RequireInTheMiddleSingleton {
private _moduleNameTrie: ModuleNameTrie = new ModuleNameTrie();
private static _instance?: RequireInTheMiddleSingleton;

private 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._moduleNameTrie.search(normalizedModuleName, { maintainInsertionOrder: true });

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

return exports;
}
);
}

/**
* Register a hook with `require-in-the-middle`
*
* @param {string} moduleName Module name
* @param {RequireInTheMiddle.OnRequireFn} onRequire Hook function
* @returns {Hooked} Registered hook
*/
register(moduleName: string, onRequire: RequireInTheMiddle.OnRequireFn): Hooked {
const hooked = { moduleName, onRequire };
this._moduleNameTrie.insert(hooked);
return hooked;
}

/**
* Get the `RequireInTheMiddleSingleton` singleton
*
* @returns {RequireInTheMiddleSingleton} Singleton of `RequireInTheMiddleSingleton`
*/
static getInstance(): RequireInTheMiddleSingleton {
// Mocha runs all test suites in the same process
// This prevents test suites from sharing a singleton
if (isMocha) return new RequireInTheMiddleSingleton();

return this._instance = this._instance ?? new RequireInTheMiddleSingleton();
}
}

/**
* 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 !== ModuleNameSeparator
? moduleNameOrPath.split(path.sep).join(ModuleNameSeparator)
: moduleNameOrPath;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -29,7 +29,8 @@ export abstract class InstrumentationBase<T = any>
extends InstrumentationAbstract
implements types.Instrumentation {
private _modules: InstrumentationModuleDefinition<T>[];
private _hooks: RequireInTheMiddle.Hooked[] = [];
mhassan1 marked this conversation as resolved.
Show resolved Hide resolved
private _hooks: Hooked[] = [];
private _requireInTheMiddleSingleton: RequireInTheMiddleSingleton = RequireInTheMiddleSingleton.getInstance();
private _enabled = false;

constructor(
Expand Down Expand Up @@ -159,9 +160,8 @@ export abstract class InstrumentationBase<T = any>
this._warnOnPreloadedModules();
for (const module of this._modules) {
this._hooks.push(
RequireInTheMiddle(
[module.name],
{ internals: true },
this._requireInTheMiddleSingleton.register(
module.name,
(exports, name, baseDir) => {
return this._onRequire<typeof exports>(
(module as unknown) as InstrumentationModuleDefinition<
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 { Hooked } from '../../src/platform/node/RequireInTheMiddleSingleton';
import { ModuleNameTrie } from '../../src/platform/node/ModuleNameTrie';

describe('ModuleNameTrie', () => {
describe('search', () => {
const trie = new ModuleNameTrie();
const inserts = [
{ moduleName: 'a', onRequire: () => {} },
{ moduleName: 'a/b', onRequire: () => {} },
{ moduleName: 'a', onRequire: () => {} },
{ moduleName: 'a/c', onRequire: () => {} },
{ moduleName: 'd', onRequire: () => {} }
] as Hooked[];
inserts.forEach(trie.insert.bind(trie));

it('should return a list of exact matches (no results)', () => {
assert.deepEqual(trie.search('e'), []);
});

it('should return a list of exact matches (one result)', () => {
assert.deepEqual(trie.search('d'), [inserts[4]]);
});

it('should return a list of exact matches (more than one result)', () => {
assert.deepEqual(trie.search('a'), [
inserts[0],
inserts[2]
]);
});

describe('maintainInsertionOrder = false', () => {
it('should return a list of matches in prefix order', () => {
assert.deepEqual(trie.search('a/b'), [
inserts[0],
inserts[2],
inserts[1]
]);
});
});

describe('maintainInsertionOrder = true', () => {
it('should return a list of matches in insertion order', () => {
assert.deepEqual(trie.search('a/b', { maintainInsertionOrder: true }), [
inserts[0],
inserts[1],
inserts[2]
]);
});
});
});
});
Loading