-
Notifications
You must be signed in to change notification settings - Fork 309
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
874 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,338 @@ | ||
'use strict' | ||
|
||
const path = require('path') | ||
const uuid = require('crypto-randomuuid') | ||
const getPort = require('get-port') | ||
const Axios = require('axios') | ||
const { assert } = require('chai') | ||
const { assertObjectContains, assertUUID, createSandbox, FakeAgent, spawnProc } = require('../helpers') | ||
|
||
const probeFile = 'debugger/target-app/index.js' | ||
const probeLineNo = 13 | ||
|
||
describe('Dynamic Instrumentation', function () { | ||
let axios, sandbox, cwd, appPort, appFile, agent, proc, probeConfig | ||
|
||
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 () { | ||
const probeId = uuid() | ||
probeConfig = { | ||
product: 'LIVE_DEBUGGING', | ||
id: `logProbe_${probeId}`, | ||
config: generateProbeConfig({ id: probeId }) | ||
} | ||
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 probe is received and triggered', function (done) { | ||
const probeId = probeConfig.config.id | ||
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 thrown 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) | ||
}) | ||
|
||
it('should send expected diagnostics messages if probe is first received and then updated', function (done) { | ||
const probeId = probeConfig.config.id | ||
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: 1, status: 'RECEIVED' } } | ||
}, { | ||
ddsource: 'dd_debugger', | ||
service: 'node', | ||
debugger: { diagnostics: { probeId, version: 1, status: 'INSTALLED' } } | ||
}] | ||
const triggers = [ | ||
() => { | ||
probeConfig.config.version++ | ||
agent.updateRemoteConfig(probeConfig.id, probeConfig.config) | ||
}, | ||
() => {} | ||
] | ||
|
||
agent.on('debugger-diagnostics', ({ payload }) => { | ||
const expected = expectedPayloads.shift() | ||
assertObjectContains(payload, expected) | ||
assertUUID(payload.debugger.diagnostics.runtimeId) | ||
if (payload.debugger.diagnostics.status === 'INSTALLED') triggers.shift()() | ||
if (expectedPayloads.length === 0) done() | ||
}) | ||
|
||
agent.addRemoteConfig(probeConfig) | ||
}) | ||
|
||
it('should send expected diagnostics messages if probe is first received and then deleted', function (done) { | ||
const probeId = probeConfig.config.id | ||
// TODO: Is it correct that the client should respond with just a RECEIVED status if a probe is removed? | ||
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: 'RECEIVED' } } | ||
}] | ||
|
||
agent.on('debugger-diagnostics', ({ payload }) => { | ||
const expected = expectedPayloads.shift() | ||
assertObjectContains(payload, expected) | ||
assertUUID(payload.debugger.diagnostics.runtimeId) | ||
if (payload.debugger.diagnostics.status === 'INSTALLED') { | ||
agent.removeRemoteConfig(probeConfig.id) | ||
} | ||
if (expectedPayloads.length === 0) done() | ||
}) | ||
|
||
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 probeId = config.id | ||
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_${config.id}`, | ||
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: probeConfig.config.id, | ||
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) | ||
}) | ||
|
||
it('should respond with updated message if probe message is updated', function (done) { | ||
const expectedMessages = ['Hello World!', 'Hello Updated World!'] | ||
const triggers = [ | ||
async () => { | ||
await axios.get('/foo') | ||
probeConfig.config.template = 'Hello Updated World!' | ||
agent.updateRemoteConfig(probeConfig.id, probeConfig.config) | ||
}, | ||
async () => { | ||
await axios.get('/foo') | ||
} | ||
] | ||
|
||
agent.on('debugger-diagnostics', async ({ payload }) => { | ||
try { | ||
if (payload.debugger.diagnostics.status === 'INSTALLED') await triggers.shift()() | ||
} catch (err) { | ||
// Nessecary hack: Any errors thrown 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.on('debugger-input', ({ payload }) => { | ||
assert.strictEqual(payload.message, expectedMessages.shift()) | ||
if (expectedMessages.length === 0) done() | ||
}) | ||
|
||
agent.addRemoteConfig(probeConfig) | ||
}) | ||
|
||
it('should not trigger if probe is deleted', function (done) { | ||
let removed = false | ||
|
||
agent.on('debugger-diagnostics', async ({ payload }) => { | ||
try { | ||
if (payload.debugger.diagnostics.status === 'INSTALLED') { | ||
agent.removeRemoteConfig(probeConfig.id) | ||
removed = true | ||
} else if (removed && payload.debugger.diagnostics.status === 'RECEIVED') { | ||
await axios.get('/foo') | ||
// We want to wait enough time to see if the client triggers on the breakpoint so that the test can fail if | ||
// it does, but not so long that the test times out. | ||
// TODO: Is there some signal we can use instead of a timer? | ||
setTimeout(done, 5000) | ||
} | ||
} catch (err) { | ||
// Nessecary hack: Any errors thrown 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.on('debugger-input', () => { | ||
assert.fail('should not capture anything when the probe is deleted') | ||
}) | ||
|
||
agent.addRemoteConfig(probeConfig) | ||
}) | ||
}) | ||
}) | ||
|
||
function generateProbeConfig (overrides) { | ||
return { | ||
id: uuid(), | ||
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
Oops, something went wrong.