-
Notifications
You must be signed in to change notification settings - Fork 312
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for Dynamic Instrumentation
- Loading branch information
Showing
16 changed files
with
743 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
'use strict' | ||
|
||
const path = require('path') | ||
const getPort = require('get-port') | ||
const Axios = require('axios') | ||
const { assert } = require('chai') | ||
const { assertObjectContains, assertUUID, createSandbox, FakeAgent, spawnProc } = require('../helpers') | ||
|
||
const probeId = 'my-uuid' | ||
const probeFile = 'debugger/target-app/index.js' | ||
const probeLineNo = 13 | ||
|
||
describe('Dynamic Instrumentation', function () { | ||
let axios, sandbox, cwd, appPort, appFile, agent, proc | ||
const probeConfig = { | ||
product: 'LIVE_DEBUGGING', | ||
id: `logProbe_${probeId}`, | ||
config: generateProbeConfig() | ||
} | ||
|
||
before(async function () { | ||
sandbox = await createSandbox(['fastify']) | ||
cwd = sandbox.folder | ||
appFile = path.join(cwd, 'debugger', 'target-app', 'index.js') | ||
}) | ||
|
||
after(async function () { | ||
await sandbox.remove() | ||
}) | ||
|
||
beforeEach(async function () { | ||
appPort = await getPort() | ||
agent = await new FakeAgent().start() | ||
proc = await spawnProc(appFile, { | ||
cwd, | ||
env: { | ||
APP_PORT: appPort, | ||
DD_TRACE_AGENT_PORT: agent.port, | ||
DD_EXPERIMENTAL_DYNAMIC_INSTRUMENTATION_ENABLED: true | ||
} | ||
}) | ||
axios = Axios.create({ | ||
baseURL: `http://localhost:${appPort}` | ||
}) | ||
}) | ||
|
||
afterEach(async function () { | ||
proc.kill() | ||
await agent.stop() | ||
}) | ||
|
||
it('base case: target app should work as expected if no test probe has been added', async function () { | ||
const response = await axios.get('/foo') | ||
assert.strictEqual(response.status, 200) | ||
assert.deepStrictEqual(response.data, { hello: 'foo' }) | ||
}) | ||
|
||
describe('diagnostics messages', function () { | ||
it('should send expected diagnostics messages if valid probe is received and triggered', function (done) { | ||
const expectedPayloads = [{ | ||
ddsource: 'dd_debugger', | ||
service: 'node', | ||
debugger: { diagnostics: { probeId, version: 0, status: 'RECEIVED' } } | ||
}, { | ||
ddsource: 'dd_debugger', | ||
service: 'node', | ||
debugger: { diagnostics: { probeId, version: 0, status: 'INSTALLED' } } | ||
}, { | ||
ddsource: 'dd_debugger', | ||
service: 'node', | ||
debugger: { diagnostics: { probeId, version: 0, status: 'EMITTING' } } | ||
}] | ||
|
||
agent.on('debugger-diagnostics', async ({ payload }) => { | ||
try { | ||
const expected = expectedPayloads.shift() | ||
assertObjectContains(payload, expected) | ||
assertUUID(payload.debugger.diagnostics.runtimeId) | ||
|
||
if (payload.debugger.diagnostics.status === 'INSTALLED') { | ||
const response = await axios.get('/foo') | ||
assert.strictEqual(response.status, 200) | ||
assert.deepStrictEqual(response.data, { hello: 'foo' }) | ||
} | ||
|
||
if (expectedPayloads.length === 0) done() | ||
} catch (err) { | ||
// Nessecary hack: Any errors throw inside of an async function is invisible to Mocha unless the outer `it` | ||
// callback is also `async` (which we can't do in this case since we rely on the `done` callback). | ||
done(err) | ||
} | ||
}) | ||
|
||
agent.addRemoteConfig(probeConfig) | ||
}) | ||
|
||
const unsupporedOrInvalidProbes = [[ | ||
'should send expected error diagnostics messages if probe doesn\'t conform to expected schema', | ||
'bad config!!!', | ||
{ status: 'ERROR' } | ||
], [ | ||
'should send expected error diagnostics messages if probe type isn\'t supported', | ||
generateProbeConfig({ type: 'INVALID_PROBE' }) | ||
], [ | ||
'should send expected error diagnostics messages if it isn\'t a line-probe', | ||
generateProbeConfig({ where: { foo: 'bar' } }) // TODO: Use valid schema for method probe instead | ||
]] | ||
|
||
for (const [title, config, costumErrorDiagnosticsObj] of unsupporedOrInvalidProbes) { | ||
// TODO: Test that we report the error via the RC client as well | ||
it(title, function (done) { | ||
const expectedPayloads = [{ | ||
ddsource: 'dd_debugger', | ||
service: 'node', | ||
debugger: { diagnostics: { status: 'RECEIVED' } } | ||
}, { | ||
ddsource: 'dd_debugger', | ||
service: 'node', | ||
debugger: { diagnostics: costumErrorDiagnosticsObj ?? { probeId, version: 0, status: 'ERROR' } } | ||
}] | ||
|
||
agent.on('debugger-diagnostics', ({ payload }) => { | ||
const expected = expectedPayloads.shift() | ||
assertObjectContains(payload, expected) | ||
const { diagnostics } = payload.debugger | ||
assertUUID(diagnostics.runtimeId) | ||
|
||
if (diagnostics.status === 'ERROR') { | ||
assert.property(diagnostics, 'exception') | ||
assert.hasAllKeys(diagnostics.exception, ['message', 'stacktrace']) | ||
assert.typeOf(diagnostics.exception.message, 'string') | ||
assert.typeOf(diagnostics.exception.stacktrace, 'string') | ||
} | ||
|
||
if (expectedPayloads.length === 0) done() | ||
}) | ||
|
||
agent.addRemoteConfig({ | ||
product: 'LIVE_DEBUGGING', | ||
id: `logProbe_${probeId}`, | ||
config | ||
}) | ||
}) | ||
} | ||
}) | ||
|
||
describe('input messages', function () { | ||
it('should capture and send expected snapshot when a log line probe is triggered', function (done) { | ||
agent.on('debugger-diagnostics', ({ payload }) => { | ||
if (payload.debugger.diagnostics.status === 'INSTALLED') { | ||
axios.get('/foo') | ||
} | ||
}) | ||
|
||
agent.on('debugger-input', ({ payload }) => { | ||
const expected = { | ||
ddsource: 'dd_debugger', | ||
service: 'node', | ||
message: 'Hello World!', | ||
logger: { | ||
method: 'send', | ||
version: 2, | ||
thread_id: 1 | ||
}, | ||
'debugger.snapshot': { | ||
probe: { | ||
id: probeId, | ||
version: 0, | ||
location: { file: probeFile, lines: [probeLineNo] } | ||
}, | ||
language: 'javascript' | ||
} | ||
} | ||
|
||
assertObjectContains(payload, expected) | ||
assert.isTrue(payload.logger.name.endsWith(path.join('src', 'debugger', 'devtools_client', 'send.js'))) | ||
assert.match(payload.logger.thread_name, new RegExp(`${process.argv0};pid:\\d+$`)) | ||
assertUUID(payload['debugger.snapshot'].id) | ||
assert.typeOf(payload['debugger.snapshot'].timestamp, 'number') | ||
assert.isTrue(payload['debugger.snapshot'].timestamp > Date.now() - 1000 * 60) | ||
assert.isTrue(payload['debugger.snapshot'].timestamp <= Date.now()) | ||
|
||
done() | ||
}) | ||
|
||
agent.addRemoteConfig(probeConfig) | ||
}) | ||
}) | ||
}) | ||
|
||
function generateProbeConfig (overrides) { | ||
return { | ||
id: probeId, | ||
version: 0, | ||
type: 'LOG_PROBE', | ||
language: 'javascript', | ||
where: { sourceFile: probeFile, lines: [String(probeLineNo)] }, | ||
tags: [], | ||
template: 'Hello World!', | ||
segments: [{ str: 'Hello World!' }], | ||
captureSnapshot: false, | ||
capture: { maxReferenceDepth: 3 }, | ||
sampling: { snapshotsPerSecond: 5000 }, | ||
evaluateAt: 'EXIT', | ||
...overrides | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
'use strict' | ||
|
||
const Fastify = require('fastify') | ||
const tracer = require('dd-trace') | ||
|
||
tracer.init({ | ||
flushInterval: 0 | ||
}) | ||
|
||
const fastify = Fastify() | ||
|
||
fastify.get('/:name', function handler (request) { | ||
return { hello: request.params.name } | ||
}) | ||
|
||
fastify.listen({ port: process.env.APP_PORT }, (err) => { | ||
if (err) { | ||
fastify.log.error(err) | ||
process.exit(1) | ||
} | ||
process.send({ port: process.env.APP_PORT }) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
'use strict' | ||
|
||
const { workerData: { config: parentConfig, configPort } } = require('node:worker_threads') | ||
const { URL, format } = require('node:url') | ||
|
||
const config = module.exports = { | ||
runtimeId: parentConfig.tags['runtime-id'], | ||
service: parentConfig.service | ||
} | ||
|
||
updateUrl(parentConfig) | ||
|
||
configPort.on('message', updateUrl) | ||
|
||
function updateUrl (updates) { | ||
const url = updates.url || new URL(format({ | ||
// TODO: Can this ever be anything other than `http:`, and if so, how do we get the configured value? | ||
protocol: config.url?.protocol || 'http:', | ||
hostname: updates.hostname || config.url?.hostname || 'localhost', | ||
port: updates.port || config.url?.port | ||
})) | ||
|
||
config.url = url.toString() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
'use strict' | ||
|
||
const uuid = require('crypto-randomuuid') | ||
const { breakpoints } = require('./state') | ||
const session = require('./session') | ||
const send = require('./send') | ||
const { ackEmitting } = require('./status') | ||
require('./remote_config') | ||
const log = require('../../log') | ||
|
||
session.on('Debugger.paused', async ({ params }) => { | ||
const start = process.hrtime.bigint() | ||
const timestamp = Date.now() | ||
const probes = params.hitBreakpoints.map((id) => breakpoints.get(id)) | ||
await session.post('Debugger.resume') | ||
const diff = process.hrtime.bigint() - start // TODO: Should this be recored as telemetry? | ||
|
||
log.debug(`Finished processing breakpoints - main thread paused for: ${Number(diff) / 1000000} ms`) | ||
|
||
// TODO: Is this the correct way of handling multiple breakpoints hit at the same time? | ||
for (const probe of probes) { | ||
await send( | ||
probe.template, // TODO: Process template | ||
{ | ||
id: uuid(), | ||
timestamp, | ||
probe: { | ||
id: probe.id, | ||
version: probe.version, | ||
location: probe.location | ||
}, | ||
language: 'javascript' | ||
} | ||
) | ||
|
||
ackEmitting(probe) | ||
} | ||
}) |
Oops, something went wrong.