Skip to content

Commit 65c4e4b

Browse files
committed
Add max concurrency when processing object props
1 parent 03b634f commit 65c4e4b

File tree

10 files changed

+205
-69
lines changed

10 files changed

+205
-69
lines changed

integration-tests/debugger/snapshot-time-budget.spec.js

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,86 @@
33
const assert = require('node:assert')
44
const { setup } = require('./utils')
55

6-
describe('Dynamic Instrumentation', function () {
7-
// Force a very small time budget in ms to trigger partial snapshots
8-
const t = setup({
9-
dependencies: ['fastify'],
10-
env: { DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS: '1' }
11-
})
6+
const tolerance = 5
127

8+
describe('Dynamic Instrumentation', function () {
139
describe('input messages', function () {
1410
describe('with snapshot under tight time budget', function () {
15-
beforeEach(t.triggerBreakpoint)
16-
17-
it('should include partial snapshot marked with notCapturedReason: timeout', function (done) {
18-
t.agent.on('debugger-input', ({ payload: [{ debugger: { snapshot: { captures } } }] }) => {
19-
const { locals } = captures.lines[t.breakpoint.line]
20-
assert.strictEqual(
21-
containsTimeBudget(locals),
22-
true,
23-
'expected at least one field/element to be marked with notCapturedReason: "timeout"'
24-
)
25-
done()
11+
context('1ms time budget', function () {
12+
// Force a very small time budget in ms to trigger partial snapshots
13+
const target = 1
14+
const t = setup({
15+
dependencies: ['fastify'],
16+
env: { DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS: String(target) }
2617
})
2718

28-
t.agent.addRemoteConfig(t.generateRemoteConfig({
29-
captureSnapshot: true,
30-
capture: { maxReferenceDepth: 5 }
31-
}))
19+
it(
20+
'should include partial snapshot marked with notCapturedReason: timeout',
21+
test({ t, maxPausedTime: target + tolerance, breakpointIndex: 0, maxReferenceDepth: 5 })
22+
)
23+
})
24+
25+
context('default time budget', function () {
26+
const target = 10 // default time budget in ms
27+
const t = setup({ dependencies: ['fastify'] })
28+
29+
// TODO: Make this pass
30+
// eslint-disable-next-line mocha/no-pending-tests
31+
it.skip(
32+
'should keep budget when state includes an object with 1 million properties',
33+
test({ t, maxPausedTime: target + tolerance, breakpointIndex: 1, maxReferenceDepth: 1 })
34+
)
35+
36+
// TODO: Make this pass
37+
// eslint-disable-next-line mocha/no-pending-tests
38+
it.skip(
39+
'should keep budget when state includes an array of 1 million primitives',
40+
test({ t, maxPausedTime: target + tolerance, breakpointIndex: 2, maxReferenceDepth: 1 })
41+
)
42+
43+
// TODO: Make this pass
44+
// eslint-disable-next-line mocha/no-pending-tests
45+
it.skip(
46+
'should keep budget when state includes an array of 1 million objects',
47+
test({ t, maxPausedTime: target + tolerance, breakpointIndex: 3, maxReferenceDepth: 1 })
48+
)
3249
})
3350
})
3451
})
3552
})
3653

54+
function test ({ t, maxPausedTime, breakpointIndex, maxReferenceDepth }) {
55+
const breakpoint = t.breakpoints[breakpointIndex]
56+
57+
return async function () {
58+
const payloadReceived = new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
59+
t.agent.on('debugger-input', ({ payload: [{ debugger: { snapshot: { captures } } }] }) => {
60+
const { locals } = captures.lines[breakpoint.line]
61+
assert.strictEqual(
62+
containsTimeBudget(locals),
63+
true,
64+
'expected at least one field/element to be marked with notCapturedReason: "timeout"'
65+
)
66+
resolve()
67+
})
68+
})
69+
70+
t.agent.addRemoteConfig(breakpoint.generateRemoteConfig({
71+
captureSnapshot: true,
72+
capture: { maxReferenceDepth }
73+
}))
74+
75+
const { data } = await breakpoint.triggerBreakpoint()
76+
77+
assert.ok(
78+
data.paused <= maxPausedTime,
79+
`expected thread to be paused <=${maxPausedTime}ms, but was paused for ~${data.paused}ms`
80+
)
81+
82+
await payloadReceived
83+
}
84+
}
85+
3786
function containsTimeBudget (node) {
3887
if (node == null || typeof node !== 'object') return false
3988
if (node.notCapturedReason === 'timeout') return true

integration-tests/debugger/snapshot.spec.js

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

212-
assert.deepStrictEqual(Object.keys(locals), [
212+
assert.deepStrictEqual(Object.keys(locals).sort(), [
213213
// Up to 3 properties from the closure scope
214214
'fastify', 'getUndefined',
215215
// Up to 3 properties from the local scope
216216
'request', 'nil', 'undef'
217-
])
217+
].sort())
218218

219219
assert.strictEqual(locals.request.type, 'Request')
220220
assert.strictEqual(Object.keys(locals.request.fields).length, maxFieldCount)

integration-tests/debugger/target-app/snapshot-time-budget.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,35 @@ const Fastify = require('fastify')
66

77
const fastify = Fastify({ logger: { level: 'error' } })
88

9-
fastify.get('/:name', function handler (request) {
9+
fastify.get('/deeply-nested-large-object', function handler () {
1010
// The size of `obj` generated is carefully tuned to never result in a snapshot larger than the 1MB size limit, while
1111
// still being large enough to trigger the time budget limit. However, the size of `fastify` and `request` is not
1212
// stable across Node.js and Fastify versions, so the generated object might need to be adjusted.
13-
const obj = generateObject(5, 6) // eslint-disable-line no-unused-vars
13+
const obj = generateObject(5, 12) // eslint-disable-line no-unused-vars
14+
const start = process.hrtime.bigint()
15+
const diff = process.hrtime.bigint() - start // BREAKPOINT: /deeply-nested-large-object
16+
return { paused: Number(diff) / 1_000_000 }
17+
})
18+
19+
fastify.get('/large-object-of-primitives', function handler () {
20+
const obj = generateObject(0, 1_000_000) // eslint-disable-line no-unused-vars
21+
const start = process.hrtime.bigint()
22+
const diff = process.hrtime.bigint() - start // BREAKPOINT: /large-object-of-primitives
23+
return { paused: Number(diff) / 1_000_000 }
24+
})
25+
26+
fastify.get('/large-array-of-primitives', function handler () {
27+
const arr = Array.from({ length: 1_000_000 }, (_, i) => i) // eslint-disable-line no-unused-vars
28+
const start = process.hrtime.bigint()
29+
const diff = process.hrtime.bigint() - start // BREAKPOINT: /large-array-of-primitives
30+
return { paused: Number(diff) / 1_000_000 }
31+
})
1432

15-
return { hello: request.params.name } // BREAKPOINT: /foo
33+
fastify.get('/large-array-of-objects', function handler () {
34+
const arr = Array.from({ length: 1_000_000 }, (_, i) => ({ i })) // eslint-disable-line no-unused-vars
35+
const start = process.hrtime.bigint()
36+
const diff = process.hrtime.bigint() - start // BREAKPOINT: /large-array-of-objects
37+
return { paused: Number(diff) / 1_000_000 }
1638
})
1739

1840
fastify.listen({ port: process.env.APP_PORT || 0 }, (err) => {

integration-tests/debugger/utils.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,16 @@ function setup ({ env, testApp, testAppSource, dependencies, silent, stdioHandle
5353
}
5454

5555
// Trigger the breakpoint once probe is successfully installed
56-
function triggerBreakpoint (url) {
56+
async function triggerBreakpoint (url) {
5757
let triggered = false
58-
t.agent.on('debugger-diagnostics', ({ payload }) => {
59-
payload.forEach((event) => {
60-
if (!triggered && event.debugger.diagnostics.status === 'INSTALLED') {
61-
triggered = true
62-
t.axios.get(url)
63-
}
58+
return new Promise((resolve, reject) => {
59+
t.agent.on('debugger-diagnostics', ({ payload }) => {
60+
payload.forEach((event) => {
61+
if (!triggered && event.debugger.diagnostics.status === 'INSTALLED') {
62+
triggered = true
63+
t.axios.get(url).then(resolve).catch(reject)
64+
}
65+
})
6466
})
6567
})
6668
}

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)