Skip to content

Commit

Permalink
[DI] Add ability to take state snapshot feature
Browse files Browse the repository at this point in the history
  • Loading branch information
watson committed Sep 13, 2024
1 parent 7588208 commit 03bbb52
Show file tree
Hide file tree
Showing 6 changed files with 699 additions and 4 deletions.
134 changes: 133 additions & 1 deletion integration-tests/debugger/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,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 @@ -373,6 +373,138 @@ 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 } = locals
delete locals.request

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: '42n' },
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' }
})

// 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, 'object')
assert.deepEqual(request.fields.id, { type: 'string', value: 'req-1' })
assert.deepEqual(request.fields.params, {
type: 'object', fields: { name: { type: 'string', value: 'foo' } }
})
assert.deepEqual(request.fields.query, { type: 'object', fields: {} })
assert.deepEqual(request.fields.body, { type: 'undefined' })

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

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: '42n' },
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' }
})

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
32 changes: 32 additions & 0 deletions integration-tests/debugger/target-app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ 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 } = getSomeData()
return { hello: request.params.name }
})

Expand All @@ -16,3 +21,30 @@ fastify.listen({ port: process.env.APP_PORT }, (err) => {
}
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: () => {}
}
}
64 changes: 61 additions & 3 deletions packages/dd-trace/src/debugger/devtools_client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
const { randomUUID } = require('crypto')
const { breakpoints } = require('./state')
const session = require('./session')
const { getLocalStateForBreakpoint } = require('./snapshot')
const send = require('./send')
const { ackEmitting } = require('./status')
const { ackEmitting, ackError } = require('./status')
const { parentThreadId } = require('./config')
const log = require('../../log')
const { version } = require('../../../../../package.json')
Expand All @@ -19,7 +20,23 @@ 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
const probes = params.hitBreakpoints.map((id) => {
const probe = breakpoints.get(id)
if (captureSnapshotForProbe === null && probe.captureSnapshot) captureSnapshotForProbe = probe
return probe
})

let state
if (captureSnapshotForProbe !== null) {
try {
state = await getLocalStateForBreakpoint(params)
} catch (err) {
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?

Expand Down Expand Up @@ -48,10 +65,51 @@ session.on('Debugger.paused', async ({ params }) => {
language: 'javascript'
}

if (probe.captureSnapshot && state) {
snapshot.captures = {
lines: {
[probe.location.lines[0]]: {
// TODO: We can technically split state up in `block` and `locals`. Is `block` a valid key?
locals: state
}
}
}
}

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

// TODO: Remove before shipping
log.debug(
'\nLocal state:\n' +
'--------------------------------------------------\n' +
stateToString(state) +
'--------------------------------------------------\n' +
'\nStats:\n' +
'--------------------------------------------------\n' +
` Total state JSON size: ${state === undefined ? 0 : JSON.stringify(state).length} bytes\n` +
`Processed was paused for: ${Number(diff) / 1000000} ms\n` +
'--------------------------------------------------\n'
)
}
})
}
})

// TODO: Remove this function before shipping
function stateToString (state) {
if (state === undefined) return '<not captured>\n'
let str = ''
for (const [name, value] of Object.entries(state)) {
str += `${name}: ${color(value)}\n`
}
return str
}

// TODO: Remove this function before shipping
function color (obj) {
return require('node:util').inspect(obj, { depth: null, colors: true })
}
Loading

0 comments on commit 03bbb52

Please sign in to comment.