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
@@ -26,6 +26,7 @@ class CypressPlugin extends Plugin {
26
26
  userAfterRunHandlers,
27
27
  userAfterScreenshotHandlers,
28
28
  cleanupWrapper,
29
+ registerBeforeRun,
29
30
  } = payload
30
31
 
31
32
  const registerAfterRunWithCleanup = (afterRunHandler) => {
@@ -79,6 +80,7 @@ class CypressPlugin extends Plugin {
79
80
  }
80
81
 
81
82
  if (cypressPlugin._isInit) {
83
+ registerBeforeRun()
82
84
  // Already initialized by manual plugin call — just chain user handlers.
83
85
  // Pass the plugin's afterScreenshot so chaining a user handler doesn't drop the upload
84
86
  // (the chained registration replaces the one plugin.js set, so it must include it).
@@ -94,7 +96,7 @@ class CypressPlugin extends Plugin {
94
96
  return
95
97
  }
96
98
 
97
- on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin))
99
+ registerBeforeRun(cypressPlugin.beforeRun.bind(cypressPlugin))
98
100
  registerAfterScreenshot(datadogAfterScreenshotHandler)
99
101
 
100
102
  on('after:spec', (spec, results) => {
@@ -17,6 +17,9 @@ class FetchPlugin extends HttpClientPlugin {
17
17
 
18
18
  const store = super.bindStart(ctx)
19
19
 
20
+ // Native undici diagnostics also fire for requests already traced by this wrapper.
21
+ store.skipUndici = true
22
+
20
23
  for (const name in options.headers) {
21
24
  if (!req.headers.has(name)) {
22
25
  req.headers.set(name, options.headers[name])
@@ -117,19 +117,18 @@ class JestPlugin extends CiPlugin {
117
117
  isEarlyFlakeDetectionEnabled,
118
118
  isEarlyFlakeDetectionFaulty,
119
119
  isTestManagementTestsEnabled,
120
- isExpectedEmptySession,
120
+ testSessionEmptyReason,
121
121
  onDone,
122
122
  }) => {
123
123
  const finishSession = () => {
124
124
  this.testSessionSpan.setTag(TEST_STATUS, status)
125
125
  this.testModuleSpan.setTag(TEST_STATUS, status)
126
126
 
127
- if (isExpectedEmptySession) {
127
+ if (testSessionEmptyReason) {
128
128
  setExpectedEmptyTestSessionTags(
129
129
  this.testSessionSpan,
130
130
  this.testModuleSpan,
131
- 'No tests were found',
132
- 'zero_tests'
131
+ testSessionEmptyReason
133
132
  )
134
133
  }
135
134
 
@@ -885,7 +885,7 @@ class MochaPlugin extends CiPlugin {
885
885
  isTestManagementEnabled,
886
886
  isParallel,
887
887
  isFrameworkError,
888
- isExpectedEmptySession,
888
+ testSessionEmptyReason,
889
889
  onDone,
890
890
  }) => {
891
891
  this._exportPendingWorkerTraces()
@@ -898,12 +898,11 @@ class MochaPlugin extends CiPlugin {
898
898
  this.testSessionSpan.setTag(TEST_STATUS, status)
899
899
  this.testModuleSpan.setTag(TEST_STATUS, status)
900
900
 
901
- if (isExpectedEmptySession) {
901
+ if (testSessionEmptyReason) {
902
902
  setExpectedEmptyTestSessionTags(
903
903
  this.testSessionSpan,
904
904
  this.testModuleSpan,
905
- 'No tests were executed',
906
- 'zero_tests'
905
+ testSessionEmptyReason
907
906
  )
908
907
  }
909
908
 
@@ -3,6 +3,8 @@
3
3
  const { URL } = require('url')
4
4
  const log = require('../../dd-trace/src/log')
5
5
 
6
+ const easyConnectProtocol = /^tcps?:\/\//i
7
+
6
8
  function parseOracleDescriptor (descriptor) {
7
9
  const hostnameMatch = descriptor.match(/HOST\s*=\s*([^)]+)/i)
8
10
  const hostname = hostnameMatch?.[1] || 'localhost' // Default Oracle hostname
@@ -24,7 +26,7 @@ module.exports = function getDBInformation (connAttrs) {
24
26
  return parseOracleDescriptor(connectString)
25
27
  }
26
28
  try {
27
- const url = new URL(`oracle://${connectString}`)
29
+ const url = new URL(easyConnectProtocol.test(connectString) ? connectString : `oracle://${connectString}`)
28
30
  return {
29
31
  hostname: url.hostname || 'localhost', // Default Oracle hostname
30
32
  port: url.port || '1521', // Default Oracle port
@@ -70,8 +70,6 @@ const log = require('../../dd-trace/src/log')
70
70
 
71
71
  const PLAYWRIGHT_FAILURE_SCREENSHOT_RE = /^test-failed-\d+\.png$/
72
72
  const PLAYWRIGHT_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm'])
73
- const EMPTY_SHARD_SKIP_REASON = 'No tests were assigned to this shard'
74
- const EMPTY_SHARD_REASON = 'zero_test_shard'
75
73
  const RETRY_TEST_ID = '_dd.playwright.retry_test_id'
76
74
  const DEFER_FINAL_STATUS = '_dd.playwright.defer_final_status'
77
75
  const noop = () => {}
@@ -186,7 +184,7 @@ class PlaywrightPlugin extends CiPlugin {
186
184
  isEarlyFlakeDetectionEnabled,
187
185
  isEarlyFlakeDetectionFaulty,
188
186
  isTestManagementTestsEnabled,
189
- isExpectedEmptyShard,
187
+ testSessionEmptyReason,
190
188
  error,
191
189
  onDone,
192
190
  }) => {
@@ -212,12 +210,11 @@ class PlaywrightPlugin extends CiPlugin {
212
210
  this.testModuleSpan.setTag(TEST_STATUS, status)
213
211
  this.testSessionSpan.setTag(TEST_STATUS, status)
214
212
 
215
- if (isExpectedEmptyShard) {
213
+ if (testSessionEmptyReason) {
216
214
  setExpectedEmptyTestSessionTags(
217
215
  this.testSessionSpan,
218
216
  this.testModuleSpan,
219
- EMPTY_SHARD_SKIP_REASON,
220
- EMPTY_SHARD_REASON
217
+ testSessionEmptyReason
221
218
  )
222
219
  }
223
220
 
@@ -526,6 +523,8 @@ class PlaywrightPlugin extends CiPlugin {
526
523
  this.addSub('ci:playwright:test:finish', ({
527
524
  span,
528
525
  testStatus,
526
+ isExpectedFailure,
527
+ hasNonRetriableError,
529
528
  steps,
530
529
  error,
531
530
  extraTags,
@@ -565,6 +564,12 @@ class PlaywrightPlugin extends CiPlugin {
565
564
  if (extraTags) {
566
565
  span.addTags(extraTags)
567
566
  }
567
+ if (isExpectedFailure) {
568
+ span.setTag('test.result', 'xfail')
569
+ }
570
+ if (hasNonRetriableError === true) {
571
+ span.setTag('test.playwright.has_non_retriable_error', 'true')
572
+ }
568
573
  if (isNew) {
569
574
  span.setTag(TEST_IS_NEW, 'true')
570
575
  if (isEfdRetry) {
@@ -47,6 +47,8 @@ class UndiciPlugin extends HttpClientPlugin {
47
47
  if (!request) return
48
48
 
49
49
  const store = storage('legacy').getStore()
50
+ if (store?.skipUndici) return
51
+
50
52
  const { origin = '', path = '/' } = request
51
53
  const method = request.method?.toUpperCase() ?? 'GET'
52
54
 
@@ -207,6 +209,9 @@ class UndiciPlugin extends HttpClientPlugin {
207
209
 
208
210
  const store = super.bindStart(ctx)
209
211
 
212
+ // Undici 5.0 emits native diagnostics even though its fetch needs the legacy wrapper.
213
+ store.skipUndici = true
214
+
210
215
  // Inject trace headers back into the request
211
216
  for (const name of Object.keys(options.headers)) {
212
217
  if (!req.headers.has(name)) {
@@ -580,7 +580,7 @@ class VitestPlugin extends CiPlugin {
580
580
  requestErrorTags,
581
581
  vitestPool,
582
582
  isVitestNoWorkerInitActive,
583
- isExpectedEmptySession,
583
+ testSessionEmptyReason,
584
584
  onDone,
585
585
  }) => {
586
586
  for (const [tag, value] of Object.entries(requestErrorTags)) {
@@ -589,12 +589,11 @@ class VitestPlugin extends CiPlugin {
589
589
  }
590
590
  this.testSessionSpan.setTag(TEST_STATUS, status)
591
591
  this.testModuleSpan.setTag(TEST_STATUS, status)
592
- if (isExpectedEmptySession) {
592
+ if (testSessionEmptyReason) {
593
593
  setExpectedEmptyTestSessionTags(
594
594
  this.testSessionSpan,
595
595
  this.testModuleSpan,
596
- 'No tests were executed',
597
- 'zero_tests'
596
+ testSessionEmptyReason
598
597
  )
599
598
  }
600
599
  if (error) {
@@ -21,7 +21,7 @@ class WSProducerPlugin extends TracingPlugin {
21
21
 
22
22
  bindStart (ctx) {
23
23
  const { byteLength, socket, binary } = ctx
24
- if (!socket.spanContext) return
24
+ if (!socket?.spanContext) return
25
25
 
26
26
  const spanTags = socket.spanTags
27
27
  const path = getSegment(spanTags['resource.name'], ' ', 1)
@@ -53,8 +53,15 @@ function copyProperties (original, wrapped) {
53
53
  const descriptor = /** @type {Descriptor} */ (Object.getOwnPropertyDescriptor(original, key))
54
54
  if (descriptor.writable && descriptor.enumerable && descriptor.configurable) {
55
55
  wrapped[key] = original[key]
56
- } else if (descriptor.writable || descriptor.configurable || !Object.hasOwn(wrapped, key)) {
57
- Object.defineProperty(wrapped, key, descriptor)
56
+ } else {
57
+ try {
58
+ Object.defineProperty(wrapped, key, descriptor)
59
+ } catch (error) {
60
+ const wrappedDescriptor = Object.getOwnPropertyDescriptor(wrapped, key)
61
+ if (wrappedDescriptor?.configurable !== false || wrappedDescriptor.writable) {
62
+ throw error
63
+ }
64
+ }
58
65
  }
59
66
  }
60
67
  }
@@ -320,19 +327,16 @@ function assertMethod (target, name, method) {
320
327
  }
321
328
 
322
329
  /**
323
- * Asserts that a target is not a class constructor.
330
+ * Asserts that a target is not an identifiable class constructor.
324
331
  *
325
332
  * @param {Function} target - The target function.
326
- * @throws {Error} If the target is a class constructor.
333
+ * @throws {TypeError} If the target is an identifiable class constructor.
327
334
  */
328
335
  function assertNotClass (target) {
329
- // Class constructors have a non-writable `prototype` property; functions have a
330
- // writable one and arrows / async / method-shorthand have none at all. The
331
- // `'prototype' in target` gate skips the descriptor lookup for the no-prototype
332
- // shapes; the `in` operator is cheaper than reading `target.prototype` since
333
- // it returns a boolean instead of materialising the prototype reference.
336
+ // Frozen functions also have a non-writable `prototype`; inspect their source only then.
334
337
  if ('prototype' in target &&
335
- Object.getOwnPropertyDescriptor(target, 'prototype').writable === false) {
338
+ Object.getOwnPropertyDescriptor(target, 'prototype').writable === false &&
339
+ (!Object.isFrozen(target) || Function.prototype.toString.call(target).startsWith('class'))) {
336
340
  throw new TypeError('Target is a native class constructor and cannot be wrapped.')
337
341
  }
338
342
  }
@@ -6,6 +6,7 @@ const path = require('node:path')
6
6
 
7
7
  const satisfies = require('../../vendor/dist/semifies')
8
8
  const hooks = require('../datadog-instrumentations/src/helpers/hooks')
9
+ const { getRewriteTargetNames } = require('../datadog-instrumentations/src/helpers/rewriter/targets')
9
10
  const { SYNTHETIC_EXTENSION } = require('./src/constants')
10
11
 
11
12
  const BUILTIN_MODULES = new Set(Module.builtinModules)
@@ -158,7 +159,9 @@ function hasDatadogLoader (value) {
158
159
  */
159
160
  function createPackagePathPattern () {
160
161
  const names = []
161
- for (const name of Object.keys(hooks)) {
162
+ const targetPackages = new Set(Object.keys(hooks))
163
+ for (const name of getRewriteTargetNames()) targetPackages.add(name)
164
+ for (const name of targetPackages) {
162
165
  if (!name.startsWith('.') && !BUILTIN_MODULES.has(name)) names.push(escapeRegExp(name))
163
166
  }
164
167
  names.sort()
@@ -11,7 +11,10 @@ const hooks = require('../../datadog-instrumentations/src/helpers/hooks')
11
11
  const instrumentations = require('../../datadog-instrumentations/src/helpers/instrumentations')
12
12
  const { filename, matchesInstrumentation } = require('../../datadog-instrumentations/src/helpers/instrumentation-utils')
13
13
  const { createBundlerRewriter } = require('../../datadog-instrumentations/src/helpers/rewriter')
14
- const { getRewriteTarget } = require('../../datadog-instrumentations/src/helpers/rewriter/targets')
14
+ const {
15
+ getRewriteTarget,
16
+ getRewriteTargetNames,
17
+ } = require('../../datadog-instrumentations/src/helpers/rewriter/targets')
15
18
  const { isESMFile } = require('../../datadog-esbuild/src/utils')
16
19
  const { SYNTHETIC_EXTENSION } = require('./constants')
17
20
 
@@ -21,6 +24,7 @@ const MODULE_BINDING = 2
21
24
  const REQUIRE_BINDING = 4
22
25
  const COMMONJS_BINDINGS = MODULE_BINDING | REQUIRE_BINDING
23
26
  const targetPackages = new Set(Object.keys(hooks))
27
+ for (const name of getRewriteTargetNames()) targetPackages.add(name)
24
28
  const entrypoints = new Map()
25
29
  const loadedHooks = new Set()
26
30
  const packageCache = new Map()
@@ -98,7 +102,7 @@ module.exports = function loader (source, inputSourceMap) {
98
102
  }
99
103
 
100
104
  if (publications.length > 0) code = appendCommonJsPublications(code, publications, dcModule)
101
- if (rewritten && publications.length === 0) {
105
+ if (rewritten && publications.length === 0 && (rewriteTarget.activate || hooks[rewriteTarget.moduleName])) {
102
106
  if (!esm && hasUnsafeCommonJsBindings(code)) {
103
107
  this.emitWarning?.(new Error(`Skipped CommonJS activation for unsafe wrapper bindings in ${resourcePath}`))
104
108
  this.callback(undefined, source, inputSourceMap)
@@ -17,21 +17,22 @@ module.exports = {
17
17
  * Fetches agent information from the /info endpoint
18
18
  * @param {URL} url - The agent URL
19
19
  * @param {Function} callback - Callback function with signature (err, agentInfo)
20
- * @param {{ deadline?: number, signal?: AbortSignal }} [options] - Request finalization options
20
+ * @param {{ deadline?: number, signal?: AbortSignal, path?: string, retry?: boolean }} [options]
21
+ * Request finalization and routing options
21
22
  * @param {Function} [makeRequest] - Request implementation
22
23
  */
23
24
  function fetchAgentInfo (url, callback, options = {}, makeRequest = request) {
24
- const urlKey = url.href
25
+ const urlKey = JSON.stringify([url.href, options.path ?? '/info'])
25
26
 
26
27
  if (cachedUrl !== null && cachedUrl !== urlKey) {
27
- // Clear cache if URL changes
28
+ // Different consumers may query different paths on the same Agent URL.
28
29
  clearCache()
29
30
  } else if (cachedData !== null && (Date.now() - cachedTimestamp) < CACHE_TTL_MS) {
30
31
  // Return cached result if still valid
31
32
  return process.nextTick(callback, null, cachedData)
32
33
  }
33
34
 
34
- options.path = '/info'
35
+ options.path ??= '/info'
35
36
  options.url = url
36
37
  makeRequest('', options, (err, res) => {
37
38
  if (err) {
@@ -1,10 +1,15 @@
1
1
  'use strict'
2
2
 
3
3
  const tracerVersion = require('../../../../package.json').version
4
+ const { createAgents } = require('../exporters/common/agents')
5
+ const request = require('../exporters/common/request')
4
6
  const { AIGuardClientError } = require('./errors')
5
7
  const { parseEvaluationResponse } = require('./evaluation')
6
8
  const TAGS = require('./tags')
7
9
 
10
+ // Evaluations can run concurrently on the application's request path.
11
+ const { httpAgent, httpsAgent } = createAgents(16)
12
+
8
13
  /**
9
14
  * Resolves the AI Guard host for a Datadog site.
10
15
  *
@@ -18,29 +23,43 @@ function aiGuardHost (site) {
18
23
  * Sends a request to the AI Guard service.
19
24
  *
20
25
  * @param {object} body
21
- * @param {{ url: string, headers: Record<string, string>, timeout: number }} opts
26
+ * @param {{ url: string, headers: Record<string, string|undefined>, timeout: number }} opts
22
27
  * @returns {Promise<{ status: number, body: unknown }>}
23
28
  */
24
- async function executeRequest (body, opts) {
25
- const postData = JSON.stringify(body)
26
- const headers = {
27
- 'Content-Type': 'application/json',
28
- 'Content-Length': Buffer.byteLength(postData),
29
- ...opts.headers,
30
- }
29
+ function executeRequest (body, opts) {
30
+ return new Promise((resolve, reject) => {
31
+ const postData = JSON.stringify(body)
32
+ const url = new URL(opts.url)
33
+ request(postData, {
34
+ url,
35
+ method: 'POST',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ ...opts.headers,
39
+ },
40
+ agent: url.protocol === 'https:' ? httpsAgent : httpAgent,
41
+ timeout: opts.timeout,
42
+ signal: AbortSignal.timeout(opts.timeout),
43
+ retry: false,
44
+ includeErrorResponseBody: true,
45
+ }, (error, result, status) => {
46
+ if (status === undefined) {
47
+ reject(error || new Error('AI Guard request completed without a status'))
48
+ return
49
+ }
31
50
 
32
- const response = await fetch(opts.url, {
33
- method: 'POST',
34
- headers,
35
- body: postData,
36
- signal: AbortSignal.timeout(opts.timeout),
51
+ let responseBody
52
+ try {
53
+ const responseText = error ? /** @type {Error & { responseBody?: string }} */ (error).responseBody : result
54
+ if (typeof responseText !== 'string') throw new TypeError('AI Guard response body is missing')
55
+ responseBody = JSON.parse(responseText)
56
+ } catch (cause) {
57
+ reject(cause)
58
+ return
59
+ }
60
+ resolve({ status, body: responseBody })
61
+ })
37
62
  })
38
-
39
- const responseBody = await response.json()
40
- return {
41
- status: response.status,
42
- body: responseBody,
43
- }
44
63
  }
45
64
 
46
65
  class AIGuardClient {
@@ -2,10 +2,15 @@
2
2
 
3
3
  const { channel } = require('dc-polyfill')
4
4
 
5
- const { getMessagesInputMessages, getMessagesOutputMessages } = require('../messages/anthropic')
5
+ const {
6
+ getMessagesInputMessages,
7
+ getMessagesOutputMessages,
8
+ getStreamedMessagesOutputMessages,
9
+ } = require('../messages/anthropic')
6
10
  const { decode } = require('../messages/utils')
7
11
  const { SOURCE_AUTO } = require('../tags')
8
12
  const { evaluate } = require('./evaluate')
13
+ const { interceptStream } = require('./stream')
9
14
 
10
15
  const messagesPrepareChannel = channel('dd-trace:anthropic:messages:prepare')
11
16
  const messagesInterceptChannel = channel('dd-trace:anthropic:messages:intercept')
@@ -13,18 +18,21 @@ const messagesInterceptChannel = channel('dd-trace:anthropic:messages:intercept'
13
18
  let isEnabled = false
14
19
  let aiguard
15
20
  let opts
21
+ let analyzeStreamResponses
16
22
 
17
23
  /**
18
24
  * Subscribes AI Guard to the Anthropic interception channels.
19
25
  *
20
26
  * @param {object} aiguardInstance
21
27
  * @param {boolean} block
28
+ * @param {boolean} analyzeStreams
22
29
  */
23
- function enable (aiguardInstance, block) {
30
+ function enable (aiguardInstance, block, analyzeStreams) {
24
31
  if (isEnabled) return
25
32
 
26
33
  aiguard = aiguardInstance
27
34
  opts = { block, source: SOURCE_AUTO, integration: 'anthropic' }
35
+ analyzeStreamResponses = analyzeStreams
28
36
 
29
37
  messagesPrepareChannel.subscribe(onMessagesPrepare)
30
38
  messagesInterceptChannel.subscribe(onMessagesIntercept)
@@ -40,6 +48,7 @@ function disable () {
40
48
 
41
49
  aiguard = undefined
42
50
  opts = undefined
51
+ analyzeStreamResponses = undefined
43
52
  isEnabled = false
44
53
  }
45
54
 
@@ -76,19 +85,35 @@ function onMessagesIntercept (ctx) {
76
85
  return inputEvaluation
77
86
  }
78
87
 
88
+ const isStream = ctx.arguments[0].stream
89
+ if (isStream && !analyzeStreamResponses) return
90
+
79
91
  // One model call has one output however many readers observe it: `parse`, `json()`, `text()`
80
92
  // and every `clone()` of the raw response share this callback.
81
93
  let outputEvaluation
82
94
  ctx.onResult = body => {
83
95
  if (!isEnabled) return body
84
96
 
97
+ if (isStream) {
98
+ outputEvaluation ??= interceptStream(body, chunks => {
99
+ if (!isEnabled) return
100
+ const outputMessages = decode(
101
+ () => getStreamedMessagesOutputMessages(chunks),
102
+ null,
103
+ 'AIGuard: unable to decode the streamed Anthropic response: %s'
104
+ )
105
+ if (!outputMessages?.length) return
106
+ return evaluate(ctx, aiguard, [[...inputMessages, ...outputMessages]], opts)
107
+ })
108
+ return outputEvaluation
109
+ }
110
+
85
111
  const outputMessages = decode(
86
112
  () => getMessagesOutputMessages(body),
87
113
  null,
88
114
  'AIGuard: unable to decode Anthropic response body: %s'
89
115
  )
90
- if (outputMessages === null) return body
91
- if (!outputMessages.length) return body
116
+ if (!outputMessages?.length) return body
92
117
 
93
118
  outputEvaluation ??= evaluate(ctx, aiguard, [[...inputMessages, ...outputMessages]], opts)
94
119
  return outputEvaluation.then(() => body)
@@ -16,7 +16,7 @@ let isEnabled = false
16
16
  function enable (aiguard, block, analyzeStreamResponses) {
17
17
  if (isEnabled) return
18
18
 
19
- anthropic.enable(aiguard, block)
19
+ anthropic.enable(aiguard, block, analyzeStreamResponses)
20
20
  openai.enable(aiguard, block, analyzeStreamResponses)
21
21
  vercelAi.enable(aiguard, block, analyzeStreamResponses)
22
22
 
@@ -13,6 +13,7 @@ const {
13
13
  const { decode } = require('../messages/utils')
14
14
  const { SOURCE_AUTO } = require('../tags')
15
15
  const { evaluate } = require('./evaluate')
16
+ const { interceptStream } = require('./stream')
16
17
 
17
18
  const chatCompletionsInterceptChannel = channel('dd-trace:openai:chat.completions:intercept')
18
19
  const responsesInterceptChannel = channel('dd-trace:openai:responses:intercept')
@@ -74,9 +75,16 @@ function onChatCompletions (ctx) {
74
75
 
75
76
  if (isStream) {
76
77
  outputEvaluation ??= interceptStream(body, chunks => {
77
- return getStreamedChatCompletionsOutputMessages(chunks)
78
- .map(message => [...inputMessages, message])
79
- }, ctx)
78
+ if (!isEnabled) return
79
+ const conversations = decode(
80
+ () => getStreamedChatCompletionsOutputMessages(chunks)
81
+ .map(message => [...inputMessages, message]),
82
+ null,
83
+ 'AIGuard: unable to decode the streamed OpenAI response: %s'
84
+ )
85
+ if (!conversations?.length) return
86
+ return evaluate(ctx, aiguard, conversations, opts)
87
+ })
80
88
  return outputEvaluation
81
89
  }
82
90
 
@@ -112,9 +120,15 @@ function onResponses (ctx) {
112
120
 
113
121
  if (isStream) {
114
122
  outputEvaluation ??= interceptStream(body, chunks => {
115
- const outputMessages = getStreamedResponsesOutputMessages(chunks)
116
- return outputMessages.length ? [[...inputMessages, ...outputMessages]] : []
117
- }, ctx)
123
+ if (!isEnabled) return
124
+ const outputMessages = decode(
125
+ () => getStreamedResponsesOutputMessages(chunks),
126
+ null,
127
+ 'AIGuard: unable to decode the streamed OpenAI response: %s'
128
+ )
129
+ if (!outputMessages?.length) return
130
+ return evaluate(ctx, aiguard, [[...inputMessages, ...outputMessages]], opts)
131
+ })
118
132
  return outputEvaluation
119
133
  }
120
134
 
@@ -130,54 +144,4 @@ function onResponses (ctx) {
130
144
  }
131
145
  }
132
146
 
133
- /**
134
- * Uses the OpenAI SDK's stream splitting support to evaluate one branch and return the other.
135
- *
136
- * @param {object} stream
137
- * @param {(chunks: Array<object>) => Array<Array<object>>} getConversations
138
- * @param {object} ctx
139
- * @returns {object|Promise<object>}
140
- */
141
- function interceptStream (stream, getConversations, ctx) {
142
- if (typeof stream?.tee !== 'function') return stream
143
-
144
- let branches
145
- try {
146
- branches = stream.tee()
147
- } catch {
148
- return stream
149
- }
150
-
151
- const [evaluationStream, resultStream] = branches
152
- return drainStream(evaluationStream).then(chunks => {
153
- if (!isEnabled) return resultStream
154
- const conversations = decode(
155
- () => getConversations(chunks),
156
- null,
157
- 'AIGuard: unable to decode the streamed OpenAI response: %s'
158
- )
159
- if (!conversations?.length) return resultStream
160
- return evaluate(ctx, aiguard, conversations, opts).then(() => resultStream)
161
- }, () => resultStream)
162
- }
163
-
164
- /**
165
- * @param {object} stream
166
- * @returns {Promise<Array<object>>}
167
- */
168
- function drainStream (stream) {
169
- const chunks = []
170
- const iterator = stream[Symbol.asyncIterator]()
171
-
172
- function readAll () {
173
- return iterator.next().then(({ done, value }) => {
174
- if (done) return chunks
175
- chunks.push(value)
176
- return readAll()
177
- })
178
- }
179
-
180
- return readAll()
181
- }
182
-
183
147
  module.exports = { enable, disable }
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+
3
+ const log = require('../../log')
4
+
5
+ /**
6
+ * Splits an SDK stream, consumes one branch, and returns the other after inspection.
7
+ *
8
+ * @param {object} stream
9
+ * @param {(chunks: Array<object>) => void|Promise<void>} inspect
10
+ * @returns {object|Promise<object>}
11
+ */
12
+ function interceptStream (stream, inspect) {
13
+ if (typeof stream?.tee !== 'function') return stream
14
+
15
+ try {
16
+ const [inspectionStream, resultStream] = stream.tee()
17
+ return drainStream(inspectionStream).then(chunks => {
18
+ return Promise.resolve(inspect(chunks)).then(() => resultStream)
19
+ })
20
+ } catch {
21
+ return stream
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Buffers the whole stream. A stream that ends early still yields what it delivered: truncated
27
+ * output can carry the very violation the evaluation is looking for, so it is judged rather than
28
+ * skipped, and the read failure is logged because nothing downstream reports it.
29
+ *
30
+ * @param {object} stream
31
+ * @returns {Promise<Array<object>>}
32
+ */
33
+ function drainStream (stream) {
34
+ const chunks = []
35
+ const iterator = stream[Symbol.asyncIterator]()
36
+
37
+ function onReadError (error) {
38
+ log.error('AIGuard: the streamed response ended after %s chunks: %s', chunks.length, error)
39
+ return chunks
40
+ }
41
+
42
+ function readAll () {
43
+ let next
44
+ try {
45
+ next = iterator.next()
46
+ } catch (error) {
47
+ return Promise.resolve(onReadError(error))
48
+ }
49
+
50
+ return next.then(({ done, value }) => {
51
+ if (done) return chunks
52
+ chunks.push(value)
53
+ return readAll()
54
+ }, onReadError)
55
+ }
56
+
57
+ return readAll()
58
+ }
59
+
60
+ module.exports = { interceptStream }