dd-trace 6.13.0 → 6.14.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 (201) hide show
  1. package/LICENSE-3rdparty.csv +3 -1
  2. package/ci/init.js +8 -3
  3. package/ci/test-optimization-validation/ci-package-scripts.js +2 -2
  4. package/ci/test-optimization-validation/ci-remediation.js +6 -6
  5. package/ci/test-optimization-validation/command-blocker.js +7 -5
  6. package/ci/test-optimization-validation/framework-adapters/cucumber.js +1 -1
  7. package/ci/test-optimization-validation/framework-adapters/vitest.js +3 -3
  8. package/ci/test-optimization-validation/package-check.js +1 -1
  9. package/ci/test-optimization-validation/report-writer.js +7 -5
  10. package/ci/vitest-no-worker-init-runner-setup.mjs +8 -0
  11. package/ci/vitest-no-worker-init-setup.mjs +13 -13
  12. package/ci/vitest-no-worker-init-v5-setup.mjs +4 -0
  13. package/package.json +13 -10
  14. package/packages/datadog-esbuild/index.js +3 -1
  15. package/packages/datadog-instrumentations/src/cypress-config.js +19 -17
  16. package/packages/datadog-instrumentations/src/fs.js +24 -20
  17. package/packages/datadog-instrumentations/src/google-cloud-vertexai.js +2 -2
  18. package/packages/datadog-instrumentations/src/graphql.js +16 -16
  19. package/packages/datadog-instrumentations/src/helpers/graphql-jit-runtime.js +504 -0
  20. package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -0
  21. package/packages/datadog-instrumentations/src/helpers/pool-acquire.js +265 -0
  22. package/packages/datadog-instrumentations/src/helpers/rewriter/compiler.js +1 -2
  23. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +95 -30
  24. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/ai.js +0 -25
  25. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/graphql-jit.js +83 -0
  26. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/graphql.js +85 -1
  27. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
  28. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/mercurius.js +30 -12
  29. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +13 -0
  30. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +251 -0
  31. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +11 -1
  32. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +562 -27
  33. package/packages/datadog-instrumentations/src/jest.js +25 -20
  34. package/packages/datadog-instrumentations/src/knex.js +76 -0
  35. package/packages/datadog-instrumentations/src/mariadb.js +6 -0
  36. package/packages/datadog-instrumentations/src/mocha/common.js +59 -2
  37. package/packages/datadog-instrumentations/src/mocha/main.js +25 -15
  38. package/packages/datadog-instrumentations/src/mocha/utils.js +2 -1
  39. package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +98 -0
  40. package/packages/datadog-instrumentations/src/mocha/worker.js +12 -8
  41. package/packages/datadog-instrumentations/src/net.js +14 -17
  42. package/packages/datadog-instrumentations/src/next.js +212 -62
  43. package/packages/datadog-instrumentations/src/openai.js +42 -40
  44. package/packages/datadog-instrumentations/src/pg.js +15 -0
  45. package/packages/datadog-instrumentations/src/playwright.js +109 -13
  46. package/packages/datadog-instrumentations/src/rum-browser-scripts.js +40 -0
  47. package/packages/datadog-instrumentations/src/sequelize.js +94 -2
  48. package/packages/datadog-instrumentations/src/undici.js +53 -4
  49. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +37 -3
  50. package/packages/datadog-instrumentations/src/vitest-main.js +92 -9
  51. package/packages/datadog-instrumentations/src/vitest-worker.js +71 -14
  52. package/packages/datadog-instrumentations/src/webdriverio.js +965 -6
  53. package/packages/datadog-plugin-amqp10/src/consumer.js +3 -1
  54. package/packages/datadog-plugin-amqp10/src/producer.js +3 -1
  55. package/packages/datadog-plugin-amqp10/src/util.js +2 -1
  56. package/packages/datadog-plugin-avsc/src/schema_iterator.js +6 -1
  57. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +16 -5
  58. package/packages/datadog-plugin-cassandra-driver/src/index.js +8 -3
  59. package/packages/datadog-plugin-cucumber/src/index.js +0 -2
  60. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +262 -48
  61. package/packages/datadog-plugin-google-cloud-pubsub/src/client.js +4 -1
  62. package/packages/datadog-plugin-graphql/src/execute.js +998 -196
  63. package/packages/datadog-plugin-graphql/src/index.js +27 -7
  64. package/packages/datadog-plugin-graphql/src/jit.js +136 -0
  65. package/packages/datadog-plugin-graphql/src/request.js +18 -27
  66. package/packages/datadog-plugin-graphql/src/resolve-error.js +108 -0
  67. package/packages/datadog-plugin-graphql/src/tools/signature.js +1 -2
  68. package/packages/datadog-plugin-graphql/src/tools/transforms.js +45 -3
  69. package/packages/datadog-plugin-graphql/src/utils.js +208 -120
  70. package/packages/datadog-plugin-graphql/src/validate.js +10 -23
  71. package/packages/datadog-plugin-jest/src/index.js +0 -6
  72. package/packages/datadog-plugin-langchain/src/tracing.js +7 -0
  73. package/packages/datadog-plugin-mocha/src/index.js +403 -75
  74. package/packages/datadog-plugin-mongodb-core/src/query.js +6 -0
  75. package/packages/datadog-plugin-mysql/src/index.js +41 -15
  76. package/packages/datadog-plugin-net/src/tcp.js +3 -1
  77. package/packages/datadog-plugin-next/src/index.js +63 -29
  78. package/packages/datadog-plugin-playwright/src/index.js +167 -27
  79. package/packages/datadog-plugin-undici/src/index.js +27 -41
  80. package/packages/datadog-plugin-vitest/src/index.js +6 -12
  81. package/packages/dd-trace/src/aiguard/evaluation.js +2 -1
  82. package/packages/dd-trace/src/aiguard/messages/anthropic.js +8 -1
  83. package/packages/dd-trace/src/aiguard/messages/openai.js +8 -1
  84. package/packages/dd-trace/src/aiguard/messages/vercel-ai.js +20 -6
  85. package/packages/dd-trace/src/appsec/api_security/index.js +23 -6
  86. package/packages/dd-trace/src/appsec/api_security/sampler.js +99 -40
  87. package/packages/dd-trace/src/appsec/graphql.js +6 -2
  88. package/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +3 -3
  89. package/packages/dd-trace/src/appsec/iast/taint-tracking/taint-tracking-impl.js +2 -1
  90. package/packages/dd-trace/src/appsec/iast/telemetry/span-tags.js +8 -2
  91. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/evidence-redaction/sensitive-analyzers/sql-sensitive-analyzer.js +11 -36
  92. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/evidence-redaction/sensitive-analyzers/url-sensitive-analyzer.js +1 -1
  93. package/packages/dd-trace/src/appsec/lambda.js +44 -26
  94. package/packages/dd-trace/src/ci-visibility/encode/json-encoder.js +32 -2
  95. package/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +8 -8
  96. package/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js +6 -4
  97. package/packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js +20 -4
  98. package/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js +5 -173
  99. package/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js +7 -6
  100. package/packages/dd-trace/src/ci-visibility/exporters/agents.js +54 -0
  101. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/writer.js +1 -2
  102. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +394 -225
  103. package/packages/dd-trace/src/ci-visibility/exporters/limits.js +9 -0
  104. package/packages/dd-trace/src/ci-visibility/exporters/request.js +323 -31
  105. package/packages/dd-trace/src/ci-visibility/exporters/settings-cache-key.js +42 -0
  106. package/packages/dd-trace/src/ci-visibility/final-flush.js +2 -1
  107. package/packages/dd-trace/src/ci-visibility/requests/fs-cache.js +57 -18
  108. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +0 -3
  109. package/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +164 -71
  110. package/packages/dd-trace/src/ci-visibility/requests/video-request.js +176 -0
  111. package/packages/dd-trace/src/ci-visibility/telemetry.js +10 -7
  112. package/packages/dd-trace/src/ci-visibility/test-video.js +61 -0
  113. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -0
  114. package/packages/dd-trace/src/config/index.js +73 -13
  115. package/packages/dd-trace/src/config/parsers.js +11 -1
  116. package/packages/dd-trace/src/config/stable.js +3 -3
  117. package/packages/dd-trace/src/config/supported-configurations.json +20 -2
  118. package/packages/dd-trace/src/crashtracking/crashtracker.js +82 -18
  119. package/packages/dd-trace/src/debugger/config.js +12 -1
  120. package/packages/dd-trace/src/debugger/constants.js +1 -0
  121. package/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +2 -1
  122. package/packages/dd-trace/src/debugger/devtools_client/request-options.js +33 -0
  123. package/packages/dd-trace/src/debugger/devtools_client/send.js +7 -40
  124. package/packages/dd-trace/src/debugger/devtools_client/status.js +11 -6
  125. package/packages/dd-trace/src/debugger/devtools_client/tags.js +41 -0
  126. package/packages/dd-trace/src/debugger/index.js +23 -3
  127. package/packages/dd-trace/src/dogstatsd.js +510 -76
  128. package/packages/dd-trace/src/encode/0.4-cross-payload.js +170 -0
  129. package/packages/dd-trace/src/encode/0.4.js +5 -5
  130. package/packages/dd-trace/src/encode/agentless-ci-visibility.js +9 -8
  131. package/packages/dd-trace/src/evp_proxy/direct.js +27 -23
  132. package/packages/dd-trace/src/exporters/agent/index.js +2 -1
  133. package/packages/dd-trace/src/exporters/agent/writer.js +24 -7
  134. package/packages/dd-trace/src/exporters/agentless/index.js +7 -9
  135. package/packages/dd-trace/src/exporters/agentless/writer.js +100 -118
  136. package/packages/dd-trace/src/exporters/common/agents.js +28 -6
  137. package/packages/dd-trace/src/exporters/common/buffering-exporter.js +2 -2
  138. package/packages/dd-trace/src/exporters/common/final-flush-request-tracker.js +191 -0
  139. package/packages/dd-trace/src/exporters/common/request.js +31 -11
  140. package/packages/dd-trace/src/exporters/common/url.js +40 -1
  141. package/packages/dd-trace/src/exporters/common/writer.js +9 -7
  142. package/packages/dd-trace/src/flush.js +6 -3
  143. package/packages/dd-trace/src/id.js +14 -0
  144. package/packages/dd-trace/src/index.js +5 -2
  145. package/packages/dd-trace/src/llmobs/experiments/dataset.js +2 -1
  146. package/packages/dd-trace/src/llmobs/index.js +32 -32
  147. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +22 -71
  148. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +23 -13
  149. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +7 -0
  150. package/packages/dd-trace/src/llmobs/util.js +0 -33
  151. package/packages/dd-trace/src/llmobs/writers/base.js +49 -32
  152. package/packages/dd-trace/src/log/index.js +7 -1
  153. package/packages/dd-trace/src/{ci-visibility/log-submission → log-submission}/log-submission-plugin.js +48 -19
  154. package/packages/dd-trace/src/openfeature/writers/base.js +14 -12
  155. package/packages/dd-trace/src/openfeature/writers/util.js +1 -1
  156. package/packages/dd-trace/src/opentelemetry/logs/index.js +5 -24
  157. package/packages/dd-trace/src/opentelemetry/logs/otlp_http_log_exporter.js +14 -4
  158. package/packages/dd-trace/src/opentelemetry/metrics/index.js +15 -22
  159. package/packages/dd-trace/src/opentelemetry/metrics/otlp_http_metric_exporter.js +14 -4
  160. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js +10 -0
  161. package/packages/dd-trace/src/opentelemetry/otlp/otlp_transformer_base.js +19 -4
  162. package/packages/dd-trace/src/opentelemetry/resource-attributes.js +55 -0
  163. package/packages/dd-trace/src/opentelemetry/trace/index.js +6 -1
  164. package/packages/dd-trace/src/opentelemetry/trace/otlp_http_trace_exporter.js +10 -0
  165. package/packages/dd-trace/src/opentracing/propagation/text_map.js +93 -18
  166. package/packages/dd-trace/src/payload-tagging/index.js +2 -1
  167. package/packages/dd-trace/src/plugins/ci_plugin.js +1 -6
  168. package/packages/dd-trace/src/plugins/tracing.js +12 -10
  169. package/packages/dd-trace/src/plugins/util/ci.js +3 -1
  170. package/packages/dd-trace/src/plugins/util/inferred_proxy.js +16 -15
  171. package/packages/dd-trace/src/plugins/util/test.js +83 -20
  172. package/packages/dd-trace/src/plugins/util/url.js +2 -2
  173. package/packages/dd-trace/src/plugins/util/web.js +4 -9
  174. package/packages/dd-trace/src/profiling/config.js +1 -0
  175. package/packages/dd-trace/src/profiling/oom.js +7 -2
  176. package/packages/dd-trace/src/profiling/profiler.js +23 -13
  177. package/packages/dd-trace/src/profiling/profilers/space.js +38 -11
  178. package/packages/dd-trace/src/profiling/webspan-utils.js +7 -4
  179. package/packages/dd-trace/src/proxy.js +75 -5
  180. package/packages/dd-trace/src/remote_config/index.js +319 -101
  181. package/packages/dd-trace/src/remote_config/scheduler.js +37 -12
  182. package/packages/dd-trace/src/runtime_metrics/client.js +37 -8
  183. package/packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js +5 -1
  184. package/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +5 -1
  185. package/packages/dd-trace/src/sampling_rule.js +36 -9
  186. package/packages/dd-trace/src/serverless/telemetry-delivery-tracker.js +4 -8
  187. package/packages/dd-trace/src/serverless/vercel.js +53 -20
  188. package/packages/dd-trace/src/serverless.js +10 -3
  189. package/packages/dd-trace/src/service-naming/schemas/util.js +1 -1
  190. package/packages/dd-trace/src/span_stats.js +24 -28
  191. package/packages/dd-trace/src/startup-log.js +3 -2
  192. package/packages/dd-trace/src/telemetry/agentless-url.js +28 -0
  193. package/packages/dd-trace/src/telemetry/send-data.js +18 -16
  194. package/packages/dd-trace/src/telemetry/session-propagation.js +3 -2
  195. package/packages/dd-trace/src/tracer.js +4 -5
  196. package/packages/dd-trace/src/tracer_metadata.js +11 -3
  197. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  198. package/vendor/dist/protobufjs/index.js +1 -1
  199. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  200. package/packages/datadog-instrumentations/src/playwright-browser-scripts.js +0 -27
  201. package/packages/dd-trace/src/encode/agentless-json.js +0 -210
@@ -1,36 +1,56 @@
1
1
  'use strict'
2
2
 
3
+ const fs = require('node:fs')
3
4
  const { hostname: getHostname } = require('node:os')
4
5
  const URL = require('url').URL
5
6
 
6
7
  const { version: tracerVersion } = require('../../../../../package.json')
7
8
  const { EMPTY_EFD_RETRY_POLICY, createEfdRetryPolicy } = require('../efd-retry-policy')
8
9
  const { getLibraryConfiguration: getLibraryConfigurationRequest } = require('../requests/get-library-configuration')
10
+ const { getCachePath, withCache, writeToCache } = require('../requests/fs-cache')
9
11
  const { getSkippableSuites: getSkippableSuitesRequest } = require('../intelligent-test-runner/get-skippable-suites')
10
12
  const { getKnownTests: getKnownTestsRequest } = require('../early-flake-detection/get-known-tests')
11
13
  const { getTestManagementTests: getTestManagementTestsRequest } =
12
14
  require('../test-management/get-test-management-tests')
13
15
  const { writeSettingsToCache } = require('../test-optimization-cache')
14
16
  const { CACHE_MISS, TestOptimizationHttpCache } = require('../test-optimization-http-cache')
15
- const { incrementCountMetric, TELEMETRY_EVENTS_ENQUEUED_FOR_SERIALIZATION } = require('../telemetry')
17
+ const { MAX_RETRIES } = require('../test-optimization-http-cache-schema')
16
18
  const { uploadCoverageReport: uploadCoverageReportRequest } = require('../requests/upload-coverage-report')
17
- const { uploadTestScreenshot: uploadTestScreenshotRequest } = require('../requests/upload-test-screenshot')
19
+ const {
20
+ uploadTestScreenshot: uploadTestScreenshotRequest,
21
+ uploadTestSuiteVideo: uploadTestSuiteVideoRequest,
22
+ uploadTestVideo: uploadTestVideoRequest,
23
+ } = require('../requests/upload-test-screenshot')
18
24
  const { parsers } = require('../../config/parsers')
19
25
  const log = require('../../log')
20
- const spanFormat = require('../../span_format')
21
26
  const { getSegment } = require('../../util')
22
27
  const BufferingExporter = require('../../exporters/common/buffering-exporter')
23
28
  const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA } = require('../../plugins/util/tags')
24
- const { TEST_STATUS } = require('../../plugins/util/test')
25
29
  const {
26
30
  createFinalFlushTimeoutError,
27
31
  FINAL_FLUSH_FALLBACK_DELAY,
28
32
  FINAL_FLUSH_TIMEOUT,
29
33
  } = require('../final-flush')
34
+ const { incrementCountMetric, TELEMETRY_ENDPOINT_PAYLOAD_DROPPED } = require('../telemetry')
30
35
  const { sendGitMetadata: sendGitMetadataRequest } = require('./git/git_metadata')
36
+ const buildSettingsCacheKey = require('./settings-cache-key')
31
37
 
32
38
  const hostname = getHostname()
33
39
  const EMPTY_SETTINGS = Object.freeze({})
40
+ const SETTINGS_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
41
+ const SETTINGS_BOOLEAN_FIELDS = Object.freeze([
42
+ 'isCodeCoverageEnabled',
43
+ 'isSuitesSkippingEnabled',
44
+ 'isItrEnabled',
45
+ 'requireGit',
46
+ 'isEarlyFlakeDetectionEnabled',
47
+ 'isFlakyTestRetriesEnabled',
48
+ 'isDiEnabled',
49
+ 'isKnownTestsEnabled',
50
+ 'isTestManagementEnabled',
51
+ 'isImpactedTestsEnabled',
52
+ 'isCoverageReportUploadEnabled',
53
+ ])
34
54
 
35
55
  /**
36
56
  * Test session identity sent with every request. Fields are optional because the CI provider,
@@ -56,50 +76,131 @@ function getTestConfigurationTags (tags) {
56
76
  }, {})
57
77
  }
58
78
 
79
+ function isTestSessionEvent (span) {
80
+ return span.type === 'test_session_end' || span.type === 'test_suite_end' || span.type === 'test_module_end'
81
+ }
82
+
59
83
  function getIsTestSessionTrace (trace) {
60
- return trace.some(span =>
61
- span.type === 'test_session_end' || span.type === 'test_suite_end' || span.type === 'test_module_end'
62
- )
84
+ return trace.some(isTestSessionEvent)
85
+ }
86
+
87
+ /**
88
+ * Checks whether a value is a non-negative safe integer.
89
+ *
90
+ * @param {unknown} value - Candidate integer.
91
+ * @returns {value is number}
92
+ */
93
+ function isNonNegativeSafeInteger (value) {
94
+ return Number.isSafeInteger(value) && value >= 0
95
+ }
96
+
97
+ /**
98
+ * Checks whether a value is a retry count accepted by the settings parser.
99
+ *
100
+ * @param {unknown} value - Candidate retry count.
101
+ * @returns {value is number}
102
+ */
103
+ function isValidRetryCount (value) {
104
+ return isNonNegativeSafeInteger(value) && value <= MAX_RETRIES
105
+ }
106
+
107
+ /**
108
+ * Checks whether a cached EFD retry policy has the complete parsed shape.
109
+ *
110
+ * @param {unknown} retryPolicy - Candidate retry policy.
111
+ * @returns {boolean}
112
+ */
113
+ function isValidCachedEfdRetryPolicy (retryPolicy) {
114
+ if (retryPolicy === null || typeof retryPolicy !== 'object' || Array.isArray(retryPolicy)) return false
115
+ if (!isValidRetryCount(retryPolicy.schedulingRetryCount)) return false
116
+ if (!Array.isArray(retryPolicy.durationRetryCounts) ||
117
+ retryPolicy.durationRetryCounts.length !== EMPTY_EFD_RETRY_POLICY.durationRetryCounts.length) {
118
+ return false
119
+ }
120
+
121
+ let schedulingRetryCount = 0
122
+ for (let index = 0; index < EMPTY_EFD_RETRY_POLICY.durationRetryCounts.length; index++) {
123
+ const durationRetryCount = retryPolicy.durationRetryCounts[index]
124
+ if (durationRetryCount === null || typeof durationRetryCount !== 'object' ||
125
+ Array.isArray(durationRetryCount) ||
126
+ durationRetryCount.durationLimitMs !==
127
+ EMPTY_EFD_RETRY_POLICY.durationRetryCounts[index].durationLimitMs ||
128
+ !isValidRetryCount(durationRetryCount.retryCount)) {
129
+ return false
130
+ }
131
+ if (durationRetryCount.retryCount > schedulingRetryCount) {
132
+ schedulingRetryCount = durationRetryCount.retryCount
133
+ }
134
+ }
135
+ return retryPolicy.schedulingRetryCount === schedulingRetryCount
136
+ }
137
+
138
+ /**
139
+ * Checks whether a filesystem cache value has the complete shape produced by
140
+ * parseLibraryConfigurationResponse.
141
+ *
142
+ * @param {unknown} settings - Cached settings value.
143
+ * @returns {settings is Record<string, unknown>}
144
+ */
145
+ function isValidCachedSettings (settings) {
146
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) return false
147
+ for (const field of SETTINGS_BOOLEAN_FIELDS) {
148
+ if (typeof settings[field] !== 'boolean') return false
149
+ }
150
+ if (settings.isDiEnabled && !settings.isFlakyTestRetriesEnabled) return false
151
+ if (settings.isEarlyFlakeDetectionEnabled && !settings.isKnownTestsEnabled) return false
152
+ if (!isValidCachedEfdRetryPolicy(settings.earlyFlakeDetectionRetryPolicy)) return false
153
+ if (!isNonNegativeSafeInteger(settings.earlyFlakeDetectionFaultyThreshold) ||
154
+ settings.earlyFlakeDetectionFaultyThreshold > 100) {
155
+ return false
156
+ }
157
+ return settings.testManagementAttemptToFixRetries === undefined ||
158
+ isValidRetryCount(settings.testManagementAttemptToFixRetries)
63
159
  }
64
160
 
65
161
  const GIT_UPLOAD_TIMEOUT = 60_000 // 60 seconds
66
162
  const CAN_USE_CI_VIS_PROTOCOL_TIMEOUT = GIT_UPLOAD_TIMEOUT
67
163
  const MAX_COVERAGE_REPORT_FLAGS = 32
164
+ // Give a 200 MiB background video five minutes; final flush still caps shutdown at 60 seconds.
165
+ const BACKGROUND_VIDEO_UPLOAD_TIMEOUT = 5 * FINAL_FLUSH_TIMEOUT
68
166
 
167
+ /**
168
+ * @param {string} tags
169
+ * @param {string} key
170
+ * @param {string | undefined} value
171
+ */
69
172
  function appendLogTag (tags, key, value) {
70
- if (value !== undefined) {
71
- tags.push(`${key}:${value}`)
72
- }
173
+ if (value === undefined) return tags
174
+
175
+ const tag = `${key}:${value}`
176
+ return tags ? `${tags},${tag}` : tag
73
177
  }
74
178
 
75
179
  function getLogTags (logMessage, { env, version }, gitRepositoryUrl, gitCommitSha) {
76
- const tags = []
180
+ let tags = ''
77
181
  if (Array.isArray(logMessage.ddtags)) {
78
- for (const tag of logMessage.ddtags) {
79
- tags.push(tag)
80
- }
182
+ tags = logMessage.ddtags.join(',')
81
183
  } else if (logMessage.ddtags) {
82
- for (const tag of logMessage.ddtags.split(',')) {
83
- tags.push(tag)
84
- }
184
+ tags = logMessage.ddtags
85
185
  }
86
186
 
87
- appendLogTag(tags, 'env', env)
88
- appendLogTag(tags, 'version', version)
89
- appendLogTag(tags, 'debugger_version', tracerVersion)
90
- appendLogTag(tags, 'host_name', hostname)
91
- appendLogTag(tags, GIT_COMMIT_SHA, gitCommitSha)
92
- appendLogTag(tags, GIT_REPOSITORY_URL, gitRepositoryUrl)
187
+ tags = appendLogTag(tags, 'env', env)
188
+ tags = appendLogTag(tags, 'version', version)
189
+ tags = appendLogTag(tags, 'debugger_version', tracerVersion)
190
+ tags = appendLogTag(tags, 'host_name', hostname)
191
+ tags = appendLogTag(tags, GIT_COMMIT_SHA, gitCommitSha)
192
+ tags = appendLogTag(tags, GIT_REPOSITORY_URL, gitRepositoryUrl)
93
193
 
94
- return tags.join(',')
194
+ return tags
95
195
  }
96
196
 
97
197
  class CiVisibilityExporter extends BufferingExporter {
98
198
  #finalFlush
99
199
  #deferredTestSessionTraces = []
100
- #pendingScreenshotUploads = new Set()
101
- #screenshotFlushWaiters = new Set()
200
+ #pendingMediaUploads = new Set()
201
+ #mediaFlushWaiters = new Set()
102
202
  #deferredTestSuiteSpans = new Map()
203
+ #gitUploadTimeoutId
103
204
 
104
205
  constructor (config, options = {}) {
105
206
  super(config)
@@ -125,11 +226,8 @@ class CiVisibilityExporter extends BufferingExporter {
125
226
 
126
227
  this._isTestFailureScreenshotsEnabled =
127
228
  Boolean(config?.testOptimization?.DD_TEST_FAILURE_SCREENSHOTS_ENABLED)
128
-
129
- const gitUploadTimeoutId = setTimeout(() => {
130
- this._resolveGit(new Error('Timeout while uploading git metadata'))
131
- }, GIT_UPLOAD_TIMEOUT)
132
- gitUploadTimeoutId.unref?.()
229
+ this._isTestFailureVideosEnabled =
230
+ Boolean(config?.testOptimization?.DD_TEST_FAILURE_VIDEOS_ENABLED)
133
231
 
134
232
  const canUseCiVisProtocolTimeoutId = setTimeout(() => {
135
233
  this._resolveCanUseCiVisProtocol(false)
@@ -138,7 +236,8 @@ class CiVisibilityExporter extends BufferingExporter {
138
236
 
139
237
  this._gitUploadPromise = new Promise(resolve => {
140
238
  this._resolveGit = (err) => {
141
- clearTimeout(gitUploadTimeoutId)
239
+ clearTimeout(this.#gitUploadTimeoutId)
240
+ this.#gitUploadTimeoutId = null
142
241
  resolve(err)
143
242
  }
144
243
  })
@@ -270,51 +369,142 @@ class CiVisibilityExporter extends BufferingExporter {
270
369
  const cachedLibraryConfig = this._testOptimizationHttpCache.readSettings()
271
370
  if (cachedLibraryConfig !== CACHE_MISS) {
272
371
  log.debug('Test Optimization HTTP cache settings found, skipping settings request')
273
- writeSettingsToCache(cachedLibraryConfig)
274
- this._libraryConfig = this.filterConfiguration(cachedLibraryConfig)
275
- const canUseCachedSkippableSuites = !this.shouldRequestSkippableSuites() ||
276
- this._testOptimizationHttpCache.hasValidSkippableSuites({
277
- testLevel: configuration.testLevel,
278
- isCoverageReportUploadEnabled: configuration.isCoverageReportUploadEnabled,
279
- })
280
- if (this._libraryConfig.requireGit && !canUseCachedSkippableSuites) {
281
- this.sendGitMetadata(repositoryUrl)
282
- } else {
283
- this._resolveGit()
284
- }
285
- return callback(null, this._libraryConfig)
372
+ return this._applyCachedSettings(cachedLibraryConfig, configuration, repositoryUrl, false, callback)
286
373
  }
287
374
 
288
375
  if (this._isTestOptimizationCacheOnly) {
289
376
  return callback(this._getCacheOnlyError('settings'), {})
290
377
  }
291
378
 
292
- this.sendGitMetadata(repositoryUrl)
293
- getLibraryConfigurationRequest(configuration, (err, libraryConfig) => {
379
+ // The settings request has a two-phase shape: when the backend returns
380
+ // `require_git`, we upload git metadata and re-request settings. Only the
381
+ // final (post-git-upload) configuration is cached, so the cross-process
382
+ // filesystem cache never serves a pre-git-upload response that would skip
383
+ // the git upload in other processes.
384
+ //
385
+ // The live-fetch and cache-hit paths resolve the git upload promise
386
+ // differently, so they must not share a single apply path:
387
+ // - Live fetch: `sendGitMetadata` is started in `_fetchLibraryConfigurationFromBackend`
388
+ // and resolves `_gitUploadPromise` itself (with the upload result). We must NOT call
389
+ // `_resolveGit()` here, or we would race the in-flight upload and mask its error.
390
+ // - Filesystem cache hit: no upload was started in this process, so `_applyCachedSettings`
391
+ // resolves the git promise locally (starting an upload only if the cached config still
392
+ // requires git and we lack valid cached skippable suites).
393
+ let liveFetchStarted = false
394
+ const fsCacheKey = buildSettingsCacheKey(configuration)
395
+ const fetchSettings = (activeCacheKey, done) => {
396
+ liveFetchStarted = true
397
+ this._fetchLibraryConfigurationFromBackend(configuration, repositoryUrl, activeCacheKey, done)
398
+ }
399
+ const applySettings = (err, libraryConfig) => {
294
400
  /**
295
401
  * **Important**: this._libraryConfig remains empty in testing frameworks
296
402
  * where the tests run in a subprocess, like Jest,
297
403
  * because `getLibraryConfiguration` is called only once in the main process.
298
404
  */
299
- this._libraryConfig = this.filterConfiguration(libraryConfig)
300
-
301
405
  if (err) {
302
- callback(err, {})
303
- } else if (libraryConfig?.requireGit) {
304
- // If the backend requires git, we'll wait for the upload to finish and request settings again
305
- this._gitUploadPromise.then(gitUploadError => {
306
- if (gitUploadError) {
307
- return callback(gitUploadError, {})
406
+ return callback(err, {})
407
+ }
408
+ if (liveFetchStarted) {
409
+ // Live fetch: the git upload was already started and resolves
410
+ // `_gitUploadPromise` itself. Do not call `_resolveGit()`.
411
+ writeSettingsToCache(libraryConfig)
412
+ this._libraryConfig = this.filterConfiguration(libraryConfig)
413
+ return callback(null, this._libraryConfig)
414
+ }
415
+ // Filesystem cache hit: no git upload was started in this process.
416
+ if (!isValidCachedSettings(libraryConfig)) {
417
+ // A syntactically valid cache file with an invalid settings payload is corrupt.
418
+ // Settings never writes such a value, so this is external corruption. Remove the
419
+ // file and fall back to the backend so we never serve a garbage config that would
420
+ // crash filterConfiguration or silently disable every feature.
421
+ try {
422
+ fs.unlinkSync(getCachePath(fsCacheKey))
423
+ } catch (err) {
424
+ if (err.code !== 'ENOENT') {
425
+ return fetchSettings(null, applySettings)
308
426
  }
309
- getLibraryConfigurationRequest(configuration, (err, finalLibraryConfig) => {
310
- this._libraryConfig = this.filterConfiguration(finalLibraryConfig)
311
- callback(err, this._libraryConfig)
312
- })
313
- })
314
- } else {
315
- callback(null, this._libraryConfig)
427
+ }
428
+ liveFetchStarted = false
429
+ return withCache(fsCacheKey, fetchSettings, applySettings, SETTINGS_CACHE_TTL_MS)
316
430
  }
431
+ this._applyCachedSettings(libraryConfig, configuration, repositoryUrl, true, callback)
432
+ }
433
+ withCache(fsCacheKey, fetchSettings, applySettings, SETTINGS_CACHE_TTL_MS)
434
+ })
435
+ }
436
+
437
+ /**
438
+ * Applies a resolved (cached or freshly fetched) library configuration: writes it to the
439
+ * shared settings cache, filters it through local kill switches, and resolves the git
440
+ * upload promise according to whether git metadata still needs to be uploaded.
441
+ *
442
+ * @param {Record<string, unknown>} settings - Resolved library configuration.
443
+ * @param {object} configuration - Request configuration used to evaluate cached skippable suites.
444
+ * @param {string} repositoryUrl - Repository URL for git metadata upload.
445
+ * @param {boolean} isFilesystemCache - Whether settings came from the cross-process cache.
446
+ * @param {Function} callback - Completion callback.
447
+ * @returns {void}
448
+ */
449
+ _applyCachedSettings (settings, configuration, repositoryUrl, isFilesystemCache, callback) {
450
+ writeSettingsToCache(settings)
451
+ this._libraryConfig = this.filterConfiguration(settings)
452
+ const canUseCachedSkippableSuites = !this.shouldRequestSkippableSuites() ||
453
+ this._testOptimizationHttpCache.hasValidSkippableSuites({
454
+ testLevel: configuration.testLevel,
455
+ isCoverageReportUploadEnabled: configuration.isCoverageReportUploadEnabled,
317
456
  })
457
+ if (!canUseCachedSkippableSuites && (this._libraryConfig.requireGit || isFilesystemCache)) {
458
+ this.sendGitMetadata(repositoryUrl)
459
+ } else {
460
+ this._resolveGit()
461
+ }
462
+ callback(null, this._libraryConfig)
463
+ }
464
+
465
+ /**
466
+ * Fetches library configuration from the backend, performing the two-phase
467
+ * `require_git` re-request, and writes only the final configuration to the
468
+ * filesystem cache when this process owns the cache lock.
469
+ *
470
+ * @param {object} configuration - Request configuration for the settings endpoint.
471
+ * @param {string} repositoryUrl - Repository URL for git metadata upload.
472
+ * @param {string|null} cacheKey - Filesystem cache key when this process owns the lock, null otherwise.
473
+ * @param {Function} done - Completion callback.
474
+ * @returns {void}
475
+ */
476
+ _fetchLibraryConfigurationFromBackend (configuration, repositoryUrl, cacheKey, done) {
477
+ this.sendGitMetadata(repositoryUrl)
478
+ getLibraryConfigurationRequest(configuration, (err, libraryConfig) => {
479
+ // Mirror the original live path: keep the phase-1 config on `_libraryConfig` even
480
+ // before the git upload resolves, so `shouldRequestSkippableSuites()` and the
481
+ // skippable path's `_gitUploadPromise` await behave identically to the uncached flow.
482
+ this._libraryConfig = this.filterConfiguration(libraryConfig)
483
+ if (err) {
484
+ return done(err, libraryConfig)
485
+ }
486
+ if (libraryConfig?.requireGit) {
487
+ // If the backend requires git, wait for the upload to finish and request settings again
488
+ this._gitUploadPromise.then(gitUploadError => {
489
+ if (gitUploadError) {
490
+ return done(gitUploadError, libraryConfig)
491
+ }
492
+ getLibraryConfigurationRequest(configuration, (finalErr, finalLibraryConfig) => {
493
+ if (finalErr) {
494
+ // Match the original live path: reset `_libraryConfig` from the final
495
+ // (failed) response so stale phase-1 feature flags don't stay installed.
496
+ // On error `finalLibraryConfig` is undefined, so this resolves to empty settings.
497
+ this._libraryConfig = this.filterConfiguration(finalLibraryConfig)
498
+ return done(finalErr, finalLibraryConfig)
499
+ }
500
+ writeToCache(cacheKey, finalLibraryConfig)
501
+ done(null, finalLibraryConfig)
502
+ })
503
+ })
504
+ } else {
505
+ writeToCache(cacheKey, libraryConfig)
506
+ done(null, libraryConfig)
507
+ }
318
508
  })
319
509
  }
320
510
 
@@ -381,6 +571,13 @@ class CiVisibilityExporter extends BufferingExporter {
381
571
  this._resolveGit()
382
572
  return
383
573
  }
574
+ if (this.#gitUploadTimeoutId === null) return
575
+ if (this.#gitUploadTimeoutId === undefined) {
576
+ this.#gitUploadTimeoutId = setTimeout(() => {
577
+ this._resolveGit(new Error('Timeout while uploading git metadata'))
578
+ }, GIT_UPLOAD_TIMEOUT)
579
+ this.#gitUploadTimeoutId.unref?.()
580
+ }
384
581
  this._canUseCiVisProtocolPromise.then((canUseCiVisProtocol) => {
385
582
  if (!canUseCiVisProtocol) {
386
583
  return
@@ -403,34 +600,7 @@ class CiVisibilityExporter extends BufferingExporter {
403
600
 
404
601
  export (trace) {
405
602
  this.#resetFinalFlush()
406
-
407
- if (this.#deferredTestSuiteSpans.size === 0) {
408
- this.#exportTrace(trace)
409
- return
410
- }
411
-
412
- let hasDeferredTestSuiteSpan = false
413
- let immediateTrace
414
- for (let index = 0; index < trace.length; index++) {
415
- const formattedSpan = trace[index]
416
- const spanId = formattedSpan.span_id?.toString()
417
- const deferredTestSuiteSpan = this.#deferredTestSuiteSpans.get(spanId)
418
- if (deferredTestSuiteSpan) {
419
- if (!hasDeferredTestSuiteSpan && index > 0) immediateTrace = trace.slice(0, index)
420
- hasDeferredTestSuiteSpan = true
421
- if (deferredTestSuiteSpan.emitted || (this._isInitialized && !this.canReportSessionTraces())) {
422
- this.#deferredTestSuiteSpans.delete(spanId)
423
- } else {
424
- deferredTestSuiteSpan.formattedSpan = formattedSpan
425
- }
426
- } else if (hasDeferredTestSuiteSpan) {
427
- immediateTrace ??= []
428
- immediateTrace.push(formattedSpan)
429
- }
430
- }
431
-
432
- if (!hasDeferredTestSuiteSpan) this.#exportTrace(trace)
433
- else if (immediateTrace) this.#exportTrace(immediateTrace)
603
+ this.#exportTrace(trace)
434
604
  }
435
605
 
436
606
  /**
@@ -447,6 +617,11 @@ class CiVisibilityExporter extends BufferingExporter {
447
617
  }
448
618
  const isTestSessionTrace = getIsTestSessionTrace(trace)
449
619
  if (!this.canReportSessionTraces() && isTestSessionTrace) {
620
+ const testTrace = []
621
+ for (const span of trace) {
622
+ if (!isTestSessionEvent(span)) testTrace.push(span)
623
+ }
624
+ if (testTrace.length > 0) this._export(testTrace)
450
625
  return
451
626
  }
452
627
  if (this._export(trace, undefined, undefined, isTestSessionTrace) === false && isTestSessionTrace) {
@@ -454,115 +629,6 @@ class CiVisibilityExporter extends BufferingExporter {
454
629
  }
455
630
  }
456
631
 
457
- /**
458
- * Retains a completed suite until finalization so a later framework error can still update it.
459
- *
460
- * @param {import('../../opentracing/span')} testSuiteSpan
461
- * @returns {void}
462
- */
463
- deferTestSuiteSpan (testSuiteSpan) {
464
- this.#resetFinalFlush()
465
- this.#deferredTestSuiteSpans.set(testSuiteSpan.context()._spanId.toString(), {
466
- span: testSuiteSpan,
467
- formattedSpan: undefined,
468
- emitted: false,
469
- })
470
- }
471
-
472
- /**
473
- * Retains formatted test suite events received from test framework workers until finalization.
474
- *
475
- * @param {Array<object>} trace
476
- * @returns {void}
477
- */
478
- exportTraceWithDeferredTestSuite (trace) {
479
- for (const formattedSpan of trace) {
480
- if (formattedSpan.type !== 'test_suite_end') continue
481
-
482
- this.#deferredTestSuiteSpans.set(formattedSpan.span_id.toString(), {
483
- span: undefined,
484
- formattedSpan,
485
- emitted: false,
486
- })
487
- }
488
- this.export(trace)
489
- }
490
-
491
- /**
492
- * Applies a late framework error to every completed suite retained by the current finalization boundary.
493
- *
494
- * @param {Error} error
495
- * @returns {void}
496
- */
497
- setDeferredTestSuiteError (error) {
498
- for (const { span, formattedSpan, emitted } of this.#deferredTestSuiteSpans.values()) {
499
- if (emitted) continue
500
- if (span) {
501
- span.setTag(TEST_STATUS, 'fail')
502
- span.setTag('error', error)
503
- } else if (formattedSpan) {
504
- formattedSpan.meta[TEST_STATUS] = 'fail'
505
- spanFormat.addError(formattedSpan, error)
506
- }
507
- }
508
- }
509
-
510
- /**
511
- * Appends a completed suite while preserving the ordinary writer call shape outside finalization.
512
- *
513
- * @param {object} formattedSpan
514
- * @param {{ deadline?: number }} [options]
515
- * @returns {boolean|undefined}
516
- */
517
- #appendDeferredTestSuiteSpan (formattedSpan, options) {
518
- const appended = options === undefined
519
- ? this._writer.append([formattedSpan])
520
- : this._writer.append([formattedSpan], options)
521
-
522
- if (appended !== false && this._config.isCiVisibility) {
523
- incrementCountMetric(TELEMETRY_EVENTS_ENQUEUED_FOR_SERIALIZATION)
524
- }
525
- return appended
526
- }
527
-
528
- /**
529
- * Appends completed suites before their module and session parents are exported.
530
- *
531
- * @param {{ deadline?: number }} [options]
532
- * @returns {void}
533
- */
534
- exportDeferredTestSuiteSpans (options) {
535
- if (!this._writer || !this.canReportSessionTraces()) return
536
-
537
- for (const [spanId, deferredTestSuiteSpan] of this.#deferredTestSuiteSpans) {
538
- const { span, formattedSpan, emitted } = deferredTestSuiteSpan
539
- if (emitted) continue
540
- const updatedSpan = span && spanFormat(span)
541
- if (span && formattedSpan) {
542
- formattedSpan.error = updatedSpan.error
543
- Object.assign(formattedSpan.meta, updatedSpan.meta)
544
- Object.assign(formattedSpan.metrics, updatedSpan.metrics)
545
- if (this.#appendDeferredTestSuiteSpan(formattedSpan, options) === false) continue
546
- this.#deferredTestSuiteSpans.delete(spanId)
547
- } else if (formattedSpan) {
548
- if (this.#appendDeferredTestSuiteSpan(formattedSpan, options) === false) continue
549
- this.#deferredTestSuiteSpans.delete(spanId)
550
- } else {
551
- if (this.#appendDeferredTestSuiteSpan(updatedSpan, options) === false) continue
552
- deferredTestSuiteSpan.emitted = true
553
- }
554
- }
555
- }
556
-
557
- /**
558
- * Discards completed suites that the selected transport cannot deliver.
559
- *
560
- * @returns {void}
561
- */
562
- resetDeferredTestSuiteSpans () {
563
- this.#deferredTestSuiteSpans.clear()
564
- }
565
-
566
632
  /**
567
633
  * Retries session, module, and suite traces rejected by writer backpressure within the final deadline.
568
634
  *
@@ -630,11 +696,17 @@ class CiVisibilityExporter extends BufferingExporter {
630
696
  }
631
697
 
632
698
  this.#resetFinalFlush()
633
- this._export(
699
+ const appended = this._export(
634
700
  this.formatLogMessage(testEnvironmentMetadata, logMessage),
635
701
  this._logsWriter,
636
702
  '_logsTimer'
637
703
  )
704
+ if (appended === false) {
705
+ incrementCountMetric(
706
+ TELEMETRY_ENDPOINT_PAYLOAD_DROPPED,
707
+ { endpoint: 'di_logs', statusCode: undefined, errorType: 'encoder_limit' }
708
+ )
709
+ }
638
710
  }
639
711
 
640
712
  flush (done) {
@@ -651,7 +723,8 @@ class CiVisibilityExporter extends BufferingExporter {
651
723
  if (isFinalFlush && !this._isInitialized &&
652
724
  this._traceBuffer.length === 0 && this._coverageBuffer.length === 0 &&
653
725
  this.#deferredTestSuiteSpans.size === 0 && this.#deferredTestSessionTraces.length === 0 &&
654
- this.#pendingScreenshotUploads.size === 0) {
726
+ this.#pendingMediaUploads.size === 0) {
727
+ this._initializationRequest?.controller.abort()
655
728
  onDone()
656
729
  return
657
730
  }
@@ -668,6 +741,9 @@ class CiVisibilityExporter extends BufferingExporter {
668
741
  const deadline = isFinalFlush ? Date.now() + FINAL_FLUSH_TIMEOUT : undefined
669
742
  let hasCompleted = false
670
743
  let initializationTimeoutId
744
+ let mediaTimeoutId
745
+ let writersFlushed = false
746
+ let writerFlushError
671
747
 
672
748
  const fallbackTimeoutId = isFinalFlush
673
749
  ? setTimeout(() => {
@@ -680,7 +756,8 @@ class CiVisibilityExporter extends BufferingExporter {
680
756
  hasCompleted = true
681
757
  clearTimeout(fallbackTimeoutId)
682
758
  clearTimeout(initializationTimeoutId)
683
- this.#screenshotFlushWaiters.delete(flushWriters)
759
+ clearTimeout(mediaTimeoutId)
760
+ this.#mediaFlushWaiters.delete(completeAfterMedia)
684
761
  if (error) log.error('Error flushing Test Optimization data', error)
685
762
  if (!isFinalFlush) {
686
763
  onDone(error)
@@ -700,15 +777,23 @@ class CiVisibilityExporter extends BufferingExporter {
700
777
  }
701
778
  }
702
779
 
703
- const flushWriters = () => {
704
- if (isFinalFlush && this.#pendingScreenshotUploads.size !== 0) {
705
- this.#screenshotFlushWaiters.add(flushWriters)
780
+ const completeAfterMedia = () => {
781
+ if (writersFlushed) complete(writerFlushError)
782
+ }
783
+
784
+ const onWritersFlushed = (error) => {
785
+ writersFlushed = true
786
+ writerFlushError = error
787
+ if (isFinalFlush && this.#pendingMediaUploads.size !== 0) {
788
+ this.#mediaFlushWaiters.add(completeAfterMedia)
706
789
  return
707
790
  }
791
+ complete(error)
792
+ }
708
793
 
794
+ const flushWriters = () => {
709
795
  const options = deadline === undefined ? undefined : { deadline }
710
796
  if (isFinalFlush) {
711
- this.exportDeferredTestSuiteSpans(options)
712
797
  this.#exportDeferredTestSessionTraces(options)
713
798
  }
714
799
 
@@ -722,24 +807,31 @@ class CiVisibilityExporter extends BufferingExporter {
722
807
  let flushError
723
808
 
724
809
  if (remaining === 0) {
725
- complete()
810
+ onWritersFlushed()
726
811
  return
727
812
  }
728
813
 
729
814
  const onFlushComplete = (error) => {
730
815
  flushError ||= error
731
816
  remaining -= 1
732
- if (remaining === 0) complete(flushError)
817
+ if (remaining === 0) onWritersFlushed(flushError)
733
818
  }
734
819
 
735
820
  for (const writer of writers) writer.flush(onFlushComplete, options)
736
821
  }
737
822
 
823
+ if (isFinalFlush && this.#pendingMediaUploads.size !== 0) {
824
+ mediaTimeoutId = setTimeout(() => {
825
+ const error = createFinalFlushTimeoutError()
826
+ for (const controller of this.#pendingMediaUploads) controller.abort(error)
827
+ }, Math.max(0, deadline - Date.now()))
828
+ mediaTimeoutId.unref?.()
829
+ }
830
+
738
831
  if (isFinalFlush && this._initializationRequest) {
739
832
  const initializationRequest = this._initializationRequest
740
- const { controller, options } = initializationRequest
833
+ const { controller } = initializationRequest
741
834
  initializationRequest.finalFlush = finalFlush
742
- options.deadline = deadline
743
835
  initializationTimeoutId = setTimeout(() => {
744
836
  const error = createFinalFlushTimeoutError()
745
837
  if (initializationRequest.finalFlush === finalFlush) controller.abort(error)
@@ -847,25 +939,104 @@ class CiVisibilityExporter extends BufferingExporter {
847
939
  return Boolean(this._testScreenshotUploadUrl) && this._isTestFailureScreenshotsEnabled
848
940
  }
849
941
 
942
+ /**
943
+ * Returns whether the exporter can upload test failure videos.
944
+ *
945
+ * @returns {boolean}
946
+ */
947
+ canUploadTestVideos () {
948
+ return Boolean(this._testScreenshotUploadUrl) && this._isTestFailureVideosEnabled
949
+ }
950
+
850
951
  /**
851
952
  * Uploads a single test screenshot to the Test Optimization media intake.
852
953
  *
853
954
  * @param {object} options - Upload options
854
- * @param {string} options.filePath - Path to the screenshot file
955
+ * @param {string} [options.filePath] - Path to the screenshot file
956
+ * @param {Buffer} [options.content] - In-memory screenshot content
855
957
  * @param {string} options.traceId - Test trace id used as the screenshot key
856
958
  * @param {string} options.idempotencyKey - Stable per-artifact key, reused on retry
857
959
  * @param {number} options.capturedAtMs - Capture time in epoch milliseconds
858
960
  * @param {AbortSignal} [options.signal] - Additional signal used to cancel the upload
859
961
  * @param {Function} callback - Callback function (err)
860
962
  */
861
- uploadTestScreenshot ({ filePath, traceId, idempotencyKey, capturedAtMs, signal }, callback) {
963
+ uploadTestScreenshot ({ filePath, content, traceId, idempotencyKey, capturedAtMs, signal }, callback) {
862
964
  if (!this._testScreenshotUploadUrl) {
863
965
  return callback(new Error('Test screenshot upload URL not configured'))
864
966
  }
865
967
 
968
+ this.#uploadTestMedia(uploadTestScreenshotRequest, {
969
+ filePath,
970
+ content,
971
+ traceId,
972
+ idempotencyKey,
973
+ capturedAtMs,
974
+ signal,
975
+ }, callback)
976
+ }
977
+
978
+ /**
979
+ * Uploads a test-scoped failure video to the Test Optimization media intake.
980
+ *
981
+ * @param {object} options - Upload options
982
+ * @param {string} options.filePath - Path to the video file
983
+ * @param {string} options.traceId - Test trace id used as the media key
984
+ * @param {string} options.idempotencyKey - Stable per-artifact key, reused on retry
985
+ * @param {number} options.capturedAtMs - Capture time in epoch milliseconds
986
+ * @param {AbortSignal} [options.signal] - Additional signal used to cancel the upload
987
+ * @param {Function} callback - Callback function (err)
988
+ * @returns {void}
989
+ */
990
+ uploadTestVideo (options, callback) {
991
+ if (!this._testScreenshotUploadUrl) {
992
+ return callback(new Error('Test video upload URL not configured'))
993
+ }
994
+
995
+ this.#uploadTestMedia(uploadTestVideoRequest, options, callback, BACKGROUND_VIDEO_UPLOAD_TIMEOUT)
996
+ }
997
+
998
+ /**
999
+ * Uploads a suite-scoped failure video to the Test Optimization media intake.
1000
+ *
1001
+ * @param {object} options - Upload options
1002
+ * @param {string} options.filePath - Path to the video file
1003
+ * @param {string} options.testSessionId - Test session id used as the media key
1004
+ * @param {string} options.testSuiteId - Test suite id used as the media key
1005
+ * @param {string} options.idempotencyKey - Stable per-artifact key, reused on retry
1006
+ * @param {number} options.capturedAtMs - Capture time in epoch milliseconds
1007
+ * @param {AbortSignal} [options.signal] - Additional signal used to cancel the upload
1008
+ * @param {Function} callback - Callback function (err)
1009
+ * @returns {void}
1010
+ */
1011
+ uploadTestSuiteVideo (options, callback) {
1012
+ if (!this._testScreenshotUploadUrl) {
1013
+ return callback(new Error('Test suite video upload URL not configured'))
1014
+ }
1015
+
1016
+ this.#uploadTestMedia(uploadTestSuiteVideoRequest, options, callback, BACKGROUND_VIDEO_UPLOAD_TIMEOUT)
1017
+ }
1018
+
1019
+ /**
1020
+ * Starts and tracks one media upload so final flush waits for it.
1021
+ *
1022
+ * @param {Function} uploadRequest - Media request function
1023
+ * @param {object} uploadOptions - Media request options
1024
+ * @param {AbortSignal} [uploadOptions.signal] - Additional signal used to cancel the upload
1025
+ * @param {Function} callback - Callback function (err)
1026
+ * @param {number} [timeoutMs] - Maximum background upload duration
1027
+ * @returns {void}
1028
+ */
1029
+ #uploadTestMedia (uploadRequest, uploadOptions, callback, timeoutMs = FINAL_FLUSH_TIMEOUT) {
1030
+ const { signal } = uploadOptions
1031
+
1032
+ if (signal?.aborted) {
1033
+ const error = signal.reason || Object.assign(new Error('Test screenshot upload aborted'), { code: 'ABORT_ERR' })
1034
+ callback(error)
1035
+ return
1036
+ }
866
1037
  this.#resetFinalFlush()
1038
+ const deadline = Date.now() + timeoutMs
867
1039
  const controller = new AbortController()
868
- const deadline = Date.now() + FINAL_FLUSH_TIMEOUT
869
1040
  let settled = false
870
1041
  const complete = (error) => {
871
1042
  if (settled) return
@@ -876,10 +1047,10 @@ class CiVisibilityExporter extends BufferingExporter {
876
1047
  try {
877
1048
  callback(error)
878
1049
  } finally {
879
- this.#pendingScreenshotUploads.delete(controller)
880
- if (this.#pendingScreenshotUploads.size === 0) {
881
- const waiters = [...this.#screenshotFlushWaiters]
882
- this.#screenshotFlushWaiters.clear()
1050
+ this.#pendingMediaUploads.delete(controller)
1051
+ if (this.#pendingMediaUploads.size === 0) {
1052
+ const waiters = [...this.#mediaFlushWaiters]
1053
+ this.#mediaFlushWaiters.clear()
883
1054
  for (const waiter of waiters) queueMicrotask(waiter)
884
1055
  }
885
1056
  }
@@ -890,25 +1061,23 @@ class CiVisibilityExporter extends BufferingExporter {
890
1061
  complete(error)
891
1062
  }
892
1063
 
893
- this.#pendingScreenshotUploads.add(controller)
894
- signal?.addEventListener('abort', onAbort, { once: true })
895
1064
  const timeoutId = setTimeout(() => {
896
1065
  const error = createFinalFlushTimeoutError()
897
1066
  controller.abort(error)
898
1067
  complete(error)
899
- }, FINAL_FLUSH_TIMEOUT)
1068
+ }, Math.max(0, deadline - Date.now()))
900
1069
  timeoutId.unref?.()
901
1070
 
1071
+ this.#pendingMediaUploads.add(controller)
1072
+ signal?.addEventListener('abort', onAbort, { once: true })
1073
+
902
1074
  if (signal?.aborted) {
903
1075
  onAbort()
904
1076
  return
905
1077
  }
906
1078
 
907
- uploadTestScreenshotRequest({
908
- filePath,
909
- traceId,
910
- idempotencyKey,
911
- capturedAtMs,
1079
+ uploadRequest({
1080
+ ...uploadOptions,
912
1081
  url: this._testScreenshotUploadUrl,
913
1082
  isEvpProxy: !!this._isUsingEvpProxy,
914
1083
  evpProxyPrefix: this.evpProxyPrefix,