UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

402 lines (346 loc) 13.3 kB
'use strict' /* eslint-disable no-console */ const { execSync } = require('child_process') const fs = require('fs') const RAW_BUILTINS = require('module').builtinModules const path = require('path') const { pathToFileURL, fileURLToPath } = require('url') const instrumentations = require('../datadog-instrumentations/src/helpers/instrumentations.js') const extractPackageAndModulePath = require( '../datadog-instrumentations/src/helpers/extract-package-and-module-path.js' ) const hooks = require('../datadog-instrumentations/src/helpers/hooks.js') const { processModule, isESMFile } = require('./src/utils.js') 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 (typeof hook === 'object') { hook.fn() } else { hook() } } const modulesOfInterest = new Set() for (const instrumentation of Object.values(instrumentations)) { for (const entry of instrumentation) { if (!entry.file) { modulesOfInterest.add(entry.name) // e.g. "redis" } else { modulesOfInterest.add(`${entry.name}/${entry.file}`) // e.g. "redis/my/file.js" } } } const CHANNEL = 'dd-trace:bundler:load' const builtins = new Set() for (const builtin of RAW_BUILTINS) { builtins.add(builtin) builtins.add(`node:${builtin}`) } const DEBUG = !!process.env.DD_TRACE_DEBUG 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 () { 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) { if (DEBUG) { console.warn('Warning: 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) { if (DEBUG) { console.warn('Warning: failed to get git commit SHA:', e.message) } } return gitMetadata } module.exports.setup = function (build) { 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 (error) { build.initialOptions.external ??= [] build.initialOptions.external.push('@openfeature/core') } const esmBuild = isESMBuild(build) if (esmBuild) { if (!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}` if (DEBUG) { console.log('Info: automatically injected git metadata:') console.log(`DD_GIT_REPOSITORY_URL: ${gitMetadata.repositoryURL || 'not available'}`) console.log(`DD_GIT_COMMIT_SHA: ${gitMetadata.commitSHA || 'not available'}`) } } else if (DEBUG) { console.warn('Warning: 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() if (DEBUG) console.log(`EXTERNAL: ${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 if (DEBUG) console.log(`@LOCAL: ${args.path}`) return } let fullPathToModule try { fullPathToModule = dotFriendlyResolve(args.path, args.resolveDir, args.kind === 'import-statement') } catch (err) { if (DEBUG) { console.warn(`Warning: Unable to find "${args.path}".` + "Unless it's dead code this could cause a problem at runtime.") } return } if (args.path.startsWith('.') && !args.importer.includes('node_modules/')) { // It is local application code, not an instrumented package if (DEBUG) console.log(`APP: ${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) { if (DEBUG) { console.warn(`Warning: Unable to find "${extracted.pkg}/package.json".` + "Unless it's dead code this could cause a problem at runtime.") } } return } else { throw err } } try { const packageJson = JSON.parse(fs.readFileSync(pathToPackageJson).toString()) const isESM = isESMFile(fullPathToModule, pathToPackageJson, packageJson) if (isESM && !interceptedESMModules.has(fullPathToModule)) { fullPathToModule += ESM_INTERCEPTED_SUFFIX } if (DEBUG) console.log(`RESOLVE: ${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') { if (DEBUG) { console.log([ 'Skipping `package.json` lookup.', 'This usually means the package was vendored but could indicate an issue otherwise.' ].join(' ')) } } else { throw e } } } }) build.onLoad({ filter: /.*/ }, async args => { if (args.pluginData?.pkgOfInterest) { const data = args.pluginData if (DEBUG) console.log(`LOAD: ${data.pkg}@${data.version}, pkg "${data.path}"`) const pkgPath = data.raw !== data.pkg ? `${data.pkg}/${data.path}` : data.pkg // 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 = {}; ${Array.from(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 if (DEBUG) console.log(`REWRITE: ${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], conditions }) }