Skip to content

Commit 4b3a3b6

Browse files
committed
fix(instrumentation): preserve ESM export aliases (#9436)
Named ESM imports could retain the original function because IITM only replaced the default binding after instrumentation. Preserve aliases that reference the original default export and synchronize patched builtin exports so every supported import form observes instrumentation.
1 parent 50dbd17 commit 4b3a3b6

89 files changed

Lines changed: 1015 additions & 510 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@
353353
/integration-tests/coverage-fixtures/ @DataDog/lang-platform-js
354354
/integration-tests/crashtracking/ @DataDog/lang-platform-js
355355
/integration-tests/helpers/ @DataDog/lang-platform-js
356+
/integration-tests/import-variants.spec.js @DataDog/lang-platform-js
356357
/integration-tests/init/ @DataDog/lang-platform-js
357358
/integration-tests/init.spec.js @DataDog/lang-platform-js
358359
/integration-tests/memory-leak/ @DataDog/lang-platform-js

integration-tests/helpers/index.js

Lines changed: 91 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -670,80 +670,119 @@ async function createSandbox (
670670
}
671671

672672
/**
673-
* @typedef {{ default?: string, star: string, destructure: string }} Variants
673+
* @typedef {'destructure' | 'direct' | 'namespace'} NamedExportBinding
674+
* @typedef {object} ImportVariantOptions
675+
* @property {string} bindingName
676+
* @property {string} packageName
677+
* @property {boolean} defaultExport
678+
* @property {string[]} namedExports
679+
* @property {NamedExportBinding} [namedExportBinding]
680+
* @typedef {Record<string, string>} ImportVariants
681+
* @typedef {Record<string, string>} ImportVariantFiles
674682
*/
683+
675684
/**
676-
* @overload
677-
* @param {string} filename - The file that will be copied and modified for each variant.
678-
* @param {string} bindingName - The binding name that will be use to bind to the packageName.
679-
* @param {string} [namedExport] - The name of the named variant to use.
680-
* @param {string} [packageName] - The name of the package. If not provided, the binding name will be used.
681-
* @param {boolean} [byPassDefault] - Skip default export variant generation.
682-
* @returns {Variants} A map from variant names to resulting filenames
685+
* @param {ImportVariantOptions} options
686+
* @returns {ImportVariants}
683687
*/
688+
function createImportVariants (options) {
689+
const {
690+
bindingName,
691+
packageName,
692+
defaultExport,
693+
namedExports,
694+
namedExportBinding,
695+
} = options
696+
assert(defaultExport || namedExports.length, 'At least one default or named export is required')
697+
assert(!namedExports.length || namedExportBinding, 'Named exports require a binding style')
698+
assert(
699+
!namedExportBinding ||
700+
namedExportBinding === 'destructure' ||
701+
namedExportBinding === 'direct' ||
702+
namedExportBinding === 'namespace',
703+
`Unknown named export binding style: ${namedExportBinding}`
704+
)
705+
assert(
706+
namedExportBinding !== 'direct' || namedExports.length === 1,
707+
'Direct named export bindings require exactly one export'
708+
)
709+
710+
const variants = {}
711+
const namespaceName = `mod${bindingName[0].toUpperCase()}${bindingName.slice(1)}`
712+
713+
if (defaultExport) {
714+
variants.default = `import ${bindingName} from '${packageName}'`
715+
variants['default-as-named'] = `import { default as ${bindingName} } from '${packageName}'`
716+
variants['default-from-namespace'] =
717+
`import * as ${namespaceName} from '${packageName}'; const ${bindingName} = ${namespaceName}.default`
718+
}
719+
720+
if (namedExports.length) {
721+
if (namedExportBinding === 'direct') {
722+
const [namedExport] = namedExports
723+
const importBinding = namedExport === bindingName ? namedExport : `${namedExport} as ${bindingName}`
724+
variants.named = `import { ${importBinding} } from '${packageName}'`
725+
variants['named-from-namespace'] =
726+
`import * as ${namespaceName} from '${packageName}'; const ${bindingName} = ${namespaceName}.${namedExport}`
727+
} else if (namedExportBinding === 'namespace') {
728+
const exportsList = namedExports.join(', ')
729+
variants.named = `import { ${exportsList} } from '${packageName}'; const ${bindingName} = { ${exportsList} }`
730+
variants['named-from-namespace'] = `import * as ${bindingName} from '${packageName}'`
731+
} else {
732+
const exportsList = namedExports.join(', ')
733+
variants.named = `import { ${exportsList} } from '${packageName}'`
734+
variants['named-from-namespace'] =
735+
`import * as ${bindingName} from '${packageName}'; const { ${exportsList} } = ${bindingName}`
736+
}
737+
}
738+
739+
return variants
740+
}
741+
684742
/**
685-
* Creates a bunch of files based on an original file in sandbox. Useful for varying test files
686-
* without having to create a bunch of them yourself.
687-
*
688-
* The variants object should have keys that are named variants, and values that are the text
689-
* in the file that's different in each variant. There must always be a "default" variant,
690-
* whose value is the original text within the file that will be replaced.
691-
*
692743
* @param {string} filename - The file that will be copied and modified for each variant.
693-
* @param {Variants|string} variants - The variants or binding name.
694-
* @param {string} [namedExport] - Named export to use for star/destructure variants.
695-
* @param {string} [packageName] - Module specifier for the import.
696-
* @param {boolean} [byPassDefault] - Skip default export variant generation.
697-
* @returns {Variants} A map from variant names to resulting filenames
744+
* @param {ImportVariants} variants - Import statements keyed by variant name.
745+
* @param {ImportVariantFiles} variantFilenames - Resulting filenames keyed by variant name.
698746
*/
699-
function varySandbox (filename, variants, namedExport, packageName, byPassDefault) {
700-
if (typeof variants === 'string') {
701-
const bindingName = variants
702-
const resolvedName = packageName || bindingName
703-
// Default namedVariant to bindingName when bypassing default export
704-
if (byPassDefault && !namedExport) namedExport = bindingName
705-
variants = byPassDefault
706-
? {
707-
// eslint-disable-next-line @stylistic/max-len
708-
star: `import * as mod${bindingName} from '${resolvedName}'; const ${bindingName} = mod${bindingName}.${namedExport}`,
709-
destructure: `import { ${namedExport} } from '${resolvedName}'`,
710-
}
711-
: {
712-
default: `import ${bindingName} from '${resolvedName}'`,
713-
star: namedExport
714-
? `import * as ${bindingName} from '${resolvedName}'`
715-
: `import * as mod${bindingName} from '${resolvedName}'; const ${bindingName} = mod${bindingName}.default`,
716-
destructure: namedExport
717-
? `import { ${namedExport} } from '${resolvedName}'; const ${bindingName} = { ${namedExport} }`
718-
: `import { default as ${bindingName}} from '${resolvedName}'`,
719-
}
720-
}
721-
747+
function writeSandboxVariants (filename, variants, variantFilenames) {
722748
const origFileData = readFileSync(path.join(sandbox.folder, filename), 'utf8')
723-
const { name: prefix, ext: suffix } = path.parse(filename)
724-
const variantFilenames = /** @type {Variants} */ ({})
725-
const baseVariant = byPassDefault ? 'destructure' : 'default'
749+
const baseVariant = variants.default ? 'default' : 'named'
726750

727751
for (const [variant, value] of Object.entries(variants)) {
728-
const variantFilename = `${prefix}-${variant}${suffix}`
729-
variantFilenames[variant] = variantFilename
752+
const variantFilename = variantFilenames[variant]
730753
let newFileData = origFileData
731754
if (variant !== baseVariant) {
732755
const baseValue = variants[baseVariant]
733756
assert(baseValue, `Missing ${baseVariant} variant`)
734757
newFileData = origFileData.replace(baseValue, `${value}`)
735-
// Error out when the default import does not match that of server.mjs
736758
if (newFileData === origFileData) throw Error(`Unable to match ${baseVariant}`)
737759
}
738760
writeFileSync(path.join(sandbox.folder, variantFilename), newFileData)
739761
}
740-
return variantFilenames
741762
}
742763

743764
/**
744-
* @type {['default', 'star', 'destructure']}
765+
* Call after useSandbox so variant materialization runs after the sandbox setup.
766+
*
767+
* @param {string} filename - The file that will be copied and modified for each import variant.
768+
* @param {ImportVariantOptions} options
769+
* @returns {ImportVariantFiles} Resulting filenames keyed by variant name.
745770
*/
746-
varySandbox.VARIANTS = ['default', 'star', 'destructure']
771+
function varySandbox (filename, options) {
772+
const variants = createImportVariants(options)
773+
const { name: prefix, ext: suffix } = path.parse(filename)
774+
const variantFilenames = {}
775+
776+
for (const variant of Object.keys(variants)) {
777+
variantFilenames[variant] = `${prefix}-${variant}${suffix}`
778+
}
779+
780+
before(function () {
781+
writeSandboxVariants(filename, variants, variantFilenames)
782+
})
783+
784+
return variantFilenames
785+
}
747786

748787
/**
749788
* @param {boolean} shouldExpectTelemetryPoints
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
'use strict'
2+
3+
const assert = require('node:assert/strict')
4+
5+
const sinon = require('sinon')
6+
7+
const { varySandbox } = require('./helpers')
8+
9+
describe('varySandbox', () => {
10+
beforeEach(() => {
11+
sinon.stub(global, 'before')
12+
})
13+
14+
afterEach(() => {
15+
sinon.restore()
16+
})
17+
18+
it('creates every default export form', () => {
19+
assert.deepStrictEqual(varySandbox('logger.mjs', {
20+
bindingName: 'logger',
21+
packageName: 'logger',
22+
defaultExport: true,
23+
namedExports: [],
24+
}), {
25+
default: 'logger-default.mjs',
26+
'default-as-named': 'logger-default-as-named.mjs',
27+
'default-from-namespace': 'logger-default-from-namespace.mjs',
28+
})
29+
})
30+
31+
it('creates every direct named export form', () => {
32+
assert.deepStrictEqual(varySandbox('logger.mjs', {
33+
bindingName: 'logger',
34+
packageName: 'logger',
35+
defaultExport: true,
36+
namedExports: ['createLogger'],
37+
namedExportBinding: 'direct',
38+
}), {
39+
default: 'logger-default.mjs',
40+
'default-as-named': 'logger-default-as-named.mjs',
41+
'default-from-namespace': 'logger-default-from-namespace.mjs',
42+
named: 'logger-named.mjs',
43+
'named-from-namespace': 'logger-named-from-namespace.mjs',
44+
})
45+
})
46+
47+
it('creates every namespace-shaped named export form', () => {
48+
assert.deepStrictEqual(varySandbox('logger.mjs', {
49+
bindingName: 'logger',
50+
packageName: 'logger',
51+
defaultExport: true,
52+
namedExports: ['createLogger', 'levels'],
53+
namedExportBinding: 'namespace',
54+
}), {
55+
default: 'logger-default.mjs',
56+
'default-as-named': 'logger-default-as-named.mjs',
57+
'default-from-namespace': 'logger-default-from-namespace.mjs',
58+
named: 'logger-named.mjs',
59+
'named-from-namespace': 'logger-named-from-namespace.mjs',
60+
})
61+
})
62+
63+
it('creates named-only forms', () => {
64+
assert.deepStrictEqual(varySandbox('logger.mjs', {
65+
bindingName: 'Logger',
66+
packageName: 'logger',
67+
defaultExport: false,
68+
namedExports: ['Logger'],
69+
namedExportBinding: 'direct',
70+
}), {
71+
named: 'logger-named.mjs',
72+
'named-from-namespace': 'logger-named-from-namespace.mjs',
73+
})
74+
})
75+
76+
it('creates separately bound named export forms', () => {
77+
assert.deepStrictEqual(varySandbox('logger.mjs', {
78+
bindingName: 'loggerModule',
79+
packageName: 'logger',
80+
defaultExport: false,
81+
namedExports: ['createLogger', 'levels'],
82+
namedExportBinding: 'destructure',
83+
}), {
84+
named: 'logger-named.mjs',
85+
'named-from-namespace': 'logger-named-from-namespace.mjs',
86+
})
87+
})
88+
89+
it('rejects an impossible export configuration', () => {
90+
assert.throws(() => varySandbox('logger.mjs', {
91+
bindingName: 'logger',
92+
packageName: 'logger',
93+
defaultExport: false,
94+
namedExports: [],
95+
}), {
96+
message: 'At least one default or named export is required',
97+
})
98+
})
99+
100+
it('requires a named export binding style', () => {
101+
assert.throws(() => varySandbox('logger.mjs', {
102+
bindingName: 'logger',
103+
packageName: 'logger',
104+
defaultExport: false,
105+
namedExports: ['createLogger'],
106+
}), {
107+
message: 'Named exports require a binding style',
108+
})
109+
})
110+
111+
it('rejects an unknown named export binding style', () => {
112+
assert.throws(() => varySandbox('logger.mjs', {
113+
bindingName: 'logger',
114+
packageName: 'logger',
115+
defaultExport: false,
116+
namedExports: ['createLogger'],
117+
namedExportBinding: 'unknown',
118+
}), {
119+
message: 'Unknown named export binding style: unknown',
120+
})
121+
})
122+
123+
it('rejects multiple direct named exports', () => {
124+
assert.throws(() => varySandbox('logger.mjs', {
125+
bindingName: 'logger',
126+
packageName: 'logger',
127+
defaultExport: false,
128+
namedExports: ['Logger', 'levels'],
129+
namedExportBinding: 'direct',
130+
}), {
131+
message: 'Direct named export bindings require exactly one export',
132+
})
133+
})
134+
})

packages/datadog-instrumentations/src/helpers/hook.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,19 @@ function Hook (modules, hookOptions, onrequire) {
4949
this._patched = Object.create(null)
5050
const patched = new WeakMap()
5151

52+
/**
53+
* @param {object|Function|undefined} moduleExports
54+
* @param {string} moduleName
55+
* @param {string|undefined} moduleBaseDir
56+
* @param {string|undefined} moduleVersion
57+
* @param {boolean|undefined} isIitm
58+
*/
5259
const safeHook = (moduleExports, moduleName, moduleBaseDir, moduleVersion, isIitm) => {
5360
const parts = [moduleBaseDir, moduleName].filter(Boolean)
5461
const filename = path.join(...parts)
5562

63+
const defaultExport = isIitm && moduleExports.default
64+
let defaultExportAliases
5665
let defaultWrapResult
5766

5867
const wrappedOnrequire = (moduleExports, ...args) => {
@@ -77,17 +86,29 @@ function Hook (modules, hookOptions, onrequire) {
7786
}
7887

7988
if (
80-
isIitm &&
81-
moduleExports.default &&
82-
(typeof moduleExports.default === 'object' ||
83-
typeof moduleExports.default === 'function')
89+
defaultExport &&
90+
(typeof defaultExport === 'object' ||
91+
typeof defaultExport === 'function')
8492
) {
85-
defaultWrapResult = wrappedOnrequire(moduleExports.default, moduleName, moduleBaseDir, moduleVersion, isIitm)
93+
defaultWrapResult = wrappedOnrequire(defaultExport, moduleName, moduleBaseDir, moduleVersion, isIitm)
94+
if (defaultWrapResult && defaultWrapResult !== defaultExport) {
95+
defaultExportAliases = []
96+
for (const exportName of Object.keys(moduleExports)) {
97+
if (exportName !== 'default' && moduleExports[exportName] === defaultExport) {
98+
defaultExportAliases.push(exportName)
99+
}
100+
}
101+
}
86102
}
87103

88104
const newExports = wrappedOnrequire(moduleExports, moduleName, moduleBaseDir, moduleVersion, isIitm)
89105

90-
if (defaultWrapResult) newExports.default = defaultWrapResult
106+
if (defaultWrapResult && defaultExportAliases) {
107+
newExports.default = defaultWrapResult
108+
for (const exportName of defaultExportAliases) {
109+
moduleExports[exportName] = defaultWrapResult
110+
}
111+
}
91112

92113
this._patched[filename] = true
93114

packages/datadog-instrumentations/src/process.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict'
22

3+
const { syncBuiltinESMExports } = require('node:module')
4+
35
const { channel } = require('dc-polyfill')
46
const shimmer = require('../../datadog-shimmer')
57

@@ -26,4 +28,5 @@ if (process.setUncaughtExceptionCaptureCallback) {
2628
return result
2729
}
2830
})
31+
syncBuiltinESMExports()
2932
}

0 commit comments

Comments
 (0)