@instana/core
Version:
Core library for Instana's Node.js packages
206 lines (179 loc) • 7.71 kB
JavaScript
/*
* (c) Copyright IBM Corp. 2021
* (c) Copyright Instana Inc. and contributors 2016
*/
;
const Module = require('module');
const path = require('path');
/**
* @typedef {Object} FileNamePatternTransformer
* @property {Function} fn
* @property {RegExp} pattern
*/
/**
* @typedef {Object} ExecutedHook
* @property {*} originalModuleExports
* @property {*} moduleExports
* @property {Array<string>} appliedByModuleNameTransformers
* @property {boolean} byFileNamePatternTransformersApplied
*/
/**
* @typedef {Object.<string, ExecutedHook>} ExecutedHooks
*/
/** @type {ExecutedHooks} */
let executedHooks = {};
/** @type {Object.<string, *>} */
let byModuleNameTransformers = {};
/** @type {Array<FileNamePatternTransformer>} */
let byFileNamePatternTransformers = [];
const origLoad = /** @type {*} */ (Module)._load;
/** @type {import('../core').GenericLogger} */
let logger;
/**
* @param {import('./normalizeConfig').InstanaConfig} [config]
*/
exports.init = function (config) {
logger = config.logger;
/** @type {*} */ (Module)._load = patchedModuleLoad;
};
/**
* @param {string} moduleName
*/
function patchedModuleLoad(moduleName) {
// CASE: when using ESM, the Node runtime passes a full path to Module._load
// We aim to extract the module name to apply our instrumentation.
// CASE: we ignore all file endings, which we are not interested in. Any module can load any file.
// CASE: The requireHook is not compatible with native ESM so the native ESM is not handled here.
// Exception: Starting from version 12, the 'got' module is transitioning to a pure ESM module but
// continues to function. This is because 'got' is instrumented coincidentally with the 'http' module.
// The instrumentation of 'http' and 'https' works without the requireHook.
// See: https://github.com/search?q=repo%3Asindresorhus%2Fgot%20from%20%27http2-wrapper%27&type=code.
// This is also the case with 'node-fetch'; from version 3, 'node-fetch' is a pure ESM and uses 'http'
// under the hood, working well without requireHook. This differs from our other instrumentations,
// where we require 'http' and 'https' at the top. Native ESM libraries that import core Node modules
// (e.g., import http from 'node:http') do not trigger Module._load, hence do not use the requireHook.
// However, when an ESM library imports a CommonJS package, our requireHook is triggered.
// For native ESM libraries the iitmHook is triggered.
if (path.isAbsolute(moduleName) && ['.node', '.json', '.ts'].indexOf(path.extname(moduleName)) === -1) {
// EDGE CASE for ESM: mysql2/promise.js
if (moduleName.indexOf('node_modules/mysql2/promise.js') !== -1) {
moduleName = 'mysql2/promise';
} else {
// e.g. path is node_modules/@elastic/elasicsearch/index.js
let match = moduleName.match(/node_modules\/(@.*?(?=\/)\/.*?(?=\/))/);
if (match && match.length > 1) {
moduleName = match[1];
} else {
// e.g. path is node_modules/mysql/lib/index.js
match = moduleName.match(/node_modules\/(.*?(?=\/))/);
if (match && match.length > 1) {
moduleName = match[1];
}
}
}
}
// First attempt to always get the module via the original implementation
// as this action may fail. The original function populates the module cache.
const moduleExports = origLoad.apply(Module, arguments);
/** @type {string} */
const filename = /** @type {*} */ (Module)._resolveFilename.apply(Module, arguments);
// We are not directly manipulating the global module cache because there might be other tools fiddling with
// Module._load. We don't want to break any of them.
const cacheEntry = (executedHooks[filename] = executedHooks[filename] || {
originalModuleExports: moduleExports,
moduleExports,
// We might have already seen and processed, i.e. manipulated, this require statement. This is something we
// are checking using these fields.
appliedByModuleNameTransformers: [],
byFileNamePatternTransformersApplied: false
});
// Some non-APM modules are fiddling with the require cache in some very unexpected ways.
// For example the request-promise* modules use stealthy-require to always get a fresh copy
// of the request module. Instead of adding a mechanism to get a copy the request function,
// they temporarily force clear the require cache and require the request module again
// to get a copy.
//
// In order to ensure that any such (weird) use cases are supported by us, we are making sure
// that we only return our patched variant when Module._load returned the same object based
// on which we applied our patches.
if (cacheEntry.originalModuleExports !== moduleExports) {
return moduleExports;
}
const applicableByModuleNameTransformers = byModuleNameTransformers[moduleName];
if (applicableByModuleNameTransformers && cacheEntry.appliedByModuleNameTransformers.indexOf(moduleName) === -1) {
for (let i = 0; i < applicableByModuleNameTransformers.length; i++) {
const transformerFn = applicableByModuleNameTransformers[i];
if (typeof transformerFn === 'function') {
try {
cacheEntry.moduleExports = transformerFn(cacheEntry.moduleExports, filename) || cacheEntry.moduleExports;
} catch (e) {
logger.error(
`Cannot instrument ${moduleName} due to an error in instrumentation: ${e?.message}, ${e?.stack}`
);
}
} else {
logger.error(
`A requireHook invariant has been violated for module name ${moduleName}, index ${i}. ` +
`The transformer is not a function but of type "${typeof transformerFn}" (details: ${
transformerFn == null ? 'null/undefined' : transformerFn
}). The most likely cause is that something has messed with Node.js' ` +
'module cache. This can result in limited tracing and health check functionality (for example, missing ' +
'calls in Instana).'
);
}
}
cacheEntry.appliedByModuleNameTransformers.push(moduleName);
}
if (!cacheEntry.byFileNamePatternTransformersApplied) {
for (let i = 0; i < byFileNamePatternTransformers.length; i++) {
if (byFileNamePatternTransformers[i].pattern.test(filename)) {
cacheEntry.moduleExports =
byFileNamePatternTransformers[i].fn(cacheEntry.moduleExports, filename) || cacheEntry.moduleExports;
}
}
cacheEntry.byFileNamePatternTransformersApplied = true;
}
return cacheEntry.moduleExports;
}
exports.teardownForTestPurposes = function () {
/** @type {*} */ (Module)._load = origLoad;
executedHooks = {};
byModuleNameTransformers = {};
byFileNamePatternTransformers = [];
};
/**
* @param {string} moduleName
* @param {Function} fn
*/
exports.onModuleLoad = function on(moduleName, fn) {
byModuleNameTransformers[moduleName] = byModuleNameTransformers[moduleName] || [];
byModuleNameTransformers[moduleName].push(fn);
};
/**
* @param {RegExp} pattern
* @param {Function} fn
*/
exports.onFileLoad = function onFileLoad(pattern, fn) {
byFileNamePatternTransformers.push({
pattern,
fn
});
};
/**
* @param {Array<string>} arr
*/
exports.buildFileNamePattern = function buildFileNamePattern(arr) {
return new RegExp(`${arr.reduce(buildFileNamePatternReducer, '')}$`);
};
/**
* @param {string} agg
* @param {string} pathSegment
* @returns {string}
*/
function buildFileNamePatternReducer(agg, pathSegment) {
if (agg.length > 0) {
agg += `\\${path.sep}`;
}
agg += pathSegment;
return agg;
}