Skip to content

Commit 0b299bc

Browse files
authored
feat(debugger): add support for v2 input endpoint detection (#7308)
Add support for the v2 debugger input endpoint (/debugger/v2/input) with intelligent fallback to the diagnostics endpoint (/api/v2/debugger) when the agent does not support v2 or returns 404. The implementation: - Detects v2 endpoint support from agent /info response - Automatically falls back to diagnostics endpoint if v2 is not advertised - Handles runtime 404 errors from v2 endpoint by falling back to diagnostics - Ensures remote config handler is registered synchronously to avoid race conditions This change maintains backward compatibility with older agents while enabling new v2 functionality when available.
1 parent 0ac5788 commit 0b299bc

File tree

16 files changed

+539
-80
lines changed

16 files changed

+539
-80
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"aerospike",
77
"appsec",
88
"backportability",
9+
"ddsource",
910
"kafkajs",
1011
"llmobs",
1112
"microbenchmarks",
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
'use strict'
2+
3+
const assert = require('node:assert')
4+
const { once } = require('node:events')
5+
6+
const { assertObjectContains } = require('../helpers')
7+
const { setup } = require('./utils')
8+
9+
describe('Dynamic Instrumentation - Endpoint Fallback', function () {
10+
describe('diagnostics endpoint when agent does not advertise v2 support', function () {
11+
const t = setup({
12+
dependencies: ['fastify'],
13+
testApp: 'target-app/basic.js',
14+
agentOptions: {
15+
advertiseDebuggerV2IntakeSupport: false,
16+
},
17+
})
18+
19+
it('should use diagnostics endpoint when agent does not advertise v2 support', async function () {
20+
const diagnosticsInput = once(t.agent, 'debugger-diagnostics-input')
21+
22+
t.agent.once('debugger-input-v2', () => {
23+
assert.fail('v2 endpoint should not be called')
24+
})
25+
26+
t.agent.addRemoteConfig(t.rcConfig)
27+
const response = await t.triggerBreakpoint()
28+
assert.strictEqual(response.status, 200)
29+
assert.deepStrictEqual(response.data, { hello: 'bar' })
30+
31+
const [{ payload }] = await diagnosticsInput
32+
33+
assertObjectContains(payload[0], {
34+
ddsource: 'dd_debugger',
35+
service: 'node',
36+
debugger: { snapshot: {} },
37+
})
38+
})
39+
40+
it('should continue using diagnostics endpoint for multiple requests', async function () {
41+
const expectedSnapshots = 2
42+
let snapshotsReceived = 0
43+
44+
const allSnapshotsReceived = new Promise(/** @type {() => void} */ (resolve) => {
45+
t.agent.on('debugger-diagnostics-input', ({ payload }) => {
46+
// The payload is an array of snapshots, count them all
47+
snapshotsReceived += payload.length
48+
payload.forEach((item) => {
49+
assertObjectContains(item, {
50+
ddsource: 'dd_debugger',
51+
service: 'node',
52+
debugger: { snapshot: {} },
53+
})
54+
})
55+
if (snapshotsReceived >= expectedSnapshots) {
56+
resolve()
57+
}
58+
})
59+
})
60+
61+
t.agent.once('debugger-input-v2', () => {
62+
assert.fail('v2 endpoint should not be called')
63+
})
64+
65+
t.agent.addRemoteConfig(t.rcConfig)
66+
const response1 = await t.triggerBreakpoint()
67+
assert.strictEqual(response1.status, 200)
68+
assert.deepStrictEqual(response1.data, { hello: 'bar' })
69+
70+
const response2 = await t.axios.get(t.breakpoint.url)
71+
assert.strictEqual(response2.status, 200)
72+
assert.deepStrictEqual(response2.data, { hello: 'bar' })
73+
74+
await allSnapshotsReceived
75+
})
76+
})
77+
78+
describe('v2 endpoint works when agent supports it', function () {
79+
const t = setup({ dependencies: ['fastify'], testApp: 'target-app/basic.js' })
80+
81+
it('should successfully use v2 endpoint when agent supports it', async function () {
82+
const v2Input = once(t.agent, 'debugger-input-v2')
83+
84+
t.agent.once('debugger-diagnostics-input', () => {
85+
assert.fail('Snapshots should not be sent to diagnostics endpoint when using v2')
86+
})
87+
88+
t.agent.addRemoteConfig(t.rcConfig)
89+
const response = await t.triggerBreakpoint()
90+
assert.strictEqual(response.status, 200)
91+
assert.deepStrictEqual(response.data, { hello: 'bar' })
92+
93+
const [{ payload }] = await v2Input
94+
95+
assertObjectContains(payload[0], {
96+
ddsource: 'dd_debugger',
97+
service: 'node',
98+
debugger: { snapshot: {} },
99+
})
100+
})
101+
})
102+
103+
describe('runtime fallback from v2 to diagnostics when v2 returns 404', function () {
104+
const t = setup({
105+
dependencies: ['fastify'],
106+
testApp: 'target-app/basic.js',
107+
agentOptions: {
108+
// Agent advertises v2 support in /info, but returns 404 when actually called
109+
// This simulates an edge case where agent changes between /info and actual request
110+
advertiseDebuggerV2IntakeSupport: true,
111+
debuggerV2IntakeStatusCode: 404,
112+
},
113+
})
114+
115+
it('should fallback to diagnostics endpoint when v2 returns 404 at runtime', async function () {
116+
const v2404Event = once(t.agent, 'debugger-input-v2-404')
117+
const diagnosticsInput = once(t.agent, 'debugger-diagnostics-input')
118+
119+
t.agent.addRemoteConfig(t.rcConfig)
120+
const response = await t.triggerBreakpoint()
121+
assert.strictEqual(response.status, 200)
122+
assert.deepStrictEqual(response.data, { hello: 'bar' })
123+
124+
const [, [{ payload }]] = await Promise.all([v2404Event, diagnosticsInput])
125+
126+
assertObjectContains(payload[0], {
127+
ddsource: 'dd_debugger',
128+
service: 'node',
129+
debugger: { snapshot: {} },
130+
})
131+
})
132+
133+
it('should continue using diagnostics endpoint after runtime fallback', async function () {
134+
const expectedSnapshots = 2
135+
let snapshotsReceived = 0
136+
137+
const v2404Event = once(t.agent, 'debugger-input-v2-404')
138+
139+
const allSnapshotsReceived = new Promise(/** @type {() => void} */ (resolve) => {
140+
t.agent.on('debugger-diagnostics-input', ({ payload }) => {
141+
// The payload is an array of snapshots, count them all
142+
snapshotsReceived += payload.length
143+
payload.forEach((item) => {
144+
assertObjectContains(item, {
145+
ddsource: 'dd_debugger',
146+
service: 'node',
147+
debugger: { snapshot: {} },
148+
})
149+
})
150+
if (snapshotsReceived >= expectedSnapshots) {
151+
resolve()
152+
}
153+
})
154+
})
155+
156+
t.agent.addRemoteConfig(t.rcConfig)
157+
const response1 = await t.triggerBreakpoint()
158+
assert.strictEqual(response1.status, 200)
159+
assert.deepStrictEqual(response1.data, { hello: 'bar' })
160+
161+
const response2 = await t.axios.get(t.breakpoint.url)
162+
assert.strictEqual(response2.status, 200)
163+
assert.deepStrictEqual(response2.data, { hello: 'bar' })
164+
165+
await Promise.all([v2404Event, allSnapshotsReceived])
166+
})
167+
})
168+
})

integration-tests/debugger/utils.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,10 @@ module.exports = {
9595
* environment.
9696
* @param {(data: Buffer) => void} [options.stderrHandler] The function to handle the standard error output of the test
9797
* environment.
98+
* @param {object} [options.agentOptions] Optional configuration options to pass to the FakeAgent constructor.
9899
* @returns {DebuggerTestEnvironment} Test harness with agent, app process, axios client and breakpoint helpers.
99100
*/
100-
function setup ({ env, testApp, testAppSource, dependencies, silent, stdioHandler, stderrHandler } = {}) {
101+
function setup ({ env, testApp, testAppSource, dependencies, silent, stdioHandler, stderrHandler, agentOptions } = {}) {
101102
let cwd, axios, appFile, agent, proc
102103

103104
const breakpoints = getBreakpointInfo({
@@ -203,7 +204,8 @@ function setup ({ env, testApp, testAppSource, dependencies, silent, stdioHandle
203204
// Allow specific access to each breakpoint
204205
t.breakpoints.forEach((breakpoint) => { breakpoint.rcConfig = generateRemoteConfig(breakpoint) })
205206

206-
agent = await new FakeAgent().start()
207+
agent = await new FakeAgent(0, agentOptions).start()
208+
207209
proc = await spawnProc(/** @type {string} */ (t.appFile), {
208210
cwd,
209211
env: {

integration-tests/helpers/fake-agent.js

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const noop = () => {}
2929

3030
module.exports = class FakeAgent extends EventEmitter {
3131
port = 0
32+
advertiseDebuggerV2IntakeSupport = true
33+
debuggerV2IntakeStatusCode = 202
3234
/** @type {Set<import('net').Socket>} */
3335
#sockets = new Set()
3436
/** @type {Record<string, RemoteConfigFile>} */
@@ -37,10 +39,17 @@ module.exports = class FakeAgent extends EventEmitter {
3739
/** @type {Set<string>} */
3840
_rcSeenStates = new Set()
3941

40-
constructor (port = 0) {
42+
constructor (port = 0, options = {}) {
4143
// Redirect rejections to the error event
4244
super({ captureRejections: true })
4345
this.port = port
46+
47+
if (options.advertiseDebuggerV2IntakeSupport !== undefined) {
48+
this.advertiseDebuggerV2IntakeSupport = options.advertiseDebuggerV2IntakeSupport
49+
}
50+
if (options.debuggerV2IntakeStatusCode !== undefined) {
51+
this.debuggerV2IntakeStatusCode = options.debuggerV2IntakeStatusCode
52+
}
4453
}
4554

4655
start () {
@@ -313,9 +322,11 @@ function buildExpressServer (agent) {
313322
app.use(bodyParser.json({ limit: Infinity, type: 'application/json' }))
314323

315324
app.get('/info', (req, res) => {
316-
res.json({
317-
endpoints: ['/evp_proxy/v2'],
318-
})
325+
const endpoints = ['/evp_proxy/v2', '/debugger/v1/input']
326+
if (agent.advertiseDebuggerV2IntakeSupport) {
327+
endpoints.push('/debugger/v2/input')
328+
}
329+
res.json({ endpoints })
319330
})
320331

321332
app.put('/v0.4/traces', (req, res) => {
@@ -408,22 +419,57 @@ function buildExpressServer (agent) {
408419
})
409420

410421
app.post('/debugger/v1/input', (req, res) => {
411-
res.status(200).send()
422+
res.status(202).send()
412423
agent.emit('debugger-input', {
413424
headers: req.headers,
414425
query: req.query,
415426
payload: req.body,
416427
})
428+
agent.emit('debugger-input-v1', {
429+
headers: req.headers,
430+
query: req.query,
431+
payload: req.body,
432+
})
417433
})
418434

419-
app.post('/debugger/v1/diagnostics', upload.any(), (req, res) => {
420-
res.status(200).send()
421-
agent.emit('debugger-diagnostics', {
435+
app.post('/debugger/v2/input', (req, res) => {
436+
res.status(agent.debuggerV2IntakeStatusCode).send()
437+
if (agent.debuggerV2IntakeStatusCode === 404) {
438+
agent.emit('debugger-input-v2-404')
439+
return
440+
}
441+
agent.emit('debugger-input', {
422442
headers: req.headers,
423-
payload: JSON.parse((/** @type {Express.Multer.File[]} */ (req.files))[0].buffer.toString()),
443+
query: req.query,
444+
payload: req.body,
445+
})
446+
agent.emit('debugger-input-v2', {
447+
headers: req.headers,
448+
query: req.query,
449+
payload: req.body,
424450
})
425451
})
426452

453+
app.post('/debugger/v1/diagnostics', upload.any(), (req, res) => {
454+
res.status(200).send() // TODO: Should we send a 202 here instead?
455+
// The diagnostics endpoint can receive both probe results and status messages
456+
// Emit the appropriate events based on payload structure
457+
const isProbeResult = req.body[0]?.debugger?.snapshot !== undefined
458+
if (isProbeResult) {
459+
const event = {
460+
headers: req.headers,
461+
payload: req.body,
462+
}
463+
agent.emit('debugger-input', event)
464+
agent.emit('debugger-diagnostics-input', event)
465+
} else {
466+
agent.emit('debugger-diagnostics', {
467+
headers: req.headers,
468+
payload: JSON.parse((/** @type {Express.Multer.File[]} */ (req.files))[0].buffer.toString()),
469+
})
470+
}
471+
})
472+
427473
app.post('/profiling/v1/input', upload.any(), (req, res) => {
428474
res.status(200).send()
429475
agent.emit('message', {

packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ const AgentlessWriter = require('../agentless/writer')
55
const CoverageWriter = require('../agentless/coverage-writer')
66
const CiVisibilityExporter = require('../ci-visibility-exporter')
77
const { fetchAgentInfo } = require('../../../agent/info')
8+
const { DEBUGGER_INPUT_V1 } = require('../../../debugger/constants')
89

910
const AGENT_EVP_PROXY_PATH_PREFIX = '/evp_proxy/v'
1011
const AGENT_EVP_PROXY_PATH_REGEX = /\/evp_proxy\/v(\d+)\/?/
11-
const AGENT_DEBUGGER_INPUT = '/debugger/v1/input'
1212

1313
function getLatestEvpProxyVersion (err, agentInfo) {
1414
if (err) {
@@ -27,7 +27,7 @@ function getLatestEvpProxyVersion (err, agentInfo) {
2727
}
2828

2929
function getCanForwardDebuggerLogs (err, agentInfo) {
30-
return !err && agentInfo.endpoints.includes(AGENT_DEBUGGER_INPUT)
30+
return !err && agentInfo.endpoints.includes(DEBUGGER_INPUT_V1)
3131
}
3232

3333
class AgentProxyCiVisibilityExporter extends CiVisibilityExporter {

packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const log = require('../../../log')
44
const { safeJSONStringify } = require('../../../exporters/common/util')
55
const { JSONEncoder } = require('../../encode/json-encoder')
66
const { getValueFromEnvSources } = require('../../../config/helper')
7+
const { DEBUGGER_INPUT_V1 } = require('../../../debugger/constants')
78

89
const BaseWriter = require('../../../exporters/common/writer')
910

@@ -34,7 +35,7 @@ class DynamicInstrumentationLogsWriter extends BaseWriter {
3435

3536
if (this._isAgentProxy) {
3637
delete options.headers['dd-api-key']
37-
options.path = '/debugger/v1/input'
38+
options.path = DEBUGGER_INPUT_V1
3839
}
3940

4041
log.debug(() => `Request to the logs intake: ${safeJSONStringify(options)}`)

packages/dd-trace/src/debugger/config.js

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

3-
module.exports = function getDebuggerConfig (config) {
3+
module.exports = function getDebuggerConfig (config, inputPath) {
44
return {
55
commitSHA: config.commitSHA,
66
debug: config.debug,
@@ -13,5 +13,6 @@ module.exports = function getDebuggerConfig (config) {
1313
runtimeId: config.tags['runtime-id'],
1414
service: config.service,
1515
url: config.url?.toString(),
16+
inputPath,
1617
}
1718
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use strict'
2+
3+
module.exports = {
4+
DEBUGGER_DIAGNOSTICS_V1: '/debugger/v1/diagnostics',
5+
DEBUGGER_INPUT_V1: '/debugger/v1/input',
6+
DEBUGGER_INPUT_V2: '/debugger/v2/input',
7+
}

0 commit comments

Comments
 (0)