UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

225 lines (188 loc) 8.63 kB
'use strict' const { channel } = require('dc-polyfill') const path = require('path') const satisfies = require('semifies') const Hook = require('./hook') const requirePackageJson = require('../../../dd-trace/src/require-package-json') const log = require('../../../dd-trace/src/log') const checkRequireCache = require('./check-require-cache') const telemetry = require('../../../dd-trace/src/guardrails/telemetry') const { isInServerlessEnvironment } = require('../../../dd-trace/src/serverless') const { getEnvironmentVariables } = require('../../../dd-trace/src/config-helper') const envs = getEnvironmentVariables() const { DD_TRACE_DISABLED_INSTRUMENTATIONS = '', DD_TRACE_DEBUG = '' } = envs const hooks = require('./hooks') const instrumentations = require('./instrumentations') const names = Object.keys(hooks) const pathSepExpr = new RegExp(`\\${path.sep}`, 'g') const disabledInstrumentations = new Set( DD_TRACE_DISABLED_INSTRUMENTATIONS?.split(',') ) const loadChannel = channel('dd-trace:instrumentation:load') // Globals if (!disabledInstrumentations.has('fetch')) { require('../fetch') } if (!disabledInstrumentations.has('process')) { require('../process') } const HOOK_SYMBOL = Symbol('hookExportsSet') if (DD_TRACE_DEBUG && DD_TRACE_DEBUG.toLowerCase() !== 'false') { checkRequireCache.checkForRequiredModules() setImmediate(checkRequireCache.checkForPotentialConflicts) } const seenCombo = new Set() const allInstrumentations = {} // TODO: make this more efficient for (const packageName of names) { if (disabledInstrumentations.has(packageName)) continue const hookOptions = {} let hook = hooks[packageName] if (hook !== null && typeof hook === 'object') { if (hook.serverless === false && isInServerlessEnvironment()) continue hookOptions.internals = hook.esmFirst hook = hook.fn } // get the instrumentation file name to save all hooked versions const instrumentationFileName = parseHookInstrumentationFileName(packageName) Hook([packageName], hookOptions, (moduleExports, moduleName, moduleBaseDir, moduleVersion) => { moduleName = moduleName.replace(pathSepExpr, '/') // This executes the integration file thus adding its entries to `instrumentations` hook() if (!instrumentations[packageName]) { return moduleExports } const namesAndSuccesses = {} for (const { name, file, versions, hook, filePattern } of instrumentations[packageName]) { let fullFilePattern = filePattern const fullFilename = filename(name, file) if (fullFilePattern) { fullFilePattern = filename(name, fullFilePattern) } // Create a WeakSet associated with the hook function so that patches on the same moduleExport only happens once // for example by instrumenting both dns and node:dns double the spans would be created // since they both patch the same moduleExport, this WeakSet is used to mitigate that // TODO(BridgeAR): Instead of using a WeakSet here, why not just use aliases for the hook in register? // That way it would also not be duplicated. The actual name being used has to be identified else wise. // Maybe it is also not important to know what name was actually used? hook[HOOK_SYMBOL] ??= new WeakSet() let matchesFile = moduleName === fullFilename if (fullFilePattern) { // Some libraries include a hash in their filenames when installed, // so our instrumentation has to include a '.*' to match them for more than a single version. matchesFile = matchesFile || new RegExp(fullFilePattern).test(moduleName) } if (matchesFile) { let version = moduleVersion try { version = version || getVersion(moduleBaseDir) allInstrumentations[instrumentationFileName] = allInstrumentations[instrumentationFileName] || false } catch (e) { log.error('Error getting version for "%s": %s', name, e.message, e) continue } if (namesAndSuccesses[`${name}@${version}`] === undefined && !file) { // TODO If `file` is present, we might elsewhere instrument the result of the module // for a version range that actually matches, so we can't assume that we're _not_ // going to instrument that. However, the way the data model around instrumentation // works, we can't know either way just yet, so to avoid false positives, we'll just // ignore this if there is a `file` in the hook. The thing to do here is rework // everything so that we can be sure that there are _no_ instrumentations that it // could match. namesAndSuccesses[`${name}@${version}`] = false } if (matchVersion(version, versions)) { allInstrumentations[instrumentationFileName] = true // Check if the hook already has a set moduleExport if (hook[HOOK_SYMBOL].has(moduleExports)) { namesAndSuccesses[`${name}@${version}`] = true return moduleExports } try { loadChannel.publish({ name, version, file }) // Send the name and version of the module back to the callback because now addHook // takes in an array of names so by passing the name the callback will know which module name is being used // TODO(BridgeAR): This is only true in case the name is identical // in all loads. If they deviate, the deviating name would not be // picked up due to the unification. Check what modules actually use the name. // TODO(BridgeAR): Only replace moduleExports if the hook returns a new value. // This allows to reduce the instrumentation code (no return needed). moduleExports = hook(moduleExports, version, name) ?? moduleExports // Set the moduleExports in the hooks WeakSet hook[HOOK_SYMBOL].add(moduleExports) } catch (e) { log.info('Error during ddtrace instrumentation of application, aborting.', e) telemetry('error', [ `error_type:${e.constructor.name}`, `integration:${name}`, `integration_version:${version}` ]) } namesAndSuccesses[`${name}@${version}`] = true } } } for (const nameVersion of Object.keys(namesAndSuccesses)) { const [name, version] = nameVersion.split('@') const success = namesAndSuccesses[nameVersion] // we check allVersions to see if any version of the integration was successfully instrumented if (!success && !seenCombo.has(nameVersion) && !allInstrumentations[instrumentationFileName]) { telemetry('abort.integration', [ `integration:${name}`, `integration_version:${version}` ]) log.info('Found incompatible integration version: %s', nameVersion) seenCombo.add(nameVersion) } } return moduleExports }) } function matchVersion (version, ranges) { return !version || !ranges || ranges.some(range => satisfies(version, range)) } function getVersion (moduleBaseDir) { if (moduleBaseDir) { return requirePackageJson(moduleBaseDir, module).version } } function filename (name, file) { return [name, file].filter(Boolean).join('/') } // This function captures the instrumentation file name for a given package by parsing the hook require // function given the module name. It is used to ensure that instrumentations such as redis // that have several different modules being hooked, ie: 'redis' main package, and @redis/client submodule // return a consistent instrumentation name. This is used later to ensure that at least some portion of // the integration was successfully instrumented. Prevents incorrect `Found incompatible integration version: ` messages // Example: // redis -> "() => require('../redis')" -> redis // @redis/client -> "() => require('../redis')" -> redis // function parseHookInstrumentationFileName (packageName) { let hook = hooks[packageName] if (hook.fn) { hook = hook.fn } const hookString = hook.toString() const regex = /require\('([^']*)'\)/ const match = hookString.match(regex) // try to capture the hook require file location. if (match && match[1]) { let moduleName = match[1] // Remove leading '../' if present if (moduleName.startsWith('../')) { moduleName = moduleName.slice(3) } return moduleName } return null } module.exports = { filename, pathSepExpr, loadChannel, matchVersion }