dd-trace 6.11.0 → 6.13.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 (139) hide show
  1. package/LICENSE-3rdparty.csv +5 -0
  2. package/ci/test-optimization-validation/framework-adapters/cypress.js +9 -11
  3. package/ci/test-optimization-validation/scenarios/ci-wiring.js +5 -4
  4. package/index.d.ts +143 -25
  5. package/package.json +26 -25
  6. package/packages/datadog-instrumentations/src/bunyan.js +4 -2
  7. package/packages/datadog-instrumentations/src/cucumber.js +4 -4
  8. package/packages/datadog-instrumentations/src/helpers/bunyan.js +12 -3
  9. package/packages/datadog-instrumentations/src/helpers/pool-acquire.js +1 -0
  10. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +28 -0
  11. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +1 -0
  12. package/packages/datadog-instrumentations/src/jest.js +140 -18
  13. package/packages/datadog-instrumentations/src/mariadb-bundle.js +1597 -0
  14. package/packages/datadog-instrumentations/src/mariadb.js +7 -0
  15. package/packages/datadog-instrumentations/src/mocha/utils.js +5 -2
  16. package/packages/datadog-instrumentations/src/pino.js +15 -4
  17. package/packages/datadog-instrumentations/src/playwright-reporter.js +149 -1
  18. package/packages/datadog-instrumentations/src/playwright.js +67 -53
  19. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +13 -12
  20. package/packages/datadog-instrumentations/src/vitest-worker.js +11 -0
  21. package/packages/datadog-instrumentations/src/webdriverio.js +26 -0
  22. package/packages/datadog-instrumentations/src/winston.js +6 -3
  23. package/packages/datadog-plugin-cypress/src/support.js +1 -0
  24. package/packages/datadog-plugin-http/src/client.js +2 -15
  25. package/packages/datadog-plugin-http2/src/client.js +2 -15
  26. package/packages/datadog-plugin-openai-agents/src/integration.js +15 -11
  27. package/packages/datadog-plugin-playwright/src/index.js +13 -1
  28. package/packages/datadog-plugin-undici/src/index.js +2 -15
  29. package/packages/dd-trace/src/aiguard/evaluation.js +94 -20
  30. package/packages/dd-trace/src/aiguard/integrations/vercel-ai.js +2 -3
  31. package/packages/dd-trace/src/aiguard/messages/anthropic.js +21 -9
  32. package/packages/dd-trace/src/aiguard/noop.js +9 -1
  33. package/packages/dd-trace/src/aiguard/redaction.js +183 -0
  34. package/packages/dd-trace/src/aiguard/sdk.js +7 -2
  35. package/packages/dd-trace/src/aiguard/tags.js +2 -0
  36. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +10 -9
  37. package/packages/dd-trace/src/appsec/reporter.js +2 -0
  38. package/packages/dd-trace/src/appsec/rule_manager.js +2 -0
  39. package/packages/dd-trace/src/appsec/telemetry/index.js +13 -0
  40. package/packages/dd-trace/src/appsec/telemetry/rasp.js +16 -0
  41. package/packages/dd-trace/src/appsec/telemetry/waf.js +24 -0
  42. package/packages/dd-trace/src/carrier.js +107 -48
  43. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +5 -5
  44. package/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +2 -0
  45. package/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js +5 -3
  46. package/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js +51 -26
  47. package/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js +4 -2
  48. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +5 -11
  49. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +6 -4
  50. package/packages/dd-trace/src/ci-visibility/final-flush.js +20 -0
  51. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +5 -3
  52. package/packages/dd-trace/src/ci-visibility/log-submission/log-submission-plugin.js +196 -29
  53. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +4 -2
  54. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +4 -2
  55. package/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +4 -2
  56. package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +5 -5
  57. package/packages/dd-trace/src/config/generated-config-types.d.ts +6 -0
  58. package/packages/dd-trace/src/config/index.js +2 -1
  59. package/packages/dd-trace/src/config/supported-configurations.json +27 -0
  60. package/packages/dd-trace/src/debugger/constants.js +1 -0
  61. package/packages/dd-trace/src/debugger/devtools_client/condition.js +1 -1
  62. package/packages/dd-trace/src/debugger/devtools_client/index.js +3 -10
  63. package/packages/dd-trace/src/debugger/devtools_client/send.js +4 -3
  64. package/packages/dd-trace/src/debugger/index.js +4 -2
  65. package/packages/dd-trace/src/debugger/inspect-segment.js +154 -0
  66. package/packages/dd-trace/src/dogstatsd.js +45 -18
  67. package/packages/dd-trace/src/encode/agentless-json.js +5 -4
  68. package/packages/dd-trace/src/evp_proxy/constants.js +8 -0
  69. package/packages/dd-trace/src/evp_proxy/direct.js +64 -0
  70. package/packages/dd-trace/src/evp_proxy/discovery.js +133 -0
  71. package/packages/dd-trace/src/evp_proxy/path.js +22 -0
  72. package/packages/dd-trace/src/exporters/agent/index.js +22 -2
  73. package/packages/dd-trace/src/exporters/agent/writer.js +4 -4
  74. package/packages/dd-trace/src/exporters/common/request.js +5 -2
  75. package/packages/dd-trace/src/exporters/common/writer.js +24 -2
  76. package/packages/dd-trace/src/exporters/span-stats/index.js +23 -4
  77. package/packages/dd-trace/src/external-logger/src/index.js +4 -3
  78. package/packages/dd-trace/src/flush.js +102 -0
  79. package/packages/dd-trace/src/llmobs/experiments/client.js +10 -2
  80. package/packages/dd-trace/src/llmobs/experiments/dataset.js +301 -14
  81. package/packages/dd-trace/src/llmobs/experiments/experiment.js +440 -112
  82. package/packages/dd-trace/src/llmobs/experiments/index.js +68 -44
  83. package/packages/dd-trace/src/llmobs/experiments/noop.js +103 -12
  84. package/packages/dd-trace/src/llmobs/experiments/result.js +1 -0
  85. package/packages/dd-trace/src/llmobs/experiments/util.js +81 -4
  86. package/packages/dd-trace/src/llmobs/index.js +80 -17
  87. package/packages/dd-trace/src/llmobs/noop.js +2 -1
  88. package/packages/dd-trace/src/llmobs/plugins/anthropic/util.js +8 -4
  89. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +6 -3
  90. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +13 -6
  91. package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +5 -7
  92. package/packages/dd-trace/src/llmobs/writers/base.js +60 -24
  93. package/packages/dd-trace/src/noop/span.js +5 -0
  94. package/packages/dd-trace/src/openfeature/constants/constants.js +0 -18
  95. package/packages/dd-trace/src/openfeature/index.js +7 -4
  96. package/packages/dd-trace/src/openfeature/writers/base.js +153 -11
  97. package/packages/dd-trace/src/openfeature/writers/exposures.js +70 -15
  98. package/packages/dd-trace/src/openfeature/writers/util.js +97 -15
  99. package/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +45 -3
  100. package/packages/dd-trace/src/opentelemetry/logs/index.js +4 -1
  101. package/packages/dd-trace/src/opentelemetry/logs/logger_provider.js +7 -4
  102. package/packages/dd-trace/src/opentelemetry/metrics/index.js +3 -1
  103. package/packages/dd-trace/src/opentelemetry/metrics/meter_provider.js +8 -0
  104. package/packages/dd-trace/src/opentelemetry/metrics/otlp_http_metric_exporter.js +4 -2
  105. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js +4 -2
  106. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +25 -5
  107. package/packages/dd-trace/src/opentelemetry/otlp/otlp_http_exporter_base.js +61 -25
  108. package/packages/dd-trace/src/opentelemetry/tracer.js +26 -2
  109. package/packages/dd-trace/src/opentracing/propagation/text_map.js +429 -352
  110. package/packages/dd-trace/src/opentracing/span.js +28 -0
  111. package/packages/dd-trace/src/otel-thread-ctx.js +23 -5
  112. package/packages/dd-trace/src/plugin_manager.js +2 -0
  113. package/packages/dd-trace/src/plugins/util/status-validator.js +98 -0
  114. package/packages/dd-trace/src/plugins/util/test.js +74 -21
  115. package/packages/dd-trace/src/plugins/util/web.js +2 -65
  116. package/packages/dd-trace/src/profiling/oom.js +4 -3
  117. package/packages/dd-trace/src/profiling/profiler.js +7 -0
  118. package/packages/dd-trace/src/profiling/profilers/events.js +3 -3
  119. package/packages/dd-trace/src/profiling/profilers/wall.js +14 -14
  120. package/packages/dd-trace/src/proxy.js +36 -8
  121. package/packages/dd-trace/src/runtime_metrics/index.js +5 -0
  122. package/packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js +5 -0
  123. package/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +17 -6
  124. package/packages/dd-trace/src/serverless/telemetry-delivery-tracker.js +55 -0
  125. package/packages/dd-trace/src/serverless/vercel.js +122 -0
  126. package/packages/dd-trace/src/serverless.js +37 -20
  127. package/packages/dd-trace/src/span_stats.js +40 -3
  128. package/packages/dd-trace/src/telemetry/metrics.js +62 -8
  129. package/packages/dd-trace/src/telemetry/send-data.js +1 -0
  130. package/packages/dd-trace/src/tracer.js +22 -0
  131. package/packages/dd-trace/src/web-tags-cache.js +67 -13
  132. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
  133. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  134. package/vendor/dist/https-proxy-agent/LICENSE +22 -0
  135. package/vendor/dist/https-proxy-agent/index.js +4 -0
  136. package/vendor/dist/protobufjs/index.js +1 -1
  137. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  138. package/vendor/dist/proxy-from-env/LICENSE +20 -0
  139. package/vendor/dist/proxy-from-env/index.js +1 -0
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const NoopExperiments = require('./experiments/noop')
3
+ let NoopExperiments
4
4
 
5
5
  class NoopLLMObs {
6
6
  constructor (noopTracer) {
@@ -12,6 +12,7 @@ class NoopLLMObs {
12
12
  }
13
13
 
14
14
  get experiments () {
15
+ NoopExperiments ??= require('./experiments/noop')
15
16
  return new NoopExperiments('LLM Observability is not enabled')
16
17
  }
17
18
 
@@ -38,16 +38,20 @@ function formatAnthropicToolResultContent (content) {
38
38
  if (typeof content === 'string') {
39
39
  return content
40
40
  } else if (Array.isArray(content)) {
41
- const formattedContent = []
41
+ let formattedContent = ''
42
42
  for (const toolResultBlock of content) {
43
+ let part
43
44
  if (toolResultBlock.text) {
44
- formattedContent.push(toolResultBlock.text)
45
+ part = toolResultBlock.text
45
46
  } else if (toolResultBlock.type === 'image') {
46
- formattedContent.push('([IMAGE DETECTED])')
47
+ part = '([IMAGE DETECTED])'
47
48
  }
49
+ if (part === undefined) continue
50
+ if (formattedContent) formattedContent += ','
51
+ formattedContent += part
48
52
  }
49
53
 
50
- return formattedContent.join(',')
54
+ return formattedContent
51
55
  }
52
56
  return JSON.stringify(content)
53
57
  }
@@ -18,12 +18,15 @@ function getToolOutputText (raw) {
18
18
  if (raw == null) return
19
19
 
20
20
  if (Array.isArray(raw)) {
21
- const output = []
21
+ let output = ''
22
+ let separator = ''
22
23
  for (const block of raw) {
23
24
  const text = getToolOutputText(block)
24
- if (text) output.push(text)
25
+ if (!text) continue
26
+ output += separator + text
27
+ separator = '\n'
25
28
  }
26
- return output.join('\n') || undefined
29
+ return output
27
30
  }
28
31
 
29
32
  if (raw.type === 'tool_result') return getToolOutputText(raw.content)
@@ -134,15 +134,17 @@ function hasMultimodalInputs (variables) {
134
134
  * @returns {{ content: string, audioParts: Array<{ mimeType: string, content: string }> }}
135
135
  */
136
136
  function extractContentParts (parts) {
137
- const extracted = []
137
+ let content = ''
138
+ let hasContent = false
138
139
  const audioParts = []
139
140
 
140
141
  for (const part of parts) {
141
142
  const partType = part?.type ?? ''
143
+ let extracted
142
144
  if (partType === 'text') {
143
- extracted.push(part.text ?? '')
145
+ extracted = part.text ?? ''
144
146
  } else if (partType === 'image_url') {
145
- extracted.push(IMAGE_FALLBACK)
147
+ extracted = IMAGE_FALLBACK
146
148
  } else if (partType === 'input_audio') {
147
149
  const inputAudio = part.input_audio ?? {}
148
150
  const data = inputAudio.data
@@ -151,14 +153,19 @@ function extractContentParts (parts) {
151
153
  // is needed. Only fall back to "[audio]" when there's no audio to capture.
152
154
  audioParts.push(formatAudioPart(data, audioMimeTypeFromFormat(inputAudio.format, AUDIO_MIME_TYPES)))
153
155
  } else {
154
- extracted.push(AUDIO_FALLBACK)
156
+ extracted = AUDIO_FALLBACK
155
157
  }
156
158
  } else {
157
- extracted.push(`[${partType}]`)
159
+ extracted = `[${partType}]`
158
160
  }
161
+
162
+ if (extracted === undefined) continue
163
+ if (hasContent) content += '\n'
164
+ content += extracted
165
+ hasContent = true
159
166
  }
160
167
 
161
- return { content: extracted.join('\n'), audioParts }
168
+ return { content, audioParts }
162
169
  }
163
170
 
164
171
  /**
@@ -11,23 +11,23 @@ const { safeJsonParse } = require('../../util')
11
11
  * @returns {{ content: string, audioParts: Array<{ mimeType: string, content: string }> }}
12
12
  */
13
13
  function extractMessageContent (parts) {
14
- const contentParts = []
14
+ let content = ''
15
15
  const audioParts = []
16
16
 
17
17
  for (const part of parts) {
18
18
  if (!part) continue
19
19
  const text = extractTextFromContentItem(part)
20
20
  if (text) {
21
- contentParts.push(text)
21
+ content += text
22
22
  continue
23
23
  }
24
24
 
25
25
  const extracted = extractContentParts([part])
26
- if (extracted.content) contentParts.push(extracted.content)
26
+ if (extracted.content) content += extracted.content
27
27
  if (extracted.audioParts.length > 0) audioParts.push(...extracted.audioParts)
28
28
  }
29
29
 
30
- return { content: contentParts.join(''), audioParts }
30
+ return { content, audioParts }
31
31
  }
32
32
 
33
33
  /**
@@ -167,13 +167,11 @@ function extractOutputMessages (result) {
167
167
  if (item.type === 'message') {
168
168
  let content = ''
169
169
  if (Array.isArray(item.content)) {
170
- const textParts = []
171
170
  for (const contentItem of item.content) {
172
171
  if (contentItem?.type === 'output_text' && contentItem.text) {
173
- textParts.push(contentItem.text)
172
+ content += contentItem.text
174
173
  }
175
174
  }
176
- content = textParts.join('')
177
175
  } else if (typeof item.content === 'string') {
178
176
  content = item.content
179
177
  }
@@ -4,6 +4,7 @@ const { URL, format } = require('node:url')
4
4
  const path = require('node:path')
5
5
  const request = require('../../exporters/common/request')
6
6
  const { getEnvironmentVariable } = require('../../config/helper')
7
+ const { createServerlessDeliveryTracker } = require('../../serverless')
7
8
 
8
9
  const logger = require('../../log')
9
10
 
@@ -33,6 +34,9 @@ class LLMObsBuffer {
33
34
 
34
35
  class BaseLLMObsWriter {
35
36
  #destroyer
37
+ /** @type {Function[]} */
38
+ #pendingFlushes = []
39
+ #serverlessDeliveryTracker = createServerlessDeliveryTracker()
36
40
  /** @type {Map<string, LLMObsBuffer>} */
37
41
  #multiTenantBuffers = new Map()
38
42
 
@@ -111,35 +115,52 @@ class BaseLLMObsWriter {
111
115
  return true
112
116
  }
113
117
 
114
- flush () {
118
+ /**
119
+ * Drains buffered events and joins requests active at this flush boundary.
120
+ * @param {Function} [done]
121
+ */
122
+ flush (done) {
115
123
  if (this._agentless == null) {
124
+ if (done) this.#pendingFlushes.push(done)
116
125
  return
117
126
  }
118
127
 
119
- // Flush default buffer
120
- if (this._buffer.events.length > 0) {
121
- const events = this._buffer.events
122
- this._buffer.clear()
123
-
124
- const payload = this._encode(this.makePayload(events))
128
+ const requests = this.#drainBuffers()
129
+ if (!this.#serverlessDeliveryTracker) {
130
+ // Only invocation-retaining platforms need completion-aware delivery.
131
+ for (const request of requests) this.#sendSafely(request)
132
+ done?.()
133
+ return
134
+ }
125
135
 
126
- log.debug('Encoded LLMObs payload: %s', payload)
136
+ for (const request of requests) this.#sendSafely(request)
137
+ this.#serverlessDeliveryTracker.waitForIdle(done)
138
+ }
127
139
 
128
- const options = this._getOptions()
140
+ #sendSafely (requestToSend) {
141
+ try {
142
+ if (this.#serverlessDeliveryTracker) {
143
+ this.#serverlessDeliveryTracker.track(done => this.#send(requestToSend, done))
144
+ } else {
145
+ this.#send(requestToSend)
146
+ }
147
+ } catch (error) {
148
+ logger.error('Failed to send LLMObs %s events: %s', this._eventType, error.message)
149
+ }
150
+ }
129
151
 
130
- request(payload, options, (err, resp, code) => {
131
- parseResponseAndLog(err, code, events.length, this.url, this._eventType)
132
- })
152
+ #drainBuffers () {
153
+ const requests = []
154
+ if (this._buffer.events.length > 0) {
155
+ const events = this._buffer.events
156
+ this._buffer.clear()
157
+ requests.push({ events, options: this._getOptions(), url: this.url })
133
158
  }
134
159
 
135
- // Flush multi-tenant buffers
136
160
  for (const [apiKey, buffer] of this.#multiTenantBuffers) {
137
161
  if (buffer.events.length === 0) continue
138
-
139
162
  const events = buffer.events
140
163
  buffer.clear()
141
-
142
- const payload = this._encode(this.makePayload(events))
143
164
  const site = buffer.routing.site || this._config.site
144
165
  const options = {
145
166
  headers: {
@@ -156,16 +177,21 @@ class BaseLLMObsWriter {
156
177
  }
157
178
  const url = this.#buildUrl(options.url.href, options.path)
158
179
  const maskedApiKey = apiKey ? `****${apiKey.slice(-4)}` : ''
159
-
160
180
  log.debug('Encoding and flushing multi-tenant buffer for %s', maskedApiKey)
161
- log.debug('Encoded LLMObs payload: %s', payload)
162
-
163
- request(payload, options, (err, resp, code) => {
164
- parseResponseAndLog(err, code, events.length, url, this._eventType)
165
- })
181
+ requests.push({ events, options, url })
166
182
  }
167
183
 
168
184
  this.#cleanupEmptyBuffers()
185
+ return requests
186
+ }
187
+
188
+ #send ({ events, options, url }, done) {
189
+ const payload = this._encode(this.makePayload(events))
190
+ log.debug('Encoded LLMObs payload: %s', payload)
191
+ request(payload, options, (err, resp, code) => {
192
+ parseResponseAndLog(err, code, events.length, url, this._eventType)
193
+ done?.()
194
+ })
169
195
  }
170
196
 
171
197
  #cleanupEmptyBuffers () {
@@ -178,13 +204,19 @@ class BaseLLMObsWriter {
178
204
 
179
205
  makePayload (events) {}
180
206
 
181
- destroy () {
207
+ /**
208
+ * Stops periodic flushing and drains buffered events.
209
+ * @param {Function} [done] Called after queued and active deliveries complete.
210
+ */
211
+ destroy (done) {
182
212
  if (this.#destroyer) {
183
213
  logger.debug(`Stopping ${this.constructor.name}`)
184
214
  clearInterval(this._periodic)
185
215
  globalThis[Symbol.for('dd-trace')].beforeExitHandlers.delete(this.#destroyer)
186
- this.flush()
216
+ this.flush(done)
187
217
  this.#destroyer = undefined
218
+ } else {
219
+ done?.()
188
220
  }
189
221
  }
190
222
 
@@ -196,6 +228,10 @@ class BaseLLMObsWriter {
196
228
  this._endpoint = endpoint
197
229
 
198
230
  logger.debug(`Configuring ${this.constructor.name} to ${this.url}`)
231
+
232
+ const pendingFlushes = this.#pendingFlushes
233
+ this.#pendingFlushes = []
234
+ for (const done of pendingFlushes) this.flush(done)
199
235
  }
200
236
 
201
237
  _getUrlAndPath () {
@@ -26,6 +26,11 @@ class NoopSpan {
26
26
  addLink (link) { return this }
27
27
  addLinks (links) { return this }
28
28
  addSpanPointer (ptrKind, ptrDir, ptrHash) { return this }
29
+ /**
30
+ * @param {import('../../../../index').Exception} exception
31
+ * @param {import('../../../../index').SpanEventAttributes} [attributes]
32
+ */
33
+ recordException (exception, attributes) {}
29
34
  log () { return this }
30
35
  logEvent () {}
31
36
  finish (finishTime) {}
@@ -1,24 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  module.exports = {
4
- /**
5
- * @constant
6
- * @type {string} Base path for EVP proxy agent endpoint
7
- */
8
- EVP_PROXY_AGENT_BASE_PATH: '/evp_proxy/v2/',
9
-
10
- /**
11
- * @constant
12
- * @type {string} HTTP header name for EVP subdomain routing
13
- */
14
- EVP_SUBDOMAIN_HEADER_NAME: 'X-Datadog-EVP-Subdomain',
15
-
16
- /**
17
- * @constant
18
- * @type {string} EVP subdomain value for event platform intake
19
- */
20
- EVP_SUBDOMAIN_VALUE: 'event-platform-intake',
21
-
22
4
  /**
23
5
  * @constant
24
6
  * @type {string} API endpoint for exposure events EVP track
@@ -3,7 +3,7 @@
3
3
  const { channel } = require('dc-polyfill')
4
4
  const log = require('../log')
5
5
  const ExposuresWriter = require('./writers/exposures')
6
- const { setAgentStrategy } = require('./writers/util')
6
+ const { setExposureDeliveryStrategy } = require('./writers/util')
7
7
 
8
8
  const exposureSubmitCh = channel('ffe:exposure:submit')
9
9
  const flushCh = channel('ffe:writers:flush')
@@ -40,12 +40,15 @@ function enable (config) {
40
40
  return
41
41
  }
42
42
 
43
- exposuresWriter = new ExposuresWriter(config)
43
+ const writer = new ExposuresWriter(config)
44
+ exposuresWriter = writer
44
45
  exposureSubmitCh.subscribe(_handleExposureSubmit)
45
46
  flushCh.subscribe(_handleFlush)
46
47
 
47
- setAgentStrategy(config, hasAgent => {
48
- exposuresWriter?.setEnabled(hasAgent)
48
+ setExposureDeliveryStrategy(config, (enabled, route) => {
49
+ if (exposuresWriter !== writer) return
50
+
51
+ writer.setEnabled(enabled, route)
49
52
  })
50
53
  }
51
54
 
@@ -11,14 +11,52 @@ const log = require('../../log')
11
11
  * @property {number} [timeout] - Request timeout in milliseconds
12
12
  * @property {object} config - Tracer configuration object
13
13
  * @property {string} endpoint - API endpoint path
14
- * @property {URL} [agentUrl] - Base URL for the agent
14
+ * @property {URL} [agentUrl] - Initial delivery URL
15
15
  * @property {number} [payloadSizeLimit] - Maximum payload size in bytes
16
16
  * @property {number} [eventSizeLimit] - Maximum individual event size in bytes
17
17
  * @property {object} [headers] - Additional HTTP headers
18
18
  */
19
19
 
20
20
  /**
21
- * BaseFFEWriter is the base class for sending Feature Flagging & Exposure Events payloads to the Datadog Agent.
21
+ * @typedef {object} WriterRoute
22
+ * @property {URL} url - Route base URL
23
+ * @property {string} endpoint - Route endpoint
24
+ * @property {object} headers - Route-specific headers
25
+ * @property {import('node:https').Agent} [agent] - Optional HTTPS proxy agent
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} ActiveWriterRoute
30
+ * @property {URL} url - Route base URL
31
+ * @property {string} endpoint - Route endpoint
32
+ * @property {object} requestOptions - HTTP request options
33
+ */
34
+
35
+ /**
36
+ * Tests whether a local route definitively rejected an event batch.
37
+ *
38
+ * @param {Error | null} error - Request error
39
+ * @param {number | undefined} statusCode - HTTP response status
40
+ * @returns {boolean} Whether direct retry is safe
41
+ */
42
+ function isDefinitiveRejection (error, statusCode) {
43
+ return error?.code === 'EAI_AGAIN' || error?.code === 'ECONNREFUSED' ||
44
+ error?.code === 'ENOENT' || error?.code === 'ENOTFOUND' ||
45
+ statusCode === 403 || statusCode === 404 || statusCode === 405
46
+ }
47
+
48
+ /**
49
+ * Tests whether a local route can have accepted an event batch before failing.
50
+ *
51
+ * @param {Error | null} error - Request error
52
+ * @returns {boolean} Whether the delivery result is ambiguous
53
+ */
54
+ function isAmbiguousNetworkFailure (error) {
55
+ return error?.code === 'ECONNRESET' || error?.code === 'EPIPE' || error?.code === 'ETIMEDOUT'
56
+ }
57
+
58
+ /**
59
+ * Base writer for Feature Flagging and Experimentation event delivery.
22
60
  * @class BaseFFEWriter
23
61
  */
24
62
  class BaseFFEWriter {
@@ -40,6 +78,7 @@ class BaseFFEWriter {
40
78
  this._payloadSizeLimit = payloadSizeLimit
41
79
  this._eventSizeLimit = eventSizeLimit
42
80
  this._headers = headers || {}
81
+ this._fallbackRoute = undefined
43
82
 
44
83
  this._requestOptions = {
45
84
  headers: {
@@ -47,6 +86,7 @@ class BaseFFEWriter {
47
86
  'Content-Type': 'application/json',
48
87
  },
49
88
  method: 'POST',
89
+ retry: true,
50
90
  timeout: this._timeout,
51
91
  url: this._baseUrl,
52
92
  path: this._endpoint,
@@ -115,15 +155,8 @@ class BaseFFEWriter {
115
155
  // eslint-disable-next-line eslint-rules/eslint-log-printf-style
116
156
  log.debug(() => `${this.constructor.name} flushing payload: ${safeJSONStringify(payload)}`)
117
157
 
118
- request(payload, this._requestOptions, (err, resp, code) => {
119
- if (err) {
120
- log.error('Failed to send events to %s%s', this._baseUrl.href, this._endpoint, err)
121
- } else if (code >= 200 && code < 300) {
122
- log.debug('Successfully sent %d events', events.length)
123
- } else {
124
- log.warn('Events request returned status %d', code)
125
- }
126
- })
158
+ const route = this.#createActiveRoute()
159
+ this.#sendRequest(payload, events.length, route, this._fallbackRoute)
127
160
  }
128
161
 
129
162
  /**
@@ -161,6 +194,115 @@ class BaseFFEWriter {
161
194
  _encode (payload) {
162
195
  return JSON.stringify(payload)
163
196
  }
197
+
198
+ /**
199
+ * Applies the active route and an optional direct fallback route.
200
+ *
201
+ * @param {WriterRoute} route - Active route
202
+ * @param {WriterRoute} [fallbackRoute] - Direct fallback route
203
+ * @returns {void}
204
+ */
205
+ _setRoutes (route, fallbackRoute) {
206
+ this.#activateRoute(this.#createRoute(route))
207
+ this._fallbackRoute = fallbackRoute ? this.#createRoute(fallbackRoute) : undefined
208
+ }
209
+
210
+ /**
211
+ * Creates request state for a configured writer route.
212
+ *
213
+ * @param {WriterRoute} route - Configured route
214
+ * @returns {ActiveWriterRoute} Active route state
215
+ */
216
+ #createRoute (route) {
217
+ return {
218
+ url: route.url,
219
+ endpoint: route.endpoint,
220
+ requestOptions: {
221
+ ...(route.agent && { agent: route.agent }),
222
+ headers: {
223
+ ...route.headers,
224
+ 'Content-Type': 'application/json',
225
+ },
226
+ method: 'POST',
227
+ retry: true,
228
+ timeout: this._timeout,
229
+ url: route.url,
230
+ path: route.endpoint,
231
+ },
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Captures the current route for one event batch.
237
+ *
238
+ * @returns {ActiveWriterRoute} Active route state
239
+ */
240
+ #createActiveRoute () {
241
+ return {
242
+ url: this._baseUrl,
243
+ endpoint: this._endpoint,
244
+ requestOptions: this._requestOptions,
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Makes a route active for future event batches.
250
+ *
251
+ * @param {ActiveWriterRoute} route - Route state
252
+ * @returns {void}
253
+ */
254
+ #activateRoute (route) {
255
+ this._baseUrl = route.url
256
+ this._endpoint = route.endpoint
257
+ this._requestOptions = route.requestOptions
258
+ }
259
+
260
+ /**
261
+ * Sends an encoded batch and retries it through direct intake after a local route failure.
262
+ *
263
+ * @param {string} payload - Encoded event batch
264
+ * @param {number} eventCount - Event count
265
+ * @param {ActiveWriterRoute} route - Selected route
266
+ * @param {ActiveWriterRoute} [fallbackRoute] - Direct fallback route
267
+ * @returns {void}
268
+ */
269
+ #sendRequest (payload, eventCount, route, fallbackRoute) {
270
+ request(payload, route.requestOptions, (error, response, statusCode) => {
271
+ if (fallbackRoute && isDefinitiveRejection(error, statusCode)) {
272
+ log.debug(
273
+ '%s switching from %s%s to direct intake after definitive rejection',
274
+ this.constructor.name,
275
+ route.url.href,
276
+ route.endpoint
277
+ )
278
+ this.#activateRoute(fallbackRoute)
279
+ this._fallbackRoute = undefined
280
+ this.#sendRequest(payload, eventCount, fallbackRoute)
281
+ return
282
+ }
283
+
284
+ if (fallbackRoute && isAmbiguousNetworkFailure(error)) {
285
+ log.debug(
286
+ '%s retrying through direct intake and switching future batches from %s%s after ambiguous failure',
287
+ this.constructor.name,
288
+ route.url.href,
289
+ route.endpoint
290
+ )
291
+ this.#activateRoute(fallbackRoute)
292
+ this._fallbackRoute = undefined
293
+ this.#sendRequest(payload, eventCount, fallbackRoute)
294
+ return
295
+ }
296
+
297
+ if (error) {
298
+ log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, error.message)
299
+ } else if (statusCode >= 200 && statusCode < 300) {
300
+ log.debug('Successfully sent %d events', eventCount)
301
+ } else {
302
+ log.warn('Events request returned status %d', statusCode)
303
+ }
304
+ })
305
+ }
164
306
  }
165
307
 
166
308
  module.exports = BaseFFEWriter