Skip to content

Commit efb8e44

Browse files
authored
[DI] Add source map support (#5205)
1 parent 2fea9b5 commit efb8e44

File tree

16 files changed

+403
-79
lines changed

16 files changed

+403
-79
lines changed

eslint.config.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export default [
4141
'**/versions', // This is effectively a node_modules tree.
4242
'**/acmeair-nodejs', // We don't own this.
4343
'**/vendor', // Generally, we didn't author this code.
44+
'integration-tests/debugger/target-app/source-map-support/index.js', // Generated
4445
'integration-tests/esbuild/out.js', // Generated
4546
'integration-tests/esbuild/aws-sdk-out.js', // Generated
4647
'packages/dd-trace/src/appsec/blocked_templates.js', // TODO Why is this ignored?

integration-tests/debugger/basic.spec.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ function assertBasicInputPayload (t, payload) {
535535
service: 'node',
536536
message: 'Hello World!',
537537
logger: {
538-
name: t.breakpoint.file,
538+
name: t.breakpoint.deployedFile,
539539
method: 'fooHandler',
540540
version,
541541
thread_name: 'MainThread'
@@ -544,7 +544,7 @@ function assertBasicInputPayload (t, payload) {
544544
probe: {
545545
id: t.rcConfig.config.id,
546546
version: 0,
547-
location: { file: t.breakpoint.file, lines: [String(t.breakpoint.line)] }
547+
location: { file: t.breakpoint.deployedFile, lines: [String(t.breakpoint.line)] }
548548
},
549549
language: 'javascript'
550550
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use strict'
2+
3+
const { assert } = require('chai')
4+
const { setup } = require('./utils')
5+
6+
describe('Dynamic Instrumentation', function () {
7+
describe('source map support', function () {
8+
const t = setup({
9+
testApp: 'target-app/source-map-support/index.js',
10+
testAppSource: 'target-app/source-map-support/index.ts'
11+
})
12+
13+
beforeEach(t.triggerBreakpoint)
14+
15+
it('should support source maps', function (done) {
16+
t.agent.on('debugger-input', ({ payload: [{ 'debugger.snapshot': { probe: { location } } }] }) => {
17+
assert.deepEqual(location, {
18+
file: 'target-app/source-map-support/index.ts',
19+
lines: ['9']
20+
})
21+
done()
22+
})
23+
24+
t.agent.addRemoteConfig(t.rcConfig)
25+
})
26+
})
27+
})

integration-tests/debugger/target-app/source-map-support/index.js

+13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration-tests/debugger/target-app/source-map-support/index.js.map

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
require('dd-trace/init')
2+
3+
import { createServer } from 'node:http'
4+
5+
const server = createServer((req, res) => {
6+
// Blank lines below to ensure line numbers in transpiled file differ from original file
7+
8+
9+
res.end('hello world') // BREAKPOINT: /
10+
})
11+
12+
server.listen(process.env.APP_PORT, () => {
13+
process.send?.({ port: process.env.APP_PORT })
14+
})

integration-tests/debugger/utils.js

+12-8
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ module.exports = {
1818
setup
1919
}
2020

21-
function setup ({ env, testApp } = {}) {
21+
function setup ({ env, testApp, testAppSource } = {}) {
2222
let sandbox, cwd, appPort
23-
const breakpoints = getBreakpointInfo({ file: testApp, stackIndex: 1 }) // `1` to disregard the `setup` function
23+
const breakpoints = getBreakpointInfo({
24+
deployedFile: testApp,
25+
sourceFile: testAppSource,
26+
stackIndex: 1 // `1` to disregard the `setup` function
27+
})
2428
const t = {
2529
breakpoint: breakpoints[0],
2630
breakpoints,
@@ -71,7 +75,7 @@ function setup ({ env, testApp } = {}) {
7175
sandbox = await createSandbox(['fastify']) // TODO: Make this dynamic
7276
cwd = sandbox.folder
7377
// The sandbox uses the `integration-tests` folder as its root
74-
t.appFile = join(cwd, 'debugger', breakpoints[0].file)
78+
t.appFile = join(cwd, 'debugger', breakpoints[0].deployedFile)
7579
})
7680

7781
after(async function () {
@@ -110,8 +114,8 @@ function setup ({ env, testApp } = {}) {
110114
return t
111115
}
112116

113-
function getBreakpointInfo ({ file, stackIndex = 0 }) {
114-
if (!file) {
117+
function getBreakpointInfo ({ deployedFile, sourceFile = deployedFile, stackIndex = 0 } = {}) {
118+
if (!deployedFile) {
115119
// First, get the filename of file that called this function
116120
const testFile = new Error().stack
117121
.split('\n')[stackIndex + 2] // +2 to skip this function + the first line, which is the error message
@@ -120,17 +124,17 @@ function getBreakpointInfo ({ file, stackIndex = 0 }) {
120124
.split(':')[0]
121125

122126
// Then, find the corresponding file in which the breakpoint(s) exists
123-
file = join('target-app', basename(testFile).replace('.spec', ''))
127+
deployedFile = sourceFile = join('target-app', basename(testFile).replace('.spec', ''))
124128
}
125129

126130
// Finally, find the line number(s) of the breakpoint(s)
127-
const lines = readFileSync(join(__dirname, file), 'utf8').split('\n')
131+
const lines = readFileSync(join(__dirname, sourceFile), 'utf8').split('\n')
128132
const result = []
129133
for (let i = 0; i < lines.length; i++) {
130134
const index = lines[i].indexOf(BREAKPOINT_TOKEN)
131135
if (index !== -1) {
132136
const url = lines[i].slice(index + BREAKPOINT_TOKEN.length + 1).trim()
133-
result.push({ file, line: i + 1, url })
137+
result.push({ sourceFile, deployedFile, line: i + 1, url })
134138
}
135139
}
136140

packages/datadog-plugin-cucumber/src/index.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const {
4747
const id = require('../../dd-trace/src/id')
4848

4949
const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200
50+
const BREAKPOINT_SET_GRACE_PERIOD_MS = 200
5051
const isCucumberWorker = !!process.env.CUCUMBER_WORKER_ID
5152

5253
function getTestSuiteTags (testSuiteSpan) {
@@ -251,7 +252,12 @@ class CucumberPlugin extends CiPlugin {
251252
const { file, line, stackIndex } = probeInformation
252253
this.runningTestProbe = { file, line }
253254
this.testErrorStackIndex = stackIndex
254-
// TODO: we're not waiting for setProbePromise to be resolved, so there might be race conditions
255+
const waitUntil = Date.now() + BREAKPOINT_SET_GRACE_PERIOD_MS
256+
while (Date.now() < waitUntil) {
257+
// TODO: To avoid a race condition, we should wait until `probeInformation.setProbePromise` has resolved.
258+
// However, Cucumber doesn't have a mechanism for waiting asyncrounously here, so for now, we'll have to
259+
// fall back to a fixed syncronous delay.
260+
}
255261
}
256262
}
257263
span.setTag(TEST_STATUS, 'fail')

packages/datadog-plugin-mocha/src/index.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const {
4848
const id = require('../../dd-trace/src/id')
4949
const log = require('../../dd-trace/src/log')
5050

51+
const BREAKPOINT_SET_GRACE_PERIOD_MS = 200
52+
5153
function getTestSuiteLevelVisibilityTags (testSuiteSpan) {
5254
const testSuiteSpanContext = testSuiteSpan.context()
5355
const suiteTags = {
@@ -279,7 +281,12 @@ class MochaPlugin extends CiPlugin {
279281
this.runningTestProbe = { file, line }
280282
this.testErrorStackIndex = stackIndex
281283
test._ddShouldWaitForHitProbe = true
282-
// TODO: we're not waiting for setProbePromise to be resolved, so there might be race conditions
284+
const waitUntil = Date.now() + BREAKPOINT_SET_GRACE_PERIOD_MS
285+
while (Date.now() < waitUntil) {
286+
// TODO: To avoid a race condition, we should wait until `probeInformation.setProbePromise` has resolved.
287+
// However, Mocha doesn't have a mechanism for waiting asyncrounously here, so for now, we'll have to
288+
// fall back to a fixed syncronous delay.
289+
}
283290
}
284291
}
285292

packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js

+13-48
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use strict'
2-
const path = require('path')
2+
33
const {
44
workerData: {
55
breakpointSetChannel,
@@ -8,10 +8,11 @@ const {
88
}
99
} = require('worker_threads')
1010
const { randomUUID } = require('crypto')
11-
const sourceMap = require('source-map')
1211

1312
// TODO: move debugger/devtools_client/session to common place
1413
const session = require('../../../debugger/devtools_client/session')
14+
// TODO: move debugger/devtools_client/source-maps to common place
15+
const { getSourceMappedLine } = require('../../../debugger/devtools_client/source-maps')
1516
// TODO: move debugger/devtools_client/snapshot to common place
1617
const { getLocalStateForCallFrame } = require('../../../debugger/devtools_client/snapshot')
1718
// TODO: move debugger/devtools_client/state to common place
@@ -98,17 +99,21 @@ async function addBreakpoint (probe) {
9899
throw new Error(`No loaded script found for ${file}`)
99100
}
100101

101-
const [path, scriptId, sourceMapURL] = script
102+
const { url, scriptId, sourceMapURL, source } = script
102103

103-
log.warn(`Adding breakpoint at ${path}:${line}`)
104+
log.warn(`Adding breakpoint at ${url}:${line}`)
104105

105106
let lineNumber = line
106107

107-
if (sourceMapURL && sourceMapURL.startsWith('data:')) {
108+
if (sourceMapURL) {
108109
try {
109-
lineNumber = await processScriptWithInlineSourceMap({ file, line, sourceMapURL })
110+
lineNumber = await getSourceMappedLine(url, source, line, sourceMapURL)
110111
} catch (err) {
111-
log.error('Error processing script with inline source map', err)
112+
log.error('Error processing script with source map', err)
113+
}
114+
if (lineNumber === null) {
115+
log.error('Could not find generated position for %s:%s', url, line)
116+
lineNumber = line
112117
}
113118
}
114119

@@ -123,51 +128,11 @@ async function addBreakpoint (probe) {
123128
breakpointIdToProbe.set(breakpointId, probe)
124129
probeIdToBreakpointId.set(probe.id, breakpointId)
125130
} catch (e) {
126-
log.error(`Error setting breakpoint at ${path}:${line}:`, e)
131+
log.error('Error setting breakpoint at %s:%s', url, line, e)
127132
}
128133
}
129134

130135
function start () {
131136
sessionStarted = true
132137
return session.post('Debugger.enable') // return instead of await to reduce number of promises created
133138
}
134-
135-
async function processScriptWithInlineSourceMap (params) {
136-
const { file, line, sourceMapURL } = params
137-
138-
// Extract the base64-encoded source map
139-
const base64SourceMap = sourceMapURL.split('base64,')[1]
140-
141-
// Decode the base64 source map
142-
const decodedSourceMap = Buffer.from(base64SourceMap, 'base64').toString('utf8')
143-
144-
// Parse the source map
145-
const consumer = await new sourceMap.SourceMapConsumer(decodedSourceMap)
146-
147-
let generatedPosition
148-
149-
// Map to the generated position. We'll attempt with the full file path first, then with the basename.
150-
// TODO: figure out why sometimes the full path doesn't work
151-
generatedPosition = consumer.generatedPositionFor({
152-
source: file,
153-
line,
154-
column: 0
155-
})
156-
if (generatedPosition.line === null) {
157-
generatedPosition = consumer.generatedPositionFor({
158-
source: path.basename(file),
159-
line,
160-
column: 0
161-
})
162-
}
163-
164-
consumer.destroy()
165-
166-
// If we can't find the line, just return the original line
167-
if (generatedPosition.line === null) {
168-
log.error(`Could not find generated position for ${file}:${line}`)
169-
return line
170-
}
171-
172-
return generatedPosition.line
173-
}

packages/dd-trace/src/debugger/devtools_client/breakpoints.js

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict'
22

3+
const { getSourceMappedLine } = require('./source-maps')
34
const session = require('./session')
45
const { MAX_SNAPSHOTS_PER_SECOND_PER_PROBE, MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE } = require('./defaults')
56
const { findScriptFromPartialPath, probes, breakpoints } = require('./state')
@@ -16,7 +17,7 @@ async function addBreakpoint (probe) {
1617
if (!sessionStarted) await start()
1718

1819
const file = probe.where.sourceFile
19-
const line = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints
20+
let line = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints
2021

2122
// Optimize for sending data to /debugger/v1/input endpoint
2223
probe.location = { file, lines: [String(line)] }
@@ -34,11 +35,15 @@ async function addBreakpoint (probe) {
3435
// not continue untill all scripts have been parsed?
3536
const script = findScriptFromPartialPath(file)
3637
if (!script) throw new Error(`No loaded script found for ${file} (probe: ${probe.id}, version: ${probe.version})`)
37-
const [path, scriptId] = script
38+
const { url, scriptId, sourceMapURL, source } = script
39+
40+
if (sourceMapURL) {
41+
line = await getSourceMappedLine(url, source, line, sourceMapURL)
42+
}
3843

3944
log.debug(
4045
'[debugger:devtools_client] Adding breakpoint at %s:%d (probe: %s, version: %d)',
41-
path, line, probe.id, probe.version
46+
url, line, probe.id, probe.version
4247
)
4348

4449
const { breakpointId } = await session.post('Debugger.setBreakpoint', {
@@ -66,7 +71,7 @@ async function removeBreakpoint ({ id }) {
6671
probes.delete(id)
6772
breakpoints.delete(breakpointId)
6873

69-
if (breakpoints.size === 0) await stop()
74+
if (breakpoints.size === 0) return stop() // return instead of await to reduce number of promises created
7075
}
7176

7277
async function start () {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use strict'
2+
3+
const { join, dirname } = require('path')
4+
const { readFileSync } = require('fs')
5+
const { readFile } = require('fs/promises')
6+
const { SourceMapConsumer } = require('source-map')
7+
8+
const cache = new Map()
9+
let cacheTimer = null
10+
11+
const self = module.exports = {
12+
async loadSourceMap (dir, url) {
13+
if (url.startsWith('data:')) return loadInlineSourceMap(url)
14+
const path = join(dir, url)
15+
if (cache.has(path)) return cache.get(path)
16+
return cacheIt(path, JSON.parse(await readFile(path, 'utf8')))
17+
},
18+
19+
loadSourceMapSync (dir, url) {
20+
if (url.startsWith('data:')) return loadInlineSourceMap(url)
21+
const path = join(dir, url)
22+
if (cache.has(path)) return cache.get(path)
23+
return cacheIt(path, JSON.parse(readFileSync(path, 'utf8')))
24+
},
25+
26+
async getSourceMappedLine (url, source, line, sourceMapURL) {
27+
const dir = dirname(new URL(url).pathname)
28+
return await SourceMapConsumer.with(
29+
await self.loadSourceMap(dir, sourceMapURL),
30+
null,
31+
(consumer) => consumer.generatedPositionFor({ source, line, column: 0 }).line
32+
)
33+
}
34+
}
35+
36+
function cacheIt (key, value) {
37+
clearTimeout(cacheTimer)
38+
cacheTimer = setTimeout(function () {
39+
// Optimize for app boot, where a lot of reads might happen
40+
// Clear cache a few seconds after it was last used
41+
cache.clear()
42+
}, 10_000).unref()
43+
cache.set(key, value)
44+
return value
45+
}
46+
47+
function loadInlineSourceMap (data) {
48+
data = data.slice(data.indexOf('base64,') + 7)
49+
return JSON.parse(Buffer.from(data, 'base64').toString('utf8'))
50+
}

0 commit comments

Comments
 (0)