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

[DI] Add ability to take state snapshot #4549

Merged
merged 1 commit into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
169 changes: 167 additions & 2 deletions integration-tests/debugger/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const { ACKNOWLEDGED, ERROR } = require('../../packages/dd-trace/src/appsec/remo
const { version } = require('../../package.json')

const probeFile = 'debugger/target-app/index.js'
const probeLineNo = 9
const probeLineNo = 14
const pollInterval = 1

describe('Dynamic Instrumentation', function () {
Expand Down Expand Up @@ -275,7 +275,7 @@ describe('Dynamic Instrumentation', function () {
})

describe('input messages', function () {
it('should capture and send expected snapshot when a log line probe is triggered', function (done) {
it('should capture and send expected payload when a log line probe is triggered', function (done) {
agent.on('debugger-diagnostics', ({ payload }) => {
if (payload.debugger.diagnostics.status === 'INSTALLED') {
axios.get('/foo')
Expand Down Expand Up @@ -392,6 +392,171 @@ describe('Dynamic Instrumentation', function () {

agent.addRemoteConfig(rcConfig)
})

describe('with snapshot', () => {
beforeEach(() => {
// Trigger the breakpoint once probe is successfully installed
agent.on('debugger-diagnostics', ({ payload }) => {
if (payload.debugger.diagnostics.status === 'INSTALLED') {
axios.get('/foo')
}
})
})

it('should capture a snapshot', (done) => {
agent.on('debugger-input', ({ payload: { 'debugger.snapshot': { captures } } }) => {
assert.deepEqual(Object.keys(captures), ['lines'])
assert.deepEqual(Object.keys(captures.lines), [String(probeLineNo)])

const { locals } = captures.lines[probeLineNo]
const { request, fastify, getSomeData } = locals
delete locals.request
delete locals.fastify
delete locals.getSomeData

// from block scope
assert.deepEqual(locals, {
nil: { type: 'null', isNull: true },
undef: { type: 'undefined' },
bool: { type: 'boolean', value: 'true' },
num: { type: 'number', value: '42' },
bigint: { type: 'bigint', value: '42' },
str: { type: 'string', value: 'foo' },
lstr: {
type: 'string',
// eslint-disable-next-line max-len
value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor i',
truncated: true,
size: 445
},
sym: { type: 'symbol', value: 'Symbol(foo)' },
regex: { type: 'RegExp', value: '/bar/i' },
arr: {
type: 'Array',
elements: [
{ type: 'number', value: '1' },
{ type: 'number', value: '2' },
{ type: 'number', value: '3' }
]
},
obj: {
type: 'Object',
fields: {
foo: {
type: 'Object',
fields: {
baz: { type: 'number', value: '42' },
nil: { type: 'null', isNull: true },
undef: { type: 'undefined' },
watson marked this conversation as resolved.
Show resolved Hide resolved
deep: {
type: 'Object',
fields: { nested: { type: 'Object', notCapturedReason: 'depth' } }
}
}
},
bar: { type: 'boolean', value: 'true' }
}
},
emptyObj: { type: 'Object', fields: {} },
fn: {
type: 'Function',
fields: {
length: { type: 'number', value: '0' },
name: { type: 'string', value: 'fn' }
}
},
p: {
type: 'Promise',
fields: {
'[[PromiseState]]': { type: 'string', value: 'fulfilled' },
'[[PromiseResult]]': { type: 'undefined' }
}
}
})

// from local scope
// There's no reason to test the `request` object 100%, instead just check its fingerprint
assert.deepEqual(Object.keys(request), ['type', 'fields'])
assert.equal(request.type, 'Request')
assert.deepEqual(request.fields.id, { type: 'string', value: 'req-1' })
assert.deepEqual(request.fields.params, {
type: 'NullObject', fields: { name: { type: 'string', value: 'foo' } }
})
assert.deepEqual(request.fields.query, { type: 'Object', fields: {} })
assert.deepEqual(request.fields.body, { type: 'undefined' })

// from closure scope
// There's no reason to test the `fastify` object 100%, instead just check its fingerprint
assert.deepEqual(Object.keys(fastify), ['type', 'fields'])
assert.equal(fastify.type, 'Object')

assert.deepEqual(getSomeData, {
type: 'Function',
fields: {
length: { type: 'number', value: '0' },
name: { type: 'string', value: 'getSomeData' }
}
})

done()
})

agent.addRemoteConfig(generateRemoteConfig({ captureSnapshot: true }))
})

it('should respect maxReferenceDepth', (done) => {
agent.on('debugger-input', ({ payload: { 'debugger.snapshot': { captures } } }) => {
const { locals } = captures.lines[probeLineNo]
delete locals.request
delete locals.fastify
delete locals.getSomeData

assert.deepEqual(locals, {
nil: { type: 'null', isNull: true },
undef: { type: 'undefined' },
bool: { type: 'boolean', value: 'true' },
num: { type: 'number', value: '42' },
bigint: { type: 'bigint', value: '42' },
str: { type: 'string', value: 'foo' },
lstr: {
type: 'string',
// eslint-disable-next-line max-len
value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor i',
truncated: true,
size: 445
},
sym: { type: 'symbol', value: 'Symbol(foo)' },
regex: { type: 'RegExp', value: '/bar/i' },
arr: { type: 'Array', notCapturedReason: 'depth' },
obj: { type: 'Object', notCapturedReason: 'depth' },
emptyObj: { type: 'Object', notCapturedReason: 'depth' },
fn: { type: 'Function', notCapturedReason: 'depth' },
p: { type: 'Promise', notCapturedReason: 'depth' }
})

done()
})

agent.addRemoteConfig(generateRemoteConfig({ captureSnapshot: true, capture: { maxReferenceDepth: 0 } }))
})

it('should respect maxLength', (done) => {
agent.on('debugger-input', ({ payload: { 'debugger.snapshot': { captures } } }) => {
const { locals } = captures.lines[probeLineNo]

assert.deepEqual(locals.lstr, {
type: 'string',
value: 'Lorem ipsu',
truncated: true,
size: 445
})

done()
})

agent.addRemoteConfig(generateRemoteConfig({ captureSnapshot: true, capture: { maxLength: 10 } }))
})
})
})

describe('race conditions', () => {
Expand Down
35 changes: 35 additions & 0 deletions integration-tests/debugger/target-app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,49 @@ const Fastify = require('fastify')

const fastify = Fastify()

// Since line probes have hardcoded line numbers, we want to try and keep the line numbers from changing within the
// `handler` function below when making changes to this file. This is achieved by calling `getSomeData` and keeping all
// variable names on the same line as much as possible.
fastify.get('/:name', function handler (request) {
// eslint-disable-next-line no-unused-vars
const { nil, undef, bool, num, bigint, str, lstr, sym, regex, arr, obj, emptyObj, fn, p } = getSomeData()
return { hello: request.params.name }
})

// WARNING: Breakpoints present above this line - Any changes to the lines above might influence tests!

fastify.listen({ port: process.env.APP_PORT }, (err) => {
if (err) {
fastify.log.error(err)
process.exit(1)
}
process.send({ port: process.env.APP_PORT })
})

function getSomeData () {
return {
nil: null,
undef: undefined,
bool: true,
num: 42,
bigint: 42n,
str: 'foo',
// eslint-disable-next-line max-len
lstr: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
sym: Symbol('foo'),
regex: /bar/i,
arr: [1, 2, 3],
obj: {
foo: {
baz: 42,
nil: null,
undef: undefined,
deep: { nested: { obj: { that: { goes: { on: { forever: true } } } } } }
watson marked this conversation as resolved.
Show resolved Hide resolved
},
bar: true
},
emptyObj: {},
fn: () => {},
p: Promise.resolve()
}
}
48 changes: 43 additions & 5 deletions packages/dd-trace/src/debugger/devtools_client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
const { randomUUID } = require('crypto')
const { breakpoints } = require('./state')
const session = require('./session')
const { getLocalStateForCallFrame } = require('./snapshot')
const send = require('./send')
const { getScriptUrlFromId } = require('./state')
const { ackEmitting } = require('./status')
const { ackEmitting, ackError } = require('./status')
const { parentThreadId } = require('./config')
const log = require('../../log')
const { version } = require('../../../../../package.json')
Expand All @@ -20,9 +21,33 @@ const threadName = parentThreadId === 0 ? 'MainThread' : `WorkerThread:${parentT
session.on('Debugger.paused', async ({ params }) => {
const start = process.hrtime.bigint()
const timestamp = Date.now()
const probes = params.hitBreakpoints.map((id) => breakpoints.get(id))

let captureSnapshotForProbe = null
let maxReferenceDepth, maxLength
const probes = params.hitBreakpoints.map((id) => {
const probe = breakpoints.get(id)
if (probe.captureSnapshot) {
captureSnapshotForProbe = probe
maxReferenceDepth = highestOrUndefined(probe.capture.maxReferenceDepth, maxReferenceDepth)
maxLength = highestOrUndefined(probe.capture.maxLength, maxLength)
watson marked this conversation as resolved.
Show resolved Hide resolved
}
return probe
})

let processLocalState
if (captureSnapshotForProbe !== null) {
try {
// TODO: Create unique states for each affected probe based on that probes unique `capture` settings (DEBUG-2863)
watson marked this conversation as resolved.
Show resolved Hide resolved
processLocalState = await getLocalStateForCallFrame(params.callFrames[0], { maxReferenceDepth, maxLength })
} catch (err) {
// TODO: This error is not tied to a specific probe, but to all probes with `captureSnapshot: true`.
// However, in 99,99% of cases, there will be just a single probe, so I guess this simplification is ok?
ackError(err, captureSnapshotForProbe) // TODO: Ok to continue after sending ackError?
}
}

await session.post('Debugger.resume')
const diff = process.hrtime.bigint() - start // TODO: Should this be recored as telemetry?
const diff = process.hrtime.bigint() - start // TODO: Recored as telemetry (DEBUG-2858)

log.debug(`Finished processing breakpoints - main thread paused for: ${Number(diff) / 1000000} ms`)

Expand All @@ -47,7 +72,7 @@ session.on('Debugger.paused', async ({ params }) => {
}
})

// TODO: Send multiple probes in one HTTP request as an array
// TODO: Send multiple probes in one HTTP request as an array (DEBUG-2848)
for (const probe of probes) {
const snapshot = {
id: randomUUID(),
Expand All @@ -61,10 +86,23 @@ session.on('Debugger.paused', async ({ params }) => {
language: 'javascript'
}

// TODO: Process template
if (probe.captureSnapshot) {
const state = processLocalState()
if (state) {
snapshot.captures = {
lines: { [probe.location.lines[0]]: { locals: state } }
}
}
}

// TODO: Process template (DEBUG-2628)
send(probe.template, logger, snapshot, (err) => {
if (err) log.error(err)
else ackEmitting(probe)
})
}
})

function highestOrUndefined (num, max) {
return num === undefined ? max : Math.max(num, max ?? 0)
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ async function processMsg (action, probe) {
await addBreakpoint(probe)
break
case 'modify':
// TODO: Can we modify in place?
// TODO: Modify existing probe instead of removing it (DEBUG-2817)
await removeBreakpoint(probe)
await addBreakpoint(probe)
break
Expand Down
Loading
Loading