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
@@ -1,9 +1,5 @@
1
1
  'use strict'
2
2
 
3
- // Capture real timers at module load time, before any test can install fake timers.
4
- const realDateNow = Date.now.bind(Date)
5
- const realSetTimeout = setTimeout
6
-
7
3
  const CiPlugin = require('../../dd-trace/src/plugins/ci_plugin')
8
4
  const { storage } = require('../../datadog-core')
9
5
  const { getEnvironmentVariable } = require('../../dd-trace/src/config/helper')
@@ -54,9 +50,6 @@ const {
54
50
  TELEMETRY_TEST_SESSION,
55
51
  } = require('../../dd-trace/src/ci-visibility/telemetry')
56
52
 
57
- const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200
58
- const BREAKPOINT_SET_GRACE_PERIOD_MS = 400
59
-
60
53
  const isCucumberWorker = !!getEnvironmentVariable('CUCUMBER_WORKER_ID')
61
54
 
62
55
  class CucumberPlugin extends CiPlugin {
@@ -248,17 +241,14 @@ class CucumberPlugin extends CiPlugin {
248
241
  ctx.currentStore = { ...store, span }
249
242
 
250
243
  this.activeTestSpan = span
251
- // Time we give the breakpoint to be hit
252
244
  if (promises && this.runningTestProbe) {
253
- promises.hitBreakpointPromise = new Promise((resolve) => {
254
- realSetTimeout(resolve, BREAKPOINT_HIT_GRACE_PERIOD_MS)
255
- })
245
+ promises.hitBreakpointPromise = this.waitForDiBreakpointHits()
256
246
  }
257
247
 
258
248
  return ctx.currentStore
259
249
  })
260
250
 
261
- this.addSub('ci:cucumber:test:retry', ({ span, isFirstAttempt, error, isAtrRetry }) => {
251
+ this.addSub('ci:cucumber:test:retry', ({ span, isFirstAttempt, error, isAtrRetry, promises, canWaitForDi }) => {
262
252
  if (!isFirstAttempt) {
263
253
  span.setTag(TEST_IS_RETRY, 'true')
264
254
  if (isAtrRetry) {
@@ -268,18 +258,18 @@ class CucumberPlugin extends CiPlugin {
268
258
  }
269
259
  }
270
260
  span.setTag('error', error)
271
- if (isFirstAttempt && this.di && error && this.libraryConfig?.isDiEnabled) {
272
- const probeInformation = this.addDiProbe(error)
273
- if (probeInformation) {
274
- const { file, line, stackIndex } = probeInformation
275
- this.runningTestProbe = { file, line }
276
- this.testErrorStackIndex = stackIndex
277
- const waitUntil = realDateNow() + BREAKPOINT_SET_GRACE_PERIOD_MS
278
- while (realDateNow() < waitUntil) {
279
- // TODO: To avoid a race condition, we should wait until `probeInformation.setProbePromise` has resolved.
280
- // However, Cucumber doesn't have a mechanism for waiting asyncrounously here, so for now, we'll have to
281
- // fall back to a fixed syncronous delay.
261
+ if (canWaitForDi !== false && promises && this.di && error && this.libraryConfig?.isDiEnabled) {
262
+ if (isFirstAttempt) {
263
+ const probeInformation = this.addDiProbe(error)
264
+ if (probeInformation) {
265
+ const { file, line, stackIndex, setProbePromise } = probeInformation
266
+ this.runningTestProbe = { file, line }
267
+ this.testErrorStackIndex = stackIndex
268
+ this.prepareDiBreakpointHitWait()
269
+ promises.setProbePromise = this.waitForDiOperation(setProbePromise)
282
270
  }
271
+ } else if (this.runningTestProbe) {
272
+ this.prepareDiBreakpointHitWait()
283
273
  }
284
274
  }
285
275
  span.setTag(TEST_STATUS, 'fail')
@@ -410,6 +400,7 @@ class CucumberPlugin extends CiPlugin {
410
400
  this.tracer._exporter.flush()
411
401
  }
412
402
  this.activeTestSpan = null
403
+ this.cancelDiBreakpointHitWait()
413
404
  if (this.runningTestProbe) {
414
405
  this.removeDiProbe(this.runningTestProbe)
415
406
  this.runningTestProbe = null
@@ -2,6 +2,8 @@
2
2
 
3
3
  // Capture real timers at module load, before any test can install fake timers.
4
4
  const { performance } = require('perf_hooks')
5
+ const { statSync } = require('node:fs')
6
+ const { basename } = require('node:path')
5
7
  const dateNow = Date.now
6
8
 
7
9
  const { createCoverageMap } = require('../../../vendor/dist/istanbul-lib-coverage')
@@ -77,6 +79,8 @@ const {
77
79
  getMaxEfdRetryCount,
78
80
  getPullRequestBaseBranch,
79
81
  TEST_FINAL_STATUS,
82
+ TEST_FAILURE_SCREENSHOT_UPLOADED,
83
+ TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR,
80
84
  getTestOptimizationRequestResults,
81
85
  } = require('../../dd-trace/src/plugins/util/test')
82
86
  const { isMarkedAsUnskippable } = require('../../datadog-plugin-jest/src/util')
@@ -136,6 +140,127 @@ const CYPRESS_STATUS_TO_TEST_STATUS = {
136
140
  skipped: 'skip',
137
141
  }
138
142
 
143
+ const SCREENSHOT_ATTEMPT_RE = /\(attempt \d+\)/
144
+ const SCREENSHOT_UPLOAD_RESULT_UPLOADED = 'uploaded'
145
+ const SCREENSHOT_UPLOAD_RESULT_ERROR = 'error'
146
+
147
+ function getScreenshotFilePath (screenshot) {
148
+ return typeof screenshot === 'string' ? screenshot : screenshot?.path
149
+ }
150
+
151
+ function isFailureScreenshotByMetadata (screenshot, screenshotFilePath) {
152
+ if (screenshot !== null && typeof screenshot === 'object' && screenshot.testFailure !== undefined) {
153
+ return screenshot.testFailure === true
154
+ }
155
+ return screenshotFilePath.includes('(failed)')
156
+ }
157
+
158
+ /**
159
+ * Resolves a screenshot's capture time (epoch ms) for the media upload. Cypress
160
+ * screenshot objects carry an ISO `takenAt`; falls back to the file's mtime, then
161
+ * to the current time. Stamped once here and reused on retry via the idempotency
162
+ * key, so the stored object is overwritten rather than duplicated.
163
+ *
164
+ * @param {object|string} screenshot - Cypress screenshot object or path
165
+ * @param {string} filePath - Resolved screenshot file path
166
+ * @returns {number} Capture time in epoch milliseconds
167
+ */
168
+ function getScreenshotCapturedAtMs (screenshot, filePath) {
169
+ const takenAt = screenshot !== null && typeof screenshot === 'object' ? screenshot.takenAt : undefined
170
+ if (takenAt) {
171
+ const parsedMs = new Date(takenAt).getTime()
172
+ if (Number.isInteger(parsedMs) && parsedMs > 0) {
173
+ return parsedMs
174
+ }
175
+ }
176
+ try {
177
+ return Math.floor(statSync(filePath).mtimeMs)
178
+ } catch {
179
+ return dateNow()
180
+ }
181
+ }
182
+
183
+ function isFailureScreenshotForUpload (screenshot) {
184
+ const screenshotFilePath = getScreenshotFilePath(screenshot)
185
+ if (!screenshotFilePath) {
186
+ return false
187
+ }
188
+ if (screenshot !== null && typeof screenshot === 'object') {
189
+ // after:screenshot details omit testFailure for manual cy.screenshot() captures, so this
190
+ // path cannot safely fall back to the '(failed)' filename marker.
191
+ return screenshot.testFailure === true
192
+ }
193
+ return screenshotFilePath.includes('(failed)')
194
+ }
195
+
196
+ function isFailureScreenshot (screenshot) {
197
+ const screenshotFilePath = getScreenshotFilePath(screenshot)
198
+ // Require an explicit failure signal: prefer the `testFailure` metadata, and fall back to the
199
+ // '(failed)' filename marker only when the RunResult omits `testFailure`. This keeps manual
200
+ // cy.screenshot() captures out of the failure-screenshot upload (privacy).
201
+ return !!screenshotFilePath && isFailureScreenshotByMetadata(screenshot, screenshotFilePath)
202
+ }
203
+
204
+ function getAttemptScreenshots (cypressTest, attemptIndex) {
205
+ if (!Array.isArray(cypressTest.attempts)) {
206
+ return []
207
+ }
208
+ const attempt = cypressTest.attempts[attemptIndex]
209
+ if (!Array.isArray(attempt?.screenshots)) {
210
+ return []
211
+ }
212
+ return attempt.screenshots.filter(isFailureScreenshot)
213
+ }
214
+
215
+ function isScreenshotForTestAttempt (screenshot, titleParts, attemptIndex) {
216
+ const screenshotFilePath = getScreenshotFilePath(screenshot)
217
+ if (!screenshotFilePath || !isFailureScreenshot(screenshot)) {
218
+ return false
219
+ }
220
+ for (const titlePart of titleParts) {
221
+ if (!screenshotFilePath.includes(titlePart)) {
222
+ return false
223
+ }
224
+ }
225
+ if (attemptIndex === 0) {
226
+ return !SCREENSHOT_ATTEMPT_RE.test(screenshotFilePath)
227
+ }
228
+ return screenshotFilePath.includes(`(attempt ${attemptIndex + 1})`)
229
+ }
230
+
231
+ function getTestScreenshots (cypressTest, attemptIndex, specScreenshots) {
232
+ const attemptScreenshots = getAttemptScreenshots(cypressTest, attemptIndex)
233
+ if (attemptScreenshots.length > 0) {
234
+ return attemptScreenshots
235
+ }
236
+ if (!Array.isArray(specScreenshots)) {
237
+ return []
238
+ }
239
+ const titleParts = Array.isArray(cypressTest.title) ? cypressTest.title : []
240
+ return specScreenshots.filter(screenshot => isScreenshotForTestAttempt(screenshot, titleParts, attemptIndex))
241
+ }
242
+
243
+ function getScreenshotUploadResult (uploadResults) {
244
+ let hasUploaded = false
245
+ for (const uploadResult of uploadResults) {
246
+ if (uploadResult === SCREENSHOT_UPLOAD_RESULT_ERROR) {
247
+ return SCREENSHOT_UPLOAD_RESULT_ERROR
248
+ }
249
+ if (uploadResult === SCREENSHOT_UPLOAD_RESULT_UPLOADED) {
250
+ hasUploaded = true
251
+ }
252
+ }
253
+ return hasUploaded ? SCREENSHOT_UPLOAD_RESULT_UPLOADED : undefined
254
+ }
255
+
256
+ function setScreenshotUploadTags (testSpan, uploadResult) {
257
+ if (uploadResult === SCREENSHOT_UPLOAD_RESULT_ERROR) {
258
+ testSpan.setTag(TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR, 'true')
259
+ } else if (uploadResult === SCREENSHOT_UPLOAD_RESULT_UPLOADED) {
260
+ testSpan.setTag(TEST_FAILURE_SCREENSHOT_UPLOADED, 'true')
261
+ }
262
+ }
263
+
139
264
  function getSessionStatus (summary) {
140
265
  if (summary.totalFailed !== undefined && summary.totalFailed > 0) {
141
266
  return 'fail'
@@ -404,6 +529,11 @@ class CypressPlugin {
404
529
  newTestsWithDynamicNames = new Set()
405
530
  attemptToFixExecutions = new Map()
406
531
  loggedAttemptToFixTests = new Set()
532
+ uploadedScreenshotPaths = new Set()
533
+ screenshotUploadPromisesByTraceId = new Map()
534
+ afterScreenshotHandler = undefined
535
+ lastFinishedTest = null
536
+ pendingScreenshotUploads = []
407
537
 
408
538
  constructor () {
409
539
  const {
@@ -489,6 +619,10 @@ class CypressPlugin {
489
619
  this.modifiedFiles = []
490
620
  this.attemptToFixExecutions = new Map()
491
621
  this.loggedAttemptToFixTests = new Set()
622
+ this.uploadedScreenshotPaths = new Set()
623
+ this.screenshotUploadPromisesByTraceId = new Map()
624
+ this.lastFinishedTest = null
625
+ this.pendingScreenshotUploads = []
492
626
  this.activeTestSpan = null
493
627
  this.testSuiteSpan = null
494
628
  this.testModuleSpan = null
@@ -505,6 +639,50 @@ class CypressPlugin {
505
639
  this._perfOrigin = 0
506
640
  }
507
641
 
642
+ /**
643
+ * Returns a stable after:screenshot handler so auto-instrumentation can recognize
644
+ * the handler registered by the manual plugin and avoid treating it as a user hook.
645
+ *
646
+ * @returns {Function} Datadog after:screenshot handler
647
+ */
648
+ getAfterScreenshotHandler () {
649
+ if (!this.afterScreenshotHandler) {
650
+ this.afterScreenshotHandler = this.afterScreenshot.bind(this)
651
+ }
652
+ return this.afterScreenshotHandler
653
+ }
654
+
655
+ /**
656
+ * Tracks a screenshot upload by its owning test trace id so the test event can be tagged
657
+ * before the span is finished.
658
+ *
659
+ * @param {string} traceId - Test trace id used for the upload
660
+ * @param {Promise<string|undefined>} uploadPromise - Promise resolving to the upload outcome
661
+ * @returns {void}
662
+ */
663
+ addScreenshotUploadPromise (traceId, uploadPromise) {
664
+ const uploadPromises = this.screenshotUploadPromisesByTraceId.get(traceId)
665
+ if (uploadPromises) {
666
+ uploadPromises.push(uploadPromise)
667
+ } else {
668
+ this.screenshotUploadPromisesByTraceId.set(traceId, [uploadPromise])
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Returns the aggregate upload outcome for all screenshots associated with a test trace id.
674
+ *
675
+ * @param {string} traceId - Test trace id used for the upload
676
+ * @returns {Promise<string|undefined>|undefined} Promise resolving to the aggregate upload outcome
677
+ */
678
+ getScreenshotUploadResultPromise (traceId) {
679
+ const uploadPromises = this.screenshotUploadPromisesByTraceId.get(traceId)
680
+ if (!uploadPromises?.length) {
681
+ return
682
+ }
683
+ return Promise.all(uploadPromises).then(getScreenshotUploadResult)
684
+ }
685
+
508
686
  /**
509
687
  * Returns the current time in the same coordinate system used by span
510
688
  * start/finish. Captured at session span creation so it shares the same
@@ -627,6 +805,37 @@ class CypressPlugin {
627
805
  })
628
806
  }
629
807
 
808
+ /**
809
+ * Warns when screenshot upload is enabled but Cypress cannot produce or send the screenshots.
810
+ *
811
+ * @param {object} cypressConfig - Cypress resolved config
812
+ * @param {object} tracer - dd-trace proxy tracer
813
+ * @param {object} testOptimizationConfig - Test Optimization config
814
+ * @returns {void}
815
+ */
816
+ warnIfMisconfiguredTestFailureScreenshots (cypressConfig, tracer, testOptimizationConfig) {
817
+ if (!testOptimizationConfig.DD_TEST_FAILURE_SCREENSHOTS_ENABLED) {
818
+ return
819
+ }
820
+
821
+ if (cypressConfig.screenshotOnRunFailure === false) {
822
+ log.warn(
823
+ '%s %s',
824
+ 'DD_TEST_FAILURE_SCREENSHOTS_ENABLED is true, but Cypress screenshotOnRunFailure is false.',
825
+ 'Datadog cannot upload failure screenshots unless Cypress is configured to capture them.'
826
+ )
827
+ return
828
+ }
829
+
830
+ if (!tracer?._tracer?._exporter?.canUploadTestScreenshots?.()) {
831
+ log.warn(
832
+ '%s %s',
833
+ 'DD_TEST_FAILURE_SCREENSHOTS_ENABLED is true, but Cypress failure screenshot upload is only supported',
834
+ 'in agentless mode.'
835
+ )
836
+ }
837
+ }
838
+
630
839
  // Init function returns a promise that resolves with the Cypress configuration
631
840
  // Depending on the received configuration, the Cypress configuration can be modified:
632
841
  // for example, to enable retries for failed tests.
@@ -639,7 +848,9 @@ class CypressPlugin {
639
848
 
640
849
  this.isTestIsolationEnabled = getIsTestIsolationEnabled(cypressConfig)
641
850
 
642
- this.rumFlushWaitMillis = getConfig().testOptimization.DD_CIVISIBILITY_RUM_FLUSH_WAIT_MILLIS
851
+ const testOptimizationConfig = getConfig().testOptimization
852
+ this.rumFlushWaitMillis = testOptimizationConfig.DD_CIVISIBILITY_RUM_FLUSH_WAIT_MILLIS
853
+ this.warnIfMisconfiguredTestFailureScreenshots(cypressConfig, tracer, testOptimizationConfig)
643
854
 
644
855
  if (!this.isTestIsolationEnabled) {
645
856
  log.warn('Test isolation is disabled, retries will not be enabled')
@@ -1182,10 +1393,42 @@ class CypressPlugin {
1182
1393
  })
1183
1394
  }
1184
1395
 
1396
+ /**
1397
+ * Uploads failure screenshots as soon as Cypress creates them.
1398
+ *
1399
+ * @param {object} details - Cypress screenshot details
1400
+ * @returns {void}
1401
+ */
1402
+ afterScreenshot (details) {
1403
+ const lastFailedTestSpan = this.lastFinishedTest?.testStatus === 'fail'
1404
+ ? this.lastFinishedTest.testSpan
1405
+ : undefined
1406
+ const testSpan = this.activeTestSpan || lastFailedTestSpan
1407
+
1408
+ if (!testSpan) {
1409
+ return
1410
+ }
1411
+
1412
+ if (!isFailureScreenshotForUpload(details)) {
1413
+ return
1414
+ }
1415
+
1416
+ const screenshotUploadPromise = this.uploadTestScreenshots({
1417
+ screenshots: [details],
1418
+ traceId: testSpan.context().toTraceId(),
1419
+ })
1420
+ if (screenshotUploadPromise) {
1421
+ this.pendingScreenshotUploads.push(screenshotUploadPromise)
1422
+ }
1423
+ }
1424
+
1185
1425
  afterSpec (spec, results) {
1186
- const { tests, stats } = results || {}
1426
+ const { tests, stats, screenshots } = results || {}
1187
1427
  const cypressTests = tests || []
1428
+ const specScreenshots = screenshots || []
1188
1429
  const finishedTests = this.finishedTestsByFile[spec.relative] || []
1430
+ const screenshotUploadPromises = []
1431
+ const testSpanFinishPromises = []
1189
1432
 
1190
1433
  if (!this.testSuiteSpan) {
1191
1434
  // dd:testSuiteStart hasn't been triggered for whatever reason
@@ -1292,6 +1535,20 @@ class CypressPlugin {
1292
1535
  if (cypressTest.displayError) {
1293
1536
  latestError = new Error(cypressTest.displayError)
1294
1537
  }
1538
+
1539
+ let failedTestTraceId
1540
+ if (cypressTestStatus === 'fail' || finishedTest.testStatus === 'fail' || cypressTest.displayError) {
1541
+ failedTestTraceId = finishedTest.testSpan.context().toTraceId()
1542
+ const testScreenshots = getTestScreenshots(cypressTest, attemptIndex, specScreenshots)
1543
+ const screenshotUploadPromise = this.uploadTestScreenshots({
1544
+ screenshots: testScreenshots,
1545
+ traceId: failedTestTraceId,
1546
+ })
1547
+ if (screenshotUploadPromise) {
1548
+ screenshotUploadPromises.push(screenshotUploadPromise)
1549
+ }
1550
+ }
1551
+
1295
1552
  // Update test status - but NOT for non-ATF quarantined tests where we intentionally
1296
1553
  // report 'fail' to Datadog even though Cypress sees it as 'pass'
1297
1554
  const isQuarantinedTest = finishedTest.testSpan?.context()?.getTag(TEST_MANAGEMENT_IS_QUARANTINED) === 'true'
@@ -1348,20 +1605,102 @@ class CypressPlugin {
1348
1605
  }
1349
1606
  }
1350
1607
 
1351
- finishedTest.testSpan.finish(finishedTest.finishTime)
1608
+ const screenshotUploadResultPromise = failedTestTraceId
1609
+ ? this.getScreenshotUploadResultPromise(failedTestTraceId)
1610
+ : undefined
1611
+ if (screenshotUploadResultPromise) {
1612
+ testSpanFinishPromises.push(screenshotUploadResultPromise.then((uploadResult) => {
1613
+ setScreenshotUploadTags(finishedTest.testSpan, uploadResult)
1614
+ this.screenshotUploadPromisesByTraceId.delete(failedTestTraceId)
1615
+ finishedTest.testSpan.finish(finishedTest.finishTime)
1616
+ }))
1617
+ } else {
1618
+ finishedTest.testSpan.finish(finishedTest.finishTime)
1619
+ }
1352
1620
  }
1353
1621
  }
1354
1622
 
1355
- if (this.testSuiteSpan) {
1356
- const status = getSuiteStatus(stats)
1357
- this.testSuiteSpan.setTag(TEST_STATUS, status)
1623
+ const finishSuite = () => {
1624
+ if (this.testSuiteSpan) {
1625
+ const status = getSuiteStatus(stats)
1626
+ this.testSuiteSpan.setTag(TEST_STATUS, status)
1627
+
1628
+ if (latestError) {
1629
+ this.testSuiteSpan.setTag('error', latestError)
1630
+ }
1631
+ this.testSuiteSpan.finish()
1632
+ this.testSuiteSpan = null
1633
+ this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
1634
+ }
1635
+ }
1636
+
1637
+ const waitForScreenshotUploads = () => {
1638
+ if (screenshotUploadPromises.length > 0 || this.pendingScreenshotUploads.length > 0) {
1639
+ const pendingScreenshotUploads = this.pendingScreenshotUploads
1640
+ this.pendingScreenshotUploads = []
1641
+ return Promise.all([...pendingScreenshotUploads, ...screenshotUploadPromises]).then(() => null)
1642
+ }
1643
+ }
1644
+
1645
+ finishSuite()
1646
+
1647
+ const screenshotUploadsPromise = waitForScreenshotUploads()
1648
+ if (testSpanFinishPromises.length > 0) {
1649
+ const testSpansPromise = Promise.all(testSpanFinishPromises).then(() => null)
1650
+ if (screenshotUploadsPromise) {
1651
+ return Promise.all([testSpansPromise, screenshotUploadsPromise]).then(() => null)
1652
+ }
1653
+ return testSpansPromise
1654
+ }
1655
+
1656
+ return screenshotUploadsPromise
1657
+ }
1358
1658
 
1359
- if (latestError) {
1360
- this.testSuiteSpan.setTag('error', latestError)
1659
+ /**
1660
+ * Uploads failure screenshots for a Cypress test attempt.
1661
+ *
1662
+ * @param {object} options - Upload options
1663
+ * @param {Array<object|string>} options.screenshots - Cypress screenshot objects or screenshot paths
1664
+ * @param {string} options.traceId - Test trace id used as the screenshot key
1665
+ * @returns {Promise<string|undefined>|undefined}
1666
+ */
1667
+ uploadTestScreenshots ({ screenshots, traceId }) {
1668
+ const exporter = this.tracer?._tracer?._exporter
1669
+ if (!screenshots.length ||
1670
+ !exporter?.canUploadTestScreenshots?.() ||
1671
+ !exporter.uploadTestScreenshot) {
1672
+ return
1673
+ }
1674
+
1675
+ const uploadPromises = []
1676
+
1677
+ for (const screenshot of screenshots) {
1678
+ const filePath = getScreenshotFilePath(screenshot)
1679
+ if (!filePath || this.uploadedScreenshotPaths.has(filePath)) {
1680
+ continue
1361
1681
  }
1362
- this.testSuiteSpan.finish()
1363
- this.testSuiteSpan = null
1364
- this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
1682
+ this.uploadedScreenshotPaths.add(filePath)
1683
+ // Stable per-artifact key (trace + filename) so an upload retry overwrites the
1684
+ // same stored object instead of duplicating; the Cypress filename encodes the
1685
+ // test name and attempt, so it is unique within the trace.
1686
+ const idempotencyKey = `${traceId}:${basename(filePath)}`
1687
+ const capturedAtMs = getScreenshotCapturedAtMs(screenshot, filePath)
1688
+ uploadPromises.push(new Promise(resolve => {
1689
+ exporter.uploadTestScreenshot({
1690
+ filePath,
1691
+ traceId,
1692
+ idempotencyKey,
1693
+ capturedAtMs,
1694
+ }, (err) => {
1695
+ resolve(err ? SCREENSHOT_UPLOAD_RESULT_ERROR : SCREENSHOT_UPLOAD_RESULT_UPLOADED)
1696
+ })
1697
+ }))
1698
+ }
1699
+
1700
+ if (uploadPromises.length > 0) {
1701
+ const uploadPromise = Promise.all(uploadPromises).then(getScreenshotUploadResult)
1702
+ this.addScreenshotUploadPromise(traceId, uploadPromise)
1703
+ return uploadPromise
1365
1704
  }
1366
1705
  }
1367
1706
 
@@ -1643,6 +1982,7 @@ class CypressPlugin {
1643
1982
  } else {
1644
1983
  this.finishedTestsByFile[testSuite] = [finishedTest]
1645
1984
  }
1985
+ this.lastFinishedTest = finishedTest
1646
1986
  // test spans are finished at after:spec
1647
1987
  this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'test', {
1648
1988
  hasCodeOwners: !!activeSpanTags[TEST_CODE_OWNERS],
@@ -18,7 +18,14 @@ class CypressPlugin extends Plugin {
18
18
  // Mirrors the guard in the manual plugin entrypoint (plugin.js).
19
19
  if (this._tracer._tracer instanceof NoopTracer) return
20
20
 
21
- const { on, config, userAfterSpecHandlers, userAfterRunHandlers, cleanupWrapper } = payload
21
+ const {
22
+ on,
23
+ config,
24
+ userAfterSpecHandlers,
25
+ userAfterRunHandlers,
26
+ userAfterScreenshotHandlers,
27
+ cleanupWrapper,
28
+ } = payload
22
29
 
23
30
  const registerAfterRunWithCleanup = (afterRunHandler) => {
24
31
  on('after:run', (results) => {
@@ -34,16 +41,54 @@ class CypressPlugin extends Plugin {
34
41
  }
35
42
 
36
43
  const cypressPlugin = require('./cypress-plugin')
44
+ const datadogAfterScreenshotHandler = cypressPlugin.getAfterScreenshotHandler()
45
+
46
+ // Cypress keeps a single after:screenshot handler, so registering the
47
+ // plugin's would drop any user handler (e.g. one that moves/renames a
48
+ // screenshot). Chain the user handler(s) first, threading the returned
49
+ // { path, size, dimensions } forward — Cypress uses the final path for
50
+ // downstream steps — then run the plugin's afterScreenshot on those
51
+ // details and propagate the value back to Cypress. The user handler runs
52
+ // regardless of whether screenshot upload is enabled.
53
+ const registerAfterScreenshot = (afterScreenshotHandler) => {
54
+ const filteredUserAfterScreenshotHandlers = userAfterScreenshotHandlers.filter(
55
+ handler => handler !== afterScreenshotHandler
56
+ )
57
+ if (filteredUserAfterScreenshotHandlers.length === 0) {
58
+ if (afterScreenshotHandler) on('after:screenshot', afterScreenshotHandler)
59
+ return
60
+ }
61
+
62
+ on('after:screenshot', (details) => {
63
+ const chain = filteredUserAfterScreenshotHandlers.reduce(
64
+ (p, h) => p.then((latestDetails) => Promise.resolve(h(latestDetails)).then(
65
+ // Merge the user handler's (possibly partial) return into the running details
66
+ // rather than replacing them, so Cypress metadata such as `testFailure` and
67
+ // `takenAt` survives a handler that only returns { path, size, dimensions }.
68
+ (returned) => (returned == null ? latestDetails : { ...latestDetails, ...returned })
69
+ )),
70
+ Promise.resolve(details)
71
+ )
72
+ return chain.then((finalDetails) => {
73
+ if (afterScreenshotHandler) afterScreenshotHandler(finalDetails)
74
+ return finalDetails
75
+ })
76
+ })
77
+ }
37
78
 
38
79
  if (cypressPlugin._isInit) {
39
- // Already initialized by manual plugin call — just chain user handlers
80
+ // Already initialized by manual plugin call — just chain user handlers.
81
+ // Pass the plugin's afterScreenshot so chaining a user handler doesn't drop the upload
82
+ // (the chained registration replaces the one plugin.js set, so it must include it).
40
83
  for (const h of userAfterSpecHandlers) on('after:spec', h)
84
+ registerAfterScreenshot(datadogAfterScreenshotHandler)
41
85
  registerAfterRunWithCleanup()
42
86
  payload.registered = true
43
87
  return
44
88
  }
45
89
 
46
90
  on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin))
91
+ registerAfterScreenshot(datadogAfterScreenshotHandler)
47
92
 
48
93
  on('after:spec', (spec, results) => {
49
94
  const chain = userAfterSpecHandlers.reduce(
@@ -40,6 +40,7 @@ module.exports = function CypressPlugin (on, config) {
40
40
  }
41
41
 
42
42
  on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin))
43
+ on('after:screenshot', cypressPlugin.getAfterScreenshotHandler())
43
44
  on('after:spec', cypressPlugin.afterSpec.bind(cypressPlugin))
44
45
  on('after:run', cypressPlugin.afterRun.bind(cypressPlugin))
45
46
  on('task', cypressPlugin.getTasks())