-
Notifications
You must be signed in to change notification settings - Fork 306
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
base: master
Are you sure you want to change the base?
Changes from 7 commits
d017e4a
97cf8d3
4b221e5
c20ae05
cacd571
bff4609
9d9f7dd
bcd2b3e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||||||
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of using a type of You can see how I use those here: dd-trace-js/packages/dd-trace/src/debugger/index.js Lines 24 to 25 in 57f8a10
Comment on lines
+70
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume no calling |
||||||
} | ||||||
} | ||||||
|
||||||
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') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No |
||
// TODO: move debugger/devtools_client/session to common place | ||
const session = require('../../../debugger/devtools_client/session') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. session should be simple to move There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. on second thought, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thoughts @watson ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TL;DR: Leave 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 So normally my conclusion to the above would be that we should each have our own version of |
||
// TODO: move debugger/devtools_client/snapshot to common place | ||
const { getLocalStateForCallFrame } = require('../../../debugger/devtools_client/snapshot') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Especially if we use two different |
||
// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -32,8 +32,17 @@ module.exports = { | |
.sort(([a], [b]) => a.length - b.length)[0] | ||
}, | ||
|
||
getScriptUrlFromId (id) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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 | ||
} | ||
}) | ||
} | ||
} | ||
|
||
|
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: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider if you can re-use the |
||
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' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
}) | ||
}) |
There was a problem hiding this comment.
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