Skip to content

Commit f999386

Browse files
committed
feat(esbuild): add DI worker bundling support
Add support for bundling the Live Debugging (LD) / Dynamic Instrumentation (DI) worker when building applications with esbuild. This enables LD/DI features to work in bundled applications. The plugin now: - Emits a separate worker bundle (dd-trace-debugger-worker.cjs) alongside the main application bundle - Patches the debugger module to reference the emitted worker - Patches the worker's dd-trace lookup to use global._ddtrace for bundler compatibility
1 parent fe7175a commit f999386

File tree

3 files changed

+270
-0
lines changed

3 files changed

+270
-0
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env node
2+
'use strict'
3+
4+
/* eslint-disable no-console */
5+
6+
const assert = require('node:assert/strict')
7+
const fs = require('node:fs')
8+
const { spawnSync } = require('node:child_process')
9+
10+
const esbuild = require('esbuild')
11+
const ddPlugin = require('../../esbuild')
12+
13+
const MAIN_SCRIPT = './di-worker-out.js'
14+
const WORKER_SCRIPT = './dd-trace-debugger-worker.cjs'
15+
16+
// Create a minimal test app that initializes dd-trace with DI enabled
17+
const testAppCode = `
18+
'use strict';
19+
const tracer = require('../../').init();
20+
21+
// Just verify the process can start with DI enabled
22+
console.log('DI worker test: app started successfully');
23+
24+
// Give it a moment to initialize, then exit cleanly
25+
setTimeout(() => {
26+
process.exit(0);
27+
}, 100);
28+
`
29+
30+
// Write the test app to a temp file
31+
const testAppPath = './di-test-app.js'
32+
fs.writeFileSync(testAppPath, testAppCode)
33+
34+
esbuild.build({
35+
entryPoints: [testAppPath],
36+
bundle: true,
37+
outfile: MAIN_SCRIPT,
38+
plugins: [ddPlugin],
39+
platform: 'node',
40+
target: ['node18'],
41+
external: [
42+
// dead code paths introduced by knex if required
43+
'pg',
44+
'mysql2',
45+
'better-sqlite3',
46+
'sqlite3',
47+
'mysql',
48+
'oracledb',
49+
'pg-query-stream',
50+
'tedious',
51+
'@yaacovcr/transform',
52+
],
53+
}).then(() => {
54+
// Verify the worker bundle was emitted
55+
assert.ok(
56+
fs.existsSync(WORKER_SCRIPT),
57+
`Expected DI worker bundle to exist at ${WORKER_SCRIPT}`
58+
)
59+
60+
// Verify the worker file is a valid CJS file with substantial content
61+
const workerContent = fs.readFileSync(WORKER_SCRIPT, 'utf8')
62+
assert.ok(
63+
workerContent.length > 1000,
64+
'DI worker bundle should have substantial content'
65+
)
66+
assert.ok(
67+
workerContent.includes('Debugger.paused') || workerContent.includes('debugger'),
68+
'DI worker bundle should contain debugger-related code'
69+
)
70+
71+
// Verify the patched dd-trace lookup is present in the worker
72+
assert.ok(
73+
workerContent.includes('global._ddtrace'),
74+
'DI worker bundle should have patched dd-trace lookup using global._ddtrace'
75+
)
76+
77+
// Verify the main bundle references the worker file
78+
const mainContent = fs.readFileSync(MAIN_SCRIPT, 'utf8')
79+
assert.ok(
80+
mainContent.includes('dd-trace-debugger-worker.cjs'),
81+
'Main bundle should reference the DI worker bundle'
82+
)
83+
84+
console.log('DI worker bundle emitted correctly')
85+
86+
// Run the bundled app with DI enabled to verify it starts without crashing
87+
// Note: The app will exit quickly since we're just testing startup
88+
const { status, stderr } = spawnSync('node', [MAIN_SCRIPT], {
89+
env: {
90+
...process.env,
91+
DD_TRACE_DEBUG: 'true',
92+
DD_DYNAMIC_INSTRUMENTATION_ENABLED: 'true',
93+
},
94+
encoding: 'utf8',
95+
timeout: 10000,
96+
})
97+
98+
// Log stderr for debugging if there's an issue
99+
if (stderr.length) {
100+
console.error('stderr:', stderr)
101+
}
102+
103+
// The app should exit cleanly (status 0)
104+
// Note: DI may not fully initialize in this short-running test, but the process should not crash
105+
if (status !== 0) {
106+
throw new Error(`Generated script exited with unexpected exit code: ${status}`)
107+
}
108+
109+
console.log('DI worker test: app ran successfully with DI enabled')
110+
console.log('ok')
111+
}).catch((err) => {
112+
console.error(err)
113+
process.exit(1)
114+
}).finally(() => {
115+
fs.rmSync(testAppPath, { force: true })
116+
fs.rmSync(MAIN_SCRIPT, { force: true })
117+
fs.rmSync(WORKER_SCRIPT, { force: true })
118+
})

integration-tests/esbuild/index.spec.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ esbuildVersions.forEach((version) => {
6161
process.exit(1)
6262
} finally {
6363
rmSync('./out.js', { force: true })
64+
rmSync('./dd-trace-debugger-worker.cjs', { force: true })
6465
}
6566
})
6667

@@ -106,11 +107,18 @@ esbuildVersions.forEach((version) => {
106107
})
107108
})
108109

110+
it('emits debugger worker bundle and allows LD/DI-enabled startup', () => {
111+
execSync('node ./build-and-test-debugger-worker.js', {
112+
timeout,
113+
})
114+
})
115+
109116
describe('ESM', () => {
110117
afterEach(() => {
111118
rmSync('./out.mjs', { force: true })
112119
rmSync('./out.js', { force: true })
113120
rmSync('./basic-test.mjs', { force: true })
121+
rmSync('./dd-trace-debugger-worker.cjs', { force: true })
114122
})
115123

116124
it('works', () => {

packages/datadog-esbuild/index.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ for (const builtin of RAW_BUILTINS) {
4949
// eslint-disable-next-line eslint-rules/eslint-process-env
5050
const DD_IAST_ENABLED = process.env.DD_IAST_ENABLED?.toLowerCase() === 'true' || process.env.DD_IAST_ENABLED === '1'
5151

52+
const DEBUGGER_WORKER_FILENAME = 'dd-trace-debugger-worker.cjs'
53+
54+
// Path pattern to match the debugger index.js inside dd-trace package
55+
const DEBUGGER_INDEX_PATH = path.join('dd-trace', 'src', 'debugger', 'index.js')
56+
5257
module.exports.name = 'datadog-esbuild'
5358

5459
function isESMBuild (build) {
@@ -377,7 +382,146 @@ register(${JSON.stringify(toRegister)}, _, set, get, ${JSON.stringify(data.raw)}
377382
resolveDir: path.dirname(args.path),
378383
}
379384
}
385+
386+
// Rewrite the debugger worker path so it points to the emitted worker bundle
387+
if (args.path.includes(DEBUGGER_INDEX_PATH)) {
388+
log.debug('TRANSFORM DEBUGGER PATH: %s', args.path)
389+
let contents = fs.readFileSync(args.path, 'utf8')
390+
391+
// Replace: join(__dirname, 'devtools_client', 'index.js')
392+
// With: join(__dirname, 'dd-trace-debugger-worker.cjs')
393+
// The bundled __dirname will point to the output directory where we emit the worker bundle
394+
contents = contents.replaceAll(
395+
/join\(__dirname,\s*['"]devtools_client['"],\s*['"]index\.js['"]\)/g,
396+
`join(__dirname, '${DEBUGGER_WORKER_FILENAME}')`
397+
)
398+
399+
return {
400+
contents,
401+
loader: 'js',
402+
resolveDir: path.dirname(args.path),
403+
}
404+
}
380405
})
406+
407+
// Build the Dynamic Instrumentation worker bundle as a secondary artifact
408+
build.onEnd(async (result) => {
409+
if (result.errors.length > 0) {
410+
log.debug('Skipping DI worker build due to main build errors')
411+
return
412+
}
413+
414+
const outputDir = getOutputDirectory(build.initialOptions)
415+
if (!outputDir) {
416+
log.warn(
417+
// eslint-disable-next-line @stylistic/max-len
418+
'Cannot emit Live Debugger/Dynamic Instrumentation worker bundle. No outfile or outdir specified. LD/DI will not work in the bundled application.'
419+
)
420+
return
421+
}
422+
423+
await buildDebuggerWorker(build.initialOptions, outputDir, build.esbuild)
424+
})
425+
}
426+
427+
/**
428+
* Determine the output directory from esbuild options
429+
*
430+
* @param {object} initialOptions - esbuild initial options
431+
* @returns {string | null} - Output directory path or null
432+
*/
433+
function getOutputDirectory (initialOptions) {
434+
if (initialOptions.outdir) {
435+
return initialOptions.outdir
436+
}
437+
if (initialOptions.outfile) {
438+
return path.dirname(initialOptions.outfile)
439+
}
440+
return null
441+
}
442+
443+
/**
444+
* Build the Dynamic Instrumentation worker bundle
445+
*
446+
* @param {object} parentOptions - Parent build options
447+
* @param {string} outputDir - Output directory for the worker bundle
448+
* @param {object} esbuild - esbuild module instance from the parent build
449+
*/
450+
async function buildDebuggerWorker (parentOptions, outputDir, esbuild) {
451+
if (!esbuild) {
452+
log.warn('esbuild instance not available. LD/DI worker bundle cannot be built.')
453+
return
454+
}
455+
456+
// Resolve the devtools_client entry point from the installed dd-trace package
457+
let workerEntryPoint
458+
try {
459+
// First try to resolve dd-trace to find its installation path
460+
// eslint-disable-next-line n/no-missing-require -- dd-trace is a peer dependency
461+
const ddTracePath = require.resolve('dd-trace/package.json')
462+
workerEntryPoint = path.join(path.dirname(ddTracePath), 'src', 'debugger', 'devtools_client', 'index.js')
463+
} catch {
464+
// Fallback: resolve relative to this plugin (for development/linked packages)
465+
workerEntryPoint = path.join(__dirname, '..', 'dd-trace', 'src', 'debugger', 'devtools_client', 'index.js')
466+
}
467+
468+
if (!fs.existsSync(workerEntryPoint)) {
469+
log.warn(
470+
'Could not find DI worker entry point at %s. LD/DI will not work in the bundled application.',
471+
workerEntryPoint
472+
)
473+
return
474+
}
475+
476+
const workerOutfile = path.join(outputDir, DEBUGGER_WORKER_FILENAME)
477+
478+
log.debug('Building DI worker bundle: %s -> %s', workerEntryPoint, workerOutfile)
479+
480+
// Plugin to patch the trace/span lookup to use global._ddtrace
481+
const patchDDTracePlugin = {
482+
name: 'patch-ddtrace-lookup',
483+
setup (workerBuild) {
484+
workerBuild.onLoad({ filter: /devtools_client[/\\]index\.js$/ }, (args) => {
485+
let contents = fs.readFileSync(args.path, 'utf8')
486+
487+
// Replace the dd-trace require expression with a bundler-safe version
488+
// Original: global.require('dd-trace').scope().active()?.context()
489+
// New: (global._ddtrace ?? global.require?.('dd-trace'))?.scope?.()?.active?.()?.context?.()
490+
contents = contents.replaceAll(
491+
/global\.require\(['"]dd-trace['"]\)\.scope\(\)\.active\(\)\?\.context\(\)/g,
492+
"(global._ddtrace ?? global.require?.('dd-trace'))?.scope?.()?.active?.()?.context?.()"
493+
)
494+
495+
return {
496+
contents,
497+
loader: 'js',
498+
}
499+
})
500+
},
501+
}
502+
503+
try {
504+
await esbuild.build({
505+
entryPoints: [workerEntryPoint],
506+
bundle: true,
507+
platform: 'node',
508+
format: 'cjs',
509+
outfile: workerOutfile,
510+
target: parentOptions.target || ['node18'],
511+
// Keep Node.js builtins external
512+
external: [...RAW_BUILTINS, ...RAW_BUILTINS.map(m => `node:${m}`)],
513+
// Ensure function/class names are preserved for debugging
514+
keepNames: true,
515+
plugins: [patchDDTracePlugin],
516+
// Don't minify the worker - keep it debuggable
517+
minify: false,
518+
logLevel: 'warning',
519+
})
520+
521+
log.debug('DI worker bundle emitted: %s', workerOutfile)
522+
} catch (err) {
523+
log.warn('Failed to build DI worker bundle: %s', err.message)
524+
}
381525
}
382526

383527
// @see https://github.com/nodejs/node/issues/47000

0 commit comments

Comments
 (0)