From b6e69cd54d60bfd74adf0b76a1887ec86ce36f29 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 23 Jul 2024 16:13:02 +0200 Subject: [PATCH] [DI] Add ability to take state snapshot feature --- .../src/debugger/devtools_client/index.js | 61 ++++- .../src/debugger/devtools_client/snapshot.js | 154 +++++++++++++ .../devtools_client/_inspected_file.js | 62 ++++++ .../debugger/devtools_client/snapshot.spec.js | 210 ++++++++++++++++++ 4 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 packages/dd-trace/src/debugger/devtools_client/snapshot.js create mode 100644 packages/dd-trace/test/debugger/devtools_client/_inspected_file.js create mode 100644 packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js diff --git a/packages/dd-trace/src/debugger/devtools_client/index.js b/packages/dd-trace/src/debugger/devtools_client/index.js index d58dfcde818..0603e859898 100644 --- a/packages/dd-trace/src/debugger/devtools_client/index.js +++ b/packages/dd-trace/src/debugger/devtools_client/index.js @@ -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') @@ -15,7 +16,23 @@ 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? @@ -23,11 +40,23 @@ session.on('Debugger.paused', async ({ params }) => { // 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??? @@ -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 '' + 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 }) +} diff --git a/packages/dd-trace/src/debugger/devtools_client/snapshot.js b/packages/dd-trace/src/debugger/devtools_client/snapshot.js new file mode 100644 index 00000000000..4912bbc6074 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/snapshot.js @@ -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)}`) + } +} diff --git a/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js b/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js new file mode 100644 index 00000000000..97210686ab1 --- /dev/null +++ b/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js @@ -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 +} diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js new file mode 100644 index 00000000000..63fe7caefd9 --- /dev/null +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js @@ -0,0 +1,210 @@ +'use strict' + +require('../../setup/tap') + +const { expect } = require('chai') + +const inspector = require('../../../src/debugger/devtools_client/inspector_promises_polyfill') +const session = new inspector.Session() +session.connect() + +const { getPrimitives, getComplextTypes, getNestedObj } = require('./_inspected_file') + +const mockedState = { + breakpoints: new Map(), + '@noCallThru': true +} +session['@noCallThru'] = true +const { getLocalStateForBreakpoint } = proxyquire('../src/debugger/devtools_client/snapshot', { + './state': mockedState, + './session': session +}) + +let scriptId + +// Be aware, if any of these tests fail, a nasty native stack trace will be thrown along with the test error! +// Just ignore it, as it's a bug in tap: https://github.com/tapjs/libtap/issues/53 +describe('debugger -> devtools client -> snapshot.getLocalStateForBreakpoint', () => { + beforeEach(async () => { + scriptId = new Promise((resolve) => { + session.on('Debugger.scriptParsed', ({ params }) => { + if (params.url.endsWith('/_inspected_file.js')) { + session.removeAllListeners('Debugger.scriptParsed') // TODO: Can we do this in prod code? + resolve(params.scriptId) + } + }) + }) + + await session.post('Debugger.enable') + }) + + afterEach(async () => { + await session.post('Debugger.disable') + }) + + it('should return expected object for primitives', async () => { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = await getLocalStateForBreakpoint(params) + + expect(Object.entries(state).length).to.equal(7) + expect(state).to.have.deep.property('myUndef', { type: 'undefined', value: undefined }) + expect(state).to.have.deep.property('myNull', { type: 'null', isNull: true }) + expect(state).to.have.deep.property('myBool', { type: 'boolean', value: true }) + expect(state).to.have.deep.property('myNumber', { type: 'number', value: 42 }) + expect(state).to.have.deep.property('myBigInt', { type: 'bigint', value: '42n' }) + expect(state).to.have.deep.property('myString', { type: 'string', value: 'foo' }) + expect(state).to.have.deep.property('mySym', { type: 'symbol', value: 'Symbol(foo)' }) + }) + + await setBreakpointOnLine(6) + getPrimitives() + }) + + it('should return expected object for complex types', async () => { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = await getLocalStateForBreakpoint(params) + + expect(Object.entries(state).length).to.equal(10) + expect(state).to.have.deep.property('myRegex', { type: 'regexp', value: '/foo/' }) + expect(state).to.have.deep.property('myMap', { type: 'map', value: 'Map(2)' }) + expect(state).to.have.deep.property('mySet', { type: 'set', value: 'Set(3)' }) + expect(state).to.have.deep.property('myArr', { + type: 'array', + elements: [ + { type: 'number', value: 1 }, + { type: 'number', value: 2 }, + { type: 'number', value: 3 } + ] + }) + expect(state).to.have.deep.property('myObj', { + type: 'object', + fields: { + a: { type: 'number', value: 1 }, + b: { type: 'number', value: 2 }, + c: { type: 'number', value: 3 } + } + }) + expect(state).to.have.deep.property('myFunc', { type: 'function' }) + expect(state).to.have.deep.property('myArrowFunc', { type: 'function' }) + expect(state).to.have.deep.property('myInstance', { + type: 'object', + fields: { foo: { type: 'number', value: 42 } } + }) + expect(state).to.have.deep.property('MyClass', { type: 'class' }) + expect(state).to.have.property('circular') + expect(state.circular).to.have.property('type', 'object') + expect(state.circular).to.have.property('fields') + // For the circular field, just check that at least one of the expected properties are present + expect(state.circular.fields).to.deep.include({ + myRegex: { type: 'regexp', value: '/foo/' } + }) + }) + + await setBreakpointOnLine(12) + getComplextTypes() + }) + + it('should return expected object for nested objects with maxReferenceDepth: 1', async () => { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = await getLocalStateForBreakpoint(params) + + expect(Object.entries(state).length).to.equal(1) + + expect(state).to.have.property('myNestedObj') + expect(state.myNestedObj).to.have.property('type', 'object') + expect(state.myNestedObj).to.have.property('fields') + expect(Object.entries(state.myNestedObj).length).to.equal(2) + + expect(state.myNestedObj.fields).to.have.deep.property('deepObj', { + type: 'object', notCapturedReason: 'depth' + }) + + expect(state.myNestedObj.fields).to.have.deep.property('deepArr', { + type: 'array', notCapturedReason: 'depth' + }) + }) + + await setBreakpointOnLine(18, 1) + getNestedObj() + }) + + it('should return expected object for nested objects with maxReferenceDepth: 5', async () => { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = await getLocalStateForBreakpoint(params) + + expect(Object.entries(state).length).to.equal(1) + + expect(state).to.have.property('myNestedObj') + expect(state.myNestedObj).to.have.property('type', 'object') + expect(state.myNestedObj).to.have.property('fields') + expect(Object.entries(state.myNestedObj).length).to.equal(2) + + expect(state.myNestedObj.fields).to.have.deep.property('deepObj', { + type: 'object', + fields: { + foo: { + type: 'object', + fields: { + foo: { + type: 'object', + fields: { + foo: { + type: 'object', + fields: { + foo: { type: 'object', notCapturedReason: 'depth' } + } + } + } + } + } + } + } + }) + + expect(state.myNestedObj.fields).to.have.deep.property('deepArr', { + type: 'array', + elements: [ + { + type: 'array', + elements: [ + { + type: 'array', + elements: [ + { + type: 'array', + elements: [{ type: 'array', notCapturedReason: 'depth' }] + } + ] + } + ] + } + ] + }) + }) + + await setBreakpointOnLine(18, 5) + getNestedObj() + }) +}) + +async function setBreakpointOnLine (line, maxReferenceDepth = 2) { + const { breakpointId } = await session.post('Debugger.setBreakpoint', { + location: { + scriptId: await scriptId, + lineNumber: line - 1 // Beware! lineNumber is zero-indexed + } + }) + mockedState.breakpoints.set(breakpointId, { + capture: { + maxReferenceDepth + } + }) +}