Skip to content

Commit

Permalink
[DI] Add ability to take state snapshot feature (#4549)
Browse files Browse the repository at this point in the history
Take a "snapshot" of the variables that are in scope when a probe is hit
(except the global scope, which intentionally have been omitted since it's too
noisy):

- For each variable in scope, we traverse objects and their properties up to
  `maxReferenceDepth` deep (default is 3 levels).
- Strings are truncated to `maxLength` (default is 255 characters).
- Binary data is converted to strings with appropriate escaping of non
  printable characters (the `maxLength` limit is also applied)

Out of scope:
- Information about `this` is not captured.
- maxCollectionSize limit
- maxFieldCount limit
- Special handling for snapshots larger than 1MB (e.g. snapshot pruning or
  something simpler)
- PII redaction
  • Loading branch information
watson authored and tlhunter committed Oct 7, 2024
1 parent 8dd0bc2 commit 5693238
Show file tree
Hide file tree
Showing 10 changed files with 1,434 additions and 9 deletions.
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' },
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 } } } } } }
},
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)
}
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)
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

0 comments on commit 5693238

Please sign in to comment.