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

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 +22 to +23
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
Comment on lines +70 to +88
Copy link
Collaborator

Choose a reason for hiding this comment

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

Instead of using a type of BREAKPOINT_SET and BREAKPOINT_HIT that you send over the message channel, you could also just use two different distinct MessageChannel instances. That way you always knew that if a message was received on the breakpointSetChannel vs the breakpointHitChannel what it meant. And you'd also avoid this if/else blocks.

You can see how I use those here:

const rcChannel = new MessageChannel()
configChannel = new MessageChannel()

Comment on lines +70 to +88
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?

}
}

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')
Copy link
Collaborator

Choose a reason for hiding this comment

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

No 'use strict'?

// TODO: move debugger/devtools_client/session to common place
const session = require('../../../debugger/devtools_client/session')
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

session should be simple to move

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

on second thought, session should probably go together with inspector_promises_polyfill.js and state.js since they seem to be self contained? I'm honestly OK with importing code directly here, as this code is evolving quickly. I don't know if it makes sense to invest on this now

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

thoughts @watson ?

Copy link
Collaborator

Choose a reason for hiding this comment

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

TL;DR: Leave session.js where it is for now.

This file currently acts a singleton, so it can't both be called from your code and from DI in the same worker thread. But I don't assume that's ever going to happen - just an observation.

If we were to move this to a generic shared location, there's a chance that others might use it for other things in the future. If so, this singleton behavior might throw someone for a loop. But since it's such simple code, I don't see any good reason to make this into a shared module. The inspector_promises_polyfill.js file on the other hand, should me moved to a central location.

So normally my conclusion to the above would be that we should each have our own version of session.js that just called a shared inspector_promises_polyfill.js. However, since the current session.js so used all over the place in my code, it would mean that all that would have to be refactored to allow a session object to be passed into the called functions. And I think that's too big of a change right now - hence my comment at the top about leaving it as-is for now.

// TODO: move debugger/devtools_client/snapshot to common place
const { getLocalStateForCallFrame } = require('../../../debugger/devtools_client/snapshot')
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 might be a bit trickier but no reason not to do it

Copy link
Collaborator

Choose a reason for hiding this comment

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

Especially if we use two different session.js files as I just laid out in my previous comment. If so, we need a way to pass in the current session object. So for now, I'd just leave this as-is

// 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')
}
Comment on lines +21 to +23
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this just an escape hatch in case the state is somehow messed up, is there a valid use-case for a breakpoint to exist, but no matching probe?

If this is just an escape hatch, I'd probably add a log line to log that you encountered this issue. But I'll leave that up you :)


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: {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Consider if you can re-use the getBreakpointInfo function for you tests. That way, if you ever change the line your breakpoint is on, your tests just adapt automatically.

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