UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

415 lines (355 loc) 13.7 kB
'use strict' const { execSync } = require('node:child_process') const fs = require('node:fs') const path = require('node:path') const { pathToFileURL, fileURLToPath } = require('node:url') const instrumentations = require('../datadog-instrumentations/src/helpers/instrumentations') const extractPackageAndModulePath = require('../datadog-instrumentations/src/helpers/extract-package-and-module-path') const hooks = require('../datadog-instrumentations/src/helpers/hooks') const { processModule, isESMFile } = require('./src/utils') const log = require('./src/log') const ESM_INTERCEPTED_SUFFIX = '._dd_esbuild_intercepted' const INTERNAL_ESM_INTERCEPTED_PREFIX = '/_dd_esm_internal_/' let rewriter for (const hook of Object.values(hooks)) { if (hook !== null && typeof hook === 'object') { hook.fn() } else { hook() } } function moduleOfInterestKey (name, file) { return file ? `${name}/${file}` : name } const builtinModules = new Set(require('module').builtinModules) function addModuleOfInterest (name, file) { if (!name) return modulesOfInterest.add(moduleOfInterestKey(name, file)) if (builtinModules.has(name)) { modulesOfInterest.add(moduleOfInterestKey(`node:${name}`, file)) } } const modulesOfInterest = new Set() for (const [name, instrumentation] of Object.entries(instrumentations)) { for (const entry of instrumentation) { addModuleOfInterest(name, entry.file) } } const CHANNEL = 'dd-trace:bundler:load' const builtins = new Set() for (const builtin of builtinModules) { builtins.add(builtin) builtins.add(`node:${builtin}`) } // eslint-disable-next-line eslint-rules/eslint-process-env const DD_IAST_ENABLED = process.env.DD_IAST_ENABLED?.toLowerCase() === 'true' || process.env.DD_IAST_ENABLED === '1' module.exports.name = 'datadog-esbuild' function isESMBuild (build) { // check toLowerCase? to be safe if unexpected object is there instead of a string const format = build.initialOptions.format?.toLowerCase?.() const outputFile = build.initialOptions.outfile?.toLowerCase?.() const outExtension = build.initialOptions.outExtension?.['.js'] return format === 'esm' || outputFile?.endsWith('.mjs') || outExtension === '.mjs' } function getGitMetadata () { /** * @type {object} * @property {string | null} repositoryURL * @property {string | null} commitSHA */ const gitMetadata = { repositoryURL: null, commitSHA: null, } try { gitMetadata.repositoryURL = execSync('git config --get remote.origin.url', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], cwd: process.cwd(), }).trim() } catch (e) { log.warn('failed to get git repository URL:', e.message) } try { gitMetadata.commitSHA = execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], cwd: process.cwd(), }).trim() } catch (e) { log.warn('failed to get git commit SHA:', e.message) } return gitMetadata } module.exports.setup = function (build) { if (build.initialOptions.minify && !build.initialOptions.keepNames) { throw new Error( 'Using --minify without --keep-names will break some dd-trace behavior. Refusing to bundle.' ) } if (DD_IAST_ENABLED) { const iastRewriter = require('../dd-trace/src/appsec/iast/taint-tracking/rewriter') rewriter = iastRewriter.getRewriter() } const isSourceMapEnabled = !!build.initialOptions.sourcemap || ['internal', 'both'].includes(build.initialOptions.sourcemap) const externalModules = new Set(build.initialOptions.external || []) build.initialOptions.banner ??= {} build.initialOptions.banner.js ??= '' if (DD_IAST_ENABLED) { build.initialOptions.banner.js = `globalThis.__DD_ESBUILD_IAST_${isSourceMapEnabled ? 'WITH_SM' : 'WITH_NO_SM'} = true; ${isSourceMapEnabled ? `globalThis.__DD_ESBUILD_BASEPATH = '${require('../dd-trace/src/util').ddBasePath}';` : ''} ${build.initialOptions.banner.js}` } try { // eslint-disable-next-line n/no-unpublished-require require.resolve('@openfeature/core') } catch { build.initialOptions.external ??= [] build.initialOptions.external.push('@openfeature/core') } const esmBuild = isESMBuild(build) if ( esmBuild && !build.initialOptions.banner.js.includes('import { createRequire as $dd_createRequire } from \'module\'') ) { build.initialOptions.banner.js = `import { createRequire as $dd_createRequire } from 'module'; import { fileURLToPath as $dd_fileURLToPath } from 'url'; import { dirname as $dd_dirname } from 'path'; globalThis.require ??= $dd_createRequire(import.meta.url); globalThis.__filename ??= $dd_fileURLToPath(import.meta.url); globalThis.__dirname ??= $dd_dirname(globalThis.__filename); ${build.initialOptions.banner.js}` } // Get git metadata at build time and add it to the banner for both ESM and CommonJS builds const gitMetadata = getGitMetadata() if (gitMetadata.repositoryURL || gitMetadata.commitSHA) { build.initialOptions.banner ??= {} build.initialOptions.banner.js ??= '' build.initialOptions.banner.js = `if (typeof process === 'object' && process !== null && process.env !== null && typeof process.env === 'object') { ${gitMetadata.repositoryURL ? `process.env.DD_GIT_REPOSITORY_URL = '${gitMetadata.repositoryURL}';` : ''} ${gitMetadata.commitSHA ? `process.env.DD_GIT_COMMIT_SHA = '${gitMetadata.commitSHA}';` : ''} } ${build.initialOptions.banner.js}` log.debug( 'Automatically injected git metadata (DD_GIT_REPOSITORY_URL: %s, DD_GIT_COMMIT_SHA: %s)', gitMetadata.repositoryURL || 'not available', gitMetadata.commitSHA || 'not available' ) } else { log.warn('No git metadata available - skipping injection') } // first time is intercepted, proxy should be created, next time the original should be loaded const interceptedESMModules = new Set() build.onResolve({ filter: /.*/ }, args => { if (externalModules.has(args.path)) { // Internal Node.js packages will still be instrumented via require() log.debug('EXTERNAL: %s', args.path) return } // TODO: Should this also check for namespace === 'file'? if (!modulesOfInterest.has(args.path) && args.path.startsWith('@') && !args.importer.includes('node_modules/')) { // This is the Next.js convention for loading local files log.debug('@LOCAL: %s', args.path) return } let fullPathToModule try { fullPathToModule = dotFriendlyResolve(args.path, args.resolveDir, args.kind === 'import-statement') } catch { log.warn('Unable to find "%s". Unless it\'s dead code this could cause a problem at runtime.', args.path) return } if (args.path.startsWith('.') && !args.importer.includes('node_modules/')) { // It is local application code, not an instrumented package log.debug('APP: %s', args.path) return { path: fullPathToModule, pluginData: { path: args.path, full: fullPathToModule, applicationFile: true, }, } } const extracted = extractPackageAndModulePath(fullPathToModule) const internal = builtins.has(args.path) if (args.namespace === 'file' && ( modulesOfInterest.has(args.path) || modulesOfInterest.has(`${extracted.pkg}/${extracted.path}`)) ) { // Internal module like http/fs is imported and the build output is ESM if (internal && args.kind === 'import-statement' && esmBuild && !interceptedESMModules.has(fullPathToModule)) { fullPathToModule = `${INTERNAL_ESM_INTERCEPTED_PREFIX}${fullPathToModule}${ESM_INTERCEPTED_SUFFIX}` return { path: fullPathToModule, pluginData: { pkg: extracted?.pkg, path: extracted?.path, full: fullPathToModule, raw: args.path, pkgOfInterest: true, kind: args.kind, internal, isESM: true, }, } } // The file namespace is used when requiring files from disk in userland let pathToPackageJson try { // we can't use require.resolve('pkg/package.json') as ESM modules don't make the file available pathToPackageJson = require.resolve(`${extracted.pkg}`, { paths: [args.resolveDir] }) pathToPackageJson = extractPackageAndModulePath(pathToPackageJson).pkgJson } catch (err) { if (err.code === 'MODULE_NOT_FOUND') { if (!internal) { log.warn( 'Unable to find "%s/package.json". Unless it\'s dead code this could cause a problem at runtime.', extracted.pkg ) } return } throw err } try { const packageJson = JSON.parse(fs.readFileSync(/** @type {string} */(pathToPackageJson)).toString()) const isESM = isESMFile(fullPathToModule, pathToPackageJson, packageJson) if (isESM && !interceptedESMModules.has(fullPathToModule)) { fullPathToModule += ESM_INTERCEPTED_SUFFIX } log.debug('RESOLVE: %s@%s', args.path, packageJson.version) // https://esbuild.github.io/plugins/#on-resolve-arguments return { path: fullPathToModule, pluginData: { version: packageJson.version, pkg: extracted.pkg, path: extracted.path, full: fullPathToModule, raw: args.path, pkgOfInterest: true, kind: args.kind, internal, isESM, }, } } catch (e) { // Skip vendored dependencies which never have a `package.json`. This // will use the default resolve logic of ESBuild which is what we want // since those files should be treated as regular files and not modules // even though they are in a `node_modules` folder. if (e.code === 'ENOENT') { log.debug( // eslint-disable-next-line @stylistic/max-len 'Skipping `package.json` lookup. This usually means the package was vendored but could indicate an issue otherwise.' ) } else { throw e } } } }) build.onLoad({ filter: /.*/ }, async args => { if (args.pluginData?.pkgOfInterest) { const data = args.pluginData log.debug('LOAD: %s@%s, pkg "%s"', data.pkg, data.version, data.path) const pkgPath = data.raw === data.pkg ? data.pkg : `${data.pkg}/${data.path}` // Read the content of the module file of interest let contents if (data.isESM) { if (args.path.endsWith(ESM_INTERCEPTED_SUFFIX)) { args.path = args.path.slice(0, -1 * ESM_INTERCEPTED_SUFFIX.length) if (data.internal) { args.path = args.path.slice(INTERNAL_ESM_INTERCEPTED_PREFIX.length) } interceptedESMModules.add(args.path) const setters = await processModule({ path: args.path, internal: data.internal, context: { format: 'module' }, }) const iitmPath = require.resolve('import-in-the-middle/lib/register.js') const toRegister = data.internal ? args.path : pathToFileURL(args.path) // Mimic a Module object (https://tc39.es/ecma262/#sec-module-namespace-objects). contents = ` import { register } from ${JSON.stringify(iitmPath)}; import * as namespace from ${JSON.stringify(args.path)}; const _ = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } }); const set = {}; const get = {}; ${[...setters.values()].join(';\n')}; register(${JSON.stringify(toRegister)}, _, set, get, ${JSON.stringify(data.raw)}); ` } else { contents = fs.readFileSync(args.path, 'utf8') } } else { const fileCode = fs.readFileSync(args.path, 'utf8') contents = ` (function() { ${fileCode} })(...arguments); { const dc = require('dc-polyfill'); const ch = dc.channel('${CHANNEL}'); const mod = module.exports const payload = { module: mod, version: '${data.version}', package: '${data.pkg}', path: '${pkgPath}' }; ch.publish(payload); module.exports = payload.module; } ` } // https://esbuild.github.io/plugins/#on-load-results return { contents, loader: 'js', resolveDir: path.dirname(args.path), } } if (DD_IAST_ENABLED && args.pluginData?.applicationFile) { const ext = path.extname(args.path).toLowerCase() const isJs = /^\.(js|mjs|cjs)$/.test(ext) if (!isJs) return log.debug('REWRITE: %s', args.path) const fileCode = fs.readFileSync(args.path, 'utf8') const rewritten = rewriter.rewrite(fileCode, args.path, ['iast']) return { contents: rewritten.content, loader: 'js', resolveDir: path.dirname(args.path), } } }) } // @see https://github.com/nodejs/node/issues/47000 function dotFriendlyResolve (path, directory, usesImportStatement) { if (path === '.') { path = './' } else if (path === '..') { path = '../' } let conditions if (usesImportStatement) { conditions = new Set(['import', 'node']) } if (path.startsWith('file://')) { path = fileURLToPath(path) } return require.resolve(path, { paths: [directory], // @ts-expect-error - Node.js 22+ unofficially supports a conditions option conditions, }) }