Skip to content

Commit e20aace

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 7d2637c commit e20aace

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

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

0 commit comments

Comments
 (0)