Skip to content

Commit 5de84c5

Browse files
watsonpabloerhard
authored andcommitted
perf(debugger): sample probes in breakpoint conditions (#8967)
Move probe sampling into generated breakpoint conditions so unsampled hits can avoid pausing the application. The devtools worker now reads sampled probe indexes from a shared buffer instead of evaluating conditions and sampling after every pause. Install a runtime sampler in the debuggee from the debugger bootstrap, hand the shared buffer to the worker, and uninstall it on cleanup so the sampler survives debugger stop/start. Track probe indexes in worker state and clear per-probe sampler state when a probe is removed. This keeps per-probe sampling, user conditions, and the global snapshot sampling limit in the condition path. Rework the sirun debugger benchmark to exercise the new condition path: add a second breakpoint target, make the startup-guard share and operation counts configurable, drop the install-only variant, and honor per-variant iteration counts in the overview collector. Add focused coverage for sampler expressions, breakpoint condition rebuilding, pause handling, overflow handling, stop/start, and stale sampled probe indexes.
1 parent 925bde9 commit 5de84c5

14 files changed

Lines changed: 1039 additions & 166 deletions

File tree

benchmark/sirun/collect-overview.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ for (const name of benches) {
101101
try { fs.unlinkSync(SG_FILE) } catch {}
102102

103103
const variantCfg = meta.variants?.[variant] || {}
104+
const variantIters = variantCfg.iterations || configIters
104105
const inner = innerCount(variantCfg.env)
105106
let perIterMs = '-'
106107
let stddevPct = '-'
@@ -120,7 +121,7 @@ for (const name of benches) {
120121
const { mean, stddevPct: sd } = stats(times)
121122
perIterMs = (mean / 1000).toFixed(1)
122123
stddevPct = sd.toFixed(1)
123-
totalS = ((mean / 1e6) * configIters).toFixed(0)
124+
totalS = ((mean / 1e6) * variantIters).toFixed(0)
124125
}
125126
} catch { perIterMs = 'parse-err' }
126127
} else {
@@ -131,7 +132,7 @@ for (const name of benches) {
131132

132133
fs.appendFileSync(OUT,
133134
`| ${name} | ${variant} | ${categoryOf(name)} | ${meaningOf(name)} | ${inner} | ` +
134-
`${configIters} | ${perIterMs} | ${stddevPct} | ${totalS} | ${startupPct} |\n`)
135+
`${variantIters} | ${perIterMs} | ${stddevPct} | ${totalS} | ${startupPct} |\n`)
135136
console.log(`${name}/${variant} ${perIterMs}ms sd=${stddevPct}% ${totalS}s start=${startupPct}%`)
136137
}
137138

benchmark/sirun/debugger/app.js

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

3-
// WARNING: the breakpoint target below is referenced by line number from
3+
// WARNING: the breakpoint targets below are referenced by line number from
44
// meta.json (BREAKPOINT_LINE). Update the BREAKPOINT_LINE values there if you
5-
// move the `data.n = n` line.
5+
// move the `data.n = n` line or the unreachable `return n` line.
66

77
const guard = require('../startup-guard')
88

99
const OPERATIONS = Number(process.env.OPERATIONS)
10+
const STARTUP_GUARD_MAX_SHARE = Number(process.env.STARTUP_GUARD_MAX_SHARE)
1011

1112
if (process.env.DD_DYNAMIC_INSTRUMENTATION_ENABLED === 'true') {
1213
// The devtools worker and its ports are unref'd, so nothing holds the event
@@ -16,9 +17,7 @@ if (process.env.DD_DYNAMIC_INSTRUMENTATION_ENABLED === 'true') {
1617
const keepAlive = setInterval(() => {}, 2 ** 31 - 1)
1718
require('./start-devtools-client')(() => {
1819
clearInterval(keepAlive)
19-
// The not-hit variant only measures the passive cost of installing a probe,
20-
// so it exits here instead of running the guarded work loop.
21-
if (process.env.INSTALL_ONLY !== 'true') runWork()
20+
runWork()
2221
})
2322
} else {
2423
runWork()
@@ -29,12 +28,15 @@ function runWork () {
2928
for (let i = 0; i < OPERATIONS; i++) {
3029
doSomeWork(i)
3130
}
32-
guard.done(0.35)
31+
guard.done(STARTUP_GUARD_MAX_SHARE)
3332
}
3433

3534
function doSomeWork (n) {
3635
const data = getSomeData()
37-
data.n = n
36+
data.n = n // BREAKPOINT HERE!
37+
if (n < 0) {
38+
return n // BREAKPOINT HERE!
39+
}
3840
return data.n
3941
}
4042

benchmark/sirun/debugger/meta.json

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "debugger",
33
"cachegrind": false,
4-
"iterations": 10,
4+
"iterations": 5,
55
"instructions": true,
66
"variants": {
77
"enabled-but-breakpoint-not-hit": {
@@ -10,10 +10,11 @@
1010
"run": "node app.js",
1111
"run_with_affinity": "bash -c \"taskset -c $CPU_AFFINITY node app.js\"",
1212
"env": {
13+
"STARTUP_GUARD_MAX_SHARE": "0.1",
14+
"OPERATIONS": "220000",
1315
"DD_DYNAMIC_INSTRUMENTATION_ENABLED": "true",
14-
"INSTALL_ONLY": "true",
1516
"BREAKPOINT_FILE": "app.js",
16-
"BREAKPOINT_LINE": "37"
17+
"BREAKPOINT_LINE": "38"
1718
}
1819
},
1920
"line-probe-without-snapshot": {
@@ -22,44 +23,45 @@
2223
"run": "node app.js",
2324
"run_with_affinity": "bash -c \"taskset -c $CPU_AFFINITY node app.js\"",
2425
"env": {
26+
"STARTUP_GUARD_MAX_SHARE": "0.1",
27+
"OPERATIONS": "32000",
2528
"DD_DYNAMIC_INSTRUMENTATION_ENABLED": "true",
2629
"BREAKPOINT_FILE": "app.js",
27-
"BREAKPOINT_LINE": "37",
28-
"OPERATIONS": "3500"
30+
"BREAKPOINT_LINE": "36"
2931
}
3032
},
3133
"line-probe-with-snapshot-default": {
3234
"setup": "bash -c \"nohup node agent.js >/dev/null 2>&1 &\"",
3335
"setup_with_affinity": "bash -c \"nohup taskset -c $CPU_AFFINITY node agent.js >/dev/null 2>&1 &\"",
3436
"run": "node app.js",
3537
"run_with_affinity": "bash -c \"taskset -c $CPU_AFFINITY node app.js\"",
36-
"iterations": 5,
3738
"env": {
39+
"STARTUP_GUARD_MAX_SHARE": "0.1",
40+
"OPERATIONS": "32000",
3841
"DD_DYNAMIC_INSTRUMENTATION_ENABLED": "true",
3942
"BREAKPOINT_FILE": "app.js",
40-
"BREAKPOINT_LINE": "37",
43+
"BREAKPOINT_LINE": "36",
4144
"CAPTURE_SNAPSHOT": "true",
42-
"MAX_SNAPSHOTS_PER_SECOND_GLOBALLY": "1000000",
43-
"OPERATIONS": "2450"
45+
"MAX_SNAPSHOTS_PER_SECOND_GLOBALLY": "1000000"
4446
}
4547
},
4648
"line-probe-with-snapshot-minimal": {
4749
"setup": "bash -c \"nohup node agent.js >/dev/null 2>&1 &\"",
4850
"setup_with_affinity": "bash -c \"nohup taskset -c $CPU_AFFINITY node agent.js >/dev/null 2>&1 &\"",
4951
"run": "node app.js",
5052
"run_with_affinity": "bash -c \"taskset -c $CPU_AFFINITY node app.js\"",
51-
"iterations": 7,
5253
"env": {
54+
"STARTUP_GUARD_MAX_SHARE": "0.1",
55+
"OPERATIONS": "32000",
5356
"DD_DYNAMIC_INSTRUMENTATION_ENABLED": "true",
5457
"BREAKPOINT_FILE": "app.js",
55-
"BREAKPOINT_LINE": "37",
58+
"BREAKPOINT_LINE": "36",
5659
"CAPTURE_SNAPSHOT": "true",
5760
"MAX_SNAPSHOTS_PER_SECOND_GLOBALLY": "1000000",
5861
"MAX_REFERENCE_DEPTH": "0",
5962
"MAX_COLLECTION_SIZE": "0",
6063
"MAX_FIELD_COUNT": "0",
61-
"MAX_LENGTH": "9007199254740991",
62-
"OPERATIONS": "3500"
64+
"MAX_LENGTH": "9007199254740991"
6365
}
6466
}
6567
}

integration-tests/debugger/sampling.spec.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ describe('Dynamic Instrumentation', function () {
2828
const duration = timestamp - prev
2929
clearTimeout(timer)
3030

31-
// The sampling check inside the worker uses `process.hrtime.bigint()` (monotonic), but the snapshot
32-
// `timestamp` is captured via `Date.now()` (wall clock). NTP slewing on CI runners can cause the wall clock
33-
// to drift slightly relative to the monotonic clock during the >=1s sampling window, so we allow a 75ms
34-
// tolerance on both sides of the expected 1000ms gap.
31+
// The sampling check uses `process.hrtime.bigint()` (monotonic), but the snapshot `timestamp` is captured
32+
// via `Date.now()` (wall clock). NTP slewing on CI runners can cause the wall clock to drift slightly
33+
// relative to the monotonic clock during the >=1s sampling window, so we allow a 75ms tolerance on both
34+
// sides of the expected 1000ms gap.
3535
assert.ok(duration >= 925, `duration (${duration}) should be >= 925`)
3636
assert.ok(duration < 1075, `duration (${duration}) should be < 1075`)
3737

@@ -84,10 +84,10 @@ describe('Dynamic Instrumentation', function () {
8484
const duration = timestamp - _state.prev
8585
clearTimeout(_state.timer)
8686

87-
// The sampling check inside the worker uses `process.hrtime.bigint()` (monotonic), but the snapshot
88-
// `timestamp` is captured via `Date.now()` (wall clock). NTP slewing on CI runners can cause the wall clock
89-
// to drift slightly relative to the monotonic clock during the >=1s sampling window, so we allow a 75ms
90-
// tolerance on both sides of the expected 1000ms gap.
87+
// The sampling check uses `process.hrtime.bigint()` (monotonic), but the snapshot `timestamp` is captured
88+
// via `Date.now()` (wall clock). NTP slewing on CI runners can cause the wall clock to drift slightly
89+
// relative to the monotonic clock during the >=1s sampling window, so we allow a 75ms tolerance on both
90+
// sides of the expected 1000ms gap.
9191
assert.ok(duration >= 925, `duration (${duration}) should be >= 925`)
9292
assert.ok(duration < 1075, `duration (${duration}) should be < 1075`)
9393

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

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const { getGeneratedPosition } = require('./source-maps')
55
const session = require('./session')
66
const { compile, compileSegments, templateRequiresEvaluation } = require('./condition')
77
const { MAX_SNAPSHOTS_PER_SECOND_PER_PROBE, MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE } = require('./defaults')
8+
const { compileBreakpointCondition, getRemoveProbeExpression } = require('./probe_sampler')
89
const {
910
DEFAULT_MAX_REFERENCE_DEPTH,
1011
DEFAULT_MAX_COLLECTION_SIZE,
@@ -17,6 +18,7 @@ const {
1718
locationToBreakpoint,
1819
breakpointToProbes,
1920
probeToLocation,
21+
samplingIndexToProbe,
2022
} = require('./state')
2123
const log = require('./log')
2224

@@ -26,6 +28,7 @@ const log = require('./log')
2628

2729
let sessionStarted = false
2830
const probes = new Map()
31+
let nextSamplingIndex = 0
2932
let scriptLoadingStabilizedResolve
3033
const scriptLoadingStabilized = new Promise((resolve) => { scriptLoadingStabilizedResolve = resolve })
3134

@@ -55,6 +58,8 @@ async function addBreakpoint (probe) {
5558
if (!sessionStarted) await start()
5659

5760
probes.set(probe.id, probe)
61+
probe.samplingIndex = nextSamplingIndex++
62+
samplingIndexToProbe.set(probe.samplingIndex, probe)
5863

5964
const file = probe.where.sourceFile
6065
let lineNumber = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints
@@ -75,8 +80,6 @@ async function addBreakpoint (probe) {
7580
? MAX_SNAPSHOTS_PER_SECOND_PER_PROBE
7681
: MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE)
7782
probe.nsBetweenSampling = BigInt(Math.trunc(1 / snapshotsPerSecond * 1e9))
78-
// Initialize to a large negative value to ensure first probe hit is always captured
79-
probe.lastCaptureNs = BigInt(Number.MIN_SAFE_INTEGER)
8083

8184
// Warning: The code below relies on undocumented behavior of the inspector!
8285
// It expects that `await session.post('Debugger.enable')` will wait for all loaded scripts to be emitted as
@@ -174,7 +177,7 @@ async function addBreakpoint (probe) {
174177
try {
175178
result = /** @type {SetBreakpointResponse} */ (await session.post('Debugger.setBreakpoint', {
176179
location,
177-
condition: probe.condition,
180+
condition: compileBreakpointCondition([probe]),
178181
}))
179182
} catch (err) {
180183
throw new Error(`Error setting breakpoint for probe ${probe.id} (version: ${probe.version})`, { cause: err })
@@ -195,11 +198,14 @@ async function removeBreakpoint ({ id }) {
195198
}
196199

197200
probes.delete(id)
201+
await removeProbeFromSampler(id)
198202

199203
const locationKey = probeToLocation.get(id)
200204
const breakpoint = locationToBreakpoint.get(locationKey)
201205
const probesAtLocation = breakpointToProbes.get(breakpoint.id)
206+
const probe = probesAtLocation.get(id)
202207

208+
samplingIndexToProbe.delete(probe.samplingIndex)
203209
probesAtLocation.delete(id)
204210
probeToLocation.delete(id)
205211

@@ -229,36 +235,37 @@ async function modifyBreakpoint (probe) {
229235

230236
async function updateBreakpointInternal (breakpoint, probe) {
231237
const probesAtLocation = breakpointToProbes.get(breakpoint.id)
232-
const conditionBeforeNewProbe = compileCompoundCondition([...probesAtLocation.values()])
233238

234-
// If a probe is provided, add it to the breakpoint. If not, it's because we're removing a probe, but potentially
235-
// need to update the condition of the breakpoint.
239+
// If a probe is provided, add it to the breakpoint. If not, it's because we're removing a probe. In both cases the
240+
// breakpoint condition must be rebuilt to match the remaining probes at the location.
236241
if (probe) {
237242
probesAtLocation.set(probe.id, probe)
238243
probeToLocation.set(probe.id, breakpoint.locationKey)
239244
}
240245

241-
const condition = compileCompoundCondition([...probesAtLocation.values()])
242-
243-
if (condition || conditionBeforeNewProbe !== condition) {
244-
try {
245-
await session.post('Debugger.removeBreakpoint', { breakpointId: breakpoint.id })
246-
} catch (err) {
247-
throw new Error(`Error removing breakpoint for probe ${probe.id}`, { cause: err })
248-
}
249-
breakpointToProbes.delete(breakpoint.id)
250-
let result
251-
try {
252-
result = /** @type {SetBreakpointResponse} */ (await session.post('Debugger.setBreakpoint', {
253-
location: breakpoint.location,
254-
condition,
255-
}))
256-
} catch (err) {
257-
throw new Error(`Error setting breakpoint for probe ${probe.id} (version: ${probe.version})`, { cause: err })
258-
}
259-
breakpoint.id = result.breakpointId
260-
breakpointToProbes.set(result.breakpointId, probesAtLocation)
246+
try {
247+
await session.post('Debugger.removeBreakpoint', { breakpointId: breakpoint.id })
248+
} catch (err) {
249+
const message = probe
250+
? `Error replacing breakpoint while adding probe ${probe.id} (version: ${probe.version})`
251+
: `Error replacing breakpoint after removing probe from ${breakpoint.locationKey}`
252+
throw new Error(message, { cause: err })
253+
}
254+
breakpointToProbes.delete(breakpoint.id)
255+
let result
256+
try {
257+
result = /** @type {SetBreakpointResponse} */ (await session.post('Debugger.setBreakpoint', {
258+
location: breakpoint.location,
259+
condition: compileBreakpointCondition([...probesAtLocation.values()]),
260+
}))
261+
} catch (err) {
262+
const message = probe
263+
? `Error setting breakpoint while adding probe ${probe.id} (version: ${probe.version})`
264+
: `Error setting breakpoint after removing probe from ${breakpoint.locationKey}`
265+
throw new Error(message, { cause: err })
261266
}
267+
breakpoint.id = result.breakpointId
268+
breakpointToProbes.set(result.breakpointId, probesAtLocation)
262269
}
263270

264271
async function reEvaluateProbe (probe) {
@@ -306,16 +313,20 @@ function lock (fn) {
306313
}
307314
}
308315

309-
// Only if all probes have a condition can we use a compound condition.
310-
// Otherwise, we need to evaluate each probe individually once the breakpoint is hit.
311-
function compileCompoundCondition (probes) {
312-
if (probes.length === 1) return probes[0].condition
313-
314-
return probes.every(p => p.condition)
315-
? probes
316-
.map((p) => `(() => { try { return ${p.condition} } catch { return false } })()`)
317-
.join(' || ')
318-
: undefined
316+
/**
317+
* Remove cached sampling state for a probe from the runtime sampler.
318+
*
319+
* @param {string} id - The probe id.
320+
* @returns {Promise<void>}
321+
*/
322+
async function removeProbeFromSampler (id) {
323+
try {
324+
await session.post('Runtime.evaluate', {
325+
expression: getRemoveProbeExpression(id),
326+
})
327+
} catch (err) {
328+
log.error('[debugger:devtools_client] Error removing probe %s from sampler', id, err)
329+
}
319330
}
320331

321332
function generateLocationKey (scriptId, lineNumber, columnNumber) {

0 commit comments

Comments
 (0)