dd-trace 6.20.1 → 6.22.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 (225) hide show
  1. package/ci/diagnose.js +9 -14
  2. package/ci/init.js +1 -2
  3. package/ci/test-optimization-validation/ci-discovery.js +9 -4
  4. package/ci/test-optimization-validation/executable.js +1 -5
  5. package/ci/test-optimization-validation/framework-adapters/cypress.js +1 -4
  6. package/ci/test-optimization-validation/framework-adapters/vitest.js +1 -1
  7. package/ci/test-optimization-validation/generated-files.js +6 -4
  8. package/ci/test-optimization-validation/generated-test-contract.js +7 -3
  9. package/ci/test-optimization-validation/manifest-scaffold.js +7 -2
  10. package/ci/test-optimization-validation/manifest-schema.js +6 -7
  11. package/ci/test-optimization-validation/report-writer.js +6 -2
  12. package/ci/test-optimization-validation/runner-command.js +2 -4
  13. package/ci/test-optimization-validation/runner-contract.js +1 -2
  14. package/ci/test-optimization-validation/scenarios/auto-test-retries.js +1 -15
  15. package/ci/vitest-no-worker-init-setup.mjs +10 -7
  16. package/index.d.ts +116 -17
  17. package/initialize.mjs +1 -3
  18. package/openfeature/rules-based.d.ts +11 -0
  19. package/openfeature/rules-based.js +7 -0
  20. package/package.json +13 -11
  21. package/packages/datadog-core/src/storage.js +16 -0
  22. package/packages/datadog-esbuild/index.js +18 -12
  23. package/packages/datadog-instrumentations/src/aerospike.js +3 -3
  24. package/packages/datadog-instrumentations/src/ai.js +3 -3
  25. package/packages/datadog-instrumentations/src/aws-durable-execution-sdk-js.js +0 -6
  26. package/packages/datadog-instrumentations/src/aws-sdk.js +21 -13
  27. package/packages/datadog-instrumentations/src/cassandra-driver.js +25 -20
  28. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +9 -23
  29. package/packages/datadog-instrumentations/src/cucumber.js +48 -33
  30. package/packages/datadog-instrumentations/src/elasticsearch.js +1 -5
  31. package/packages/datadog-instrumentations/src/electron.js +6 -6
  32. package/packages/datadog-instrumentations/src/fastify.js +13 -4
  33. package/packages/datadog-instrumentations/src/fs.js +2 -2
  34. package/packages/datadog-instrumentations/src/google-cloud-pubsub.js +9 -5
  35. package/packages/datadog-instrumentations/src/grpc/client.js +2 -0
  36. package/packages/datadog-instrumentations/src/helpers/bundler-register.js +2 -2
  37. package/packages/datadog-instrumentations/src/helpers/check-require-cache.js +1 -2
  38. package/packages/datadog-instrumentations/src/helpers/instrument.js +1 -1
  39. package/packages/datadog-instrumentations/src/helpers/register.js +36 -1
  40. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +2 -0
  41. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +87 -6
  42. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/graphql-jit.js +5 -6
  43. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/postgres.js +48 -31
  44. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +64 -11
  45. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +1 -0
  46. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms/postgres.js +42 -4
  47. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +7 -10
  48. package/packages/datadog-instrumentations/src/ioredis.js +7 -5
  49. package/packages/datadog-instrumentations/src/iovalkey.js +7 -5
  50. package/packages/datadog-instrumentations/src/jest.js +122 -11
  51. package/packages/datadog-instrumentations/src/knex.js +9 -5
  52. package/packages/datadog-instrumentations/src/mariadb-bundle.js +3 -3
  53. package/packages/datadog-instrumentations/src/mocha/common.js +20 -8
  54. package/packages/datadog-instrumentations/src/mocha/main.js +140 -43
  55. package/packages/datadog-instrumentations/src/mocha/utils.js +127 -38
  56. package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +61 -34
  57. package/packages/datadog-instrumentations/src/mocha/worker.js +7 -5
  58. package/packages/datadog-instrumentations/src/mongodb-core.js +3 -3
  59. package/packages/datadog-instrumentations/src/mongoose.js +3 -3
  60. package/packages/datadog-instrumentations/src/nova-sonic/audio.js +173 -0
  61. package/packages/datadog-instrumentations/src/nova-sonic/index.js +209 -0
  62. package/packages/datadog-instrumentations/src/nova-sonic/session.js +483 -0
  63. package/packages/datadog-instrumentations/src/openai-realtime/index.js +18 -6
  64. package/packages/datadog-instrumentations/src/openai-realtime/session.js +1 -1
  65. package/packages/datadog-instrumentations/src/openai.js +7 -0
  66. package/packages/datadog-instrumentations/src/pino.js +5 -6
  67. package/packages/datadog-instrumentations/src/playwright.js +3 -0
  68. package/packages/datadog-instrumentations/src/redis.js +27 -13
  69. package/packages/datadog-instrumentations/src/router.js +30 -0
  70. package/packages/datadog-instrumentations/src/rum-browser-scripts.js +2 -6
  71. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +7 -5
  72. package/packages/datadog-instrumentations/src/vitest-main.js +9 -17
  73. package/packages/datadog-instrumentations/src/vitest-util.js +2 -2
  74. package/packages/datadog-instrumentations/src/vitest-worker.js +1 -2
  75. package/packages/datadog-instrumentations/src/vitest.js +2 -2
  76. package/packages/datadog-instrumentations/src/webdriverio.js +118 -21
  77. package/packages/datadog-instrumentations/src/ws.js +1 -4
  78. package/packages/datadog-plugin-amqplib/src/client.js +1 -2
  79. package/packages/datadog-plugin-avsc/src/schema_iterator.js +1 -5
  80. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/handler.js +1 -2
  81. package/packages/datadog-plugin-aws-sdk/src/base.js +5 -8
  82. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/index.js +18 -0
  83. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/sonic.js +45 -0
  84. package/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +9 -3
  85. package/packages/datadog-plugin-aws-sdk/src/services/sqs.js +2 -5
  86. package/packages/datadog-plugin-azure-durable-functions/src/index.js +5 -4
  87. package/packages/datadog-plugin-azure-functions/src/index.js +5 -3
  88. package/packages/datadog-plugin-claude-agent-sdk/src/index.js +0 -2
  89. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +18 -5
  90. package/packages/datadog-plugin-cypress/src/index.js +6 -6
  91. package/packages/datadog-plugin-cypress/src/source-map-utils.js +1 -2
  92. package/packages/datadog-plugin-cypress/src/support.js +1 -4
  93. package/packages/datadog-plugin-express/src/code_origin.js +1 -2
  94. package/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js +2 -2
  95. package/packages/datadog-plugin-google-cloud-pubsub/src/producer.js +1 -2
  96. package/packages/datadog-plugin-graphql/src/execute.js +5 -5
  97. package/packages/datadog-plugin-grpc/src/util.js +1 -2
  98. package/packages/datadog-plugin-kafkajs/src/producer.js +1 -2
  99. package/packages/datadog-plugin-mocha/src/index.js +223 -39
  100. package/packages/datadog-plugin-mocha/src/webdriverio-video.js +453 -0
  101. package/packages/datadog-plugin-mongodb-core/src/query.js +8 -5
  102. package/packages/datadog-plugin-next/src/index.js +1 -2
  103. package/packages/datadog-plugin-openai/src/tracing.js +5 -2
  104. package/packages/datadog-plugin-openai-agents/src/integration.js +6 -2
  105. package/packages/datadog-plugin-openai-agents/src/processor.js +2 -4
  106. package/packages/datadog-plugin-postgres/src/index.js +24 -0
  107. package/packages/datadog-plugin-protobufjs/src/schema_iterator.js +1 -5
  108. package/packages/datadog-plugin-rhea/src/consumer.js +1 -4
  109. package/packages/datadog-plugin-vitest/src/index.js +5 -2
  110. package/packages/datadog-shimmer/src/shimmer.js +5 -4
  111. package/packages/datadog-webpack/index.js +55 -43
  112. package/packages/datadog-webpack/src/loader.js +56 -8
  113. package/packages/dd-trace/src/aiguard/integrations/vercel-ai.js +2 -4
  114. package/packages/dd-trace/src/aiguard/messages/utils.js +1 -2
  115. package/packages/dd-trace/src/aiguard/redaction.js +8 -2
  116. package/packages/dd-trace/src/appsec/api_security/normalized-route.js +3 -2
  117. package/packages/dd-trace/src/appsec/api_security/sampler.js +2 -6
  118. package/packages/dd-trace/src/appsec/downstream_requests.js +1 -9
  119. package/packages/dd-trace/src/appsec/handlers/http-request.js +3 -9
  120. package/packages/dd-trace/src/appsec/handlers/http-response.js +7 -2
  121. package/packages/dd-trace/src/appsec/iast/analyzers/path-traversal-analyzer.js +4 -3
  122. package/packages/dd-trace/src/appsec/iast/analyzers/set-cookies-header-interceptor.js +1 -4
  123. package/packages/dd-trace/src/appsec/iast/taint-tracking/operations-taint-object.js +7 -1
  124. package/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +1 -3
  125. package/packages/dd-trace/src/appsec/iast/taint-tracking/rewriter-esm.mjs +1 -3
  126. package/packages/dd-trace/src/appsec/iast/telemetry/iast-metric.js +1 -6
  127. package/packages/dd-trace/src/appsec/lambda.js +1 -3
  128. package/packages/dd-trace/src/appsec/rasp/fs-plugin.js +37 -27
  129. package/packages/dd-trace/src/appsec/rasp/lfi.js +9 -5
  130. package/packages/dd-trace/src/appsec/reporter.js +21 -1
  131. package/packages/dd-trace/src/appsec/stack_trace.js +1 -2
  132. package/packages/dd-trace/src/ci-visibility/dynamic-atr-retries.js +3 -6
  133. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/msgpack-to-json.js +1 -1
  134. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +13 -7
  135. package/packages/dd-trace/src/ci-visibility/exporters/settings-cache-key.js +1 -2
  136. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +2 -8
  137. package/packages/dd-trace/src/ci-visibility/resolve-runfile-path.js +54 -0
  138. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +5 -8
  139. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +2 -53
  140. package/packages/dd-trace/src/config/config-types.d.ts +2 -2
  141. package/packages/dd-trace/src/config/defaults.js +5 -5
  142. package/packages/dd-trace/src/config/generated-config-types.d.ts +8 -0
  143. package/packages/dd-trace/src/config/helper.js +34 -3
  144. package/packages/dd-trace/src/config/index.js +10 -9
  145. package/packages/dd-trace/src/config/normalize-service.js +1 -1
  146. package/packages/dd-trace/src/config/parsers.js +2 -2
  147. package/packages/dd-trace/src/config/supported-configurations.json +35 -0
  148. package/packages/dd-trace/src/datastreams/processor.js +3 -2
  149. package/packages/dd-trace/src/datastreams/schemas/schema_builder.js +1 -4
  150. package/packages/dd-trace/src/debugger/constants.js +1 -0
  151. package/packages/dd-trace/src/debugger/devtools_client/index.js +5 -3
  152. package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +17 -12
  153. package/packages/dd-trace/src/debugger/devtools_client/send.js +7 -2
  154. package/packages/dd-trace/src/debugger/devtools_client/status.js +1 -0
  155. package/packages/dd-trace/src/debugger/devtools_client/worker-error.js +21 -0
  156. package/packages/dd-trace/src/debugger/guardrail-metrics.js +2 -2
  157. package/packages/dd-trace/src/debugger/index.js +24 -2
  158. package/packages/dd-trace/src/debugger/probe_sampler.js +108 -11
  159. package/packages/dd-trace/src/dogstatsd.js +1 -1
  160. package/packages/dd-trace/src/encode/tags-processors.js +2 -2
  161. package/packages/dd-trace/src/exporters/common/request.js +2 -5
  162. package/packages/dd-trace/src/exporters/common/url.js +1 -1
  163. package/packages/dd-trace/src/id.js +15 -5
  164. package/packages/dd-trace/src/lambda/handler.js +179 -19
  165. package/packages/dd-trace/src/lambda/index.js +15 -1
  166. package/packages/dd-trace/src/lambda/runtime/patch.js +34 -7
  167. package/packages/dd-trace/src/llmobs/agent-manifest.js +296 -0
  168. package/packages/dd-trace/src/llmobs/audio-utils.js +3 -2
  169. package/packages/dd-trace/src/llmobs/constants/tags.js +13 -0
  170. package/packages/dd-trace/src/llmobs/experiments/experiment.js +1 -1
  171. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +15 -0
  172. package/packages/dd-trace/src/llmobs/plugins/genai/util.js +1 -2
  173. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +1 -3
  174. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +1 -1
  175. package/packages/dd-trace/src/llmobs/plugins/nova-sonic.js +92 -0
  176. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +56 -2
  177. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +38 -0
  178. package/packages/dd-trace/src/llmobs/prompts/manager.js +5 -7
  179. package/packages/dd-trace/src/llmobs/prompts/prompt.js +1 -1
  180. package/packages/dd-trace/src/llmobs/sdk.js +23 -6
  181. package/packages/dd-trace/src/llmobs/span_processor.js +18 -8
  182. package/packages/dd-trace/src/llmobs/tagger.js +56 -3
  183. package/packages/dd-trace/src/llmobs/util.js +1 -2
  184. package/packages/dd-trace/src/llmobs/writers/base.js +7 -6
  185. package/packages/dd-trace/src/llmobs/writers/util.js +3 -1
  186. package/packages/dd-trace/src/log/log.js +1 -5
  187. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +30 -1
  188. package/packages/dd-trace/src/openfeature/configuration_source.js +17 -2
  189. package/packages/dd-trace/src/openfeature/debug-logging-hook.js +25 -0
  190. package/packages/dd-trace/src/openfeature/evaluation-telemetry-opt-out-hook.js +51 -0
  191. package/packages/dd-trace/src/openfeature/flagging_provider.js +59 -3
  192. package/packages/dd-trace/src/openfeature/remote_config.js +9 -0
  193. package/packages/dd-trace/src/openfeature/writers/util.js +43 -4
  194. package/packages/dd-trace/src/opentelemetry/logs/otlp_transformer.js +1 -1
  195. package/packages/dd-trace/src/opentelemetry/metrics/otlp_transformer.js +1 -1
  196. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +1 -2
  197. package/packages/dd-trace/src/opentelemetry/otlp/common.proto +1 -1
  198. package/packages/dd-trace/src/opentelemetry/resource-attributes.js +1 -1
  199. package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +6 -23
  200. package/packages/dd-trace/src/opentracing/propagation/text_map.js +53 -13
  201. package/packages/dd-trace/src/otel-sampling.js +4 -5
  202. package/packages/dd-trace/src/otel-thread-ctx.js +81 -15
  203. package/packages/dd-trace/src/plugins/database.js +7 -16
  204. package/packages/dd-trace/src/plugins/util/git.js +9 -25
  205. package/packages/dd-trace/src/plugins/util/inferred_proxy.js +1 -5
  206. package/packages/dd-trace/src/plugins/util/test.js +48 -4
  207. package/packages/dd-trace/src/priority_sampler.js +1 -2
  208. package/packages/dd-trace/src/profiling/profiler.js +4 -6
  209. package/packages/dd-trace/src/profiling/profilers/wall.js +4 -10
  210. package/packages/dd-trace/src/proxy.js +3 -2
  211. package/packages/dd-trace/src/ritm.js +4 -2
  212. package/packages/dd-trace/src/serverless.js +10 -0
  213. package/packages/dd-trace/src/span_processor.js +13 -17
  214. package/packages/dd-trace/src/storage-channels.js +112 -46
  215. package/packages/dd-trace/src/telemetry/endpoints.js +1 -3
  216. package/packages/dd-trace/src/util.js +29 -0
  217. package/packages/dd-trace/src/web-tags-cache.js +6 -2
  218. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
  219. package/vendor/dist/@datadog/openfeature-node-server/rules-based/LICENSE +201 -0
  220. package/vendor/dist/@datadog/openfeature-node-server/rules-based/index.d.ts +925 -0
  221. package/vendor/dist/@datadog/openfeature-node-server/rules-based/index.js +1 -0
  222. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  223. package/vendor/dist/protobufjs/index.js +1 -1
  224. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  225. package/vendor/dist/shell-quote/index.js +1 -1
@@ -0,0 +1,453 @@
1
+ 'use strict'
2
+
3
+ const { spawn, spawnSync } = require('node:child_process')
4
+ const { mkdtempSync, rmSync, writeFileSync } = require('node:fs')
5
+ const { tmpdir } = require('node:os')
6
+ const { join } = require('node:path')
7
+ const { performance } = require('node:perf_hooks')
8
+ const { clearInterval, clearTimeout, setInterval, setTimeout } = require('node:timers')
9
+
10
+ const {
11
+ VIDEO_UPLOAD_RESULT_ERROR,
12
+ VIDEO_UPLOAD_RESULT_UPLOADED,
13
+ } = require('../../dd-trace/src/ci-visibility/test-video')
14
+ const log = require('../../dd-trace/src/log')
15
+
16
+ const CAPTURE_INTERVAL_MS = 500
17
+ const CAPTURE_TIMEOUT_MS = 5000
18
+ const ENCODING_TIMEOUT_MS = 30_000
19
+ const MAX_PENDING_ENCODINGS = 32
20
+ const MAX_PIXELS = 16 * 1024 * 1024
21
+ const MAX_FRAME_BYTES = 200 * 1024 * 1024
22
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
23
+ const { queueMicrotask } = globalThis
24
+ const captureOwners = new WeakMap()
25
+ // A local timeout does not cancel the WebDriver request; keep its slot until it actually settles.
26
+ const pendingCaptures = new WeakSet()
27
+ const unsafePromptBrowsers = new WeakSet()
28
+
29
+ let ffmpegAvailable
30
+ // PNGs remain charged through encoding and upload, until their temporary files are removed.
31
+ let frameBytes = 0
32
+
33
+ /** @type {Array<() => void>} */
34
+ const encodingQueue = []
35
+
36
+ /**
37
+ * Runs one encoder at a time across all attempts in this WDIO process. Uploads do not hold the slot.
38
+ *
39
+ * @param {{ directory: string, index: number, frames: number[], filePath: string, size: number[] }} recording
40
+ * @param {(error?: Error) => void} onDone
41
+ */
42
+ function encodeVideo (recording, onDone) {
43
+ // Include the active encoder in the queue limit.
44
+ if (encodingQueue.length > MAX_PENDING_ENCODINGS) {
45
+ queueMicrotask(() => onDone(new Error('WebdriverIO video encoding queue is full')))
46
+ return
47
+ }
48
+ let encoder
49
+ let timeout
50
+ const complete = error => {
51
+ const index = encodingQueue.indexOf(start)
52
+ if (index === -1) return
53
+ encodingQueue.splice(index, 1)
54
+ // Defer the next start so repeated process startup failures cannot recurse through the queue.
55
+ if (encodingQueue.length) queueMicrotask(encodingQueue[0])
56
+ onDone(error)
57
+ }
58
+ const start = () => {
59
+ // A queued start may have been cancelled before its microtask runs.
60
+ if (encoder || !encodingQueue.includes(start)) return
61
+ try {
62
+ const { directory, index, frames, filePath, size: [width, height] } = recording
63
+ const manifestPath = join(directory, `${index}.ffconcat`)
64
+ let manifest = 'ffconcat version 1.0\n'
65
+ for (let frame = 0; frame < frames.length; frame++) {
66
+ // Command captures are irregular: fixed 2 fps stretches assertion polling into long videos.
67
+ const duration = frame + 1 < frames.length ? (frames[frame + 1] - frames[frame]) / 1000 : 0.04
68
+ manifest += `file ${index}-${frame}.png\nduration ${duration.toFixed(6)}\n`
69
+ }
70
+ // The PNG demuxer uses 25 Hz timestamps. Repeat the final image one tick later so it survives
71
+ // rounding when the last two captures are less than 40 ms apart. WebM preserves variable timing.
72
+ manifest += `file ${index}-${frames.length - 1}.png\n`
73
+ writeFileSync(manifestPath, manifest)
74
+ encoder = spawn('ffmpeg', [
75
+ '-hide_banner', '-loglevel', 'error', '-nostdin', '-y',
76
+ '-threads', '1', '-f', 'concat', '-i', manifestPath,
77
+ '-frames:v', String(frames.length + 1), '-an', '-c:v', 'libvpx', '-deadline', 'realtime',
78
+ '-threads', '1', '-filter_threads', '1', '-pix_fmt', 'yuv420p',
79
+ '-vf', `scale=${width}:${height},setsar=1`, filePath,
80
+ ], { windowsHide: true, stdio: 'ignore' })
81
+ let encodingError
82
+ timeout = setTimeout(() => {
83
+ encodingError = new Error('WebdriverIO video encoding timed out')
84
+ // A kill request does not release the slot until the process actually closes.
85
+ encoder.kill('SIGKILL')
86
+ }, ENCODING_TIMEOUT_MS)
87
+ encoder.once('error', error => { encodingError = error })
88
+ encoder.once('close', code => {
89
+ clearTimeout(timeout)
90
+ if (code !== 0) encodingError ||= new Error(`WebdriverIO video encoder exited with code ${code}`)
91
+ complete(encodingError)
92
+ })
93
+ } catch (error) {
94
+ // Let the caller retain its cancellation callback even when process startup throws.
95
+ queueMicrotask(() => complete(error))
96
+ }
97
+ }
98
+ encodingQueue.push(start)
99
+ if (encodingQueue.length === 1) start()
100
+ return () => {
101
+ const index = encodingQueue.indexOf(start)
102
+ if (index === -1) return
103
+ if (encoder) {
104
+ clearTimeout(timeout)
105
+ // Shutdown must not wait for a process that cannot terminate. Keep its slot until close.
106
+ encoder.unref?.()
107
+ if (!encoder.kill('SIGKILL')) log.error('Error stopping WebdriverIO video encoder')
108
+ } else {
109
+ encodingQueue.splice(index, 1)
110
+ if (index === 0 && encodingQueue.length) queueMicrotask(encodingQueue[0])
111
+ }
112
+ }
113
+ }
114
+
115
+ // Like wdio-video-reporter, collect WebDriver screenshots and encode only failed attempts.
116
+ // Encoding uses a customer-installed FFmpeg executable, isolated from the WDIO test process.
117
+ class WebdriverioVideo {
118
+ #browser
119
+ #directory
120
+ #frames = []
121
+ #sizes = []
122
+ #bytes = 0
123
+ #capturing = false
124
+ #finalCapture = false
125
+ #completeCapture
126
+ #disabled = false
127
+ #stopped = false
128
+ #captureTimeout
129
+ #interval
130
+ #onCapture
131
+ #error
132
+ #onResult
133
+ #cancelEncoding
134
+ #finished = false
135
+ #onDone
136
+
137
+ /** @param {object} browser - WebdriverIO browser or multiremote browser */
138
+ constructor (browser) {
139
+ this.#browser = browser
140
+ this.#directory = mkdtempSync(join(tmpdir(), 'dd-trace-wdio-video-'))
141
+ captureOwners.set(browser, this)
142
+ this.#onResult = /** @param {{command?: string, endpoint?: string}} event */ (event) => {
143
+ const { command, endpoint } = event
144
+ if (
145
+ command === 'takeScreenshot' || endpoint?.endsWith('/screenshot') ||
146
+ // WDIO looks up the context before BiDi navigation. Capturing in that gap can block navigation.
147
+ command === 'getWindowHandle' || command === 'getWindowHandles'
148
+ ) {
149
+ return
150
+ }
151
+ this.capture()
152
+ }
153
+ browser.on?.('result', this.#onResult)
154
+ this.#interval = setInterval(() => this.capture(), CAPTURE_INTERVAL_MS)
155
+ this.#interval.unref?.()
156
+ this.capture()
157
+ }
158
+
159
+ /** Captures at most one in-flight screenshot, including all multiremote sessions. */
160
+ capture () {
161
+ if ((this.#stopped && !this.#finalCapture) || this.#capturing || this.#error || this.#disabled ||
162
+ pendingCaptures.has(this.#browser) || captureOwners.get(this.#browser) !== this) return
163
+ this.#finalCapture = false
164
+ this.#capturing = true
165
+ let completed = false
166
+ const complete = (error, screenshots, capturedAt) => {
167
+ if (completed || this.#finished) return
168
+ completed = true
169
+ this.#capturing = false
170
+ if (error) {
171
+ log.error('Error capturing WebdriverIO video frame: %s', error.message)
172
+ } else if (!this.#disabled && captureOwners.get(this.#browser) === this) {
173
+ try {
174
+ this.#writeFrames(screenshots, capturedAt)
175
+ } catch (error) {
176
+ this.#error = error
177
+ }
178
+ }
179
+ if (this.#finalCapture) {
180
+ this.capture()
181
+ if (this.#capturing) return
182
+ }
183
+ this.#finalCapture = false
184
+ clearTimeout(this.#captureTimeout)
185
+ this.#captureTimeout = undefined
186
+ this.#completeCapture = undefined
187
+ const onCapture = this.#onCapture
188
+ this.#onCapture = undefined
189
+ onCapture?.()
190
+ }
191
+ this.#completeCapture = complete
192
+ // An overlapping capture and its fresh final frame share the existing five-second deadline.
193
+ this.#captureTimeout ??= setTimeout(() => {
194
+ this.#completeCapture?.(new Error('WebdriverIO video screenshot capture timed out'))
195
+ }, CAPTURE_TIMEOUT_MS)
196
+ this.#captureTimeout.unref?.()
197
+ try {
198
+ if (!hasSafePromptPolicy(this.#browser)) {
199
+ this.#disabled = true
200
+ clearInterval(this.#interval)
201
+ this.#browser.removeListener?.('result', this.#onResult)
202
+ return complete()
203
+ }
204
+ if (this.#browser.isMultiremote || this.#browser.isMultiRemote) {
205
+ const names = this.#browser.instances
206
+ if (!names.length) return complete(new Error('WebdriverIO returned no browser sessions'))
207
+ const browsers = names.map(name => this.#browser.getInstance(name))
208
+ const frames = new Array(names.length)
209
+ const captureTimes = new Array(names.length)
210
+ let pending = names.length
211
+ let captureError
212
+ pendingCaptures.add(this.#browser)
213
+ for (let index = 0; index < names.length; index++) {
214
+ this.#captureBrowser(browsers[index], (error, frame, capturedAt) => {
215
+ captureError ||= error
216
+ frames[index] = frame
217
+ captureTimes[index] = capturedAt
218
+ if (--pending === 0) {
219
+ pendingCaptures.delete(this.#browser)
220
+ complete(captureError, frames, captureTimes)
221
+ }
222
+ })
223
+ }
224
+ } else {
225
+ pendingCaptures.add(this.#browser)
226
+ this.#captureBrowser(this.#browser, (error, frame, capturedAt) => {
227
+ pendingCaptures.delete(this.#browser)
228
+ complete(error, frame, capturedAt)
229
+ })
230
+ }
231
+ } catch (error) {
232
+ complete(error)
233
+ }
234
+ }
235
+
236
+ /**
237
+ * @param {object} browser - A single WebDriver session, including sessions with BiDi enabled
238
+ * @param {(error?: Error, frame?: string, capturedAt?: number) => void} onDone
239
+ */
240
+ #captureBrowser (browser, onDone) {
241
+ try {
242
+ // Use the session's WebDriver screenshot command. Direct BiDi captures can lose their
243
+ // browsing context during concurrent navigation and never return a frame.
244
+ // Timestamp each session before waiting for the rest of a multiremote batch.
245
+ browser.takeScreenshot().then(result => onDone(undefined, result, performance.now()), onDone)
246
+ } catch (error) {
247
+ onDone(error)
248
+ }
249
+ }
250
+
251
+ /**
252
+ * @param {string|string[]} screenshots - Base64 PNG screenshots, in session order
253
+ * @param {number|number[]} capturedAt - Completion timestamps in the same session order
254
+ */
255
+ #writeFrames (screenshots, capturedAt) {
256
+ const frames = Array.isArray(screenshots) ? screenshots : [screenshots]
257
+ if (!frames.length) throw new Error('WebdriverIO returned no video frames')
258
+ for (let index = 0; index < frames.length; index++) {
259
+ if (typeof frames[index] !== 'string') throw new TypeError('WebdriverIO returned an invalid video frame')
260
+ if (Buffer.byteLength(frames[index], 'base64') > MAX_FRAME_BYTES - frameBytes) {
261
+ throw new Error('WebdriverIO video frames exceeded the 200 MiB worker limit')
262
+ }
263
+ const frame = Buffer.from(frames[index], 'base64')
264
+ if (frame.length < 24 || !frame.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
265
+ throw new Error('WebdriverIO returned an invalid PNG video frame')
266
+ }
267
+ const width = frame.readUInt32BE(16)
268
+ const height = frame.readUInt32BE(20)
269
+ if (!width || !height || width * height > MAX_PIXELS) {
270
+ throw new Error('WebdriverIO video frame exceeds the 16 megapixel limit or has invalid dimensions')
271
+ }
272
+ if (!this.#sizes[index]) {
273
+ // Keep a fixed, even output size across viewport changes.
274
+ const scale = Math.min(1, 1280 / width, 720 / height)
275
+ this.#sizes[index] = [
276
+ Math.max(2, Math.floor(width * scale / 2) * 2),
277
+ Math.max(2, Math.floor(height * scale / 2) * 2),
278
+ ]
279
+ }
280
+ const timestamps = this.#frames[index] ||= []
281
+ const frameNumber = timestamps.length
282
+ // Reserve before writing so a partial write stays accounted for until cleanup.
283
+ frameBytes += frame.length
284
+ this.#bytes += frame.length
285
+ writeFileSync(join(this.#directory, `${index}-${frameNumber}.png`), frame)
286
+ timestamps.push(Array.isArray(capturedAt) ? capturedAt[index] : capturedAt)
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Stops capture, uploads failed attempts, and removes all owned files after upload completion.
292
+ *
293
+ * @param {boolean} failed - Whether this attempt failed
294
+ * @param {(filePath: string, index: number, onDone: (error?: Error) => void) => void} upload
295
+ * @param {(result?: string) => void} onDone
296
+ */
297
+ finish (failed, upload, onDone) {
298
+ if (this.#stopped) return
299
+ this.#onDone = onDone
300
+ if (failed) {
301
+ if (this.#capturing) this.#finalCapture = true
302
+ else this.capture()
303
+ }
304
+ this.#stopped = true
305
+ clearInterval(this.#interval)
306
+ this.#browser.removeListener?.('result', this.#onResult)
307
+ const finish = () => {
308
+ if (this.#finished) return
309
+ if (!failed || this.#disabled) return this.#cleanup(undefined, onDone)
310
+ if (this.#error || !this.#frames.length) {
311
+ log.error('Error recording WebdriverIO failure video: %s', this.#error?.message || 'No frames captured')
312
+ return this.#cleanup(VIDEO_UPLOAD_RESULT_ERROR, onDone)
313
+ }
314
+ this.#encode(0, upload, false, onDone)
315
+ }
316
+ if (this.#capturing) this.#onCapture = finish
317
+ else finish()
318
+ }
319
+
320
+ /** @param {() => void} onDone - Releases a retry once this attempt's final capture settles. */
321
+ waitForCapture (onDone) {
322
+ if (!this.#capturing || this.#finished) return onDone()
323
+ const previous = this.#onCapture
324
+ this.#onCapture = () => {
325
+ previous?.()
326
+ onDone()
327
+ }
328
+ }
329
+
330
+ /** Cancels a finishing attempt at the worker's final-flush deadline. */
331
+ cancel () {
332
+ if (this.#finished) return
333
+ this.#cancelEncoding?.()
334
+ this.#cleanup(VIDEO_UPLOAD_RESULT_ERROR, this.#onDone)
335
+ }
336
+
337
+ /**
338
+ * Encodes multiremote sessions sequentially to bound encoder resource use.
339
+ *
340
+ * @param {number} index
341
+ * @param {(filePath: string, index: number, onDone: (error?: Error) => void) => void} upload
342
+ * @param {boolean} hasError
343
+ * @param {(result?: string) => void} onDone
344
+ */
345
+ #encode (index, upload, hasError, onDone) {
346
+ if (index === this.#frames.length) {
347
+ return this.#cleanup(hasError ? VIDEO_UPLOAD_RESULT_ERROR : VIDEO_UPLOAD_RESULT_UPLOADED, onDone)
348
+ }
349
+ const filePath = join(this.#directory, `${index}.webm`)
350
+ let completed = false
351
+ const next = error => {
352
+ if (completed) return
353
+ completed = true
354
+ // Cancellation may try cleanup while encoding or upload still holds files open.
355
+ if (this.#finished) return this.#cleanup(undefined, () => {})
356
+ if (error) log.error('Error uploading WebdriverIO failure video: %s', error.message)
357
+ this.#encode(index + 1, upload, hasError || Boolean(error), onDone)
358
+ }
359
+ const recording = {
360
+ directory: this.#directory,
361
+ index,
362
+ frames: this.#frames[index],
363
+ filePath,
364
+ size: this.#sizes[index],
365
+ }
366
+ this.#cancelEncoding = encodeVideo(recording, error => {
367
+ if (this.#finished || error) return next(error)
368
+ try {
369
+ upload(filePath, index, next)
370
+ } catch (error) {
371
+ next(error)
372
+ }
373
+ })
374
+ }
375
+
376
+ /**
377
+ * @param {string|undefined} result
378
+ * @param {(result?: string) => void} onDone
379
+ */
380
+ #cleanup (result, onDone) {
381
+ this.#finished = true
382
+ if (captureOwners.get(this.#browser) === this) captureOwners.delete(this.#browser)
383
+ clearTimeout(this.#captureTimeout)
384
+ this.#completeCapture = undefined
385
+ const onCapture = this.#onCapture
386
+ this.#onCapture = undefined
387
+ onCapture?.()
388
+ try {
389
+ rmSync(this.#directory, { recursive: true, force: true })
390
+ frameBytes -= this.#bytes
391
+ this.#bytes = 0
392
+ } catch (error) {
393
+ log.error('Error removing WebdriverIO video files: %s', error.message)
394
+ }
395
+ onDone(result)
396
+ }
397
+ }
398
+
399
+ /** @param {object} browser - Current WebdriverIO browser or multiremote browser */
400
+ function hasSafePromptPolicy (browser) {
401
+ const sessions = (browser.isMultiremote || browser.isMultiRemote)
402
+ ? browser.instances.map(name => browser.getInstance(name))
403
+ : [browser]
404
+ for (const session of sessions) {
405
+ const behavior = session.capabilities?.unhandledPromptBehavior
406
+ if (behavior === 'ignore' || (behavior?.default === 'ignore' &&
407
+ Object.values(behavior).every(value => value === 'ignore'))) continue
408
+ if (!unsafePromptBrowsers.has(browser)) {
409
+ unsafePromptBrowsers.add(browser)
410
+ log.warn('WebdriverIO failure video recording is disabled: every session must use %s to preserve open dialogs.',
411
+ 'unhandledPromptBehavior: "ignore"')
412
+ }
413
+ return false
414
+ }
415
+ return true
416
+ }
417
+
418
+ /** Checks FFmpeg once in the worker, before configuring browser sessions or recording attempts. */
419
+ function isFfmpegAvailable () {
420
+ if (ffmpegAvailable !== undefined) return ffmpegAvailable
421
+ ffmpegAvailable = false
422
+ try {
423
+ const result = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], {
424
+ timeout: 5000, killSignal: 'SIGKILL', windowsHide: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
425
+ })
426
+ ffmpegAvailable = !result.error && result.status === 0 && /^[ \t]*V\S{5}[ \t]+libvpx[ \t]/m.test(result.stdout)
427
+ } catch (error) {
428
+ log.error('Error checking WebdriverIO video FFmpeg availability: %s', error.message)
429
+ }
430
+ if (!ffmpegAvailable) {
431
+ log.warn('WebdriverIO failure video recording requires FFmpeg on PATH with the libvpx encoder.')
432
+ }
433
+ return ffmpegAvailable
434
+ }
435
+
436
+ /**
437
+ * Creates a recorder when a browser is available. The encoder is loaded only for failed attempts.
438
+ *
439
+ * @param {object} browser
440
+ * @returns {WebdriverioVideo|void}
441
+ */
442
+ function createWebdriverioVideo (browser) {
443
+ if (typeof browser?.takeScreenshot !== 'function') return
444
+ try {
445
+ if (ffmpegAvailable === false || !hasSafePromptPolicy(browser) || !isFfmpegAvailable()) return
446
+ return new WebdriverioVideo(browser)
447
+ } catch (error) {
448
+ log.error('Error starting WebdriverIO video recording: %s', error.message)
449
+ }
450
+ }
451
+
452
+ module.exports = createWebdriverioVideo
453
+ module.exports.isFfmpegAvailable = isFfmpegAvailable
@@ -181,10 +181,9 @@ function canStringifyDirectWalk (value, depth) {
181
181
  child._bsontype !== undefined ||
182
182
  isRegExp(child) ||
183
183
  isMap(child) ||
184
- typeof child.toJSON === 'function') {
184
+ typeof child.toJSON === 'function' || !canStringifyDirectWalk(child, depth + 1)) {
185
185
  return false
186
186
  }
187
- if (!canStringifyDirectWalk(child, depth + 1)) return false
188
187
  }
189
188
  return true
190
189
  }
@@ -322,9 +321,13 @@ function buildRedact (value, ancestors) {
322
321
  // `value === original` short-circuit.
323
322
  if (typeof value.toJSON === 'function') {
324
323
  const json = value.toJSON()
325
- if (typeof json !== 'object' || json === null || json === value) return REDACT_LEAF
326
- // Re-screen: toJSON can return a TypedArray or Binary BSON wrapper.
327
- if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF
324
+ if (
325
+ typeof json !== 'object' || json === null || json === value ||
326
+ // Re-screen: toJSON can return a TypedArray or Binary BSON wrapper.
327
+ ArrayBuffer.isView(json) || json._bsontype === 'Binary'
328
+ ) {
329
+ return REDACT_LEAF
330
+ }
328
331
  value = json
329
332
  } else if (bsontype !== undefined) {
330
333
  return REDACT_LEAF
@@ -107,8 +107,7 @@ class NextPlugin extends ServerPlugin {
107
107
  const { req, res, nextRequest = {} } = ctx
108
108
  const store = ctx.currentStore ?? storage('legacy').getStore()
109
109
 
110
- if (!store) return
111
- if (reusedNextRequestStores.has(store)) return
110
+ if (!store || reusedNextRequestStores.has(store)) return
112
111
 
113
112
  const span = store.span
114
113
  const error = ctx.error ?? span.context().getTag('error')
@@ -295,8 +295,7 @@ class OpenAiTracingPlugin extends TracingPlugin {
295
295
  }
296
296
 
297
297
  sendLog (methodName, span, tags, openaiStore, error) {
298
- if (!openaiStore) return
299
- if (!this.sampler.isSampled(span)) return
298
+ if (!openaiStore || !this.sampler.isSampled(span)) return
300
299
 
301
300
  const log = {
302
301
  status: error ? 'error' : 'info',
@@ -334,6 +333,10 @@ function normalizeMethodName (methodName) {
334
333
  case 'responses.create':
335
334
  return 'createResponse'
336
335
 
336
+ // decisions
337
+ case 'decisions.create':
338
+ return 'createDecision'
339
+
337
340
  // files
338
341
  case 'files.create':
339
342
  return 'createFile'
@@ -460,8 +460,12 @@ class OpenAIAgentsIntegration {
460
460
  */
461
461
  #llmSpanParentAgentName (oaiSpan) {
462
462
  const traceInfo = this.#traceInfo.get(oaiSpan.traceId)
463
- if (!traceInfo?.currentTopLevelAgentSpanId) return
464
- if (!this.#hasUntracedPathToAncestor(oaiSpan.parentId, traceInfo.currentTopLevelAgentSpanId)) return
463
+ if (
464
+ !traceInfo?.currentTopLevelAgentSpanId ||
465
+ !this.#hasUntracedPathToAncestor(oaiSpan.parentId, traceInfo.currentTopLevelAgentSpanId)
466
+ ) {
467
+ return
468
+ }
465
469
  return traceInfo.currentTopLevelAgentName
466
470
  }
467
471
 
@@ -64,8 +64,7 @@ class DDOpenAIAgentsProcessor {
64
64
 
65
65
  onSpanStart (oaiSpan) {
66
66
  const integration = this._getIntegration()
67
- if (!integration?.enabled) return RESOLVED
68
- if (!oaiSpan?.spanData) return RESOLVED // guard NoopSpan
67
+ if (!integration?.enabled || !oaiSpan?.spanData) return RESOLVED // guard NoopSpan
69
68
  const type = oaiSpan.spanData.type
70
69
  const kind = SPAN_KIND_BY_TYPE[type]
71
70
  try {
@@ -84,8 +83,7 @@ class DDOpenAIAgentsProcessor {
84
83
 
85
84
  onSpanEnd (oaiSpan) {
86
85
  const integration = this._getIntegration()
87
- if (!integration?.enabled) return RESOLVED
88
- if (!oaiSpan?.spanData) return RESOLVED
86
+ if (!integration?.enabled || !oaiSpan?.spanData) return RESOLVED
89
87
  const type = oaiSpan.spanData.type
90
88
  const kind = SPAN_KIND_BY_TYPE[type]
91
89
  try {
@@ -1,9 +1,12 @@
1
1
  'use strict'
2
2
 
3
+ const { performance } = require('node:perf_hooks')
4
+
3
5
  const { CLIENT_PORT_KEY } = require('../../dd-trace/src/constants')
4
6
  const DatabasePlugin = require('../../dd-trace/src/plugins/database')
5
7
  const Plugin = require('../../dd-trace/src/plugins/plugin')
6
8
 
9
+ const ACQUIRE_START_CHANNEL = 'tracing:orchestrion:postgres:query:acquire:start'
7
10
  const PREPARE_START_CHANNEL = 'tracing:orchestrion:postgres:query:prepare:start'
8
11
 
9
12
  /**
@@ -14,6 +17,8 @@ const PREPARE_START_CHANNEL = 'tracing:orchestrion:postgres:query:prepare:start'
14
17
  * @property {string} [host]
15
18
  * @property {number} [pid]
16
19
  * @property {number} [port]
20
+ * @property {boolean} [poolWaitFinished]
21
+ * @property {number} [poolWaitStartMs]
17
22
  * @property {PostgresQuery} query
18
23
  * @property {string} [statement]
19
24
  * @property {string} user
@@ -63,12 +68,16 @@ class PostgresPlugin extends DatabasePlugin {
63
68
  /** @type {WeakMap<PostgresQuery, import('../../..').Span>} */
64
69
  #spans = new WeakMap()
65
70
 
71
+ /** @type {WeakMap<PostgresQuery, PostgresContext>} */
72
+ #waitContexts = new WeakMap()
73
+
66
74
  /**
67
75
  * @param {object} tracer
68
76
  * @param {import('../../dd-trace/src/config/config-base')} tracerConfig
69
77
  */
70
78
  constructor (tracer, tracerConfig) {
71
79
  super(tracer, tracerConfig)
80
+ this.addSub(ACQUIRE_START_CHANNEL, ctx => this.#finishPoolWait(ctx.query))
72
81
  this.#preparation = new PostgresPreparationPlugin(this.#prepare.bind(this), tracer, tracerConfig)
73
82
  }
74
83
 
@@ -112,12 +121,26 @@ class PostgresPlugin extends DatabasePlugin {
112
121
  }
113
122
 
114
123
  this.#spans.set(query, span)
124
+ this.#waitContexts.set(query, ctx)
115
125
  if (this.#dbmEnabled) {
116
126
  this.#dbmQueries.set(query, { service: service.name })
117
127
  }
118
128
  return ctx.currentStore
119
129
  }
120
130
 
131
+ /**
132
+ * @param {PostgresQuery} query
133
+ */
134
+ #finishPoolWait (query) {
135
+ const ctx = this.#waitContexts.get(query)
136
+ if (ctx === undefined) return
137
+
138
+ this.#waitContexts.delete(query)
139
+ ctx.poolWaitFinished = true
140
+ const waitTime = ctx.poolWaitStartMs === undefined ? 0 : performance.now() - ctx.poolWaitStartMs
141
+ ctx.currentStore.span.setTag('db.pool.wait_time_ms', waitTime)
142
+ }
143
+
121
144
  /**
122
145
  * @param {PostgresPreparationContext} ctx
123
146
  */
@@ -152,6 +175,7 @@ class PostgresPlugin extends DatabasePlugin {
152
175
  this.#dbmQueries.delete(query)
153
176
  if (span === undefined) return
154
177
 
178
+ this.#finishPoolWait(query)
155
179
  this.#spans.delete(query)
156
180
 
157
181
  const statement = dbmState?.statement ?? result.statement
@@ -144,12 +144,8 @@ class SchemaExtractor {
144
144
  span.setTag(SCHEMA_NAME, removeLeadingPeriod(descriptor.fullName))
145
145
  span.setTag(SCHEMA_OPERATION, operation)
146
146
 
147
- if (!tracer._dataStreamsProcessor.canSampleSchema(operation)) {
148
- return
149
- }
150
-
151
147
  // if the span is unsampled, do not sample the schema
152
- if (!tracer._prioritySampler.isSampled(span)) {
148
+ if (!tracer._dataStreamsProcessor.canSampleSchema(operation) || !tracer._prioritySampler.isSampled(span)) {
153
149
  return
154
150
  }
155
151
 
@@ -52,10 +52,7 @@ class RheaConsumerPlugin extends ConsumerPlugin {
52
52
 
53
53
  function getResourceNameFromMessage (msgObj) {
54
54
  let resourceName = 'amq.topic'
55
- let options = {}
56
- if (msgObj.receiver && msgObj.receiver.options) {
57
- options = msgObj.receiver.options
58
- }
55
+ const options = msgObj.receiver && msgObj.receiver.options ? msgObj.receiver.options : {}
59
56
  if (options.source && options.source.address) {
60
57
  resourceName = options.source.address
61
58
  }
@@ -354,6 +354,8 @@ class VitestPlugin extends CiPlugin {
354
354
  browserProjectName,
355
355
  testExecutionId,
356
356
  testStartLine,
357
+ startTime,
358
+ duration,
357
359
  }) => {
358
360
  const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot)
359
361
  const extraTags = {
@@ -380,10 +382,11 @@ class VitestPlugin extends CiPlugin {
380
382
  testSuite,
381
383
  testSuiteSpan || this.testSuiteSpan,
382
384
  extraTags,
383
- testExecutionId
385
+ testExecutionId,
386
+ startTime
384
387
  )
385
388
  this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'test', this.getTestTelemetryTags(testSpan))
386
- testSpan.finish()
389
+ testSpan.finish(Number.isFinite(duration) && duration >= 0 ? testSpan._startTime + duration : undefined)
387
390
  })
388
391
 
389
392
  this.addBind('ci:vitest:test-suite:start', (ctx) => {