Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[test visibility] Simple dynamic instrumentation - test visibility client #4826

Merged
merged 13 commits into from
Nov 6, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use strict'

const { join } = require('path')
const { Worker } = require('worker_threads')
const { randomUUID } = require('crypto')
const log = require('../../log')
const { BREAKPOINT_HIT, BREAKPOINT_SET } = require('./messages')

const probeIdToResolveBreakpointSet = new Map()
const probeIdToResolveBreakpointHit = new Map()

class TestVisDynamicInstrumentation {
constructor () {
this.worker = null
this._readyPromise = new Promise(resolve => {
this._onReady = resolve
})
}

// Return 3 elements:
// 1. Snapshot ID
// 2. Promise that's resolved when the breakpoint is set
// 3. Promise that's resolved when the breakpoint is hit
Comment on lines +23 to +24
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to be able to sync on these two to be able to know when to start a test (a test can't start before the breakpoint is set) and when a dynamic instrumentation log can be created (if a breakpoint is hit). Maybe I could use callbacks for the same? Open to ideas here

addLineProbe ({ file, line }) {
const snapshotId = randomUUID()
const probeId = randomUUID()

this.worker.postMessage({
snapshotId,
probe: { id: probeId, file, line }
})

return [
snapshotId,
new Promise(resolve => {
probeIdToResolveBreakpointSet.set(probeId, resolve)
}),
new Promise(resolve => {
probeIdToResolveBreakpointHit.set(probeId, resolve)
})
]
}

isReady () {
return this._readyPromise
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just to make sure we don't attempt to set probes before the worker is online

}

start () {
if (this.worker) return

const { NODE_OPTIONS, ...envWithoutNodeOptions } = process.env

log.debug('Starting Test Visibility - Dynamic Instrumentation client...')

this.worker = new Worker(
join(__dirname, 'worker', 'index.js'),
{
execArgv: [],
env: envWithoutNodeOptions
}
)
this.worker.on('online', () => {
log.debug('Test Visibility - Dynamic Instrumentation client is ready')
this._onReady()
})

// Allow the parent to exit even if the worker is still running
this.worker.unref()

this.worker.on('message', (message) => {
const { type } = message
if (type === BREAKPOINT_SET) {
const { probeId } = message
const resolve = probeIdToResolveBreakpointSet.get(probeId)
if (resolve) {
resolve()
probeIdToResolveBreakpointSet.delete(probeId)
}
} else if (type === BREAKPOINT_HIT) {
const { snapshot } = message
const { probe: { id: probeId } } = snapshot
const resolve = probeIdToResolveBreakpointHit.get(probeId)
if (resolve) {
resolve({ snapshot })
probeIdToResolveBreakpointHit.delete(probeId)
}
}
}).unref() // We also need to unref this message handler
juan-fernandez marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume no calling .unref() here gave you problems?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, the test framework I used to run my tests got stuck and I had to pkill node

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Odd... I never had this problem 🤔 @tlhunter @bengl Does this ring a bell?

}
}

module.exports = new TestVisDynamicInstrumentation()
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
BREAKPOINT_SET: 'breakpoint-set',
BREAKPOINT_HIT: 'breakpoint-hit'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const { parentPort } = require('worker_threads')
juan-fernandez marked this conversation as resolved.
Show resolved Hide resolved
// TODO: move debugger/devtools_client/session to common place
const session = require('../../../debugger/devtools_client/session')
juan-fernandez marked this conversation as resolved.
Show resolved Hide resolved
// TODO: move debugger/devtools_client/snapshot to common place
const { getLocalStateForCallFrame } = require('../../../debugger/devtools_client/snapshot')
juan-fernandez marked this conversation as resolved.
Show resolved Hide resolved
// TODO: move debugger/devtools_client/state to common place
const {
findScriptFromPartialPath,
getStackFromCallFrames
} = require('../../../debugger/devtools_client/state')
const { BREAKPOINT_HIT, BREAKPOINT_SET } = require('../messages')
const log = require('../../../log')

let sessionStarted = false

const breakpointIdToSnapshotId = new Map()
const breakpointIdToProbe = new Map()

session.on('Debugger.paused', async ({ params: { hitBreakpoints: [hitBreakpoint], callFrames } }) => {
const probe = breakpointIdToProbe.get(hitBreakpoint)
if (!probe) {
return session.post('Debugger.resume')
}
juan-fernandez marked this conversation as resolved.
Show resolved Hide resolved

const stack = getStackFromCallFrames(callFrames)

const getLocalState = await getLocalStateForCallFrame(callFrames[0])

await session.post('Debugger.resume')

const snapshotId = breakpointIdToSnapshotId.get(hitBreakpoint)

const snapshot = {
id: snapshotId,
timestamp: Date.now(),
probe: {
id: probe.probeId,
version: '0',
location: probe.location
},
stack,
language: 'javascript'
}

const state = getLocalState()
if (state) {
snapshot.captures = {
lines: { [probe.location.lines[0]]: { locals: state } }
}
}

parentPort.postMessage({ type: BREAKPOINT_HIT, snapshot })
})

// TODO: add option to remove breakpoint
parentPort.on('message', async ({ snapshotId, probe: { id: probeId, file, line } }) => {
await addBreakpoint(snapshotId, { probeId, file, line })
parentPort.postMessage({ type: BREAKPOINT_SET, probeId })
})

async function addBreakpoint (snapshotId, probe) {
if (!sessionStarted) await start()
const { file, line } = probe

probe.location = { file, lines: [String(line)] }

const script = findScriptFromPartialPath(file)
if (!script) throw new Error(`No loaded script found for ${file}`)

const [path, scriptId] = script

log.debug(`Adding breakpoint at ${path}:${line}`)

const { breakpointId } = await session.post('Debugger.setBreakpoint', {
location: {
scriptId,
lineNumber: line - 1
}
})

breakpointIdToProbe.set(breakpointId, probe)
breakpointIdToSnapshotId.set(breakpointId, snapshotId)
}

function start () {
sessionStarted = true
return session.post('Debugger.enable') // return instead of await to reduce number of promises created
}
13 changes: 2 additions & 11 deletions packages/dd-trace/src/debugger/devtools_client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { breakpoints } = require('./state')
const session = require('./session')
const { getLocalStateForCallFrame } = require('./snapshot')
const send = require('./send')
const { getScriptUrlFromId } = require('./state')
const { getStackFromCallFrames } = require('./state')
const { ackEmitting, ackError } = require('./status')
const { parentThreadId } = require('./config')
const log = require('../../log')
Expand Down Expand Up @@ -66,16 +66,7 @@ session.on('Debugger.paused', async ({ params }) => {
thread_name: threadName
}

const stack = params.callFrames.map((frame) => {
let fileName = getScriptUrlFromId(frame.location.scriptId)
if (fileName.startsWith('file://')) fileName = fileName.substr(7) // TODO: This might not be required
return {
fileName,
function: frame.functionName,
lineNumber: frame.location.lineNumber + 1, // Beware! lineNumber is zero-indexed
columnNumber: frame.location.columnNumber + 1 // Beware! columnNumber is zero-indexed
}
})
const stack = getStackFromCallFrames(params.callFrames)

// TODO: Send multiple probes in one HTTP request as an array (DEBUG-2848)
for (const probe of probes) {
Expand Down
13 changes: 11 additions & 2 deletions packages/dd-trace/src/debugger/devtools_client/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,17 @@ module.exports = {
.sort(([a], [b]) => a.length - b.length)[0]
},

getScriptUrlFromId (id) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getScriptUrlFromId was only used for formatting the stack, which is now done by getStackFromCallFrames

return scriptUrls.get(id)
getStackFromCallFrames (callFrames) {
return callFrames.map((frame) => {
let fileName = scriptUrls.get(frame.location.scriptId)
if (fileName.startsWith('file://')) fileName = fileName.substr(7) // TODO: This might not be required
return {
fileName,
function: frame.functionName,
lineNumber: frame.location.lineNumber + 1, // Beware! lineNumber is zero-indexed
columnNumber: frame.location.columnNumber + 1 // Beware! columnNumber is zero-indexed
}
})
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict'

require('../../../../dd-trace/test/setup/tap')

const { fork } = require('child_process')
const path = require('path')

const { assert } = require('chai')

describe('test visibility with dynamic instrumentation', () => {
let childProcess

afterEach(() => {
if (childProcess) {
childProcess.kill()
}
})

it('can grab local variables', (done) => {
childProcess = fork(path.join(__dirname, 'target-app', 'test-visibility-dynamic-instrumentation-script.js'))

childProcess.on('message', ({ snapshot: { language, stack, probe, captures }, snapshotId }) => {
assert.exists(snapshotId)
assert.exists(probe)
assert.exists(stack)
assert.equal(language, 'javascript')

assert.deepEqual(captures, {
lines: {
9: {
juan-fernandez marked this conversation as resolved.
Show resolved Hide resolved
locals: {
a: { type: 'number', value: '1' },
b: { type: 'number', value: '2' },
localVar: { type: 'number', value: '1' }
}
}
}
})

done()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use strict'

module.exports = function (a, b) {
// eslint-disable-next-line no-console
const localVar = 1
if (a > 10) {
throw new Error('a is too big')
}
return a + b + localVar // location of the breakpoint
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ script we run in the test to grab local variable data


const path = require('path')
const tvDynamicInstrumentation = require('../../../../src/ci-visibility/dynamic-instrumentation')
const sum = require('./di-dependency')

// keep process alive
const intervalId = setInterval(() => {}, 5000)

tvDynamicInstrumentation.start()

tvDynamicInstrumentation.isReady().then(() => {
const [
snapshotId,
breakpointSetPromise,
breakpointHitPromise
] = tvDynamicInstrumentation.addLineProbe({ file: path.join(__dirname, 'di-dependency.js'), line: 9 })

breakpointHitPromise.then(({ snapshot }) => {
// once the breakpoint is hit, we can grab the snapshot and send it to the parent process
process.send({ snapshot, snapshotId })
clearInterval(intervalId)
})

// We run the code once the breakpoint is set
breakpointSetPromise.then(() => {
sum(1, 2)
})
})
Loading