-
Notifications
You must be signed in to change notification settings - Fork 310
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
Changes from 7 commits
d017e4a
97cf8d3
4b221e5
c20ae05
cacd571
bff4609
9d9f7dd
bcd2b3e
9dcc8df
c92c401
cd4b6fe
ec347a2
67b7647
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 | ||
juan-fernandez marked this conversation as resolved.
Show resolved
Hide resolved
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 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. yeah, the test framework I used to run my tests got stuck and I had to 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. |
||
} | ||
} | ||
|
||
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 | ||
} |
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: { | ||
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' | ||
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