newrelic
Version:
New Relic agent
821 lines (735 loc) • 28.3 kB
JavaScript
/*
* Copyright 2020 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
const path = require('path')
const semver = require('semver')
const fs = require('./util/unwrapped-core').fs
const logger = require('./logger').child({ component: 'shimmer' })
const INSTRUMENTATIONS = require('./instrumentations')()
const CORE_INSTRUMENTATION = require('./core-instrumentation')
const shims = require('./shim')
const { Hook } = require('require-in-the-middle')
const IitmHook = require('import-in-the-middle')
const { nrEsmProxy } = require('./symbols')
const isAbsolutePath = require('./util/is-absolute-path')
const InstrumentationDescriptor = require('./instrumentation-descriptor')
const InstrumentationTracker = require('./instrumentation-tracker')
let pkgsToHook = []
const NAMES = require('./metrics/names')
const symbols = require('./symbols')
const { unsubscribe } = require('./instrumentation/undici')
const { setupOtel, teardownOtel } = require('./otel/setup')
/**
* Unwrapping is only likely to be used by test code, and is a fairly drastic
* maneuver, but it should be pretty safe if there's a desire to reboot the
* agent in flight.
*
* All of the wrapped methods are tracked in this constiable and used by unwrapAll
* below.
*/
let instrumented = []
const shimmer = (module.exports = {
/**
* If debug isn't false, the agent will retain references to wrapped methods
* for the entire lifetime of the agent. Some instrumentation depends on
* wrapping functions on individual objects, and this will cause the agent
* to retain references to a large number of dead objects.
*/
debug: false,
/**
* Detects if the given function has already been wrapped.
*
* @param {Function} fn - The function to look for a wrapper on.
* @returns {boolean} True if `fn` exists and has an attached original, else false.
*/
isWrapped: function isWrapped(fn) {
return !!(fn && fn[symbols.original])
},
/**
* Don't throw, but do log and bail out if wrapping fails.
*
* Provide an escape hatch by creating a closure around the original method
* and object / module wrapped into a helper function that will restore the
* original function / method. See Sinon for a systematic use of this
* pattern.
*
* @param {object} nodule Class or module containing the function to wrap.
* @param {object} noduleName Human-readable module / Class name. More
* helpful than you'd think.
* @param {string} methods One or more names of methods or functions to extract
* and wrap.
* @param {Function} wrapper A generator that, when called, returns a
* wrapped version of the original function.
*/
wrapMethod: function wrapMethod(nodule, noduleName, methods, wrapper) {
if (!methods) {
logger.warn(new Error(), 'Must include a method name to wrap. Called from:')
return
}
if (!noduleName) {
noduleName = '[unknown]'
}
if (!Array.isArray(methods)) {
methods = [methods]
}
methods.forEach((method) => {
const fqmn = noduleName + '.' + method
if (!nodule) {
logger.debug("Can't wrap %s from nonexistent object.", fqmn)
return
}
if (!wrapper) {
logger.debug("Can't wrap %s without a wrapper generator.", fqmn)
return
}
const original = nodule[method]
if (!original) {
logger.trace('%s not defined, so not wrapping.', fqmn)
return
}
if (original[symbols.unwrap]) {
logger.debug('%s already wrapped by agent.', fqmn)
return
}
const wrapped = wrapper(original, method)
Object.keys(original).forEach((key) => {
wrapped[key] = original[key]
})
wrapped[symbols.original] = original
wrapped[symbols.unwrap] = function unwrap() {
nodule[method] = original
logger.trace('Removed instrumentation from %s.', fqmn)
}
nodule[method] = wrapped
if (shimmer.debug) {
instrumented.push(wrapped)
}
logger.trace('Instrumented %s.', fqmn)
})
},
/**
* Sometimes you gotta do some crazy stuff to get the job done. Instead of using
* regular monkeypatching, wrapDeprecated allows you to pass in a getter and setter
* and then uses defineProperty to replace the original property with an
* accessor. Note that responsibility for unwrapping is not handled by this
* function.
*
* @param {object} nodule Class or module containing the property to
* wrap.
* @param {object} noduleName Human-readable module / Class name. More
* helpful than you'd think.
* @param {string} property The property to replace with the accessor.
* @param {Function} options Optional getter and setter to use for the accessor.
* @returns {object|undefined} returns original function
*/
wrapDeprecated: function wrapDeprecated(nodule, noduleName, property, options) {
if (!property) {
logger.warn(new Error(), 'Must include a function name to wrap. Called from:')
return
}
if (!noduleName) {
noduleName = '[unknown]'
}
const fqmn = noduleName + '.' + property
if (!nodule) {
logger.debug("Can't wrap %s from nonexistent object.", fqmn)
return
}
const original = nodule[property]
if (!original) {
logger.trace('%s not defined, so not wrapping.', fqmn)
return
}
delete nodule[property]
const descriptor = {
configurable: true,
enumerable: true
}
if (options.get) {
descriptor.get = options.get
}
if (options.set) {
descriptor.set = options.set
}
Object.defineProperty(nodule, property, descriptor)
logger.trace('Instrumented %s.', fqmn)
if (shimmer.debug) {
instrumented.push({
[symbols.unwrap]: function unwrapDeprecated() {
delete nodule[property]
nodule[property] = original
}
})
}
return original
},
unwrapMethod: function unwrapMethod(nodule, noduleName, method) {
if (!noduleName) {
noduleName = '[unknown]'
}
if (!method) {
return logger.debug(
'Must include a method name to unwrap. ' + 'Called from: %s',
new Error().stack
)
}
const fqmn = noduleName + '.' + method
if (!nodule) {
return logger.debug("Can't unwrap %s from nonexistent object.", fqmn)
}
const wrapped = nodule[method]
// keep instrumented up to date
const pos = instrumented.indexOf(wrapped)
if (pos !== -1) {
instrumented.splice(pos, 1)
}
if (!wrapped) {
return logger.debug('%s not defined, so not unwrapping.', fqmn)
}
if (!wrapped[symbols.unwrap]) {
return logger.debug("%s isn't unwrappable.", fqmn)
}
wrapped[symbols.unwrap]()
},
unwrapAll: function unwrapAll() {
instrumented.forEach((wrapper) => {
wrapper[symbols.unwrap]()
})
instrumented = []
},
/**
* Registers all instrumentation for 3rd party libraries.
*
* This is all 3rd party libs with the exception of the domain library in Node.js core
*
* @param {object} agent NR agent
*/
registerThirdPartyInstrumentation(agent) {
for (const [moduleName, instrInfo] of Object.entries(INSTRUMENTATIONS)) {
if (agent.config.instrumentation?.[moduleName]?.enabled === false) {
logger.warn(
`Instrumentation for ${moduleName} has been disabled via 'config.instrumentation.${moduleName}.enabled. Not instrumenting package`
)
} else if (instrInfo.module) {
// Because external instrumentations can change independent of
// the agent core, we don't want breakages in them to entirely
// disable the agent.
try {
const hooks = require(instrInfo.module + '/nr-hooks')
hooks.forEach(shimmer.registerInstrumentation)
} catch (e) {
logger.warn('Failed to load instrumentation for ' + instrInfo.module, e)
return
}
} else {
const fileName = path.join(__dirname, 'instrumentation', moduleName + '.js')
shimmer.registerInstrumentation({
moduleName,
type: instrInfo.type,
onRequire: _firstPartyInstrumentation.bind(null, agent, fileName)
})
}
}
// Even though domain is a core module we add it as a registered
// instrumentation to be lazy-loaded because we do not want to cause domain
// usage.
instrumentDomain(agent)
},
/**
* Registers all instrumentation for Node.js core libraries.
*
* @param {object} agent NR agent
*/
registerCoreInstrumentation(agent) {
instrumentProcessMethods(agent)
instrumentUndiciFetch(agent)
// Instrument each of the core modules.
for (const [mojule, core] of Object.entries(CORE_INSTRUMENTATION)) {
if (agent.config.instrumentation?.[mojule].enabled === false) {
logger.warn(
`Instrumentation for ${mojule} has been disabled via 'config.instrumentation.${mojule}.enabled. Not instrumenting package`
)
} else if (core.file) {
const filePath = path.join(__dirname, 'instrumentation', 'core', core.file)
let uninstrumented = null
try {
uninstrumented = require(mojule)
} catch (err) {
logger.trace('Could not load core module %s got error %s', mojule, err)
}
const shim = shims.createShimFromType({
type: core.type,
agent,
moduleName: mojule,
resolvedName: mojule
})
applyDebugState(shim, core, false)
_firstPartyInstrumentation(agent, filePath, shim, uninstrumented, mojule)
}
}
},
registerHooks(agent) {
this._ritm = new Hook(pkgsToHook, function onHook(exports, name, basedir) {
return _postLoad(agent, exports, name, basedir)
})
this._iitm = new IitmHook(pkgsToHook, function onESMHook(exports, name, basedir) {
return _postLoad(agent, exports, name, basedir, true)
})
},
removeHooks(agent) {
if (this._ritm) {
this._ritm.unhook()
this._ritm = null
}
if (this._iitm) {
this._iitm.unhook()
this._iitm = null
}
pkgsToHook = []
teardownOtel(agent)
unsubscribe()
},
bootstrapInstrumentation: function bootstrapInstrumentation(agent) {
shimmer.registerCoreInstrumentation(agent)
shimmer.registerThirdPartyInstrumentation(agent)
setupOtel(agent)
},
registerInstrumentation: function registerInstrumentation(opts) {
if (!hasValidRegisterOptions(opts)) {
return
}
const registeredInstrumentation = shimmer.registeredInstrumentations.getAllByName(
opts.moduleName
)
if (!registeredInstrumentation) {
// In cases where a customer is trying to instrument a file
// that is not within node_modules, they must provide the absolutePath
// so require-in-the-middle can call our callback. the moduleName
// still needs to be the resolved name so we can look up our instrumentation correctly
const pkgHook = opts.absolutePath || opts.moduleName
// not using a set because this is shared by reference
// to allow custom instrumentation to be loaded after the
// agent is bootstrapped
if (!pkgsToHook.includes(pkgHook)) {
pkgsToHook.push(pkgHook)
}
}
shimmer.registeredInstrumentations.track(
opts.moduleName,
new InstrumentationDescriptor({ ...opts })
)
},
registeredInstrumentations: new InstrumentationTracker(),
/**
* NOT FOR USE IN PRODUCTION CODE
*
* If an instrumented module has a dependency on another instrumented module,
* and multiple tests are being run in a single test suite with their own
* setup and teardown between tests, it's possible transitive dependencies
* will be unwrapped in the module cache in-place (which needs to happen to
* prevent stale closures from channeling instrumentation data to incorrect
* agents, but which means the transitive dependencies won't get re-wrapped
* the next time the parent module is required).
*
* Since this only applies in test code, it's not worth the drastic
* monkeypatching to Module necessary to walk the list of child modules and
* re-wrap them.
*
* Use this to re-apply any applicable instrumentation.
*
* @param {object} agent NR agent
* @param {string} modulePath path to module getting required
* @returns {object} exported module
*/
reinstrument: function reinstrument(agent, modulePath) {
return _postLoad(agent, require(modulePath), modulePath)
},
/**
* Given a NodeJS module name, return the name/identifier of our
* instrumentation. These two things are usually, but not always,
* the same.
*
* @param {string} moduleName name of module getting instrumented
* @returns {string} name unless pg.js and then returns pg
*/
getInstrumentationNameFromModuleName(moduleName) {
// XXX When updating these special cases, also update `uninstrumented`.
// To allow for instrumenting both 'pg' and 'pg.js'.
if (moduleName === 'pg.js') {
moduleName = 'pg'
}
if (isAbsolutePath(moduleName) === true) {
// moduleName is an absolute path to a module. So we need to look up
// the simple name from the registered instrumentations.
return this.registeredInstrumentations.simpleNameFromPath(moduleName)
}
return moduleName
},
/**
* Checks all registered instrumentations for a module and returns true
* only if every hook succeeded.
*
* @param {string} moduleName name of registered instrumentation
* @param {object} resolvedName the fully resolve path to the module
* @returns {boolean} if all instrumentation hooks successfully ran for a
* module
*/
isInstrumented(moduleName, resolvedName) {
const allItems = shimmer.registeredInstrumentations.getAllByName(moduleName)
const items = allItems.filter(
(item) =>
item.instrumentation.resolvedName === resolvedName && item.meta.instrumented === true
)
return items.length === allItems.length
},
instrumentPostLoad(agent, module, moduleName, resolvedName, returnModule = false) {
const result = _postLoad(agent, module, moduleName, resolvedName)
// This is to not break the public API
// previously it would just call instrumentation
// and not check the result
return returnModule ? result : shimmer.isInstrumented(moduleName, resolvedName)
},
/**
* Gets the version of a given package by parsing it from package.json
*
* @param {string} moduleName name of module
* @returns {string} version, defaults to Node.js version when it cannot parse
*/
getPackageVersion(moduleName) {
try {
const trackedItems = shimmer.registeredInstrumentations.getAllByName(moduleName)
if (trackedItems === undefined) {
throw Error(`no tracked items for module '${moduleName}'`)
}
const item = trackedItems.find((item) => item.instrumentation.resolvedName !== undefined)
if (item === undefined) {
return process.version
}
const pkg = require(path.resolve(item.instrumentation.resolvedName, 'package.json'))
return pkg.version
} catch (err) {
logger.debug('Failed to get version for `%s`, reason: %s', moduleName, err.message)
return process.version
}
}
})
function applyDebugState(shim, nodule, inEsm) {
if (shimmer.debug) {
shim.enableDebug()
instrumented.push(shim)
if (!inEsm) {
instrumented.push({
[symbols.unwrap]: function unwrapNodule() {
delete nodule[symbols.shim]
}
})
nodule[symbols.shim] = shim
}
}
}
/**
* All instrumentation files must export the same interface: a single
* initialization function that takes the agent and the module to be
* instrumented.
*
* @param {object} agent NR agent
* @param {object} nodule Class or module containing the function to wrap.
* @param {string} moduleName name of module
* @param {string} resolvedName fully qualified path to module
* @param {boolean} esmResolver indicates if it came from esm loader
* @returns {object|undefined} returns exported module unless already instrumented
*/
function instrumentPostLoad(agent, nodule, moduleName, resolvedName, esmResolver) {
// default to Node.js version, this occurs for core libraries
const pkgVersion = shimmer.getPackageVersion(moduleName)
const trackedInstrumentations = shimmer.registeredInstrumentations.getAllByName(moduleName)
trackedInstrumentations.forEach((trackedInstrumentation) => {
const isInstrumented = trackedInstrumentation.meta.instrumented === true
const failedInstrumentation = trackedInstrumentation.meta.didError === true
if (isInstrumented === true || failedInstrumentation === true) {
const msg = isInstrumented ? 'Already instrumented' : 'Failed to instrument'
logger.trace(`${msg} ${moduleName}@${pkgVersion}, skipping registering instrumentation`)
return
}
const { instrumentation } = trackedInstrumentation
const resolvedNodule = resolveNodule({ nodule, instrumentation, esmResolver })
const shim = shims.createShimFromType({
type: instrumentation.type,
agent,
moduleName,
resolvedName,
shimName: instrumentation.shimName,
pkgVersion
})
applyDebugState(shim, resolvedNodule, esmResolver)
trackInstrumentationUsage(agent, shim, moduleName, NAMES.FEATURES.INSTRUMENTATION.ON_REQUIRE)
// Tracking instrumentation is only used to add the supportability metrics
// that occur directly above this. No reason to attempt to load instrumentation
// as it does not exist.
if (instrumentation.type === InstrumentationDescriptor.TYPE_TRACKING) {
shimmer.registeredInstrumentations.setHookSuccess(trackedInstrumentation)
return
}
nodule = loadInstrumentation({
shim,
resolvedNodule,
pkgVersion,
moduleName,
nodule,
instrumentation
})
})
return nodule
}
/**
* If a module that is being shimmed was loaded via a typical `require` then
* we can use it as normal. If was loaded via an ESM import, then we need to
* handle it with some extra care. This function inspects the module,
* the conditions around its loading, and returns an appropriate object for
* subsequent shimming methods.
*
* @param {object} params Input parameters.
* @param {object} params.nodule The nodule being instrumented.
* @param {object} params.instrumentation The configuration for the nodule
* to be instrumented.
* @param {boolean|null} params.esmResolver Indicates if the nodule was loaded
* via an ESM import.
* @returns {object} The nodule or a Proxy.
*/
function resolveNodule({ nodule, instrumentation, esmResolver }) {
if (instrumentation.isEsm === true || !esmResolver) {
return nodule
}
// We have a CJS module wrapped by import-in-the-middle having been
// imported through ESM syntax. Due to the way CJS modules are parsed by
// ESM's import, we can have the same "export" attached to the `default`
// export and as a top-level named export. In order to shim things such
// that our users don't need to know to access `something.default.foo`
// when they have done `import * as something from 'something'`, we need
// to proxy the proxy in order to set our wrappers on both instances.
const noduleDefault = nodule.default
const origNodule = nodule
return new Proxy(
{ origNodule, noduleDefault },
{
get(target, name) {
if (name === nrEsmProxy) {
return true
}
if (target.noduleDefault[name]) {
return target.noduleDefault[name]
}
return target.origNodule[name]
},
set(target, name, value) {
if (target.origNodule[name]) {
target.origNodule[name] = value
}
if (target.noduleDefault[name]) {
target.noduleDefault[name] = value
}
return true
}
}
)
}
/**
* Attempts to execute an onRequire hook for a given module.
* If it fails it will call an onError hook and log warnings accordingly
*
* @param {object} params wrapping object to function
* @param {*} params.shim The instance of the shim used to instrument the module.
* @param {object} params.nodule Class or module containing the function to wrap.
* @param {object} params.resolvedNodule returns export of the default property
* @param {string} params.moduleName module name
* @param {InstrumentationDescriptor} params.instrumentation hooks for a give module
* @returns {object} updated export module
*/
function loadInstrumentation({ shim, resolvedNodule, moduleName, nodule, instrumentation }) {
const trackedItem = shimmer.registeredInstrumentations.getTrackedItem(moduleName, instrumentation)
try {
if (instrumentation.onRequire(shim, resolvedNodule, moduleName) !== false) {
shimmer.registeredInstrumentations.setHookSuccess(trackedItem)
nodule = shim.getExport(nodule)
}
} catch (instrumentationError) {
shimmer.registeredInstrumentations.setHookFailure(trackedItem)
if (instrumentation.onError) {
try {
instrumentation.onError(instrumentationError)
} catch (e) {
logger.warn(
e,
instrumentationError,
'Custom instrumentation for %s failed, then the onError handler threw an error',
moduleName
)
}
} else {
logger.warn(
instrumentationError,
'Custom instrumentation for %s failed. Please report this to the ' +
'maintainers of the custom instrumentation.',
moduleName
)
}
}
return nodule
}
function _firstPartyInstrumentation(agent, fileName, shim, nodule, moduleName) {
const fullPath = path.resolve(fileName)
if (!fs.existsSync(fileName)) {
return logger.warn('Tried to load instrumentation from %s, but file does not exist', fullPath)
}
try {
return require(fileName)(agent, nodule, moduleName, shim)
} catch (error) {
logger.warn(
error,
'Failed to instrument module %s using %s',
path.basename(fileName, '.js'),
fullPath
)
}
}
/**
* Invoked directly after a module is loaded via `require` (or `import`). This
* is the first opportunity for us to work with the newly loaded module. Prior
* to this, the only information we have is the simple name (the string used
* to load the module), as well as the `onRequire` and `onError` hooks to
* attach to the module.
*
* @param {object} agent
* @param {object} nodule The newly loaded module.
* @param {string} name The simple name used to load the module.
* @param {string} resolvedName The full file system path to the module.
* @param {object} [esmResolver]
* @returns {*|Object|undefined}
* @private
*/
function _postLoad(agent, nodule, name, resolvedName, esmResolver) {
const simpleName = shimmer.getInstrumentationNameFromModuleName(name)
const registeredInstrumentations = shimmer.registeredInstrumentations.getAllByName(simpleName)
const hasPostLoadInstrumentation =
registeredInstrumentations &&
registeredInstrumentations.length &&
registeredInstrumentations.filter((ri) => ri.instrumentation.onRequire).length
// Check if this is a known instrumentation and then run it.
if (hasPostLoadInstrumentation) {
if (resolvedName === undefined && isAbsolutePath(name) === true) {
// `resolvedName` comes from the `basedir` returned by the `Hook`
// function from import-in-the-middle or require-in-the-middle. At least
// with IITM, if the path string does not include a `node_modules` then
// `basedir` will be `undefined`. But we need it for our instrumentation
// to work. We'll only reach this situation if the module being
// instrumented has an `absolutePath` defined. So we detect that and
// assign appropriately.
resolvedName = name
}
shimmer.registeredInstrumentations.setResolvedName(simpleName, resolvedName)
logger.trace('Instrumenting %s with onRequire (module loaded) hook.', name)
return instrumentPostLoad(agent, nodule, simpleName, resolvedName, esmResolver)
}
return nodule
}
function hasValidRegisterOptions(opts) {
if (!opts) {
logger.warn('Instrumentation registration failed, no options provided')
return false
}
if (!opts.moduleName) {
logger.warn("Instrumentation registration failed, 'moduleName' not provided")
return false
}
if (!opts.onRequire) {
logger.warn(
'Instrumentation registration for %s failed, no require hooks provided.',
opts.moduleName
)
return false
}
return true
}
/**
* Adds metrics to indicate instrumentation was used for a particular module and
* what major version the module was at, if possible.
*
* @param {*} agent The agent instance.
* @param {*} shim The instance of the shim used to instrument the module.
* @param {string} moduleName The name of the required module.
* @param {string} metricPrefix Support metric prefix to prepend to the metrics. Will indicate onRequire
*
* from NAMES.FEATURES.INSTRUMENTATION.
*/
function trackInstrumentationUsage(agent, shim, moduleName, metricPrefix) {
try {
const version = tryGetVersion(shim)
const instrumentationMetricName = `${metricPrefix}/${moduleName}`
agent.metrics.getOrCreateMetric(instrumentationMetricName).incrementCallCount()
if (version) {
const majorVersion = semver.major(version)
const versionMetricName = `${instrumentationMetricName}/Version/${majorVersion}`
agent.metrics.getOrCreateMetric(versionMetricName).incrementCallCount()
}
} catch (error) {
logger.debug('Unable to track instrumentation usage for: ', moduleName, error)
}
}
function tryGetVersion(shim) {
// Indicates it is a built-in where the version is not useful as
// it is `process.version`
if (shim._moduleRoot === '.') {
return
}
return shim.pkgVersion
}
/**
* Loads the instrumentation with `./lib/instrumentation/core/globals.js`
* This instrumentation cannot be disabled by config at the moment as it just
* handles logging errors for `uncaughtException` and `unhandledRejections`
*
* @param {Agent} agent the agent instance
*/
function instrumentProcessMethods(agent) {
const globalShim = new shims.Shim(agent, 'globals', '.')
applyDebugState(globalShim, global, false)
const globalsFilepath = path.join(__dirname, 'instrumentation', 'core', 'globals.js')
_firstPartyInstrumentation(agent, globalsFilepath, globalShim, global, 'globals')
}
/**
* Checks if undici/fetch instrumentation is enabled.
* If so, it loads the diagnostics_channel hooks to instrument outbound
* undici/fetch calls.
* Since this just registers subscriptions to diagnostics_channel events from undici,
* we register this as core and it'll work for both fetch and undici
*
* @param {Agent} agent the agent instance
*/
function instrumentUndiciFetch(agent) {
if (agent.config.instrumentation?.undici.enabled === false) {
logger.warn('Instrumentation for undici/fetch has been disabled via `config.instrumentation.undici.enabled`. Not instrumenting package')
return
}
const undiciPath = path.join(__dirname, 'instrumentation', 'undici.js')
const undiciShim = shims.createShimFromType({
type: InstrumentationDescriptor.TYPE_TRANSACTION,
agent,
moduleName: 'undici',
resolvedName: '.'
})
_firstPartyInstrumentation(agent, undiciPath, undiciShim)
}
function instrumentDomain(agent) {
if (agent.config.instrumentation?.domain.enabled === false) {
logger.warn('Instrumentation for domain has been disabled via `config.instrumentation.domain.enabled`. Not instrumenting package')
return
}
const domainPath = path.join(__dirname, 'instrumentation/core/domain.js')
shimmer.registerInstrumentation({
moduleName: 'domain',
type: null,
onRequire: _firstPartyInstrumentation.bind(null, agent, domainPath)
})
}