Skip to content

Commit b4b2969

Browse files
BridgeARwatson
andauthored
[Debugger] improve snapshot performance (#5419)
The sync code got a tad optimized (dense instead of sparse arrays and some other minor improvements). The async parts now also run in parallel. These changes do not make a big difference effectively due to the overhead of the inspector calls themselves. Co-authored-by: Thomas Watson <w@tson.dk>
1 parent 29485d9 commit b4b2969

8 files changed

Lines changed: 118 additions & 109 deletions

File tree

integration-tests/debugger/redact.spec.js

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,47 @@
22

33
const { assert } = require('chai')
44
const { setup } = require('./utils')
5+
const { once } = require('node:events')
56

67
// Default settings is tested in unit tests, so we only need to test the env vars here
78
describe('Dynamic Instrumentation snapshot PII redaction', function () {
89
describe('DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS=foo,bar', function () {
910
const t = setup({ env: { DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS: 'foo,bar' } })
1011

11-
it('should respect DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS', function (done) {
12+
it('should respect DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS', async function () {
1213
t.triggerBreakpoint()
1314

14-
t.agent.on('debugger-input', ({ payload: [{ 'debugger.snapshot': { captures } }] }) => {
15-
const { locals } = captures.lines[t.breakpoint.line]
15+
const promise = once(t.agent, 'debugger-input')
1616

17-
assert.deepPropertyVal(locals, 'foo', { type: 'string', notCapturedReason: 'redactedIdent' })
18-
assert.deepPropertyVal(locals, 'bar', { type: 'string', notCapturedReason: 'redactedIdent' })
19-
assert.deepPropertyVal(locals, 'baz', { type: 'string', value: 'c' })
17+
t.agent.addRemoteConfig(t.generateRemoteConfig({ captureSnapshot: true }))
2018

21-
// existing redaction should not be impacted
22-
assert.deepPropertyVal(locals, 'secret', { type: 'string', notCapturedReason: 'redactedIdent' })
19+
const [{ payload: [{ 'debugger.snapshot': { captures } }] }] = await promise
20+
const { locals } = captures.lines[t.breakpoint.line]
2321

24-
done()
25-
})
22+
assert.deepPropertyVal(locals, 'foo', { type: 'string', notCapturedReason: 'redactedIdent' })
23+
assert.deepPropertyVal(locals, 'bar', { type: 'string', notCapturedReason: 'redactedIdent' })
24+
assert.deepPropertyVal(locals, 'baz', { type: 'string', value: 'c' })
2625

27-
t.agent.addRemoteConfig(t.generateRemoteConfig({ captureSnapshot: true }))
26+
// existing redaction should not be impacted
27+
assert.deepPropertyVal(locals, 'secret', { type: 'string', notCapturedReason: 'redactedIdent' })
2828
})
2929
})
3030

3131
describe('DD_DYNAMIC_INSTRUMENTATION_REDACTION_EXCLUDED_IDENTIFIERS=secret', function () {
3232
const t = setup({ env: { DD_DYNAMIC_INSTRUMENTATION_REDACTION_EXCLUDED_IDENTIFIERS: 'secret' } })
3333

34-
it('should respect DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS', function (done) {
34+
it('should respect DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS', async function () {
3535
t.triggerBreakpoint()
3636

37-
t.agent.on('debugger-input', ({ payload: [{ 'debugger.snapshot': { captures } }] }) => {
38-
const { locals } = captures.lines[t.breakpoint.line]
37+
const promise = once(t.agent, 'debugger-input')
3938

40-
assert.deepPropertyVal(locals, 'secret', { type: 'string', value: 'shh!' })
41-
assert.deepPropertyVal(locals, 'password', { type: 'string', notCapturedReason: 'redactedIdent' })
39+
t.agent.addRemoteConfig(t.generateRemoteConfig({ captureSnapshot: true }))
4240

43-
done()
44-
})
41+
const [{ payload: [{ 'debugger.snapshot': { captures } }] }] = await promise
42+
const { locals } = captures.lines[t.breakpoint.line]
4543

46-
t.agent.addRemoteConfig(t.generateRemoteConfig({ captureSnapshot: true }))
44+
assert.deepPropertyVal(locals, 'secret', { type: 'string', value: 'shh!' })
45+
assert.deepPropertyVal(locals, 'password', { type: 'string', notCapturedReason: 'redactedIdent' })
4746
})
4847
})
4948
})

integration-tests/debugger/snapshot-global-sample-rate.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ describe('Dynamic Instrumentation', function () {
5959
const duration = timestamp - start
6060
const timeSincePrevTimestamp = timestamp - prevTimestamp
6161

62-
// Allow for a variance of +50ms (time will tell if this is enough)
63-
assert.isAtLeast(duration, 1000)
62+
// Allow for a time variance (time will tell if this is enough). Timeouts can vary.
63+
assert.isAtLeast(duration, 925)
6464
assert.isBelow(duration, 1050)
6565

6666
// A sanity check to make sure we're not saturating the event loop. We expect a lot of snapshots to be

integration-tests/debugger/snapshot.spec.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,11 @@ describe('Dynamic Instrumentation', function () {
208208
t.agent.on('debugger-input', ({ payload: [{ 'debugger.snapshot': { captures } }] }) => {
209209
const { locals } = captures.lines[t.breakpoint.line]
210210

211-
assert.deepEqual(Object.keys(locals), [
212-
// Up to 3 properties from the local scope
213-
'request', 'nil', 'undef',
211+
assert.deepStrictEqual(Object.keys(locals), [
214212
// Up to 3 properties from the closure scope
215-
'fastify', 'getUndefined'
213+
'fastify', 'getUndefined',
214+
// Up to 3 properties from the local scope
215+
'request', 'nil', 'undef'
216216
])
217217

218218
assert.strictEqual(locals.request.type, 'Request')

integration-tests/helpers/fake-agent.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ const upload = require('multer')()
1010

1111
module.exports = class FakeAgent extends EventEmitter {
1212
constructor (port = 0) {
13-
super()
13+
// Redirect rejections to the error event
14+
super({ captureRejections: true })
1415
this.port = port
1516
this.resetRemoteConfig()
1617
}

packages/dd-trace/src/debugger/devtools_client/index.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ session.on('Debugger.paused', async ({ params }) => {
3535

3636
let maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength
3737

38-
// V8 doesn't allow seting more than one breakpoint at a specific location, however, it's possible to set two
39-
// breakpoints just next to eachother that will "snap" to the same logical location, which in turn will be hit at the
38+
// V8 doesn't allow setting more than one breakpoint at a specific location, however, it's possible to set two
39+
// breakpoints just next to each other that will "snap" to the same logical location, which in turn will be hit at the
4040
// same time. E.g. index.js:1:1 and index.js:1:2.
4141
// TODO: Investigate if it will improve performance to create a fast-path for when there's only a single breakpoint
4242
let sampled = false
4343
const length = params.hitBreakpoints.length
44-
let probes = new Array(length)
44+
const probes = []
4545
// TODO: Consider reusing this array between pauses and only recreating it if it needs to grow
4646
const snapshotProbeIndex = new Uint8Array(length) // TODO: Is a limit of 256 probes ever going to be a problem?
4747
let numberOfProbesWithSnapshots = 0
@@ -65,7 +65,7 @@ session.on('Debugger.paused', async ({ params }) => {
6565
snapshotsSampledWithinTheLastSecond++
6666
}
6767

68-
snapshotProbeIndex[numberOfProbesWithSnapshots++] = i
68+
snapshotProbeIndex[numberOfProbesWithSnapshots++] = probes.length
6969
maxReferenceDepth = highestOrUndefined(probe.capture.maxReferenceDepth, maxReferenceDepth)
7070
maxCollectionSize = highestOrUndefined(probe.capture.maxCollectionSize, maxCollectionSize)
7171
maxFieldCount = highestOrUndefined(probe.capture.maxFieldCount, maxFieldCount)
@@ -75,7 +75,7 @@ session.on('Debugger.paused', async ({ params }) => {
7575
sampled = true
7676
probe.lastCaptureNs = start
7777

78-
probes[i] = probe
78+
probes.push(probe)
7979
}
8080

8181
if (sampled === false) {
@@ -108,9 +108,6 @@ session.on('Debugger.paused', async ({ params }) => {
108108
Number(diff) / 1000000
109109
)
110110

111-
// Due to the highly optimized algorithm above, the `probes` array might have gaps
112-
probes = probes.filter((probe) => !!probe)
113-
114111
const logger = {
115112
// We can safely use `location.file` from the first probe in the array, since all probes hit by `hitBreakpoints`
116113
// must exist in the same file since the debugger can only pause the main thread in one location.

packages/dd-trace/src/debugger/devtools_client/snapshot/collector.js

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ module.exports = {
1010
getRuntimeObject: getObject
1111
}
1212

13-
// TODO: Can we speed up thread pause time by calling mutiple Runtime.getProperties in parallel when possible?
14-
// The most simple solution would be to swich from an async/await approach to a callback based approach, in which case
15-
// each lookup will just finish in its own time and traverse the child nodes when the event loop allows it.
16-
// Alternatively, use `Promise.all` or something like that, but the code would probably be more complex.
17-
1813
async function getObject (objectId, opts, depth = 0, collection = false) {
1914
const { result, privateProperties } = await session.post('Runtime.getProperties', {
2015
objectId,
@@ -27,13 +22,13 @@ async function getObject (objectId, opts, depth = 0, collection = false) {
2722
removeNonEnumerableProperties(result) // remove the `length` property
2823
const size = result.length
2924
if (size > opts.maxCollectionSize) {
30-
result.splice(opts.maxCollectionSize)
25+
result.length = opts.maxCollectionSize
3126
result[collectionSizeSym] = size
3227
}
3328
} else if (result.length > opts.maxFieldCount) {
3429
// Trim the number of properties on the object if there's too many.
3530
const size = result.length
36-
result.splice(opts.maxFieldCount)
31+
result.length = opts.maxFieldCount
3732
result[fieldCountSym] = size
3833
} else if (privateProperties) {
3934
result.push(...privateProperties)
@@ -48,18 +43,28 @@ async function traverseGetPropertiesResult (props, opts, depth) {
4843

4944
if (depth >= opts.maxReferenceDepth) return props
5045

46+
const promises = []
47+
5148
for (const prop of props) {
5249
if (prop.value === undefined) continue
5350
const { value: { type, objectId, subtype } } = prop
5451
if (type === 'object') {
5552
if (objectId === undefined) continue // if `subtype` is "null"
5653
if (LEAF_SUBTYPES.has(subtype)) continue // don't waste time with these subtypes
57-
prop.value.properties = await getObjectProperties(subtype, objectId, opts, depth)
54+
promises.push(getObjectProperties(subtype, objectId, opts, depth).then((properties) => {
55+
prop.value.properties = properties
56+
}))
5857
} else if (type === 'function') {
59-
prop.value.properties = await getFunctionProperties(objectId, opts, depth + 1)
58+
promises.push(getFunctionProperties(objectId, opts, depth + 1).then((properties) => {
59+
prop.value.properties = properties
60+
}))
6061
}
6162
}
6263

64+
if (promises.length) {
65+
await Promise.all(promises)
66+
}
67+
6368
return props
6469
}
6570

@@ -115,7 +120,7 @@ async function getIterable (objectId, opts, depth) {
115120
removeNonEnumerableProperties(result) // remove the `length` property
116121
const size = result.length
117122
if (size > opts.maxCollectionSize) {
118-
result.splice(opts.maxCollectionSize)
123+
result.length = opts.maxCollectionSize
119124
result[collectionSizeSym] = size
120125
}
121126

packages/dd-trace/src/debugger/devtools_client/snapshot/index.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ async function getLocalStateForCallFrame (
2424
const rawState = []
2525
let processedState = null
2626

27-
for (const scope of callFrame.scopeChain) {
28-
if (scope.type === 'global') continue // The global scope is too noisy
27+
await Promise.all(callFrame.scopeChain.map(async (scope) => {
28+
if (scope.type === 'global') return // The global scope is too noisy
2929
rawState.push(...await getRuntimeObject(
3030
scope.object.objectId,
3131
{ maxReferenceDepth, maxCollectionSize, maxFieldCount }
3232
))
33-
}
33+
}))
3434

3535
// Deplay calling `processRawState` so the caller gets a chance to resume the main thread before processing `rawState`
3636
return () => {

0 commit comments

Comments
 (0)