Skip to content

Commit 03362e2

Browse files
committed
feat(debugger): add snapshot time budget
Enforce a per-snapshot time budget. By default this budget is 10ms, but can be modified by the experimental config, either as an environment variable: DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS=20 Or programatically: tracer.init({ dynamicInstrumentation: { captureTimeoutMs: 20 } }) When the budget is exceeded, remaining values that are not already resolved, are marked with `notCapturedReason: 'timeout'`.
1 parent 81a2bc0 commit 03362e2

File tree

15 files changed

+348
-29
lines changed

15 files changed

+348
-29
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use strict'
2+
3+
const assert = require('node:assert')
4+
const { setup } = require('./utils')
5+
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+
})
12+
13+
describe('input messages', function () {
14+
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()
26+
})
27+
28+
t.agent.addRemoteConfig(t.generateRemoteConfig({
29+
captureSnapshot: true,
30+
capture: { maxReferenceDepth: 5 }
31+
}))
32+
})
33+
})
34+
})
35+
})
36+
37+
function containsTimeBudget (node) {
38+
if (node == null || typeof node !== 'object') return false
39+
if (node.notCapturedReason === 'timeout') return true
40+
for (const value of Object.values(node)) {
41+
if (containsTimeBudget(value)) return true
42+
}
43+
return false
44+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use strict'
2+
3+
require('dd-trace/init')
4+
5+
const Fastify = require('fastify')
6+
7+
const fastify = Fastify({ logger: { level: 'error' } })
8+
9+
fastify.get('/:name', function handler (request) {
10+
// The size of `obj` generated is carefully tuned to never result in a snapshot larger than the 1MB size limit, while
11+
// still being large enough to trigger the time budget limit. However, the size of `fastify` and `request` is not
12+
// 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
14+
15+
return { hello: request.params.name } // BREAKPOINT: /foo
16+
})
17+
18+
fastify.listen({ port: process.env.APP_PORT || 0 }, (err) => {
19+
if (err) {
20+
fastify.log.error(err)
21+
process.exit(1)
22+
}
23+
process.send?.({ port: fastify.server.address().port })
24+
})
25+
26+
const leafValues = [null, undefined, true, 1, '']
27+
const complexTypes = ['object', 'array', 'map', 'set']
28+
29+
/**
30+
* Generate a complex nested object that requires a lot of async CDP calls to traverse
31+
*/
32+
function generateObject (depth, breath) {
33+
const obj = {}
34+
for (let i = 0; i < breath; i++) {
35+
const key = `p${i}`
36+
if (depth === 0) {
37+
obj[key] = leafValues[i % leafValues.length]
38+
} else {
39+
const type = complexTypes[i % complexTypes.length]
40+
obj[key] = generateType(type, depth - 1, breath)
41+
}
42+
}
43+
return obj
44+
}
45+
46+
function generateArray (depth, breath) {
47+
const arr = []
48+
for (let i = 0; i < breath; i++) {
49+
if (depth === 0) {
50+
arr.push(leafValues[i % leafValues.length])
51+
} else {
52+
const type = complexTypes[i % complexTypes.length]
53+
arr.push(generateType(type, depth - 1, breath))
54+
}
55+
}
56+
return arr
57+
}
58+
59+
function generateMap (depth, breath) {
60+
const map = new Map()
61+
for (let i = 0; i < breath; i++) {
62+
if (depth === 0) {
63+
map.set(i, leafValues[i % leafValues.length])
64+
} else {
65+
const type = complexTypes[i % complexTypes.length]
66+
map.set(i, generateType(type, depth - 1, breath))
67+
}
68+
}
69+
return map
70+
}
71+
72+
function generateSet (depth, breath) {
73+
const set = new Set()
74+
for (let i = 0; i < breath; i++) {
75+
if (depth === 0) {
76+
set.add(leafValues[i % leafValues.length])
77+
} else {
78+
const type = complexTypes[i % complexTypes.length]
79+
set.add(generateType(type, depth - 1, breath))
80+
}
81+
}
82+
return set
83+
}
84+
85+
function generateType (type, depth, breath) {
86+
switch (type) {
87+
case 'object':
88+
return generateObject(depth, breath)
89+
case 'array':
90+
return generateArray(depth, breath)
91+
case 'map':
92+
return generateMap(depth, breath)
93+
case 'set':
94+
return generateSet(depth, breath)
95+
}
96+
}

packages/dd-trace/src/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ class Config {
445445
DD_DBM_PROPAGATION_MODE,
446446
DD_DOGSTATSD_HOST,
447447
DD_DOGSTATSD_PORT,
448+
DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS,
448449
DD_DYNAMIC_INSTRUMENTATION_ENABLED,
449450
DD_DYNAMIC_INSTRUMENTATION_PROBE_FILE,
450451
DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS,
@@ -683,6 +684,7 @@ class Config {
683684
this.#setString(target, 'dogstatsd.hostname', DD_DOGSTATSD_HOST)
684685
this.#setString(target, 'dogstatsd.port', DD_DOGSTATSD_PORT)
685686
this.#setBoolean(target, 'dsmEnabled', DD_DATA_STREAMS_ENABLED)
687+
target['dynamicInstrumentation.captureTimeoutMs'] = maybeFloat(DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS)
686688
this.#setBoolean(target, 'dynamicInstrumentation.enabled', DD_DYNAMIC_INSTRUMENTATION_ENABLED)
687689
this.#setString(target, 'dynamicInstrumentation.probeFile', DD_DYNAMIC_INSTRUMENTATION_PROBE_FILE)
688690
this.#setArray(target, 'dynamicInstrumentation.redactedIdentifiers',
@@ -983,6 +985,8 @@ class Config {
983985
this.#setString(opts, 'dogstatsd.port', options.dogstatsd.port)
984986
}
985987
this.#setBoolean(opts, 'dsmEnabled', options.dsmEnabled)
988+
opts['dynamicInstrumentation.captureTimeoutMs'] = maybeFloat(options.dynamicInstrumentation?.captureTimeoutMs)
989+
this.#optsUnprocessed['dynamicInstrumentation.captureTimeoutMs'] = options.dynamicInstrumentation?.captureTimeoutMs
986990
this.#setBoolean(opts, 'dynamicInstrumentation.enabled', options.dynamicInstrumentation?.enabled)
987991
this.#setString(opts, 'dynamicInstrumentation.probeFile', options.dynamicInstrumentation?.probeFile)
988992
this.#setArray(

packages/dd-trace/src/config_defaults.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ module.exports = {
6363
'dogstatsd.hostname': '127.0.0.1',
6464
'dogstatsd.port': '8125',
6565
dsmEnabled: false,
66+
'dynamicInstrumentation.captureTimeoutMs': 10,
6667
'dynamicInstrumentation.enabled': false,
6768
'dynamicInstrumentation.probeFile': undefined,
6869
'dynamicInstrumentation.redactedIdentifiers': [],

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { getLocalStateForCallFrame } = require('./snapshot')
77
const send = require('./send')
88
const { getStackFromCallFrames } = require('./state')
99
const { ackEmitting } = require('./status')
10-
const { parentThreadId } = require('./config')
10+
const config = require('./config')
1111
const { MAX_SNAPSHOTS_PER_SECOND_GLOBALLY } = require('./defaults')
1212
const log = require('./log')
1313
const { version } = require('../../../../../package.json')
@@ -33,8 +33,8 @@ const getDDTagsExpression = `(() => {
3333

3434
// There doesn't seem to be an official standard for the content of these fields, so we're just populating them with
3535
// something that should be useful to a Node.js developer.
36-
const threadId = parentThreadId === 0 ? `pid:${process.pid}` : `pid:${process.pid};tid:${parentThreadId}`
37-
const threadName = parentThreadId === 0 ? 'MainThread' : `WorkerThread:${parentThreadId}`
36+
const threadId = config.parentThreadId === 0 ? `pid:${process.pid}` : `pid:${process.pid};tid:${config.parentThreadId}`
37+
const threadName = config.parentThreadId === 0 ? 'MainThread' : `WorkerThread:${config.parentThreadId}`
3838

3939
const SUPPORT_ARRAY_BUFFER_RESIZE = NODE_MAJOR >= 20
4040
const oneSecondNs = 1_000_000_000n
@@ -162,6 +162,7 @@ session.on('Debugger.paused', async ({ params }) => {
162162
// TODO: Create unique states for each affected probe based on that probes unique `capture` settings (DEBUG-2863)
163163
const processLocalState = numberOfProbesWithSnapshots !== 0 && await getLocalStateForCallFrame(
164164
params.callFrames[0],
165+
start + BigInt(config.dynamicInstrumentation.captureTimeoutMs ?? 10) * 1_000_000n,
165166
{ maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength }
166167
)
167168

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
'use strict'
22

3-
const { collectionSizeSym, fieldCountSym } = require('./symbols')
3+
const { collectionSizeSym, fieldCountSym, timeBudgetSym } = require('./symbols')
44
const session = require('../session')
55

66
const LEAF_SUBTYPES = new Set(['date', 'regexp'])
77
const ITERABLE_SUBTYPES = new Set(['map', 'set', 'weakmap', 'weakset'])
88

99
module.exports = {
10-
getRuntimeObject: getObject
10+
getRuntimeObject: getObject // TODO: Called once per stack frame, but doesn't retain the `deadlineReached` flag.
1111
}
1212

1313
async function getObject (objectId, opts, depth = 0, collection = false) {
@@ -47,6 +47,10 @@ async function traverseGetPropertiesResult (props, opts, depth) {
4747

4848
for (const prop of props) {
4949
if (prop.value === undefined) continue
50+
if (overBudget(opts)) {
51+
prop.value[timeBudgetSym] = true
52+
continue
53+
}
5054
const { value: { type, objectId, subtype } } = prop
5155
if (type === 'object') {
5256
if (objectId === undefined) continue // if `subtype` is "null"
@@ -189,3 +193,9 @@ function removeNonEnumerableProperties (props) {
189193
}
190194
}
191195
}
196+
197+
function overBudget (opts) {
198+
if (opts.deadlineReached) return true
199+
opts.deadlineReached = process.hrtime.bigint() >= opts.deadlineNs
200+
return opts.deadlineReached
201+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ function returnError () {
1919

2020
async function getLocalStateForCallFrame (
2121
callFrame,
22+
deadlineNs,
2223
{
2324
maxReferenceDepth = DEFAULT_MAX_REFERENCE_DEPTH,
2425
maxCollectionSize = DEFAULT_MAX_COLLECTION_SIZE,
@@ -34,7 +35,7 @@ async function getLocalStateForCallFrame (
3435
if (scope.type === 'global') return // The global scope is too noisy
3536
rawState.push(...await getRuntimeObject(
3637
scope.object.objectId,
37-
{ maxReferenceDepth, maxCollectionSize, maxFieldCount }
38+
{ maxReferenceDepth, maxCollectionSize, maxFieldCount, deadlineNs }
3839
))
3940
}))
4041
} catch (err) {

0 commit comments

Comments
 (0)