dd-trace 6.17.0 → 6.19.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 (110) hide show
  1. package/LICENSE-3rdparty.csv +1 -0
  2. package/ci/vitest-no-worker-init-setup.mjs +22 -12
  3. package/index.d.ts +55 -11
  4. package/package.json +6 -6
  5. package/packages/datadog-instrumentations/src/anthropic.js +111 -9
  6. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +5 -1
  7. package/packages/datadog-instrumentations/src/cucumber.js +50 -9
  8. package/packages/datadog-instrumentations/src/cypress-config.js +27 -15
  9. package/packages/datadog-instrumentations/src/helpers/bundler-register.js +6 -0
  10. package/packages/datadog-instrumentations/src/helpers/check-require-cache.js +2 -1
  11. package/packages/datadog-instrumentations/src/helpers/hooks.js +0 -6
  12. package/packages/datadog-instrumentations/src/helpers/register.js +20 -0
  13. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +41 -4
  14. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +15 -13
  15. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +51 -0
  16. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +13 -0
  17. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.js +28 -3
  18. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +6 -0
  19. package/packages/datadog-instrumentations/src/jest/session-error.js +102 -0
  20. package/packages/datadog-instrumentations/src/jest.js +30 -13
  21. package/packages/datadog-instrumentations/src/mocha/main.js +7 -5
  22. package/packages/datadog-instrumentations/src/mocha/utils.js +8 -8
  23. package/packages/datadog-instrumentations/src/playwright-reporter.js +3 -4
  24. package/packages/datadog-instrumentations/src/playwright.js +73 -6
  25. package/packages/datadog-instrumentations/src/undici.js +7 -3
  26. package/packages/datadog-instrumentations/src/vitest-main.js +95 -21
  27. package/packages/datadog-instrumentations/src/vitest-worker.js +34 -3
  28. package/packages/datadog-instrumentations/src/webdriverio.js +28 -12
  29. package/packages/datadog-instrumentations/src/ws.js +41 -1
  30. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +83 -29
  31. package/packages/datadog-plugin-cucumber/src/index.js +22 -8
  32. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +51 -14
  33. package/packages/datadog-plugin-cypress/src/index.js +3 -1
  34. package/packages/datadog-plugin-fetch/src/index.js +3 -0
  35. package/packages/datadog-plugin-jest/src/index.js +3 -4
  36. package/packages/datadog-plugin-mocha/src/index.js +3 -4
  37. package/packages/datadog-plugin-oracledb/src/connection-parser.js +3 -1
  38. package/packages/datadog-plugin-playwright/src/index.js +11 -6
  39. package/packages/datadog-plugin-undici/src/index.js +5 -0
  40. package/packages/datadog-plugin-vitest/src/index.js +3 -4
  41. package/packages/datadog-plugin-ws/src/producer.js +1 -1
  42. package/packages/datadog-shimmer/src/shimmer.js +14 -10
  43. package/packages/datadog-turbopack/index.js +4 -1
  44. package/packages/datadog-turbopack/src/loader.js +6 -2
  45. package/packages/dd-trace/src/agent/info.js +5 -4
  46. package/packages/dd-trace/src/aiguard/client.js +38 -19
  47. package/packages/dd-trace/src/aiguard/integrations/anthropic.js +29 -4
  48. package/packages/dd-trace/src/aiguard/integrations/index.js +1 -1
  49. package/packages/dd-trace/src/aiguard/integrations/openai.js +20 -56
  50. package/packages/dd-trace/src/aiguard/integrations/stream.js +60 -0
  51. package/packages/dd-trace/src/aiguard/messages/anthropic.js +64 -0
  52. package/packages/dd-trace/src/config/generated-config-types.d.ts +19 -19
  53. package/packages/dd-trace/src/config/index.js +7 -6
  54. package/packages/dd-trace/src/config/supported-configurations.json +16 -5
  55. package/packages/dd-trace/src/constants.js +2 -0
  56. package/packages/dd-trace/src/debugger/devtools_client/config.js +2 -1
  57. package/packages/dd-trace/src/debugger/devtools_client/send.js +1 -1
  58. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +5 -3
  59. package/packages/dd-trace/src/debugger/devtools_client/status.js +1 -1
  60. package/packages/dd-trace/src/debugger/index.js +1 -1
  61. package/packages/dd-trace/src/evp_proxy/constants.js +2 -0
  62. package/packages/dd-trace/src/evp_proxy/direct.js +17 -13
  63. package/packages/dd-trace/src/evp_proxy/discovery.js +14 -10
  64. package/packages/dd-trace/src/evp_proxy/path.js +21 -7
  65. package/packages/dd-trace/src/exporters/common/url.js +33 -2
  66. package/packages/dd-trace/src/llmobs/constants/tags.js +31 -8
  67. package/packages/dd-trace/src/llmobs/gen-ai-tags.js +127 -0
  68. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +18 -0
  69. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +33 -8
  70. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +77 -41
  71. package/packages/dd-trace/src/llmobs/plugins/base.js +154 -15
  72. package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +162 -10
  73. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +65 -16
  74. package/packages/dd-trace/src/llmobs/plugins/genai/index.js +14 -0
  75. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +34 -35
  76. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +10 -0
  77. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +25 -2
  78. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +8 -7
  79. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +13 -0
  80. package/packages/dd-trace/src/llmobs/plugins/openai/realtime.js +5 -0
  81. package/packages/dd-trace/src/llmobs/plugins/vertexai.js +7 -0
  82. package/packages/dd-trace/src/llmobs/prompts/manager.js +10 -2
  83. package/packages/dd-trace/src/llmobs/prompts/prompt.js +79 -12
  84. package/packages/dd-trace/src/llmobs/span_processor.js +10 -65
  85. package/packages/dd-trace/src/llmobs/tagger.js +11 -40
  86. package/packages/dd-trace/src/openfeature/configuration_source.js +4 -1
  87. package/packages/dd-trace/src/openfeature/flagging_provider.js +3 -3
  88. package/packages/dd-trace/src/openfeature/index.js +6 -2
  89. package/packages/dd-trace/src/openfeature/writers/base.js +90 -17
  90. package/packages/dd-trace/src/openfeature/writers/exposures.js +4 -0
  91. package/packages/dd-trace/src/openfeature/writers/util.js +104 -33
  92. package/packages/dd-trace/src/opentelemetry/trace/index.js +5 -0
  93. package/packages/dd-trace/src/opentracing/tracer.js +3 -3
  94. package/packages/dd-trace/src/plugin_manager.js +27 -8
  95. package/packages/dd-trace/src/plugins/ci_plugin.js +1 -1
  96. package/packages/dd-trace/src/plugins/util/test.js +13 -4
  97. package/packages/dd-trace/src/profiler.js +51 -5
  98. package/packages/dd-trace/src/profiling/profiler.js +24 -20
  99. package/packages/dd-trace/src/proxy.js +2 -2
  100. package/packages/dd-trace/src/ritm.js +12 -9
  101. package/packages/dd-trace/src/span_processor.js +6 -1
  102. package/packages/dd-trace/src/telemetry/send-data.js +2 -1
  103. package/packages/dd-trace/src/telemetry/telemetry.js +1 -1
  104. package/packages/dd-trace/src/tracer.js +5 -4
  105. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
  106. package/packages/datadog-instrumentations/src/azure-cosmos.js +0 -7
  107. package/packages/datadog-instrumentations/src/bullmq.js +0 -11
  108. package/packages/datadog-instrumentations/src/langchain.js +0 -7
  109. package/packages/datadog-instrumentations/src/langgraph.js +0 -7
  110. package/packages/datadog-instrumentations/src/mercurius.js +0 -11
@@ -4,6 +4,7 @@ const fs = require('node:fs')
4
4
  const path = require('node:path')
5
5
  const { performance } = require('node:perf_hooks')
6
6
  const { fileURLToPath } = require('node:url')
7
+ const { deserialize } = require('node:v8')
7
8
  const { isMainThread, parentPort } = require('node:worker_threads')
8
9
 
9
10
  const { channel } = require('dc-polyfill')
@@ -47,6 +48,7 @@ const {
47
48
 
48
49
  const EFD_SUITE_ADMISSION_TIMEOUT_MS = 5000
49
50
  const logSubmissionFlushCh = channel('ci:log-submission:flush')
51
+ const wrappedTestRunners = new WeakSet()
50
52
  const taskToCtx = new WeakMap()
51
53
  const taskToTestProperties = new WeakMap()
52
54
  const taskToStatuses = new WeakMap()
@@ -412,6 +414,14 @@ function finishEfdSuiteAdmissionRequest (requestId, allowed) {
412
414
  * @param {unknown} message
413
415
  */
414
416
  function handleEfdSuiteAdmissionResponse (message) {
417
+ // Older Vitest 4 forks serialize parent messages explicitly; newer forks and threads send plain values.
418
+ if (Buffer.isBuffer(message) || (message?.type === 'Buffer' && Array.isArray(message.data))) {
419
+ try {
420
+ message = deserialize(Buffer.from(message))
421
+ } catch {
422
+ return
423
+ }
424
+ }
415
425
  if (!Array.isArray(message) || message[0] !== VITEST_WORKER_EFD_SUITE_ADMISSION_RESPONSE_CODE) return
416
426
 
417
427
  const { allowed, requestId } = message[1] || {}
@@ -520,6 +530,11 @@ function wrapSuiteHookFn (hookType, fn, fallbackTask) {
520
530
  }
521
531
 
522
532
  function wrapVitestTestRunner (VitestTestRunner) {
533
+ // ESM hooks can expose the same runner through multiple module identities.
534
+ // Wrapping its lifecycle methods twice would report each retry twice.
535
+ if (wrappedTestRunners.has(VitestTestRunner)) return
536
+ wrappedTestRunners.add(VitestTestRunner)
537
+
523
538
  // `onBeforeRunTask` is run before any repetition or attempt is run
524
539
  // `onBeforeRunTask` is an async function
525
540
  shimmer.wrap(VitestTestRunner.prototype, 'onBeforeRunTask', onBeforeRunTask => async function (task) {
@@ -623,6 +638,13 @@ function wrapVitestTestRunner (VitestTestRunner) {
623
638
  shimmer.wrap(VitestTestRunner.prototype, 'onAfterRunTask', onAfterRunTask => function (task) {
624
639
  const { isTestManagementTestsEnabled } = getProvidedContext()
625
640
 
641
+ // Vitest 5 aggregates failed repetitions into the final state. Preserve the last attempt's actual result.
642
+ if ((attemptToFixTasks.has(task) || efdRetryTasks.has(task)) &&
643
+ task.result.state === 'fail' && taskToCtx.get(task)?.status === 'pass' &&
644
+ (task.result.errors?.length ?? 0) === (taskToReportedErrorCount.get(task) ?? 0)) {
645
+ task.result.state = 'pass'
646
+ }
647
+
626
648
  if (isTestManagementTestsEnabled) {
627
649
  const isAttemptingToFix = attemptToFixTasks.has(task)
628
650
  const isQuarantined = quarantinedTasks.has(task)
@@ -745,7 +767,12 @@ function wrapVitestTestRunner (VitestTestRunner) {
745
767
  }
746
768
  }
747
769
 
748
- const lastExecutionStatus = task.result.state
770
+ let lastExecutionStatus = task.result.state
771
+ if (lastExecutionStatus === 'run' && numRepetition > 0) {
772
+ // Vitest 5 resets the state before each repetition but retains errors from earlier attempts.
773
+ const reportedErrorCount = taskToReportedErrorCount.get(task) ?? 0
774
+ lastExecutionStatus = (task.result.errors?.length ?? 0) > reportedErrorCount ? 'fail' : 'pass'
775
+ }
749
776
  const isAtf = attemptToFixTasks.has(task)
750
777
  const isEfd = efdRetryTasks.has(task)
751
778
  const shouldTrackStatuses = isEfd || isAtf
@@ -1101,14 +1128,18 @@ function getStartTestsWrapper (frameworkVersion) {
1101
1128
  isDisabled: disabledTasks.has(task),
1102
1129
  isQuarantined: quarantinedTasks.has(task),
1103
1130
  })
1104
- } else if (state === 'pass' && !isSwitchedStatus) {
1131
+ } else if ((state === 'pass' && !isSwitchedStatus) || switchedStatus === 'pass') {
1132
+ // Attempt-to-fix can pass its last attempt while an earlier failure still fails the suite.
1133
+ if (switchedStatus === 'pass' && errors?.length) {
1134
+ testSuiteError = testError
1135
+ }
1105
1136
  if (testCtx) {
1106
1137
  const isSkippedByTestManagement =
1107
1138
  !attemptToFixTasks.has(task) && (disabledTasks.has(task) || quarantinedTasks.has(task))
1108
1139
  const promises = {}
1109
1140
  testPassCh.publish({
1110
1141
  task,
1111
- finalStatus: isSkippedByTestManagement ? 'skip' : 'pass',
1142
+ finalStatus: isSkippedByTestManagement ? 'skip' : state,
1112
1143
  earlyFlakeAbortReason: efdSlowAbortedTasks.has(task) ? 'slow' : undefined,
1113
1144
  promises,
1114
1145
  ...testCtx.currentStore,
@@ -63,6 +63,7 @@ const baseReporterWaitForSyncCh = tracingChannel('orchestrion:@wdio/runner:BaseR
63
63
  const runnerRunCh = tracingChannel('orchestrion:@wdio/runner:Runner_run')
64
64
  const executeAsyncCh = tracingChannel('orchestrion:@wdio/utils:executeAsync')
65
65
  const launcherStartInstanceCh = tracingChannel('orchestrion:@wdio/cli:Launcher_startInstance')
66
+ const configParserShardCh = tracingChannel('orchestrion:@wdio/config:ConfigParser_shard')
66
67
  const localRunnerRunCh = tracingChannel('orchestrion:@wdio/local-runner:LocalRunner_run')
67
68
  const localRunnerShutdownCh = tracingChannel('orchestrion:@wdio/local-runner:LocalRunner_shutdown')
68
69
  const testFrameworkFnWrapperCh = tracingChannel('orchestrion:@wdio/utils:testFrameworkFnWrapper')
@@ -87,6 +88,7 @@ if (loadCh.hasSubscribers) {
87
88
  }
88
89
 
89
90
  const coordinatorStates = new WeakMap()
91
+ const emptyShardConfigurations = new WeakSet()
90
92
  const localRunnerVersions = new WeakMap()
91
93
  const rumBrowsers = new Set()
92
94
  const rumCorrelationBrowsers = new Set()
@@ -936,6 +938,7 @@ function waitForRumTestStart (context) {
936
938
  * @property {string} framework
937
939
  * @property {string|undefined} rootDir
938
940
  * @property {typeof process.env|undefined} runnerEnv
941
+ * @property {{total: number}|undefined} shard
939
942
  */
940
943
 
941
944
  /**
@@ -1806,17 +1809,18 @@ function getSessionStatus (state) {
1806
1809
  }
1807
1810
 
1808
1811
  /**
1809
- * Returns whether every started worker reported that it discovered no tests.
1812
+ * Explains a skipped session using the workers' discovery results.
1810
1813
  *
1811
1814
  * @param {CoordinatorState} state
1812
1815
  */
1813
- function isExpectedEmptySession (state) {
1814
- if (state.workers.size === 0) return false
1815
-
1816
+ function getTestSessionEmptyReason (state) {
1817
+ if (state.workers.size === 0 && emptyShardConfigurations.has(getRunnerConfiguration(state.localRunner))) {
1818
+ return 'zero_test_shard'
1819
+ }
1816
1820
  for (const workerRecord of state.workers) {
1817
- if (workerRecord.hasTests !== false) return false
1821
+ if (workerRecord.hasTests !== false) return 'all_tests_skipped'
1818
1822
  }
1819
- return true
1823
+ return 'zero_tests'
1820
1824
  }
1821
1825
 
1822
1826
  /**
@@ -1832,9 +1836,9 @@ function finishCoordinator (state, error, onDone) {
1832
1836
  return
1833
1837
  }
1834
1838
  if (!state.sessionStarted) {
1835
- if (!error && getSessionStatus(state) !== 'fail') {
1836
- onDone()
1837
- return
1839
+ if (!error && state.workers.size === 0 &&
1840
+ !emptyShardConfigurations.has(getRunnerConfiguration(state.localRunner))) {
1841
+ error = new Error('No test files were found.')
1838
1842
  }
1839
1843
  initializeCoordinator(state, () => finishCoordinator(state, error, onDone))
1840
1844
  return
@@ -1861,7 +1865,7 @@ function finishCoordinator (state, error, onDone) {
1861
1865
  const status = error ? 'fail' : getSessionStatus(state)
1862
1866
  testSessionFinishCh.publish({
1863
1867
  status,
1864
- isExpectedEmptySession: status === 'skip' && isExpectedEmptySession(state),
1868
+ testSessionEmptyReason: status === 'skip' ? getTestSessionEmptyReason(state) : undefined,
1865
1869
  error,
1866
1870
  isEarlyFlakeDetectionEnabled: state.configuration.isEarlyFlakeDetectionEnabled,
1867
1871
  isEarlyFlakeDetectionFaulty: state.configuration.isEarlyFlakeDetectionFaulty,
@@ -1976,6 +1980,17 @@ jasmineAdapterInitCh.subscribe({
1976
1980
 
1977
1981
  // dc-polyfill supports partial tracing-channel subscribers, unlike the Node.js type definition.
1978
1982
  // @ts-expect-error
1983
+ configParserShardCh.subscribe({
1984
+ end (context) {
1985
+ const config = context.self._config
1986
+ if (testFinishCh.hasSubscribers && config?.shard?.total > 1 &&
1987
+ context.arguments[0].length > 0 && context.result?.length === 0) {
1988
+ emptyShardConfigurations.add(config)
1989
+ }
1990
+ },
1991
+ })
1992
+
1993
+ // @ts-expect-error See the partial tracing-channel subscriber above.
1979
1994
  launcherStartInstanceCh.subscribe({
1980
1995
  start (context) {
1981
1996
  const localRunner = context.self?.runner
@@ -2050,10 +2065,11 @@ localRunnerRunCh.subscribe({
2050
2065
  // @ts-expect-error See the partial tracing-channel subscriber above.
2051
2066
  localRunnerShutdownCh.subscribe({
2052
2067
  asyncEnd (context) {
2053
- const state = coordinatorStates.get(context.self)
2054
- if (!state) {
2068
+ const runnerConfiguration = getRunnerConfiguration(context.self)
2069
+ if (!testFinishCh.hasSubscribers || !SUPPORTED_FRAMEWORK_ADAPTERS.has(runnerConfiguration?.framework)) {
2055
2070
  return
2056
2071
  }
2072
+ const state = getCoordinatorState(context.self)
2057
2073
 
2058
2074
  // Orchestrion uses the callback for the matching settlement path to delay LocalRunner.shutdown.
2059
2075
  const waitForCoordinator = onDone => {
@@ -94,9 +94,22 @@ function wrapSend (send) {
94
94
  return send.apply(this, arguments)
95
95
  }
96
96
 
97
+ const socket = this._sender?._socket
98
+ // No socket is attached before the handshake completes or after it is torn down.
99
+ if (!socket) {
100
+ return send.apply(this, arguments)
101
+ }
102
+
97
103
  const [data, options, cb] = arguments
98
104
 
99
- const ctx = { data, socket: this._sender?._socket }
105
+ // `ws` stringifies numbers, then spreads `options` over its own inferred default, so an
106
+ // explicitly passed `binary` wins even when it is nullish and the frame goes out as text.
107
+ const payload = typeof data === 'number' ? data.toString() : data
108
+ const binary = options != null && Object.hasOwn(options, 'binary')
109
+ ? Boolean(options.binary)
110
+ : typeof payload !== 'string'
111
+ const byteLength = sentDataLength(payload)
112
+ const ctx = { data, binary, socket, byteLength }
100
113
 
101
114
  return typeof cb === 'function'
102
115
  ? producerCh.traceCallback(send, undefined, ctx, this, data, options, cb)
@@ -241,6 +254,33 @@ addHook({
241
254
  return Receiver
242
255
  })
243
256
 
257
+ /**
258
+ * Byte length of an outgoing payload, following the coercion `ws` applies in `Sender.send`:
259
+ * strings and blobs are measured directly and everything else goes through `toBuffer()`, which
260
+ * yields one byte per element for plain arrays rather than concatenating them. Blob-likes carry
261
+ * neither a `byteLength` nor an array shape, so they are matched last to keep the common
262
+ * buffer path first.
263
+ *
264
+ * @param {WebSocketMessageData | number | undefined} data
265
+ */
266
+ function sentDataLength (data) {
267
+ if (typeof data === 'string') {
268
+ return Buffer.byteLength(data)
269
+ }
270
+ // An array is one byte per element even when it carries an unrelated `byteLength`.
271
+ if (Array.isArray(data)) {
272
+ return data.length
273
+ }
274
+ // Covers views as well as `ArrayBuffer` and `SharedArrayBuffer`.
275
+ if (typeof (/** @type {{ byteLength?: unknown }} */ (data)?.byteLength) === 'number') {
276
+ return /** @type {{ byteLength: number }} */ (data).byteLength
277
+ }
278
+ // Blobs and files, detected the way `ws` does: structurally, so shimmed and cross-realm
279
+ // implementations it accepts are measured instead of silently reported as empty.
280
+ const { size } = /** @type {{ size?: unknown }} */ (data) ?? {}
281
+ return typeof size === 'number' ? size : 0
282
+ }
283
+
244
284
  /**
245
285
  * @param {WebSocketMessageData} data
246
286
  */
@@ -23,6 +23,15 @@ const PROVIDER = {
23
23
  MISTRAL: 'MISTRAL',
24
24
  }
25
25
 
26
+ /**
27
+ * @typedef {{
28
+ * inputTokens?: number,
29
+ * outputTokens?: number,
30
+ * cacheReadTokens?: number,
31
+ * cacheWriteTokens?: number,
32
+ * }} StreamedUsage
33
+ */
34
+
26
35
  /**
27
36
  * Coerce the chunks into a single response body.
28
37
  *
@@ -44,10 +53,7 @@ function extractTextAndResponseReasonFromStream (chunks, modelProvider, modelNam
44
53
  }
45
54
 
46
55
  let message = ''
47
- let inputTokens = 0
48
- let outputTokens = 0
49
- let cacheReadTokens = 0
50
- let cacheWriteTokens = 0
56
+ let usage
51
57
 
52
58
  for (const { chunk: { bytes } } of chunks) {
53
59
  const body = JSON.parse(Buffer.from(bytes).toString('utf8'))
@@ -56,8 +62,6 @@ function extractTextAndResponseReasonFromStream (chunks, modelProvider, modelNam
56
62
  case PROVIDER.AMAZON: {
57
63
  if (body?.outputText) {
58
64
  message += body?.outputText
59
- inputTokens = body?.inputTextTokenCount
60
- outputTokens = body?.totalOutputTextTokenCount
61
65
  } else if (body?.contentBlockDelta?.delta?.text) {
62
66
  message += body.contentBlockDelta.delta.text
63
67
  }
@@ -79,9 +83,6 @@ function extractTextAndResponseReasonFromStream (chunks, modelProvider, modelNam
79
83
  message += body.delta.text
80
84
  }
81
85
 
82
- if (body.message?.usage?.input_tokens) inputTokens = body.message.usage.input_tokens
83
- if (body.message?.usage?.output_tokens) outputTokens = body.message.usage.output_tokens
84
-
85
86
  break
86
87
  }
87
88
  case PROVIDER.COHERE: {
@@ -101,24 +102,61 @@ function extractTextAndResponseReasonFromStream (chunks, modelProvider, modelNam
101
102
  }
102
103
  }
103
104
 
104
- // by default, it seems newer versions of the AWS SDK include the input/output token counts in the response body
105
- const invocationMetrics = body['amazon-bedrock-invocationMetrics']
106
- if (invocationMetrics) {
107
- inputTokens = invocationMetrics.inputTokenCount
108
- outputTokens = invocationMetrics.outputTokenCount
109
- cacheReadTokens = invocationMetrics.cacheReadInputTokenCount
110
- cacheWriteTokens = invocationMetrics.cacheWriteInputTokenCount
105
+ usage = mergeStreamedUsage(usage, body, modelProviderUpper)
106
+ }
107
+
108
+ return new Generation({ message, role: 'assistant', ...usage })
109
+ }
110
+
111
+ /**
112
+ * Merge the token counts one streamed `invokeModel` frame reports into the running totals. Each
113
+ * provider spells them differently, and most report none until the trailing invocation metrics,
114
+ * so a frame carrying no counts leaves the totals as they were.
115
+ *
116
+ * @param {StreamedUsage} [usage]
117
+ * @param {object} body parsed chunk body
118
+ * @param {string} modelProviderUpper
119
+ * @returns {StreamedUsage | undefined}
120
+ */
121
+ function mergeStreamedUsage (usage, body, modelProviderUpper) {
122
+ // by default, it seems newer versions of the AWS SDK include the input/output token counts in
123
+ // the response body; any provider can send them, and they supersede what the frames reported
124
+ const invocationMetrics = body['amazon-bedrock-invocationMetrics']
125
+ if (invocationMetrics) {
126
+ return {
127
+ inputTokens: invocationMetrics.inputTokenCount,
128
+ outputTokens: invocationMetrics.outputTokenCount,
129
+ cacheReadTokens: invocationMetrics.cacheReadInputTokenCount,
130
+ cacheWriteTokens: invocationMetrics.cacheWriteInputTokenCount,
111
131
  }
112
132
  }
113
133
 
114
- return new Generation({
115
- message,
116
- role: 'assistant',
117
- inputTokens,
118
- outputTokens,
119
- cacheReadTokens,
120
- cacheWriteTokens,
121
- })
134
+ switch (modelProviderUpper) {
135
+ case PROVIDER.AMAZON: {
136
+ const { inputTextTokenCount, totalOutputTextTokenCount } = body
137
+ if (inputTextTokenCount === undefined && totalOutputTextTokenCount === undefined) return usage
138
+
139
+ return {
140
+ ...usage,
141
+ inputTokens: inputTextTokenCount ?? usage?.inputTokens,
142
+ outputTokens: totalOutputTextTokenCount ?? usage?.outputTokens,
143
+ }
144
+ }
145
+ case PROVIDER.ANTHROPIC: {
146
+ // `message_start` reports the initial counts under `message.usage`; the closing
147
+ // `message_delta` reports the final output count at the top level
148
+ const chunkUsage = body.message?.usage ?? body.usage
149
+ if (!chunkUsage) return usage
150
+
151
+ return {
152
+ ...usage,
153
+ inputTokens: chunkUsage.input_tokens ?? usage?.inputTokens,
154
+ outputTokens: chunkUsage.output_tokens ?? usage?.outputTokens,
155
+ }
156
+ }
157
+ }
158
+
159
+ return usage
122
160
  }
123
161
 
124
162
  class Generation {
@@ -568,13 +606,27 @@ function resolveToolResultItem (item) {
568
606
  return `[Unsupported content type(s): ${getContentBlockType(item)}]`
569
607
  }
570
608
 
609
+ /**
610
+ * Normalize a Converse usage object onto the LLMObs metric names. Returns undefined when the
611
+ * response reported no counts at all, so a caller cannot mistake an empty record for a measurement.
612
+ *
613
+ * @param {object} [usage]
614
+ * @returns {{
615
+ * inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number
616
+ * } | undefined}
617
+ */
571
618
  function buildUsage (usage = {}) {
572
- return {
573
- inputTokens: usage.inputTokens,
574
- outputTokens: usage.outputTokens,
575
- cacheReadTokens: usage.cacheReadInputTokens ?? usage.cacheReadInputTokenCount,
576
- cacheWriteTokens: usage.cacheWriteInputTokens ?? usage.cacheWriteInputTokenCount,
619
+ const inputTokens = usage.inputTokens
620
+ const outputTokens = usage.outputTokens
621
+ const cacheReadTokens = usage.cacheReadInputTokens ?? usage.cacheReadInputTokenCount
622
+ const cacheWriteTokens = usage.cacheWriteInputTokens ?? usage.cacheWriteInputTokenCount
623
+
624
+ if (inputTokens === undefined && outputTokens === undefined &&
625
+ cacheReadTokens === undefined && cacheWriteTokens === undefined) {
626
+ return
577
627
  }
628
+
629
+ return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
578
630
  }
579
631
 
580
632
  /**
@@ -714,6 +766,8 @@ function extractTextAndResponseReasonConverseFromStream (chunks) {
714
766
 
715
767
  module.exports = {
716
768
  Generation,
769
+ buildUsage,
770
+ mergeStreamedUsage,
717
771
  RequestParams,
718
772
  extractTextAndResponseReasonFromStream,
719
773
  parseModelId,
@@ -73,7 +73,7 @@ class CucumberPlugin extends CiPlugin {
73
73
  isEarlyFlakeDetectionFaulty,
74
74
  isTestManagementTestsEnabled,
75
75
  isParallel,
76
- isExpectedEmptySession,
76
+ testSessionEmptyReason,
77
77
  error,
78
78
  onDone,
79
79
  }) => {
@@ -118,12 +118,11 @@ class CucumberPlugin extends CiPlugin {
118
118
 
119
119
  this.testSessionSpan.setTag(TEST_STATUS, status)
120
120
  this.testModuleSpan.setTag(TEST_STATUS, status)
121
- if (isExpectedEmptySession) {
121
+ if (testSessionEmptyReason) {
122
122
  setExpectedEmptyTestSessionTags(
123
123
  this.testSessionSpan,
124
124
  this.testModuleSpan,
125
- 'No scenarios were executed',
126
- 'zero_tests'
125
+ testSessionEmptyReason
127
126
  )
128
127
  }
129
128
  if (error) {
@@ -450,7 +449,8 @@ class CucumberPlugin extends CiPlugin {
450
449
  })
451
450
 
452
451
  this.addSub('ci:cucumber:is-modified-test', ({
453
- scenarios,
452
+ gherkinNodes,
453
+ gherkinScopeRanges,
454
454
  testFileAbsolutePath,
455
455
  modifiedFiles,
456
456
  stepIds,
@@ -458,11 +458,25 @@ class CucumberPlugin extends CiPlugin {
458
458
  setIsModified,
459
459
  }) => {
460
460
  const testScenarioPath = getTestSuitePath(testFileAbsolutePath, this.repositoryRoot || process.cwd())
461
- for (const scenario of scenarios) {
461
+ for (const [startLine, endLine] of gherkinScopeRanges) {
462
+ if (isModifiedTest(testScenarioPath, startLine, endLine, modifiedFiles, 'cucumber')) {
463
+ setIsModified(true)
464
+ return
465
+ }
466
+ }
467
+ for (const gherkinNode of gherkinNodes) {
468
+ const lastStep = gherkinNode.steps.at(-1)
469
+ let endLine = lastStep?.location.line ?? gherkinNode.location.line
470
+ if (lastStep?.dataTable?.rows.length) {
471
+ endLine = lastStep.dataTable.rows.at(-1).location.line
472
+ } else if (lastStep?.docString) {
473
+ const { content, location } = lastStep.docString
474
+ endLine = location.line + (content ? content.split('\n').length + 1 : 1)
475
+ }
462
476
  const isModified = isModifiedTest(
463
477
  testScenarioPath,
464
- scenario.location.line,
465
- scenario.steps.at(-1).location.line,
478
+ gherkinNode.location.line,
479
+ endLine,
466
480
  modifiedFiles,
467
481
  'cucumber'
468
482
  )
@@ -94,6 +94,7 @@ const {
94
94
  const { ORIGIN_KEY, COMPONENT } = require('../../dd-trace/src/constants')
95
95
  const { RESOURCE_NAME } = require('../../../ext/tags')
96
96
  const getConfig = require('../../dd-trace/src/config')
97
+ const { getValueFromEnvSources } = require('../../dd-trace/src/config/helper')
97
98
  const {
98
99
  SCREENSHOT_UPLOAD_RESULT_ERROR,
99
100
  SCREENSHOT_UPLOAD_RESULT_UPLOADED,
@@ -242,7 +243,7 @@ function getSessionStatus (summary, testSuiteStatuses) {
242
243
  if (summary.status === 'failed' || summary.failures > 0 || summary.totalFailed > 0) {
243
244
  return 'fail'
244
245
  }
245
- if (summary.totalSkipped !== undefined && summary.totalSkipped === summary.totalTests) {
246
+ if ((summary.totalSkipped ?? 0) + (summary.totalPending ?? 0) === summary.totalTests) {
246
247
  return 'skip'
247
248
  }
248
249
  return 'pass'
@@ -381,7 +382,7 @@ function getSuiteStatus (suiteStats) {
381
382
  return 'fail'
382
383
  }
383
384
  if (suiteStats.tests !== undefined &&
384
- (suiteStats.tests === suiteStats.pending || suiteStats.tests === suiteStats.skipped)) {
385
+ suiteStats.tests === (suiteStats.pending ?? 0) + (suiteStats.skipped ?? 0)) {
385
386
  return 'skip'
386
387
  }
387
388
  return 'pass'
@@ -451,6 +452,8 @@ function getFinalStatus ({
451
452
  }
452
453
 
453
454
  class CypressPlugin {
455
+ #pendingTestSuiteSpans = new Map()
456
+
454
457
  _isInit = false
455
458
  testEnvironmentMetadata = getTestEnvironmentMetadata(TEST_FRAMEWORK_NAME)
456
459
 
@@ -548,6 +551,7 @@ class CypressPlugin {
548
551
  *
549
552
  */
550
553
  resetRunState () {
554
+ this.#pendingTestSuiteSpans.clear()
551
555
  this._isInit = false
552
556
  this.finishedTestsByFile = {}
553
557
  this.hasTestsReported = false
@@ -854,6 +858,12 @@ class CypressPlugin {
854
858
 
855
859
  const testOptimizationConfig = getConfig().testOptimization
856
860
  this.rumFlushWaitMillis = testOptimizationConfig.DD_CIVISIBILITY_RUM_FLUSH_WAIT_MILLIS
861
+ if (getValueFromEnvSources('DD_TEST_FAILURE_SCREENSHOTS_ENABLED', true) === true) {
862
+ cypressConfig.screenshotOnRunFailure = true
863
+ }
864
+ if (getValueFromEnvSources('DD_TEST_FAILURE_VIDEOS_ENABLED', true) === true) {
865
+ cypressConfig.video = true
866
+ }
857
867
  this.warnIfMisconfiguredTestFailureScreenshots(cypressConfig, tracer, testOptimizationConfig)
858
868
  this.warnIfMisconfiguredTestFailureVideos(cypressConfig, tracer, testOptimizationConfig)
859
869
 
@@ -1048,7 +1058,7 @@ class CypressPlugin {
1048
1058
  }
1049
1059
  }
1050
1060
 
1051
- return this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test_suite`, {
1061
+ const testSuiteSpan = this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test_suite`, {
1052
1062
  childOf: this.testModuleSpan,
1053
1063
  tags: {
1054
1064
  [COMPONENT]: TEST_FRAMEWORK_NAME,
@@ -1057,9 +1067,13 @@ class CypressPlugin {
1057
1067
  },
1058
1068
  integrationName: TEST_FRAMEWORK_NAME,
1059
1069
  })
1070
+ this.#pendingTestSuiteSpans.set(testSuite, testSuiteSpan)
1071
+ return testSuiteSpan
1060
1072
  }
1061
1073
 
1062
- getTestSpan ({ testName, testSuite, isUnskippable, isForcedToRun, testSourceFile, isDisabled, isQuarantined }) {
1074
+ getTestSpan ({
1075
+ testName, testSuite, isUnskippable, isForcedToRun, testSourceFile, isDisabled, isQuarantined, startTime,
1076
+ }) {
1063
1077
  const testSuiteTags = {
1064
1078
  [TEST_MODULE]: TEST_FRAMEWORK_NAME,
1065
1079
  }
@@ -1116,6 +1130,7 @@ class CypressPlugin {
1116
1130
 
1117
1131
  return this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test`, {
1118
1132
  childOf,
1133
+ startTime,
1119
1134
  tags: {
1120
1135
  [COMPONENT]: TEST_FRAMEWORK_NAME,
1121
1136
  [ORIGIN_KEY]: CI_APP_ORIGIN,
@@ -1348,6 +1363,20 @@ class CypressPlugin {
1348
1363
  }
1349
1364
 
1350
1365
  afterRun (suiteStats, error) {
1366
+ const pendingFinalizations = []
1367
+ // Late manual initialization cannot intercept a later replacement of after:spec.
1368
+ // Run results let us finalize the suites whose browser hooks already created spans.
1369
+ if (this._isInit && suiteStats?.runs) {
1370
+ for (const result of suiteStats.runs) {
1371
+ const testSuiteSpan = this.#pendingTestSuiteSpans.get(result.spec?.relative)
1372
+ if (!testSuiteSpan) continue
1373
+ this.testSuiteSpan = testSuiteSpan
1374
+ const endedAt = Date.parse(result.stats?.endedAt)
1375
+ const finalization = this.afterSpec(result.spec, result, undefined,
1376
+ Number.isFinite(endedAt) ? endedAt : undefined)
1377
+ if (finalization) pendingFinalizations.push(finalization)
1378
+ }
1379
+ }
1351
1380
  const hasPendingVideoSpans = this.pendingVideoUploads.length > 0
1352
1381
  const videoUploadsPromise = this.uploadPendingTestSuiteVideos()
1353
1382
  if (!this._isInit) {
@@ -1358,7 +1387,11 @@ class CypressPlugin {
1358
1387
  }
1359
1388
  return
1360
1389
  }
1361
- const finalizationPromise = this.#finalizeRun(suiteStats, error, hasPendingVideoSpans)
1390
+ // Recovered screenshot-only tests must finish before the run closes any remaining trace spans.
1391
+ const finalizeRun = () => this.#finalizeRun(suiteStats, error, hasPendingVideoSpans)
1392
+ const finalizationPromise = pendingFinalizations.length > 0
1393
+ ? Promise.all(pendingFinalizations).then(finalizeRun)
1394
+ : finalizeRun()
1362
1395
  if (videoUploadsPromise) {
1363
1396
  return Promise.all([videoUploadsPromise, finalizationPromise])
1364
1397
  .then(() => this.#flushExporter(false))
@@ -1384,12 +1417,11 @@ class CypressPlugin {
1384
1417
 
1385
1418
  this.testModuleSpan.setTag(TEST_STATUS, testStatus)
1386
1419
  this.testSessionSpan.setTag(TEST_STATUS, testStatus)
1387
- if (testStatus !== 'fail' && hasNoTests) {
1420
+ if (testStatus !== 'fail' && (hasNoTests || testStatus === 'skip')) {
1388
1421
  setExpectedEmptyTestSessionTags(
1389
1422
  this.testSessionSpan,
1390
1423
  this.testModuleSpan,
1391
- 'No tests were executed',
1392
- 'zero_tests'
1424
+ hasNoTests ? 'zero_tests' : 'all_tests_skipped'
1393
1425
  )
1394
1426
  }
1395
1427
  if (error) {
@@ -1504,10 +1536,10 @@ class CypressPlugin {
1504
1536
  }
1505
1537
  }
1506
1538
 
1507
- afterSpec (spec, results, error) {
1539
+ afterSpec (spec, results, error, finishTime) {
1508
1540
  const { tests, stats, screenshots, video, error: resultError } = results || {}
1509
1541
  const cypressTests = tests || []
1510
- if (cypressTests.length > 0) this.hasTestsReported = true
1542
+ if (cypressTests.length > 0 || stats?.tests > 0) this.hasTestsReported = true
1511
1543
  const specScreenshots = screenshots || []
1512
1544
  const finishedTests = this.finishedTestsByFile[spec.relative] || []
1513
1545
  const screenshotUploadPromises = []
@@ -1540,7 +1572,10 @@ class CypressPlugin {
1540
1572
  ? getTestSuitePath(spec.absolute, this.repositoryRoot)
1541
1573
  : spec.relative
1542
1574
 
1543
- const skippedTestSpan = this.getTestSpan({ testName: cypressTestName, testSuite: spec.relative, testSourceFile })
1575
+ // Recovered tests that never ran have no execution time; keep them at the recorded suite end.
1576
+ const skippedTestSpan = this.getTestSpan({
1577
+ testName: cypressTestName, testSuite: spec.relative, testSourceFile, startTime: finishTime,
1578
+ })
1544
1579
  skippedTestSpan.setTag(TEST_FINAL_STATUS, 'skip')
1545
1580
 
1546
1581
  skippedTestSpan.setTag(TEST_STATUS, 'skip')
@@ -1578,7 +1613,7 @@ class CypressPlugin {
1578
1613
  })
1579
1614
  }
1580
1615
 
1581
- testSpanFinishes.push({ testSpan: skippedTestSpan, finishTime: this._now() })
1616
+ testSpanFinishes.push({ testSpan: skippedTestSpan, finishTime: finishTime ?? this._now() })
1582
1617
  }
1583
1618
 
1584
1619
  // Make sure that reported test statuses are the same as Cypress reports.
@@ -1749,7 +1784,7 @@ class CypressPlugin {
1749
1784
  }
1750
1785
  }
1751
1786
 
1752
- const testSuiteFinishTime = this._now()
1787
+ const testSuiteFinishTime = finishTime ?? this._now()
1753
1788
  const suiteError = error || resultError || latestError
1754
1789
  const suiteStatus = suiteError ||
1755
1790
  cypressTests.some(test => CYPRESS_STATUS_TO_TEST_STATUS[test.state] === 'fail')
@@ -1771,6 +1806,7 @@ class CypressPlugin {
1771
1806
  testSuiteSpan.setTag(TEST_STATUS, suiteStatus)
1772
1807
  if (suiteError) testSuiteSpan.setTag('error', suiteError)
1773
1808
  this.testSuiteSpan = null
1809
+ this.#pendingTestSuiteSpans.delete(spec.relative)
1774
1810
  }
1775
1811
 
1776
1812
  let testSpansPromise
@@ -2034,7 +2070,8 @@ class CypressPlugin {
2034
2070
  rumTestExecutionIdCookieName: RUM_TEST_EXECUTION_ID_COOKIE_NAME,
2035
2071
  }
2036
2072
 
2037
- this.testSuiteSpan ||= this.getTestSuiteSpan({ testSuite, testSuiteAbsolutePath })
2073
+ this.testSuiteSpan = this.#pendingTestSuiteSpans.get(testSuite) ||
2074
+ this.getTestSuiteSpan({ testSuite, testSuiteAbsolutePath })
2038
2075
  return suitePayload
2039
2076
  },
2040
2077
  'dd:beforeEach': (test) => {