dd-trace 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE-3rdparty.csv +1 -2
  2. package/index.d.ts +21 -1
  3. package/package.json +32 -29
  4. package/packages/datadog-instrumentations/src/ai.js +45 -0
  5. package/packages/datadog-instrumentations/src/apollo-server.js +5 -13
  6. package/packages/datadog-instrumentations/src/child_process.js +3 -3
  7. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +587 -0
  8. package/packages/datadog-instrumentations/src/cucumber.js +102 -43
  9. package/packages/datadog-instrumentations/src/cypress-config.js +11 -3
  10. package/packages/datadog-instrumentations/src/fastify.js +27 -8
  11. package/packages/datadog-instrumentations/src/helpers/hooks.js +1 -0
  12. package/packages/datadog-instrumentations/src/helpers/instrument.js +7 -0
  13. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/ai.js +6 -6
  14. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/claude-agent-sdk.js +17 -0
  15. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
  16. package/packages/datadog-instrumentations/src/jest.js +713 -63
  17. package/packages/datadog-instrumentations/src/mocha/main.js +24 -3
  18. package/packages/datadog-instrumentations/src/mocha/utils.js +128 -22
  19. package/packages/datadog-instrumentations/src/mocha/worker.js +13 -0
  20. package/packages/datadog-instrumentations/src/mongodb.js +74 -0
  21. package/packages/datadog-instrumentations/src/mongoose.js +4 -5
  22. package/packages/datadog-instrumentations/src/playwright.js +13 -9
  23. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +4 -0
  24. package/packages/datadog-instrumentations/src/vitest-main.js +28 -1
  25. package/packages/datadog-instrumentations/src/vitest-util.js +5 -0
  26. package/packages/datadog-instrumentations/src/vitest-worker.js +104 -34
  27. package/packages/datadog-plugin-ai/src/index.js +1 -1
  28. package/packages/datadog-plugin-ai/src/tracing.js +66 -3
  29. package/packages/datadog-plugin-ai/src/utils.js +17 -4
  30. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/context.js +1 -0
  31. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/handler.js +1 -0
  32. package/packages/datadog-plugin-child_process/src/index.js +13 -2
  33. package/packages/datadog-plugin-claude-agent-sdk/src/index.js +31 -0
  34. package/packages/datadog-plugin-claude-agent-sdk/src/tracing.js +107 -0
  35. package/packages/datadog-plugin-claude-agent-sdk/src/util.js +15 -0
  36. package/packages/datadog-plugin-cucumber/src/index.js +14 -23
  37. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +351 -11
  38. package/packages/datadog-plugin-cypress/src/index.js +47 -2
  39. package/packages/datadog-plugin-cypress/src/plugin.js +1 -0
  40. package/packages/datadog-plugin-graphql/src/execute.js +21 -10
  41. package/packages/datadog-plugin-graphql/src/index.js +17 -1
  42. package/packages/datadog-plugin-graphql/src/utils.js +9 -2
  43. package/packages/datadog-plugin-graphql/src/validate.js +1 -1
  44. package/packages/datadog-plugin-jest/src/index.js +30 -14
  45. package/packages/datadog-plugin-mocha/src/index.js +39 -14
  46. package/packages/datadog-plugin-mongodb-core/src/bulk-write.js +43 -0
  47. package/packages/datadog-plugin-mongodb-core/src/index.js +8 -443
  48. package/packages/datadog-plugin-mongodb-core/src/query.js +452 -0
  49. package/packages/datadog-plugin-playwright/src/index.js +3 -2
  50. package/packages/datadog-plugin-vitest/src/index.js +51 -9
  51. package/packages/dd-trace/src/aiguard/index.js +9 -14
  52. package/packages/dd-trace/src/aiguard/integrations/index.js +34 -0
  53. package/packages/dd-trace/src/aiguard/integrations/openai.js +47 -33
  54. package/packages/dd-trace/src/aiguard/integrations/vercel-ai.js +42 -28
  55. package/packages/dd-trace/src/appsec/graphql.js +9 -6
  56. package/packages/dd-trace/src/appsec/iast/analyzers/nosql-injection-mongodb-analyzer.js +3 -10
  57. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js +26 -1
  58. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +115 -24
  59. package/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +3 -0
  60. package/packages/dd-trace/src/ci-visibility/exporters/agentless/index.js +3 -0
  61. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +75 -7
  62. package/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +156 -0
  63. package/packages/dd-trace/src/config/generated-config-types.d.ts +10 -0
  64. package/packages/dd-trace/src/config/supported-configurations.json +37 -0
  65. package/packages/dd-trace/src/crashtracking/crashtracker.js +2 -2
  66. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +403 -0
  67. package/packages/dd-trace/src/llmobs/plugins/ai/index.js +8 -395
  68. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +27 -0
  69. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +363 -0
  70. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +270 -0
  71. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/utils.js +10 -0
  72. package/packages/dd-trace/src/opentelemetry/span.js +5 -0
  73. package/packages/dd-trace/src/plugin_manager.js +16 -0
  74. package/packages/dd-trace/src/plugins/ci_plugin.js +147 -7
  75. package/packages/dd-trace/src/plugins/index.js +2 -0
  76. package/packages/dd-trace/src/plugins/util/test.js +4 -0
  77. package/packages/dd-trace/src/profiling/exporters/agent.js +28 -3
@@ -16,51 +16,65 @@ const chatCompletionsAfterChannel = channel('dd-trace:openai:chat.completions:af
16
16
  const responsesBeforeChannel = channel('dd-trace:openai:responses:before')
17
17
  const responsesAfterChannel = channel('dd-trace:openai:responses:after')
18
18
 
19
+ let isEnabled = false
20
+ let aiguard
21
+ let opts
22
+
19
23
  /**
20
24
  * Subscribes AI Guard to OpenAI lifecycle channels.
21
25
  *
22
- * @param {object} aiguard
26
+ * @param {object} aiguardInstance
23
27
  * @param {boolean} block
24
- * @returns {() => void}
25
28
  */
26
- function enable (aiguard, block) {
27
- const opts = { block, source: SOURCE_AUTO, integration: 'openai' }
28
-
29
- function onChatCompletionsBefore (ctx) {
30
- pushEvaluation(ctx, aiguard, getChatCompletionsInputMessages(ctx.args?.[0]), opts)
31
- }
32
-
33
- function onChatCompletionsAfter (ctx) {
34
- const inputMessages = getChatCompletionsInputMessages(ctx.args?.[0])
35
- if (!inputMessages?.length) return
36
- for (const message of getChatCompletionsOutputMessages(ctx.body)) {
37
- pushEvaluation(ctx, aiguard, [...inputMessages, message], opts)
38
- }
39
- }
29
+ function enable (aiguardInstance, block) {
30
+ if (isEnabled) return
40
31
 
41
- function onResponsesBefore (ctx) {
42
- pushEvaluation(ctx, aiguard, getResponsesInputMessages(ctx.args?.[0]), opts)
43
- }
44
-
45
- function onResponsesAfter (ctx) {
46
- const inputMessages = getResponsesInputMessages(ctx.args?.[0])
47
- if (!inputMessages?.length) return
48
- const outputMessages = getResponsesOutputMessages(ctx.body)
49
- if (!outputMessages.length) return
50
- pushEvaluation(ctx, aiguard, [...inputMessages, ...outputMessages], opts)
51
- }
32
+ aiguard = aiguardInstance
33
+ opts = { block, source: SOURCE_AUTO, integration: 'openai' }
52
34
 
53
35
  chatCompletionsBeforeChannel.subscribe(onChatCompletionsBefore)
54
36
  chatCompletionsAfterChannel.subscribe(onChatCompletionsAfter)
55
37
  responsesBeforeChannel.subscribe(onResponsesBefore)
56
38
  responsesAfterChannel.subscribe(onResponsesAfter)
57
39
 
58
- return function disable () {
59
- chatCompletionsBeforeChannel.unsubscribe(onChatCompletionsBefore)
60
- chatCompletionsAfterChannel.unsubscribe(onChatCompletionsAfter)
61
- responsesBeforeChannel.unsubscribe(onResponsesBefore)
62
- responsesAfterChannel.unsubscribe(onResponsesAfter)
40
+ isEnabled = true
41
+ }
42
+
43
+ function disable () {
44
+ if (!isEnabled) return
45
+
46
+ chatCompletionsBeforeChannel.unsubscribe(onChatCompletionsBefore)
47
+ chatCompletionsAfterChannel.unsubscribe(onChatCompletionsAfter)
48
+ responsesBeforeChannel.unsubscribe(onResponsesBefore)
49
+ responsesAfterChannel.unsubscribe(onResponsesAfter)
50
+
51
+ aiguard = undefined
52
+ opts = undefined
53
+ isEnabled = false
54
+ }
55
+
56
+ function onChatCompletionsBefore (ctx) {
57
+ pushEvaluation(ctx, aiguard, getChatCompletionsInputMessages(ctx.args?.[0]), opts)
58
+ }
59
+
60
+ function onChatCompletionsAfter (ctx) {
61
+ const inputMessages = getChatCompletionsInputMessages(ctx.args?.[0])
62
+ if (!inputMessages?.length) return
63
+ for (const message of getChatCompletionsOutputMessages(ctx.body)) {
64
+ pushEvaluation(ctx, aiguard, [...inputMessages, message], opts)
63
65
  }
64
66
  }
65
67
 
66
- module.exports = { enable }
68
+ function onResponsesBefore (ctx) {
69
+ pushEvaluation(ctx, aiguard, getResponsesInputMessages(ctx.args?.[0]), opts)
70
+ }
71
+
72
+ function onResponsesAfter (ctx) {
73
+ const inputMessages = getResponsesInputMessages(ctx.args?.[0])
74
+ if (!inputMessages?.length) return
75
+ const outputMessages = getResponsesOutputMessages(ctx.body)
76
+ if (!outputMessages.length) return
77
+ pushEvaluation(ctx, aiguard, [...inputMessages, ...outputMessages], opts)
78
+ }
79
+
80
+ module.exports = { enable, disable }
@@ -11,45 +11,59 @@ const doGenerateAfterChannel = channel('dd-trace:vercel-ai:doGenerate:after')
11
11
  const doStreamBeforeChannel = channel('dd-trace:vercel-ai:doStream:before')
12
12
  const doStreamAfterChannel = channel('dd-trace:vercel-ai:doStream:after')
13
13
 
14
+ let isEnabled = false
15
+ let aiguard
16
+ let opts
17
+
14
18
  /**
15
19
  * Subscribes AI Guard to Vercel AI lifecycle channels.
16
20
  *
17
- * @param {object} aiguard
21
+ * @param {object} aiguardInstance
18
22
  * @param {boolean} block
19
- * @returns {() => void}
20
23
  */
21
- function enable (aiguard, block) {
22
- const opts = { block, source: SOURCE_AUTO, integration: 'ai' }
23
-
24
- function onBefore (ctx) {
25
- pushEvaluation(ctx, aiguard, convertVercelPromptToMessages(ctx.prompt), opts)
26
- }
27
-
28
- function onGenerateAfter (ctx) {
29
- const inputMessages = convertVercelPromptToMessages(ctx.prompt)
30
- if (!inputMessages.length || !ctx.result?.content?.length) return
31
-
32
- pushEvaluation(ctx, aiguard, buildOutputMessages(inputMessages, ctx.result.content), opts)
33
- }
24
+ function enable (aiguardInstance, block) {
25
+ if (isEnabled) return
34
26
 
35
- function onStreamAfter (ctx) {
36
- const inputMessages = convertVercelPromptToMessages(ctx.prompt)
37
- if (!inputMessages.length || !ctx.chunks?.length) return
38
-
39
- pushEvaluation(ctx, aiguard, buildOutputMessages(inputMessages, getStreamContent(ctx.chunks)), opts)
40
- }
27
+ aiguard = aiguardInstance
28
+ opts = { block, source: SOURCE_AUTO, integration: 'ai' }
41
29
 
42
30
  doGenerateBeforeChannel.subscribe(onBefore)
43
31
  doGenerateAfterChannel.subscribe(onGenerateAfter)
44
32
  doStreamBeforeChannel.subscribe(onBefore)
45
33
  doStreamAfterChannel.subscribe(onStreamAfter)
46
34
 
47
- return function disable () {
48
- doGenerateBeforeChannel.unsubscribe(onBefore)
49
- doGenerateAfterChannel.unsubscribe(onGenerateAfter)
50
- doStreamBeforeChannel.unsubscribe(onBefore)
51
- doStreamAfterChannel.unsubscribe(onStreamAfter)
52
- }
35
+ isEnabled = true
36
+ }
37
+
38
+ function disable () {
39
+ if (!isEnabled) return
40
+
41
+ doGenerateBeforeChannel.unsubscribe(onBefore)
42
+ doGenerateAfterChannel.unsubscribe(onGenerateAfter)
43
+ doStreamBeforeChannel.unsubscribe(onBefore)
44
+ doStreamAfterChannel.unsubscribe(onStreamAfter)
45
+
46
+ aiguard = undefined
47
+ opts = undefined
48
+ isEnabled = false
49
+ }
50
+
51
+ function onBefore (ctx) {
52
+ pushEvaluation(ctx, aiguard, convertVercelPromptToMessages(ctx.prompt), opts)
53
+ }
54
+
55
+ function onGenerateAfter (ctx) {
56
+ const inputMessages = convertVercelPromptToMessages(ctx.prompt)
57
+ if (!inputMessages.length || !ctx.result?.content?.length) return
58
+
59
+ pushEvaluation(ctx, aiguard, buildOutputMessages(inputMessages, ctx.result.content), opts)
60
+ }
61
+
62
+ function onStreamAfter (ctx) {
63
+ const inputMessages = convertVercelPromptToMessages(ctx.prompt)
64
+ if (!inputMessages.length || !ctx.chunks?.length) return
65
+
66
+ pushEvaluation(ctx, aiguard, buildOutputMessages(inputMessages, getStreamContent(ctx.chunks)), opts)
53
67
  }
54
68
 
55
69
  /**
@@ -75,4 +89,4 @@ function getStreamContent (chunks) {
75
89
  return text ? [{ type: 'text', text }] : []
76
90
  }
77
91
 
78
- module.exports = { enable }
92
+ module.exports = { enable, disable }
@@ -72,14 +72,17 @@ function enterInApolloServerCoreRequest () {
72
72
 
73
73
  function enterInApolloRequest () {
74
74
  const req = getActiveRequest()
75
+ if (!req) return
75
76
 
76
- const requestData = graphqlRequestData.get(req)
77
- if (requestData) {
78
- // Set isInGraphqlRequest=true since this function only runs for GraphQL requests
79
- // This works for both Apollo v4 (middleware) and v5 (HTTP server) contexts
80
- requestData.isInGraphqlRequest = true
81
- addSpecificEndpoint(req, specificBlockingTypes.GRAPHQL)
77
+ let requestData = graphqlRequestData.get(req)
78
+ if (!requestData) {
79
+ // executeHTTPGraphQLRequest is the GraphQL request boundary, so seed here
80
+ // when no upstream hook (express4 middleware, drainHttpServer) has run.
81
+ requestData = { blocked: false }
82
+ graphqlRequestData.set(req, requestData)
82
83
  }
84
+ requestData.isInGraphqlRequest = true
85
+ addSpecificEndpoint(req, specificBlockingTypes.GRAPHQL)
83
86
  }
84
87
 
85
88
  function beforeWriteApolloGraphqlResponse ({ abortController, abortData }) {
@@ -46,15 +46,6 @@ class NosqlInjectionMongodbAnalyzer extends InjectionAnalyzer {
46
46
  this.analyze({ filter }, store)
47
47
  }
48
48
  }
49
-
50
- return store
51
- }
52
-
53
- // mquery analyzes and marks on the same channel: its tracing start both reaches
54
- // the driver scope and is the first place the filters are available.
55
- const onStartAndBind = (message) => {
56
- onStart(message)
57
- return markExecStore()
58
49
  }
59
50
 
60
51
  this.addSub('datadog:mongodb:collection:filter:start', onStart)
@@ -65,8 +56,10 @@ class NosqlInjectionMongodbAnalyzer extends InjectionAnalyzer {
65
56
  this.addSub('datadog:mongoose:model:filter:start', onStart)
66
57
  this.addBind('datadog:mongoose:model:filter:exec', markExecStore)
67
58
 
59
+ // mquery analyzes filters at the prepare step and marks the execution scope at
60
+ // tracing start, where the deferred driver call runs under the marked store.
68
61
  this.addSub('datadog:mquery:filter:prepare', onStart)
69
- this.addBind('tracing:datadog:mquery:filter:start', onStartAndBind)
62
+ this.addBind('tracing:datadog:mquery:filter:start', markExecStore)
70
63
  }
71
64
 
72
65
  configureSanitizers () {
@@ -9,6 +9,7 @@ const getDebuggerConfig = require('../../debugger/config')
9
9
 
10
10
  const probeIdToResolveBreakpointSet = new Map()
11
11
  const probeIdToResolveBreakpointRemove = new Map()
12
+ const drainRequestIdToResolveBreakpointHit = new Map()
12
13
 
13
14
  class TestVisDynamicInstrumentation {
14
15
  /**
@@ -57,6 +58,21 @@ class TestVisDynamicInstrumentation {
57
58
  ]
58
59
  }
59
60
 
61
+ /**
62
+ * Waits until all breakpoint hits already being handled by the DI worker have been posted back.
63
+ *
64
+ * @returns {Promise<void>}
65
+ */
66
+ waitForInFlightBreakpointHits () {
67
+ if (!this.worker) return Promise.resolve()
68
+
69
+ const requestId = randomUUID()
70
+ return new Promise(resolve => {
71
+ drainRequestIdToResolveBreakpointHit.set(requestId, resolve)
72
+ this.breakpointHitChannel.port2.postMessage({ drainRequestId: requestId })
73
+ })
74
+ }
75
+
60
76
  isReady () {
61
77
  return this._readyPromise
62
78
  }
@@ -130,7 +146,16 @@ class TestVisDynamicInstrumentation {
130
146
  }
131
147
  }).unref?.()
132
148
 
133
- this.breakpointHitChannel.port2.on('message', ({ snapshot }) => {
149
+ this.breakpointHitChannel.port2.on('message', ({ snapshot, drainRequestId }) => {
150
+ if (drainRequestId) {
151
+ const resolve = drainRequestIdToResolveBreakpointHit.get(drainRequestId)
152
+ if (resolve) {
153
+ resolve()
154
+ drainRequestIdToResolveBreakpointHit.delete(drainRequestId)
155
+ }
156
+ return
157
+ }
158
+
134
159
  const { probe: { id: probeId } } = snapshot
135
160
  const onHit = this.onHitBreakpointByProbeId.get(probeId)
136
161
  if (onHit) {
@@ -28,12 +28,20 @@ const {
28
28
  getStackFromCallFrames,
29
29
  } = require('../../../debugger/devtools_client/state')
30
30
  const log = require('../../../log')
31
- const processTags = require('../../../process-tags')
31
+
32
+ let processTags
33
+ if (config.propagateProcessTags?.enabled) {
34
+ processTags = require('../../../process-tags')
35
+ processTags.initialize()
36
+ }
32
37
 
33
38
  let sessionStarted = false
39
+ let inFlightBreakpointHits = 0
40
+ let isBreakpointHitDrainScheduled = false
34
41
 
35
42
  const breakpointIdToProbe = new Map()
36
43
  const probeIdToBreakpointId = new Map()
44
+ const breakpointHitDrainRequests = []
37
45
 
38
46
  const limits = {
39
47
  maxReferenceDepth: DEFAULT_MAX_REFERENCE_DEPTH,
@@ -42,6 +50,55 @@ const limits = {
42
50
  maxLength: DEFAULT_MAX_LENGTH,
43
51
  }
44
52
 
53
+ /**
54
+ * Remove empty collection arrays before sending snapshots through the Test Optimization logs path.
55
+ *
56
+ * @param {object} value - Snapshot object or sub-object.
57
+ * @returns {void}
58
+ */
59
+ function removeEmptyCollectionProperties (value) {
60
+ const stack = [value]
61
+
62
+ while (stack.length > 0) {
63
+ const current = stack.pop()
64
+ if (!current || typeof current !== 'object') continue
65
+
66
+ if (Array.isArray(current)) {
67
+ for (const item of current) {
68
+ if (item && typeof item === 'object') {
69
+ stack.push(item)
70
+ }
71
+ }
72
+ continue
73
+ }
74
+
75
+ if (Array.isArray(current.elements)) {
76
+ if (current.elements.length === 0) {
77
+ delete current.elements
78
+ } else {
79
+ stack.push(current.elements)
80
+ }
81
+ }
82
+
83
+ if (Array.isArray(current.entries)) {
84
+ if (current.entries.length === 0) {
85
+ delete current.entries
86
+ } else {
87
+ stack.push(current.entries)
88
+ }
89
+ }
90
+
91
+ for (const key of Object.keys(current)) {
92
+ if (key === 'elements' || key === 'entries') continue
93
+
94
+ const child = current[key]
95
+ if (child && typeof child === 'object') {
96
+ stack.push(child)
97
+ }
98
+ }
99
+ }
100
+ }
101
+
45
102
  session.on('Debugger.paused', async ({ params: { hitBreakpoints: [hitBreakpoint], callFrames } }) => {
46
103
  const probe = breakpointIdToProbe.get(hitBreakpoint)
47
104
  if (!probe) {
@@ -49,32 +106,47 @@ session.on('Debugger.paused', async ({ params: { hitBreakpoints: [hitBreakpoint]
49
106
  return session.post('Debugger.resume')
50
107
  }
51
108
 
52
- const stack = await getStackFromCallFrames(callFrames)
53
-
54
- const { processLocalState } = await getLocalStateForCallFrame(callFrames[0], limits)
55
-
56
- await session.post('Debugger.resume')
57
-
58
- const snapshot = {
59
- id: randomUUID(),
60
- timestamp: Date.now(),
61
- probe: {
62
- id: probe.id,
63
- version: '0',
64
- location: probe.location,
65
- },
66
- captures: {
67
- lines: { [probe.location.lines[0]]: { locals: processLocalState() } },
68
- },
69
- stack,
70
- language: 'javascript',
71
- }
109
+ inFlightBreakpointHits++
110
+ try {
111
+ const stack = await getStackFromCallFrames(callFrames)
112
+
113
+ const { processLocalState } = await getLocalStateForCallFrame(callFrames[0], limits)
114
+
115
+ await session.post('Debugger.resume')
72
116
 
73
- if (config.propagateProcessTags?.enabled) {
74
- snapshot[processTags.DYNAMIC_INSTRUMENTATION_FIELD_NAME] = processTags.tagsObject
117
+ const snapshot = {
118
+ id: randomUUID(),
119
+ timestamp: Date.now(),
120
+ probe: {
121
+ id: probe.id,
122
+ version: 0,
123
+ location: probe.location,
124
+ },
125
+ captures: {
126
+ lines: { [probe.location.lines[0]]: { locals: processLocalState() } },
127
+ },
128
+ stack,
129
+ language: 'javascript',
130
+ }
131
+
132
+ removeEmptyCollectionProperties(snapshot.captures)
133
+
134
+ if (processTags) {
135
+ snapshot[processTags.DYNAMIC_INSTRUMENTATION_FIELD_NAME] = processTags.tagsObject
136
+ }
137
+
138
+ breakpointHitChannel.postMessage({ snapshot })
139
+ } finally {
140
+ inFlightBreakpointHits--
141
+ scheduleBreakpointHitDrain()
75
142
  }
143
+ })
76
144
 
77
- breakpointHitChannel.postMessage({ snapshot })
145
+ breakpointHitChannel.on('message', ({ drainRequestId }) => {
146
+ if (!drainRequestId) return
147
+
148
+ breakpointHitDrainRequests.push(drainRequestId)
149
+ scheduleBreakpointHitDrain()
78
150
  })
79
151
 
80
152
  breakpointRemoveChannel.on('message', async (probeId) => {
@@ -154,3 +226,22 @@ function start () {
154
226
  sessionStarted = true
155
227
  return session.post('Debugger.enable') // return instead of await to reduce number of promises created
156
228
  }
229
+
230
+ function drainBreakpointHitRequests () {
231
+ if (inFlightBreakpointHits !== 0) return
232
+
233
+ for (const drainRequestId of breakpointHitDrainRequests) {
234
+ breakpointHitChannel.postMessage({ drainRequestId })
235
+ }
236
+ breakpointHitDrainRequests.length = 0
237
+ }
238
+
239
+ function scheduleBreakpointHitDrain () {
240
+ if (isBreakpointHitDrainScheduled) return
241
+
242
+ isBreakpointHitDrainScheduled = true
243
+ setImmediate(() => {
244
+ isBreakpointHitDrainScheduled = false
245
+ drainBreakpointHitRequests()
246
+ })
247
+ }
@@ -68,6 +68,9 @@ class AgentProxyCiVisibilityExporter extends CiVisibilityExporter {
68
68
  evpProxyPrefix,
69
69
  })
70
70
  this._codeCoverageReportUrl = this._url
71
+ // Screenshot media uploads go through the Agent's evp_proxy: the uploader prefixes the
72
+ // path with evpProxyPrefix and sets X-Datadog-EVP-Subdomain: api (see uploadTestScreenshot).
73
+ this._testScreenshotUploadUrl = this._url
71
74
  if (testOptimization.DD_TEST_FAILED_TEST_REPLAY_ENABLED) {
72
75
  const canFowardLogs = getCanForwardDebuggerLogs(err, agentInfo)
73
76
  if (canFowardLogs) {
@@ -31,6 +31,8 @@ class AgentlessCiVisibilityExporter extends CiVisibilityExporter {
31
31
  }
32
32
 
33
33
  this._apiUrl = url || new URL(`https://api.${site}`)
34
+ // Media uploads (raw bytes + DD-API-KEY) go to the same api.<site> host as the rest of the API.
35
+ this._testScreenshotUploadUrl = this._apiUrl
34
36
  // Agentless is always gzip compatible
35
37
  this._isGzipCompatible = true
36
38
  }
@@ -40,6 +42,7 @@ class AgentlessCiVisibilityExporter extends CiVisibilityExporter {
40
42
  try {
41
43
  apiUrl = new URL(apiUrl)
42
44
  this._apiUrl = apiUrl
45
+ this._testScreenshotUploadUrl = apiUrl
43
46
  } catch (e) {
44
47
  log.error('Error setting CI exporter api url', e)
45
48
  }
@@ -1,7 +1,9 @@
1
1
  'use strict'
2
2
 
3
+ const { hostname: getHostname } = require('node:os')
3
4
  const URL = require('url').URL
4
5
 
6
+ const { version: tracerVersion } = require('../../../../../package.json')
5
7
  const { getLibraryConfiguration: getLibraryConfigurationRequest } = require('../requests/get-library-configuration')
6
8
  const { getSkippableSuites: getSkippableSuitesRequest } = require('../intelligent-test-runner/get-skippable-suites')
7
9
  const { getKnownTests: getKnownTestsRequest } = require('../early-flake-detection/get-known-tests')
@@ -10,11 +12,14 @@ const { getTestManagementTests: getTestManagementTestsRequest } =
10
12
  const { writeSettingsToCache } = require('../test-optimization-cache')
11
13
  const { CACHE_MISS, TestOptimizationHttpCache } = require('../test-optimization-http-cache')
12
14
  const { uploadCoverageReport: uploadCoverageReportRequest } = require('../requests/upload-coverage-report')
15
+ const { uploadTestScreenshot: uploadTestScreenshotRequest } = require('../requests/upload-test-screenshot')
13
16
  const log = require('../../log')
14
17
  const BufferingExporter = require('../../exporters/common/buffering-exporter')
15
18
  const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA } = require('../../plugins/util/tags')
16
19
  const { sendGitMetadata: sendGitMetadataRequest } = require('./git/git_metadata')
17
20
 
21
+ const hostname = getHostname()
22
+
18
23
  function getTestConfigurationTags (tags) {
19
24
  if (!tags) {
20
25
  return {}
@@ -37,6 +42,34 @@ function getIsTestSessionTrace (trace) {
37
42
  const GIT_UPLOAD_TIMEOUT = 60_000 // 60 seconds
38
43
  const CAN_USE_CI_VIS_PROTOCOL_TIMEOUT = GIT_UPLOAD_TIMEOUT
39
44
 
45
+ function appendLogTag (tags, key, value) {
46
+ if (value !== undefined) {
47
+ tags.push(`${key}:${value}`)
48
+ }
49
+ }
50
+
51
+ function getLogTags (logMessage, { env, version }, gitRepositoryUrl, gitCommitSha) {
52
+ const tags = []
53
+ if (Array.isArray(logMessage.ddtags)) {
54
+ for (const tag of logMessage.ddtags) {
55
+ tags.push(tag)
56
+ }
57
+ } else if (logMessage.ddtags) {
58
+ for (const tag of logMessage.ddtags.split(',')) {
59
+ tags.push(tag)
60
+ }
61
+ }
62
+
63
+ appendLogTag(tags, 'env', env)
64
+ appendLogTag(tags, 'version', version)
65
+ appendLogTag(tags, 'debugger_version', tracerVersion)
66
+ appendLogTag(tags, 'host_name', hostname)
67
+ appendLogTag(tags, GIT_COMMIT_SHA, gitCommitSha)
68
+ appendLogTag(tags, GIT_REPOSITORY_URL, gitRepositoryUrl)
69
+
70
+ return tags.join(',')
71
+ }
72
+
40
73
  class CiVisibilityExporter extends BufferingExporter {
41
74
  constructor (config) {
42
75
  super(config)
@@ -49,6 +82,9 @@ class CiVisibilityExporter extends BufferingExporter {
49
82
  // AKA CI Vis Protocol
50
83
  this._canUseCiVisProtocol = false
51
84
 
85
+ this._isTestFailureScreenshotsEnabled =
86
+ Boolean(config?.testOptimization?.DD_TEST_FAILURE_SCREENSHOTS_ENABLED)
87
+
52
88
  const gitUploadTimeoutId = setTimeout(() => {
53
89
  this._resolveGit(new Error('Timeout while uploading git metadata'))
54
90
  }, GIT_UPLOAD_TIMEOUT)
@@ -338,21 +374,18 @@ class CiVisibilityExporter extends BufferingExporter {
338
374
  const { service, env, version } = this._config
339
375
 
340
376
  return {
341
- ddtags: [
342
- ...(logMessage.ddtags || []),
343
- `${GIT_REPOSITORY_URL}:${gitRepositoryUrl}`,
344
- `${GIT_COMMIT_SHA}:${gitCommitSha}`,
345
- ].join(','),
377
+ ...logMessage,
378
+ ddtags: getLogTags(logMessage, { env, version }, gitRepositoryUrl, gitCommitSha),
346
379
  level: 'error',
347
380
  service,
381
+ hostname,
348
382
  dd: {
349
- ...(logMessage.dd || []),
383
+ ...logMessage.dd,
350
384
  service,
351
385
  env,
352
386
  version,
353
387
  },
354
388
  ddsource: 'dd_debugger',
355
- ...logMessage,
356
389
  }
357
390
  }
358
391
 
@@ -458,6 +491,41 @@ class CiVisibilityExporter extends BufferingExporter {
458
491
  evpProxyPrefix: this.evpProxyPrefix,
459
492
  }, callback)
460
493
  }
494
+
495
+ /**
496
+ * Returns whether the exporter can upload test failure screenshots.
497
+ *
498
+ * @returns {boolean}
499
+ */
500
+ canUploadTestScreenshots () {
501
+ return Boolean(this._testScreenshotUploadUrl) && this._isTestFailureScreenshotsEnabled
502
+ }
503
+
504
+ /**
505
+ * Uploads a single test screenshot to the Test Optimization media intake.
506
+ *
507
+ * @param {object} options - Upload options
508
+ * @param {string} options.filePath - Path to the screenshot file
509
+ * @param {string} options.traceId - Test trace id used as the screenshot key
510
+ * @param {string} options.idempotencyKey - Stable per-artifact key, reused on retry
511
+ * @param {number} options.capturedAtMs - Capture time in epoch milliseconds
512
+ * @param {Function} callback - Callback function (err)
513
+ */
514
+ uploadTestScreenshot ({ filePath, traceId, idempotencyKey, capturedAtMs }, callback) {
515
+ if (!this._testScreenshotUploadUrl) {
516
+ return callback(new Error('Test screenshot upload URL not configured'))
517
+ }
518
+
519
+ uploadTestScreenshotRequest({
520
+ filePath,
521
+ traceId,
522
+ idempotencyKey,
523
+ capturedAtMs,
524
+ url: this._testScreenshotUploadUrl,
525
+ isEvpProxy: !!this._isUsingEvpProxy,
526
+ evpProxyPrefix: this.evpProxyPrefix,
527
+ }, callback)
528
+ }
461
529
  }
462
530
 
463
531
  module.exports = CiVisibilityExporter