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
@@ -50,13 +50,25 @@ function getLockPath (cacheKey) {
50
50
  return path.join(tmpdir(), `dd-${cacheKey}.lock`)
51
51
  }
52
52
 
53
+ /**
54
+ * Checks whether a filesystem cache timestamp can be safely compared with the current time.
55
+ *
56
+ * @param {unknown} timestamp - Timestamp read from a cache or lock file.
57
+ * @param {number} now - Current Unix timestamp in milliseconds.
58
+ * @returns {timestamp is number}
59
+ */
60
+ function isValidTimestamp (timestamp, now) {
61
+ return Number.isSafeInteger(timestamp) && timestamp >= 0 && timestamp <= now
62
+ }
63
+
53
64
  /**
54
65
  * Attempts to read cached data from the filesystem.
55
66
  *
56
67
  * @param {string} cacheKey
68
+ * @param {number} [cacheTtlMs] - Maximum cache age in milliseconds.
57
69
  * @returns {{ data: unknown } | undefined}
58
70
  */
59
- function readFromCache (cacheKey) {
71
+ function readFromCache (cacheKey, cacheTtlMs = CACHE_TTL_MS) {
60
72
  const cachePath = getCachePath(cacheKey)
61
73
  try {
62
74
  const raw = fs.readFileSync(cachePath, 'utf8')
@@ -66,8 +78,14 @@ function readFromCache (cacheKey) {
66
78
  return
67
79
  }
68
80
  const { timestamp, data } = parsed
69
- if (Date.now() - timestamp > CACHE_TTL_MS) {
70
- log.debug('%s cache expired (age: %d ms)', cacheKey, Date.now() - timestamp)
81
+ const now = Date.now()
82
+ if (!isValidTimestamp(timestamp, now)) {
83
+ log.debug('%s cache file has an invalid timestamp, ignoring', cacheKey)
84
+ return
85
+ }
86
+ const age = now - timestamp
87
+ if (age > cacheTtlMs) {
88
+ log.debug('%s cache expired (age: %d ms)', cacheKey, age)
71
89
  return
72
90
  }
73
91
  log.debug('%s cache hit', cacheKey)
@@ -101,7 +119,7 @@ function writeToCache (cacheKey, data) {
101
119
  * Attempts to acquire an exclusive lock using O_CREAT|O_EXCL.
102
120
  *
103
121
  * @param {string} cacheKey
104
- * @returns {boolean}
122
+ * @returns {boolean|undefined}
105
123
  */
106
124
  function tryAcquireLock (cacheKey) {
107
125
  const lockPath = getLockPath(cacheKey)
@@ -110,8 +128,9 @@ function tryAcquireLock (cacheKey) {
110
128
  fs.writeSync(fd, String(Date.now()))
111
129
  fs.closeSync(fd)
112
130
  return true
113
- } catch {
114
- return false
131
+ } catch (err) {
132
+ if (err.code === 'EEXIST') return false
133
+ log.debug('%s lock cannot be created, bypassing cache: %s', cacheKey, err.message)
115
134
  }
116
135
  }
117
136
 
@@ -119,9 +138,15 @@ function tryAcquireLock (cacheKey) {
119
138
  * Removes the lock file.
120
139
  *
121
140
  * @param {string} cacheKey
141
+ * @returns {boolean}
122
142
  */
123
143
  function releaseLock (cacheKey) {
124
- try { fs.unlinkSync(getLockPath(cacheKey)) } catch { /* ignore */ }
144
+ try {
145
+ fs.unlinkSync(getLockPath(cacheKey))
146
+ return true
147
+ } catch (err) {
148
+ return err.code === 'ENOENT'
149
+ }
125
150
  }
126
151
 
127
152
  /**
@@ -165,7 +190,9 @@ function startLockHeartbeat (cacheKey) {
165
190
  function isLockStale (cacheKey) {
166
191
  try {
167
192
  const content = fs.readFileSync(getLockPath(cacheKey), 'utf8')
168
- return Date.now() - Number(content) > CACHE_LOCK_TIMEOUT_MS
193
+ const timestamp = Number(content)
194
+ const now = Date.now()
195
+ return !isValidTimestamp(timestamp, now) || now - timestamp > CACHE_LOCK_TIMEOUT_MS
169
196
  } catch {
170
197
  return true
171
198
  }
@@ -175,30 +202,37 @@ function isLockStale (cacheKey) {
175
202
  * Polls until the cache file appears or the timeout is reached.
176
203
  *
177
204
  * @param {string} cacheKey
178
- * @param {Function} fetchFn - function(done) that fetches from the API
205
+ * @param {Function} fetchFn - function(cacheKey, done) that fetches from the API
179
206
  * @param {Function} done - callback(err, ...results)
207
+ * @param {number} cacheTtlMs - Maximum cache age in milliseconds.
180
208
  */
181
- function waitForCache (cacheKey, fetchFn, done) {
209
+ function waitForCache (cacheKey, fetchFn, done, cacheTtlMs) {
182
210
  const poll = () => {
183
- const cached = readFromCache(cacheKey)
211
+ const cached = readFromCache(cacheKey, cacheTtlMs)
184
212
  if (cached) {
185
213
  return done(null, cached.data)
186
214
  }
187
215
  if (isLockStale(cacheKey)) {
188
216
  log.debug('%s lock is stale, attempting takeover', cacheKey)
189
- releaseLock(cacheKey)
190
- if (!tryAcquireLock(cacheKey)) {
217
+ if (!releaseLock(cacheKey)) {
218
+ return fetchFn(null, done)
219
+ }
220
+ const isLockOwner = tryAcquireLock(cacheKey)
221
+ if (isLockOwner === undefined) {
222
+ return fetchFn(null, done)
223
+ }
224
+ if (!isLockOwner) {
191
225
  return setTimeout(poll, CACHE_LOCK_POLL_MS)
192
226
  }
193
227
 
194
- const cachedAfterTakeover = readFromCache(cacheKey)
228
+ const cachedAfterTakeover = readFromCache(cacheKey, cacheTtlMs)
195
229
  if (cachedAfterTakeover) {
196
230
  releaseLock(cacheKey)
197
231
  return done(null, cachedAfterTakeover.data)
198
232
  }
199
233
 
200
234
  const stopHeartbeat = startLockHeartbeat(cacheKey)
201
- return fetchFn((err, ...results) => {
235
+ return fetchFn(cacheKey, (err, ...results) => {
202
236
  stopHeartbeat()
203
237
  done(err, ...results)
204
238
  })
@@ -218,14 +252,15 @@ function waitForCache (cacheKey, fetchFn, done) {
218
252
  * @param {Function} fetchFn - function(cacheKey, done) that performs the API request.
219
253
  * Must call writeToCache(cacheKey, data) on success before calling done(null, data).
220
254
  * @param {Function} done - callback(err, ...results)
255
+ * @param {number} [cacheTtlMs] - Maximum cache age in milliseconds.
221
256
  */
222
- function withCache (cacheKey, fetchFn, done) {
257
+ function withCache (cacheKey, fetchFn, done, cacheTtlMs = CACHE_TTL_MS) {
223
258
  if (!isCacheEnabled()) {
224
259
  return fetchFn(null, done)
225
260
  }
226
261
 
227
262
  // Fast path: cache hit
228
- const cached = readFromCache(cacheKey)
263
+ const cached = readFromCache(cacheKey, cacheTtlMs)
229
264
  if (cached) {
230
265
  return done(null, cached.data)
231
266
  }
@@ -233,9 +268,13 @@ function withCache (cacheKey, fetchFn, done) {
233
268
  // Try to become the fetcher (lock owner)
234
269
  const isLockOwner = tryAcquireLock(cacheKey)
235
270
 
271
+ if (isLockOwner === undefined) {
272
+ return fetchFn(null, done)
273
+ }
274
+
236
275
  if (!isLockOwner) {
237
276
  log.debug('%s lock held by another process, waiting for cache', cacheKey)
238
- return waitForCache(cacheKey, (cb) => fetchFn(cacheKey, cb), done)
277
+ return waitForCache(cacheKey, fetchFn, done, cacheTtlMs)
239
278
  }
240
279
 
241
280
  // This process owns the lock — start heartbeat and fetch
@@ -14,7 +14,6 @@ const {
14
14
  TELEMETRY_GIT_REQUESTS_SETTINGS_ERRORS,
15
15
  TELEMETRY_GIT_REQUESTS_SETTINGS_RESPONSE,
16
16
  } = require('../telemetry')
17
- const { writeSettingsToCache } = require('../test-optimization-cache')
18
17
  const { MAX_RETRIES, validateSettingsResponse } = require('../test-optimization-http-cache-schema')
19
18
  const request = require('./request')
20
19
 
@@ -286,8 +285,6 @@ function getLibraryConfiguration ({
286
285
 
287
286
  incrementCountMetric(TELEMETRY_GIT_REQUESTS_SETTINGS_RESPONSE, settings)
288
287
 
289
- writeSettingsToCache(settings)
290
-
291
288
  done(null, settings)
292
289
  } catch (err) {
293
290
  done(err)
@@ -1,21 +1,28 @@
1
1
  'use strict'
2
2
 
3
- const { readFileSync } = require('node:fs')
3
+ const { createReadStream, readFileSync, statSync } = require('node:fs')
4
4
  const { extname } = require('node:path')
5
5
 
6
6
  const getConfig = require('../../config')
7
7
  const { EVP_SUBDOMAIN_HEADER_NAME } = require('../../evp_proxy/constants')
8
8
  const { joinEVPProxyPath } = require('../../evp_proxy/path')
9
9
  const log = require('../../log')
10
+ const { getAgent, getMediaAgent } = require('../exporters/agents')
10
11
  const request = require('../exporters/request')
12
+ const videoRequest = require('./video-request')
11
13
 
12
14
  const UPLOAD_TIMEOUT_MS = 30_000
13
- const TEST_SCREENSHOT_ENDPOINT_PREFIX = '/api/v2/ci/test-runs/'
14
- const TEST_SCREENSHOT_ENDPOINT_SUFFIX = '/media'
15
+ const TEST_RUN_MEDIA_ENDPOINT_PREFIX = '/api/v2/ci/test-runs/'
16
+ const TEST_SUITE_MEDIA_ENDPOINT_PREFIX = '/api/v2/ci/test-suites/'
17
+ const MEDIA_ENDPOINT_SUFFIX = '/media'
18
+ const MAX_VIDEO_SIZE_BYTES = 200 * 1024 * 1024
15
19
  const UINT64_MAX = 18_446_744_073_709_551_615n
16
20
 
17
- function getContentType (filePath) {
18
- const extension = extname(filePath).toLowerCase()
21
+ function getContentType (filePath, kind) {
22
+ const extension = filePath ? extname(filePath).toLowerCase() : ''
23
+ if (kind === 'video') {
24
+ return extension === '.mp4' ? 'video/mp4' : 'video/webm'
25
+ }
19
26
  if (extension === '.gif') {
20
27
  return 'image/gif'
21
28
  }
@@ -28,135 +35,221 @@ function getContentType (filePath) {
28
35
  return 'image/png'
29
36
  }
30
37
 
31
- function isValidTraceId (traceId) {
32
- if (!/^[1-9]\d*$/.test(traceId)) {
38
+ function isValidId (value) {
39
+ if (!/^[1-9]\d*$/.test(value)) {
33
40
  return false
34
41
  }
35
- return BigInt(traceId) <= UINT64_MAX
42
+ return BigInt(value) <= UINT64_MAX
36
43
  }
37
44
 
38
45
  /**
39
- * Renders the idempotency key (`${traceId}:${basename(filePath)}`) into a value safe to carry in
40
- * the upload's query string. The Agent's evp_proxy validates the forwarded query against a
41
- * restrictive charset and rejects a raw Cypress filename (spaces, parens, non-ASCII), so the
42
- * filename part is hex-encoded to [0-9a-f]; the decimal trace id and ':' separator are already in
43
- * the allowed set and stay readable. Deterministic, so a retried upload reproduces the same key
44
- * and the backend's UUIDv5 overwrite-on-retry holds.
46
+ * Renders an idempotency key into a value safe to carry in the upload's query string.
45
47
  *
46
- * @param {string} idempotencyKey - The raw idempotency key (`<traceId>:<filename>`)
47
- * @returns {string} A query-safe, deterministic representation of the key
48
+ * @param {string} idempotencyKey - Raw per-artifact idempotency key
49
+ * @returns {string} Query-safe, deterministic representation of the key
48
50
  */
49
51
  function toIdempotencyQueryValue (idempotencyKey) {
50
52
  const separatorIndex = idempotencyKey.indexOf(':')
51
53
  if (separatorIndex === -1) {
52
54
  return Buffer.from(idempotencyKey, 'utf8').toString('hex')
53
55
  }
54
- const traceIdPart = idempotencyKey.slice(0, separatorIndex)
55
- const filenamePart = idempotencyKey.slice(separatorIndex + 1)
56
- return `${traceIdPart}:${Buffer.from(filenamePart, 'utf8').toString('hex')}`
56
+ const idPart = idempotencyKey.slice(0, separatorIndex)
57
+ const artifactPart = idempotencyKey.slice(separatorIndex + 1)
58
+ return `${idPart}:${Buffer.from(artifactPart, 'utf8').toString('hex')}`
57
59
  }
58
60
 
59
61
  /**
60
- * Uploads a single test screenshot to the Test Optimization media intake.
61
- * The trace id is included in the request path and the body is the raw image bytes.
62
- *
63
- * The media service requires two values from the tracer, sent as query params so they
64
- * survive the Agent's evp_proxy (which forwards only an allow-listed header set and would
65
- * otherwise strip the metadata, leaving the backend with an empty idempotency key):
66
- * - `idempotencyKey`: stable per artifact and reused on retry, so a retried
67
- * upload overwrites the same stored object instead of creating a duplicate.
68
- * - `capturedAtMs`: the capture time in epoch milliseconds, stamped once at
69
- * capture and resent unchanged on retry (it is part of the stored object key).
62
+ * Uploads one screenshot or video to the Test Optimization media intake.
70
63
  *
71
64
  * @param {object} options - Upload options
72
- * @param {string} options.filePath - Path to the screenshot file
73
- * @param {string} options.traceId - Test trace id used as the screenshot key
65
+ * @param {string} [options.filePath] - Path to the media file
66
+ * @param {Buffer} [options.content] - In-memory screenshot content
67
+ * @param {'screenshot'|'video'} options.kind - Media kind
68
+ * @param {string} [options.traceId] - Test trace id for test-scoped media
69
+ * @param {string} [options.testSessionId] - Test session id for suite-scoped media
70
+ * @param {string} [options.testSuiteId] - Test suite id for suite-scoped media
74
71
  * @param {string} options.idempotencyKey - Stable per-artifact key, reused on retry
75
72
  * @param {number} options.capturedAtMs - Capture time in epoch milliseconds
76
- * @param {URL} options.url - The base URL for the screenshot upload
73
+ * @param {URL} options.url - Base URL for the media upload
77
74
  * @param {boolean} [options.isEvpProxy] - Whether to upload through the Agent's evp_proxy
78
- * @param {string} [options.evpProxyPrefix] - The evp_proxy path prefix (e.g. '/evp_proxy/v4')
75
+ * @param {string} [options.evpProxyPrefix] - evp_proxy path prefix
79
76
  * @param {number} [options.deadline] - Absolute finalization deadline in epoch milliseconds
80
77
  * @param {AbortSignal} [options.signal] - Signal used to cancel the upload
81
78
  * @param {Function} callback - Callback function (err)
79
+ * @returns {void}
82
80
  */
83
- function uploadTestScreenshot (
84
- { filePath, traceId, idempotencyKey, capturedAtMs, url, isEvpProxy, evpProxyPrefix, deadline, signal },
85
- callback
86
- ) {
81
+ function uploadTestMedia (options, callback) {
82
+ const {
83
+ filePath,
84
+ content,
85
+ kind,
86
+ traceId,
87
+ testSessionId,
88
+ testSuiteId,
89
+ idempotencyKey,
90
+ capturedAtMs,
91
+ url,
92
+ isEvpProxy,
93
+ evpProxyPrefix,
94
+ deadline,
95
+ signal,
96
+ } = options
87
97
  const { DD_API_KEY } = getConfig()
98
+ const isSuiteScoped = testSessionId !== undefined || testSuiteId !== undefined
99
+ let basePath
88
100
 
89
- if (!isValidTraceId(traceId)) {
90
- return callback(new Error('A non-zero decimal uint64 trace_id is required for test screenshot upload'))
101
+ if (kind !== 'screenshot' && kind !== 'video') {
102
+ return callback(new Error('A supported media kind is required for test media upload'))
103
+ }
104
+ if (isSuiteScoped) {
105
+ if (!isValidId(testSessionId) || !isValidId(testSuiteId)) {
106
+ return callback(new Error(
107
+ 'Non-zero decimal uint64 test_session_id and test_suite_id are required for test suite media upload'
108
+ ))
109
+ }
110
+ basePath = `${TEST_SUITE_MEDIA_ENDPOINT_PREFIX}${testSessionId}/${testSuiteId}${MEDIA_ENDPOINT_SUFFIX}`
111
+ } else {
112
+ if (!isValidId(traceId)) {
113
+ return callback(new Error('A non-zero decimal uint64 trace_id is required for test media upload'))
114
+ }
115
+ basePath = `${TEST_RUN_MEDIA_ENDPOINT_PREFIX}${traceId}${MEDIA_ENDPOINT_SUFFIX}`
91
116
  }
92
117
  if (!DD_API_KEY && !isEvpProxy) {
93
- return callback(new Error('DD_API_KEY is required for test screenshot upload'))
118
+ return callback(new Error('DD_API_KEY is required for test media upload'))
94
119
  }
95
120
  if (!idempotencyKey) {
96
- return callback(new Error('An idempotency key is required for test screenshot upload'))
121
+ return callback(new Error('An idempotency key is required for test media upload'))
97
122
  }
98
123
  if (!Number.isInteger(capturedAtMs) || capturedAtMs <= 0) {
99
- return callback(new Error('A positive captured-at timestamp (epoch ms) is required for test screenshot upload'))
124
+ return callback(new Error('A positive captured-at timestamp (epoch ms) is required for test media upload'))
100
125
  }
101
126
 
127
+ let fileSize
102
128
  let screenshotContent
103
129
  try {
104
- screenshotContent = readFileSync(filePath)
105
- } catch (err) {
106
- return callback(new Error(`Failed to read screenshot at ${filePath}: ${err.message}`))
130
+ if (kind === 'video') {
131
+ fileSize = statSync(filePath).size
132
+ } else if (content === undefined) {
133
+ screenshotContent = readFileSync(filePath)
134
+ fileSize = screenshotContent.length
135
+ } else {
136
+ if (!Buffer.isBuffer(content)) {
137
+ return callback(new Error('In-memory screenshot content must be a Buffer'))
138
+ }
139
+ screenshotContent = content
140
+ fileSize = content.length
141
+ }
142
+ } catch (error) {
143
+ const action = kind === 'video' ? 'inspect video' : 'read screenshot'
144
+ return callback(new Error(`Failed to ${action} at ${filePath}: ${error.message}`))
107
145
  }
108
- if (screenshotContent.length === 0) {
109
- return callback(new Error(`Screenshot at ${filePath} is empty`))
146
+ if (fileSize === 0) {
147
+ return callback(new Error(`${kind === 'video' ? 'Video' : 'Screenshot'} at ${filePath} is empty`))
148
+ }
149
+ if (kind === 'video' && fileSize > MAX_VIDEO_SIZE_BYTES) {
150
+ const error = new Error(
151
+ `Video at ${filePath} is ${fileSize} bytes and exceeds the ${MAX_VIDEO_SIZE_BYTES}-byte upload limit`
152
+ )
153
+ log.warn('Skipping test video upload: %s', error.message)
154
+ return callback(error)
110
155
  }
111
156
 
112
- // Metadata rides the query string, not X-Dd-* headers: the Agent's evp_proxy strips
113
- // non-allow-listed headers, so header-borne metadata reached the backend empty. The key is
114
- // rendered proxy-safe (see toIdempotencyQueryValue) because evp_proxy also validates the
115
- // forwarded query against a restrictive charset. capturedAtMs is a plain integer and part of the
116
- // stored object key, so it (and the key) must stay stable across retries.
157
+ // Metadata must use query params because the Agent's evp_proxy strips non-allow-listed headers.
117
158
  const query = new URLSearchParams({
118
159
  idempotency_key: toIdempotencyQueryValue(idempotencyKey),
119
160
  captured_at_ms: String(capturedAtMs),
120
161
  }).toString()
121
- const basePath = `${TEST_SCREENSHOT_ENDPOINT_PREFIX}${traceId}${TEST_SCREENSHOT_ENDPOINT_SUFFIX}`
122
-
123
- const contentType = getContentType(filePath)
124
- const options = {
162
+ const contentType = getContentType(filePath, kind)
163
+ const requestOptions = {
125
164
  method: 'POST',
126
165
  headers: {
166
+ 'Content-Length': fileSize,
127
167
  'Content-Type': contentType,
128
168
  },
129
169
  path: `${basePath}?${query}`,
130
170
  timeout: UPLOAD_TIMEOUT_MS,
131
171
  url,
172
+ agent: kind === 'video' ? getMediaAgent(url) : getAgent(url),
132
173
  deadline,
174
+ retryUntilDeadline: false,
133
175
  signal,
134
176
  }
135
177
 
136
178
  if (isEvpProxy) {
137
- // Agent mode: prefix the evp_proxy path, tell the proxy which subdomain to forward to, and
138
- // drop the API key — the Agent injects it. The query params survive the proxy.
139
- options.path = `${joinEVPProxyPath(evpProxyPrefix, basePath)}?${query}`
140
- options.headers[EVP_SUBDOMAIN_HEADER_NAME] = 'api'
179
+ requestOptions.path = `${joinEVPProxyPath(evpProxyPrefix, basePath)}?${query}`
180
+ requestOptions.headers[EVP_SUBDOMAIN_HEADER_NAME] = 'api'
141
181
  } else {
142
- options.headers['DD-API-KEY'] = DD_API_KEY
182
+ requestOptions.headers['DD-API-KEY'] = DD_API_KEY
143
183
  }
144
184
 
145
- log.debug('Uploading test screenshot %s to %s', filePath, new URL(options.path, url).href)
185
+ log.debug(
186
+ 'Uploading test %s %s to %s',
187
+ kind,
188
+ filePath || 'from memory',
189
+ new URL(requestOptions.path, url).href
190
+ )
146
191
 
147
- request(screenshotContent, options, (err, res, statusCode) => {
148
- if (err) {
149
- log.error('Error uploading test screenshot: %s', err.message)
150
- return callback(err)
192
+ const onResponse = (error, response, statusCode) => {
193
+ if (error) {
194
+ log.error('Error uploading test %s: %s', kind, error.message)
195
+ return callback(error)
151
196
  }
152
197
  if (statusCode === undefined) {
153
- const uploadError = new Error('Test screenshot upload request was dropped before it was sent')
154
- log.error('Error uploading test screenshot: %s', uploadError.message)
198
+ const uploadError = new Error(`Test ${kind} upload request was dropped before it was sent`)
199
+ log.error('Error uploading test %s: %s', kind, uploadError.message)
155
200
  return callback(uploadError)
156
201
  }
157
- log.debug('Test screenshot uploaded successfully (status: %d)', statusCode)
202
+ log.debug('Test %s uploaded successfully (status: %d)', kind, statusCode)
158
203
  callback(null)
159
- })
204
+ }
205
+
206
+ if (kind === 'video') {
207
+ requestOptions.transport = videoRequest
208
+ request(() => createReadStream(filePath), requestOptions, onResponse)
209
+ } else {
210
+ request(screenshotContent, requestOptions, onResponse)
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Uploads a test-scoped failure screenshot.
216
+ *
217
+ * @param {object} options - Upload options
218
+ * @param {Function} callback - Callback function (err)
219
+ * @returns {void}
220
+ */
221
+ function uploadTestScreenshot (options, callback) {
222
+ uploadTestMedia({ ...options, kind: 'screenshot' }, callback)
160
223
  }
161
224
 
162
- module.exports = { TEST_SCREENSHOT_ENDPOINT_PREFIX, TEST_SCREENSHOT_ENDPOINT_SUFFIX, uploadTestScreenshot }
225
+ /**
226
+ * Uploads a test-scoped failure video.
227
+ *
228
+ * @param {object} options - Upload options
229
+ * @param {Function} callback - Callback function (err)
230
+ * @returns {void}
231
+ */
232
+ function uploadTestVideo (options, callback) {
233
+ uploadTestMedia({ ...options, kind: 'video' }, callback)
234
+ }
235
+
236
+ /**
237
+ * Uploads a suite-scoped failure video.
238
+ *
239
+ * @param {object} options - Upload options
240
+ * @param {Function} callback - Callback function (err)
241
+ * @returns {void}
242
+ */
243
+ function uploadTestSuiteVideo (options, callback) {
244
+ uploadTestMedia({ ...options, kind: 'video' }, callback)
245
+ }
246
+
247
+ module.exports = {
248
+ MAX_VIDEO_SIZE_BYTES,
249
+ MEDIA_ENDPOINT_SUFFIX,
250
+ TEST_RUN_MEDIA_ENDPOINT_PREFIX,
251
+ TEST_SUITE_MEDIA_ENDPOINT_PREFIX,
252
+ uploadTestScreenshot,
253
+ uploadTestSuiteVideo,
254
+ uploadTestVideo,
255
+ }
@@ -0,0 +1,176 @@
1
+ 'use strict'
2
+
3
+ const http = require('node:http')
4
+ const https = require('node:https')
5
+
6
+ const { storage } = require('../../../../datadog-core')
7
+ const log = require('../../log')
8
+ const { markEndpointReached } = require('../../exporters/common/retry')
9
+ const { isLoopbackHost, parseUrl } = require('../../exporters/common/url')
10
+
11
+ const legacyStorage = storage('legacy')
12
+ const MAX_ACTIVE_REQUESTS = 16
13
+
14
+ let activeRequests = 0
15
+
16
+ /**
17
+ * Streams one video request attempt. Test Optimization retry policy stays in the exporter request wrapper.
18
+ *
19
+ * @param {import('node:stream').Readable} body - Video stream for this attempt
20
+ * @param {object} options - HTTP request options
21
+ * @param {(error: Error|null, result?: string|null, statusCode?: number,
22
+ * headers?: import('node:http').IncomingHttpHeaders) => void} callback
23
+ * @returns {void}
24
+ */
25
+ function requestVideo (body, options, callback) {
26
+ if (activeRequests >= MAX_ACTIVE_REQUESTS) {
27
+ if (!body.destroyed) body.destroy()
28
+ const error = new log.NoTransmitError('Maximum active video request count reached.')
29
+ error.code = 'ERR_DD_VIDEO_REQUEST_LIMIT'
30
+ callback(error)
31
+ return
32
+ }
33
+
34
+ let requestOptions
35
+ try {
36
+ requestOptions = getRequestOptions(options)
37
+ } catch (error) {
38
+ if (!body.destroyed) body.destroy()
39
+ callback(error)
40
+ return
41
+ }
42
+
43
+ activeRequests++
44
+ legacyStorage.run({ noop: true }, () => {
45
+ const timeout = options.timeout || 2000
46
+ const client = requestOptions.protocol === 'https:' ? https : http
47
+ let finished = false
48
+ let req = null
49
+
50
+ const complete = (error, result, statusCode, headers) => {
51
+ if (finished) return
52
+ finished = true
53
+ activeRequests--
54
+ options.signal?.removeEventListener('abort', onAbort)
55
+ body.unpipe(req)
56
+ if (!body.destroyed) body.destroy()
57
+ if (req && !req.destroyed && (error || !req.writableFinished)) req.destroy()
58
+ callback(error, result, statusCode, headers)
59
+ }
60
+ const onAbort = () => complete(getAbortError(options.signal))
61
+
62
+ try {
63
+ req = client.request(requestOptions, res => {
64
+ markEndpointReached(requestOptions)
65
+ const chunks = []
66
+
67
+ res.setTimeout(timeout)
68
+ res.on('data', chunk => chunks.push(chunk))
69
+ res.once('aborted', () => complete(Object.assign(new Error('Response aborted'), { code: 'ECONNRESET' })))
70
+ res.once('error', complete)
71
+ res.once('timeout', () => complete(Object.assign(new Error('Response timed out'), { code: 'ETIMEDOUT' })))
72
+ res.once('end', () => {
73
+ const result = Buffer.concat(chunks).toString()
74
+ if (res.statusCode >= 200 && res.statusCode <= 299) {
75
+ complete(null, result, res.statusCode, res.headers)
76
+ } else {
77
+ complete(buildError(res, result, requestOptions), null, res.statusCode, res.headers)
78
+ }
79
+ })
80
+ })
81
+ } catch (error) {
82
+ activeRequests--
83
+ if (!body.destroyed) body.destroy()
84
+ callback(error)
85
+ return
86
+ }
87
+
88
+ req.once('error', complete)
89
+ req.setTimeout(timeout, () => complete(Object.assign(new Error('Request timed out'), { code: 'ETIMEDOUT' })))
90
+ body.once('error', error => {
91
+ error.retryable = false
92
+ complete(error)
93
+ })
94
+ options.signal?.addEventListener('abort', onAbort, { once: true })
95
+ if (options.signal?.aborted) return onAbort()
96
+ body.pipe(req)
97
+ })
98
+ }
99
+
100
+ /**
101
+ * @param {object} options
102
+ * @returns {object}
103
+ */
104
+ function getRequestOptions (options) {
105
+ const requestOptions = {
106
+ ...options,
107
+ headers: options.headers ? { ...options.headers } : {},
108
+ }
109
+
110
+ if (options.url) {
111
+ const url = parseUrl(options.url)
112
+ if (url.protocol === 'unix:') {
113
+ requestOptions.socketPath = url.pathname
114
+ } else {
115
+ requestOptions.protocol = url.protocol
116
+ requestOptions.hostname = url.hostname
117
+ requestOptions.port = url.port
118
+ requestOptions.path ??= url.path
119
+ }
120
+ }
121
+
122
+ const hasApiKey = requestOptions.headers['dd-api-key'] !== undefined ||
123
+ requestOptions.headers['DD-API-KEY'] !== undefined
124
+ if (hasApiKey && requestOptions.protocol === 'http:' && !isLoopbackHost(requestOptions.hostname)) {
125
+ log.error(
126
+ 'Not sending the Datadog API key over a non-TLS connection to %s. Configure an https intake URL.',
127
+ requestOptions.hostname
128
+ )
129
+ delete requestOptions.headers['dd-api-key']
130
+ delete requestOptions.headers['DD-API-KEY']
131
+ }
132
+
133
+ delete requestOptions.deadline
134
+ delete requestOptions.retry
135
+ delete requestOptions.retryUntilDeadline
136
+ delete requestOptions.signal
137
+ delete requestOptions.url
138
+ return requestOptions
139
+ }
140
+
141
+ /**
142
+ * @param {AbortSignal} signal
143
+ * @returns {Error}
144
+ */
145
+ function getAbortError (signal) {
146
+ return signal?.reason || Object.assign(new Error('Request aborted'), { code: 'ABORT_ERR' })
147
+ }
148
+
149
+ /**
150
+ * @param {import('node:http').IncomingMessage} res
151
+ * @param {string} responseData
152
+ * @param {object} options
153
+ * @returns {Error}
154
+ */
155
+ function buildError (res, responseData, options) {
156
+ let errorMessage = ''
157
+ try {
158
+ const port = options.port ? `:${options.port}` : ''
159
+ const baseUrl = `${options.protocol || 'http:'}//${options.hostname || 'localhost'}${port}`
160
+ const fullUrl = new URL(options.path, baseUrl).href
161
+ errorMessage = `Error from ${fullUrl}: ${res.statusCode} ${http.STATUS_CODES[res.statusCode]}.`
162
+ } catch {
163
+ // ignore
164
+ }
165
+ if (responseData) errorMessage += ` Response from the endpoint: "${responseData}"`
166
+
167
+ const error = new log.NoTransmitError(errorMessage)
168
+ error.status = res.statusCode
169
+ return error
170
+ }
171
+
172
+ Object.defineProperty(requestVideo, 'writable', {
173
+ get () { return activeRequests < MAX_ACTIVE_REQUESTS },
174
+ })
175
+
176
+ module.exports = requestVideo