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 Jul 25, 2024
1 parent 4a218af commit 8af7e4d
Show file tree
Hide file tree
Showing 4 changed files with 485 additions and 2 deletions.
61 changes: 59 additions & 2 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 uuid = require('crypto-randomuuid')
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')
require('./remote_config')
const log = require('../../log')

Expand All @@ -15,19 +16,47 @@ setInterval(() => {}, 1000 * 60)
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?

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) {
const captures = probe.captureSnapshot && state
? {
lines: {
[probe.location.lines[0]]: {
// TODO: We can technically split state up in `block` and `locals`. Is `block` a valid key?
locals: state
}
}
}
: undefined

await send({
message: probe.template, // TODO: Process template
snapshot: {
id: uuid(),
timestamp,
captures,
probe: {
id: probe.id,
version: probe.version, // TODO: Should this always be 2???
Expand All @@ -38,5 +67,33 @@ session.on('Debugger.paused', async ({ params }) => {
})

ackEmitting(probe)

// TODO: Remove before shipping
process._rawDebug(
'\nLocal state:\n' +
'--------------------------------------------------\n' +
stateToString(state) +
'--------------------------------------------------\n' +
'\nStats:\n' +
'--------------------------------------------------\n' +
` Total state JSON size: ${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>'
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 })
}
154 changes: 154 additions & 0 deletions packages/dd-trace/src/debugger/devtools_client/snapshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
'use strict'

const { breakpoints } = require('./state')
const session = require('./session')

module.exports = {
getLocalStateForBreakpoint
}

async function getLocalStateForBreakpoint (params) {
const scope = params.callFrames[0].scopeChain[0] // TODO: Should we only ever look at the top?
const conf = breakpoints.get(params.hitBreakpoints[0]) // TODO: Handle multiple breakpoints
return toObject(await getObjectWithChildren(scope.object.objectId, conf)).fields
}

async function getObjectWithChildren (objectId, conf, depth = 0) {
const { result } = (await session.post('Runtime.getProperties', {
objectId,
ownProperties: true // exclude inherited properties
// TODO: Remove the following commented out lines before shipping
// accessorPropertiesOnly: true, // true: only return get/set accessor properties
// generatePreview: true, // true: generate `value.preview` object with details (including content) of maps and sets
// nonIndexedPropertiesOnly: true // true: do not get array elements
}))

// TODO: Deside if we should filter out enumerable properties or not:
// result = result.filter((e) => e.enumerable)

if (depth < conf.capture.maxReferenceDepth) {
for (const entry of result) {
if (entry?.value?.type === 'object' && entry?.value?.objectId) {
entry.value.properties = await getObjectWithChildren(entry.value.objectId, conf, depth + 1)
}
}
}

return result
}

function toObject (state) {
if (state === undefined) {
return {
type: 'object',
notCapturedReason: 'depth'
}
}

const result = {
type: 'object',
fields: {}
}

for (const prop of state) {
result.fields[prop.name] = getPropVal(prop)
}

return result
}

function toArray (state) {
if (state === undefined) {
return {
type: 'array', // TODO: Should this be 'object' as typeof x === 'object'?
notCapturedReason: 'depth'
}
}

const result = {
type: 'array', // TODO: Should this be 'object' as typeof x === 'object'?
elements: []
}

for (const elm of state) {
if (elm.enumerable === false) continue // the value of the `length` property should not be part of the array
result.elements.push(getPropVal(elm))
}

return result
}

function getPropVal (prop) {
const value = prop.value ?? prop.get
switch (value.type) {
case 'undefined':
return {
type: 'undefined',
value: undefined // TODO: We can't send undefined values over JSON
}
case 'boolean':
return {
type: 'boolean',
value: value.value
}
case 'string':
return {
type: 'string',
value: value.value
}
case 'number':
return {
type: 'number',
value: value.value
}
case 'bigint':
return {
type: 'bigint',
value: value.description
}
case 'symbol':
return {
type: 'symbol',
value: value.description // TODO: Should we really send this as a string?
}
case 'function':
return {
type: value.description.startsWith('class ') ? 'class' : 'function'
}
case 'object':
return getObjVal(value)
default:
throw new Error(`Unknown type "${value.type}": ${JSON.stringify(prop)}`)
}
}

function getObjVal (obj) {
switch (obj.subtype) {
case undefined:
return toObject(obj.properties)
case 'array':
return toArray(obj.properties)
case 'null':
return {
type: 'null', // TODO: Should this be 'object' as typeof x === 'null'?
isNull: true
}
case 'set':
return {
type: 'set', // TODO: Should this be 'object' as typeof x === 'object'?
value: obj.description // TODO: Should include Set content in 'elements'
}
case 'map':
return {
type: 'map', // TODO: Should this be 'object' as typeof x === 'object'?
value: obj.description // TODO: Should include Map content 'entries'
}
case 'regexp':
return {
type: 'regexp', // TODO: Should this be 'object' as typeof x === 'object'?
value: obj.description // TODO: This doesn't seem right
}
default:
throw new Error(`Unknown subtype "${obj.subtype}": ${JSON.stringify(obj)}`)
}
}
62 changes: 62 additions & 0 deletions packages/dd-trace/test/debugger/devtools_client/_inspected_file.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict'

function getPrimitives (myArg1 = 1, myArg2 = 2) {
// eslint-disable-next-line no-unused-vars
const { myUndef, myNull, myBool, myNumber, myBigInt, myString, mySym } = primitives
return 'my return value'
}

function getComplextTypes (myArg1 = 1, myArg2 = 2) {
// eslint-disable-next-line no-unused-vars
const { myRegex, myMap, mySet, myArr, myObj, myFunc, myArrowFunc, myInstance, MyClass, circular } = customObj
return 'my return value'
}

function getNestedObj (myArg1 = 1, myArg2 = 2) {
// eslint-disable-next-line no-unused-vars
const { myNestedObj } = nested
return 'my return value'
}

class MyClass {
constructor () {
this.foo = 42
}
}

const primitives = {
myUndef: undefined,
myNull: null,
myBool: true,
myNumber: 42,
myBigInt: 42n,
myString: 'foo',
mySym: Symbol('foo')
}

const customObj = {
myRegex: /foo/,
myMap: new Map([[1, 2], [3, 4]]),
mySet: new Set([1, 2, 3]),
myArr: [1, 2, 3],
myObj: { a: 1, b: 2, c: 3 },
myFunc () { return 42 },
myArrowFunc: () => { return 42 },
myInstance: new MyClass(),
MyClass
}

customObj.circular = customObj

const nested = {
myNestedObj: {
deepObj: { foo: { foo: { foo: { foo: { foo: true } } } } },
deepArr: [[[[[42]]]]]
}
}

module.exports = {
getPrimitives,
getComplextTypes,
getNestedObj
}
Loading

0 comments on commit 8af7e4d

Please sign in to comment.