Skip to content

Commit 4f232ba

Browse files
committed
Add max concurrency when processing object props
1 parent d442c44 commit 4f232ba

File tree

6 files changed

+99
-36
lines changed

6 files changed

+99
-36
lines changed

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,13 @@ session.on('Debugger.paused', async ({ params }) => {
167167
// TODO: Create unique states for each affected probe based on that probes unique `capture` settings (DEBUG-2863)
168168
const processLocalState = numberOfProbesWithSnapshots !== 0 && await getLocalStateForCallFrame(
169169
params.callFrames[0],
170-
start + BigInt(config.dynamicInstrumentation.captureTimeoutMs ?? 10) * 1_000_000n,
171-
{ maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength }
170+
{
171+
maxReferenceDepth,
172+
maxCollectionSize,
173+
maxFieldCount,
174+
maxLength,
175+
deadlineNs: start + BigInt(config.dynamicInstrumentation.captureTimeoutMs ?? 10) * 1_000_000n
176+
}
172177
)
173178

174179
await session.post('Debugger.resume')

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

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ module.exports = {
1010
getRuntimeObject: getObject // TODO: Called once per stack frame, but doesn't retain the `deadlineReached` flag.
1111
}
1212

13+
/**
14+
* @typedef {Object} GetObjectOptions
15+
* @property {Object} maxReferenceDepth - The maximum depth of the object to traverse
16+
* @property {number} maxCollectionSize - The maximum size of a collection to include in the snapshot
17+
* @property {number} maxFieldCount - The maximum number of properties on an object to include in the snapshot
18+
* @property {bigint} deadlineNs - The deadline in nanoseconds compared to `process.hrtime.bigint()`
19+
* @property {boolean} [deadlineReached=false] - Whether the deadline has been reached. Should not be set by the
20+
* caller, but is used to signal the deadline overrun to the caller.
21+
*/
22+
23+
/**
24+
* Get the properties of an object using the Chrome DevTools Protocol.
25+
*
26+
* @param {string} objectId - The ID of the object to get the properties of
27+
* @param {GetObjectOptions} opts - The options for the snapshot. Also used to track the deadline and communicate the
28+
* deadline overrun to the caller using the `deadlineReached` flag.
29+
* @param {number} [depth=0] - The depth of the object. Only used internally by this module to track the current depth
30+
* and should not be set by the caller.
31+
* @param {boolean} [collection=false] - Whether the object is a collection. Only used internally by this module to
32+
* track the current object type and should not be set by the caller.
33+
* @returns {Promise<Object[]>} The properties of the object
34+
*/
1335
async function getObject (objectId, opts, depth = 0, collection = false) {
1436
const { result, privateProperties } = await session.post('Runtime.getProperties', {
1537
objectId,
@@ -43,30 +65,48 @@ async function traverseGetPropertiesResult (props, opts, depth) {
4365

4466
if (depth >= opts.maxReferenceDepth) return props
4567

46-
const promises = []
68+
const work = []
4769

4870
for (const prop of props) {
4971
if (prop.value === undefined) continue
50-
if (overBudget(opts)) {
51-
prop.value[timeBudgetSym] = true
52-
continue
53-
}
5472
const { value: { type, objectId, subtype } } = prop
5573
if (type === 'object') {
5674
if (objectId === undefined) continue // if `subtype` is "null"
5775
if (LEAF_SUBTYPES.has(subtype)) continue // don't waste time with these subtypes
58-
promises.push(getObjectProperties(subtype, objectId, opts, depth).then((properties) => {
59-
prop.value.properties = properties
60-
}))
76+
work.push([
77+
prop.value,
78+
() => getObjectProperties(subtype, objectId, opts, depth).then((properties) => {
79+
prop.value.properties = properties
80+
})
81+
])
6182
} else if (type === 'function') {
62-
promises.push(getFunctionProperties(objectId, opts, depth + 1).then((properties) => {
63-
prop.value.properties = properties
64-
}))
83+
work.push([
84+
prop.value,
85+
() => getFunctionProperties(objectId, opts, depth + 1).then((properties) => {
86+
prop.value.properties = properties
87+
})
88+
])
6589
}
6690
}
6791

68-
if (promises.length) {
69-
await Promise.all(promises)
92+
if (work.length) {
93+
// Iterate over the work in chunks of 2. The closer to 1, the less we'll overshoot the deadline, but the longer it
94+
// takes to complete. `2` seems to be the best compromise.
95+
// Anecdotally, on my machine, with no deadline, a concurrency of `1` takes twice as long as a concurrency of `2`.
96+
// From thereon, there's no real measureable savings with a higher concurrency.
97+
for (let i = 0; i < work.length; i += 2) {
98+
if (overBudget(opts)) {
99+
for (let j = i; j < work.length; j++) {
100+
work[j][0][timeBudgetSym] = true
101+
}
102+
break
103+
}
104+
// eslint-disable-next-line no-await-in-loop
105+
await Promise.all([
106+
work[i][1](),
107+
work[i + 1]?.[1]()
108+
])
109+
}
70110
}
71111

72112
return props

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

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const { getRuntimeObject } = require('./collector')
44
const { processRawState } = require('./processor')
55
const log = require('../log')
66

7+
const BIGINT_MAX = (1n << 256n) - 1n
8+
79
const DEFAULT_MAX_REFERENCE_DEPTH = 3
810
const DEFAULT_MAX_COLLECTION_SIZE = 100
911
const DEFAULT_MAX_FIELD_COUNT = 20
@@ -17,27 +19,44 @@ function returnError () {
1719
return new Error('Error getting local state')
1820
}
1921

20-
async function getLocalStateForCallFrame (
21-
callFrame,
22-
deadlineNs,
23-
{
24-
maxReferenceDepth = DEFAULT_MAX_REFERENCE_DEPTH,
25-
maxCollectionSize = DEFAULT_MAX_COLLECTION_SIZE,
26-
maxFieldCount = DEFAULT_MAX_FIELD_COUNT,
27-
maxLength = DEFAULT_MAX_LENGTH
28-
} = {}
29-
) {
22+
/**
23+
* @typedef {Object} GetLocalStateForCallFrameOptions
24+
* @property {number} [maxReferenceDepth=DEFAULT_MAX_REFERENCE_DEPTH] - The maximum depth of the object to traverse
25+
* @property {number} [maxCollectionSize=DEFAULT_MAX_COLLECTION_SIZE] - The maximum size of a collection to include
26+
* in the snapshot
27+
* @property {number} [maxFieldCount=DEFAULT_MAX_FIELD_COUNT] - The maximum number of properties on an object to
28+
* include in the snapshot
29+
* @property {number} [maxLength=DEFAULT_MAX_LENGTH] - The maximum length of a string to include in the snapshot
30+
* @property {bigint} [deadlineNs=BIGINT_MAX] - The deadline in nanoseconds compared to
31+
* `process.hrtime.bigint()`. If the deadline is reached, the snapshot will be truncated.
32+
* @property {boolean} [deadlineReached=false] - Whether the deadline has been reached. Should not be set by the
33+
* caller, but is used to signal the deadline overrun to the caller.
34+
*/
35+
36+
/**
37+
* Get the local state for a call frame.
38+
*
39+
* @param {import('inspector').Debugger.CallFrame} callFrame - The call frame to get the local state for
40+
* @param {GetLocalStateForCallFrameOptions} [opts={}] - The options for the snapshot
41+
* @returns {Promise<Object>} The local state for the call frame
42+
*/
43+
async function getLocalStateForCallFrame (callFrame, opts = {}) {
44+
opts.maxReferenceDepth ??= DEFAULT_MAX_REFERENCE_DEPTH
45+
opts.maxCollectionSize ??= DEFAULT_MAX_COLLECTION_SIZE
46+
opts.maxFieldCount ??= DEFAULT_MAX_FIELD_COUNT
47+
opts.maxLength ??= DEFAULT_MAX_LENGTH
48+
opts.deadlineNs ??= BIGINT_MAX
49+
opts.deadlineReached ??= false
3050
const rawState = []
3151
let processedState = null
3252

3353
try {
34-
await Promise.all(callFrame.scopeChain.map(async (scope) => {
35-
if (scope.type === 'global') return // The global scope is too noisy
36-
rawState.push(...await getRuntimeObject(
37-
scope.object.objectId,
38-
{ maxReferenceDepth, maxCollectionSize, maxFieldCount, deadlineNs }
39-
))
40-
}))
54+
for (const scope of callFrame.scopeChain) {
55+
if (opts.deadlineReached === true) break // TODO: Variables in scope are silently dropped: Not the best UX
56+
if (scope.type === 'global') continue // The global scope is too noisy
57+
// eslint-disable-next-line no-await-in-loop
58+
rawState.push(...await getRuntimeObject(scope.object.objectId, opts))
59+
}
4160
} catch (err) {
4261
// TODO: We might be able to get part of the scope chain.
4362
// Consider if we could set errors just for the part of the scope chain that throws during collection.
@@ -47,7 +66,7 @@ async function getLocalStateForCallFrame (
4766

4867
// Delay calling `processRawState` so the caller gets a chance to resume the main thread before processing `rawState`
4968
return () => {
50-
processedState = processedState ?? processRawState(rawState, maxLength)
69+
processedState = processedState ?? processRawState(rawState, opts.maxLength)
5170
return processedState
5271
}
5372
}

packages/dd-trace/test/debugger/devtools_client/snapshot/complex-types.spec.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu
3434

3535
resolve((await getLocalStateForCallFrame(
3636
params.callFrames[0],
37-
Infinity,
3837
{ maxFieldCount: Number.MAX_SAFE_INTEGER })
3938
)())
4039
})

packages/dd-trace/test/debugger/devtools_client/snapshot/error-handling.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu
1010
describe('error handling', function () {
1111
it('should generate a notCapturedReason if an error is thrown during inital collection', async function () {
1212
const invalidCallFrameThatTriggersAnException = {}
13-
const processLocalState = await getLocalStateForCallFrame(invalidCallFrameThatTriggersAnException, Infinity)
13+
const processLocalState = await getLocalStateForCallFrame(invalidCallFrameThatTriggersAnException)
1414
const result = processLocalState()
1515
assert.ok(result instanceof Error)
1616
assert.strictEqual(result.message, 'Error getting local state')

packages/dd-trace/test/debugger/devtools_client/snapshot/utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ function assertOnBreakpoint (done, snapshotConfig, callback) {
9999
session.once('Debugger.paused', ({ params }) => {
100100
assert.strictEqual(params.hitBreakpoints.length, 1)
101101

102-
getLocalStateForCallFrame(params.callFrames[0], Infinity, snapshotConfig).then((process) => {
102+
getLocalStateForCallFrame(params.callFrames[0], snapshotConfig).then((process) => {
103103
callback(process())
104104
done()
105105
}).catch(done)

0 commit comments

Comments
 (0)