dd-trace 6.17.0 → 6.18.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 (70) hide show
  1. package/index.d.ts +52 -9
  2. package/package.json +1 -1
  3. package/packages/datadog-instrumentations/src/cucumber.js +10 -5
  4. package/packages/datadog-instrumentations/src/cypress-config.js +27 -15
  5. package/packages/datadog-instrumentations/src/helpers/bundler-register.js +6 -0
  6. package/packages/datadog-instrumentations/src/helpers/check-require-cache.js +2 -1
  7. package/packages/datadog-instrumentations/src/helpers/hooks.js +0 -6
  8. package/packages/datadog-instrumentations/src/helpers/register.js +20 -0
  9. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +41 -4
  10. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +15 -13
  11. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +43 -0
  12. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +13 -0
  13. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.js +28 -3
  14. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +4 -0
  15. package/packages/datadog-instrumentations/src/jest.js +27 -3
  16. package/packages/datadog-instrumentations/src/mocha/main.js +7 -5
  17. package/packages/datadog-instrumentations/src/mocha/utils.js +8 -8
  18. package/packages/datadog-instrumentations/src/playwright-reporter.js +3 -4
  19. package/packages/datadog-instrumentations/src/playwright.js +47 -6
  20. package/packages/datadog-instrumentations/src/undici.js +7 -3
  21. package/packages/datadog-instrumentations/src/vitest-main.js +91 -20
  22. package/packages/datadog-instrumentations/src/webdriverio.js +28 -12
  23. package/packages/datadog-instrumentations/src/ws.js +41 -1
  24. package/packages/datadog-plugin-cucumber/src/index.js +3 -4
  25. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +44 -14
  26. package/packages/datadog-plugin-cypress/src/index.js +3 -1
  27. package/packages/datadog-plugin-jest/src/index.js +3 -4
  28. package/packages/datadog-plugin-mocha/src/index.js +3 -4
  29. package/packages/datadog-plugin-playwright/src/index.js +11 -6
  30. package/packages/datadog-plugin-vitest/src/index.js +3 -4
  31. package/packages/datadog-plugin-ws/src/producer.js +1 -1
  32. package/packages/datadog-shimmer/src/shimmer.js +14 -10
  33. package/packages/datadog-turbopack/index.js +4 -1
  34. package/packages/datadog-turbopack/src/loader.js +6 -2
  35. package/packages/dd-trace/src/agent/info.js +5 -4
  36. package/packages/dd-trace/src/config/generated-config-types.d.ts +15 -15
  37. package/packages/dd-trace/src/config/index.js +7 -6
  38. package/packages/dd-trace/src/config/supported-configurations.json +11 -0
  39. package/packages/dd-trace/src/debugger/devtools_client/config.js +2 -1
  40. package/packages/dd-trace/src/debugger/devtools_client/send.js +1 -1
  41. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +5 -3
  42. package/packages/dd-trace/src/debugger/devtools_client/status.js +1 -1
  43. package/packages/dd-trace/src/debugger/index.js +1 -1
  44. package/packages/dd-trace/src/evp_proxy/constants.js +2 -0
  45. package/packages/dd-trace/src/evp_proxy/direct.js +17 -13
  46. package/packages/dd-trace/src/evp_proxy/discovery.js +14 -10
  47. package/packages/dd-trace/src/evp_proxy/path.js +21 -7
  48. package/packages/dd-trace/src/exporters/common/url.js +33 -2
  49. package/packages/dd-trace/src/llmobs/prompts/manager.js +10 -2
  50. package/packages/dd-trace/src/llmobs/prompts/prompt.js +77 -11
  51. package/packages/dd-trace/src/llmobs/tagger.js +9 -4
  52. package/packages/dd-trace/src/openfeature/configuration_source.js +4 -1
  53. package/packages/dd-trace/src/openfeature/flagging_provider.js +3 -3
  54. package/packages/dd-trace/src/openfeature/index.js +6 -2
  55. package/packages/dd-trace/src/openfeature/writers/base.js +90 -17
  56. package/packages/dd-trace/src/openfeature/writers/exposures.js +4 -0
  57. package/packages/dd-trace/src/openfeature/writers/util.js +104 -33
  58. package/packages/dd-trace/src/opentracing/tracer.js +3 -3
  59. package/packages/dd-trace/src/plugin_manager.js +27 -8
  60. package/packages/dd-trace/src/plugins/ci_plugin.js +1 -1
  61. package/packages/dd-trace/src/plugins/util/test.js +9 -4
  62. package/packages/dd-trace/src/proxy.js +2 -2
  63. package/packages/dd-trace/src/telemetry/send-data.js +2 -1
  64. package/packages/dd-trace/src/telemetry/telemetry.js +1 -1
  65. package/packages/dd-trace/src/tracer.js +5 -4
  66. package/packages/datadog-instrumentations/src/azure-cosmos.js +0 -7
  67. package/packages/datadog-instrumentations/src/bullmq.js +0 -11
  68. package/packages/datadog-instrumentations/src/langchain.js +0 -7
  69. package/packages/datadog-instrumentations/src/langgraph.js +0 -7
  70. package/packages/datadog-instrumentations/src/mercurius.js +0 -11
@@ -1,7 +1,43 @@
1
1
  'use strict'
2
2
 
3
+ /** @typedef {import('../../../../../index').llmobs.FormattedPromptMessage} FormattedPromptMessage */
4
+
3
5
  const VARIABLE_PATTERN = /(?<!\{)(?:\{\{\s*(\w+)\s*\}\}(?!\})|\{\s*(\w+)\s*\}(?!\}))/g
4
6
 
7
+ function isMessage (value) {
8
+ return typeof value?.role === 'string' &&
9
+ value?.type !== 'placeholder' &&
10
+ (value?.content == null || typeof value.content === 'string') &&
11
+ (typeof value?.content === 'string' ||
12
+ (Array.isArray(value?.tool_calls) && value.tool_calls.length > 0) ||
13
+ (Array.isArray(value?.tool_results) && value.tool_results.length > 0))
14
+ }
15
+
16
+ function isPlaceholder (value) {
17
+ return value?.type === 'placeholder' && typeof value?.name === 'string'
18
+ }
19
+
20
+ function isObject (value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
22
+ }
23
+
24
+ function isTool (value) {
25
+ return isObject(value) &&
26
+ (value.id == null || typeof value.id === 'string') &&
27
+ (value.type == null || typeof value.type === 'string') &&
28
+ (value.tool_id == null || typeof value.tool_id === 'string') &&
29
+ (value.name == null || typeof value.name === 'string')
30
+ }
31
+
32
+ function hasValidTools (message) {
33
+ return (message.tool_call_id == null || typeof message.tool_call_id === 'string') &&
34
+ (message.tool_calls == null || (Array.isArray(message.tool_calls) && message.tool_calls.every(call =>
35
+ isTool(call) && (call.function == null ||
36
+ (isObject(call.function) &&
37
+ typeof call.function.name === 'string' && typeof call.function.arguments === 'string'))))) &&
38
+ (message.tool_results == null || (Array.isArray(message.tool_results) && message.tool_results.every(isTool)))
39
+ }
40
+
5
41
  function render (template, variables) {
6
42
  return template.replaceAll(VARIABLE_PATTERN, (match, doubleName, singleName) => {
7
43
  const name = doubleName ?? singleName
@@ -9,23 +45,40 @@ function render (template, variables) {
9
45
  })
10
46
  }
11
47
 
48
+ function freezeConfig (config) {
49
+ if (config === undefined) return Object.freeze({})
50
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
51
+ throw new TypeError('Invalid prompt config: expected a JSON object')
52
+ }
53
+ const copy = structuredClone(config)
54
+ const freeze = value => {
55
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
56
+ for (const nested of Object.values(value)) freeze(nested)
57
+ return Object.freeze(value)
58
+ }
59
+ return freeze(copy)
60
+ }
12
61
  class ManagedPrompt {
13
62
  /**
14
63
  * @param {object} data
15
64
  * @param {string} data.id
16
65
  * @param {string} data.version
17
66
  * @param {'registry'|'cache'|'fallback'|'ff'|'resolve'} data.source
18
- * @param {string | Array<{role: string, content: string}>} data.template
67
+ * @param {string | Array<{role: string, content: string} | {type: 'placeholder', name: string}>} data.template
68
+ * @param {Record<string, unknown>} [data.config]
19
69
  * @param {string} [data.promptUuid]
20
70
  * @param {string} [data.promptVersionUuid]
21
71
  */
22
- constructor ({ id, version, source, template, promptUuid, promptVersionUuid }) {
72
+ constructor ({ id, version, source, template, config, promptUuid, promptVersionUuid }) {
23
73
  this.id = id
24
74
  this.version = version
25
75
  this.source = source
26
76
  this.template = Array.isArray(template)
27
- ? Object.freeze(template.map(message => Object.freeze({ role: message.role, content: message.content })))
77
+ ? Object.freeze(template.map(item => Object.freeze(isPlaceholder(item)
78
+ ? { type: 'placeholder', name: item.name }
79
+ : { role: item.role, content: item.content })))
28
80
  : template
81
+ this.config = freezeConfig(config)
29
82
  this.promptUuid = promptUuid
30
83
  this.promptVersionUuid = promptVersionUuid
31
84
  Object.freeze(this)
@@ -34,11 +87,21 @@ class ManagedPrompt {
34
87
  /**
35
88
  * Render the prompt without changing its stored template.
36
89
  * @param {Record<string, unknown>} [variables]
37
- * @returns {string | Array<{role: string, content: string}>}
90
+ * @returns {string | FormattedPromptMessage[]}
38
91
  */
39
92
  format (variables = {}) {
40
93
  if (typeof this.template === 'string') return render(this.template, variables)
41
- return this.template.map(message => ({ role: message.role, content: render(message.content, variables) }))
94
+ return this.template.flatMap(item => {
95
+ if (!isPlaceholder(item)) return [{ ...item, content: render(item.content, variables) }]
96
+ if (!Object.hasOwn(variables, item.name)) {
97
+ throw new TypeError(`Missing message placeholder variable '${item.name}'`)
98
+ }
99
+ const messages = variables[item.name]
100
+ if (!Array.isArray(messages) || !messages.every(message => isMessage(message) && hasValidTools(message))) {
101
+ throw new TypeError(`Invalid message placeholder variable '${item.name}': expected an array of messages`)
102
+ }
103
+ return structuredClone(messages)
104
+ })
42
105
  }
43
106
 
44
107
  /**
@@ -52,7 +115,10 @@ class ManagedPrompt {
52
115
  version: this.version,
53
116
  template: typeof this.template === 'string' ? this.template : this.template.map(message => ({ ...message })),
54
117
  }
55
- const entries = Object.entries(variables ?? {})
118
+ const placeholderNames = new Set(Array.isArray(this.template)
119
+ ? this.template.filter(isPlaceholder).map(item => item.name)
120
+ : [])
121
+ const entries = Object.entries(variables ?? {}).filter(([name]) => !placeholderNames.has(name))
56
122
  if (entries.length) {
57
123
  annotation.variables = Object.fromEntries(entries.map(([name, value]) => [name, String(value)]))
58
124
  }
@@ -64,8 +130,8 @@ class ManagedPrompt {
64
130
  /**
65
131
  * Convert a caller fallback to a managed prompt.
66
132
  * @param {string} promptId
67
- * @param {string | object | Array<{role: string, content: string}> |
68
- * (() => string | object | Array<{role: string, content: string}>)} fallback
133
+ * @param {string | object | Array<{role: string, content: string} | {type: 'placeholder', name: string}> |
134
+ * (() => string | object | Array<{role: string, content: string} | {type: 'placeholder', name: string}>)} fallback
69
135
  * @returns {ManagedPrompt}
70
136
  */
71
137
  static fromFallback (promptId, fallback) {
@@ -73,9 +139,8 @@ class ManagedPrompt {
73
139
  const promptLike = value && !Array.isArray(value) && typeof value === 'object'
74
140
  const template = promptLike ? value.template : value
75
141
  const validTemplate = typeof template === 'string' || (
76
- Array.isArray(template) && template.every(message => {
77
- return message && typeof message.role === 'string' && typeof message.content === 'string'
78
- })
142
+ Array.isArray(template) && template.every(item =>
143
+ isPlaceholder(item) || (isMessage(item) && typeof item.content === 'string'))
79
144
  )
80
145
  if (!validTemplate) {
81
146
  throw new TypeError('Invalid prompt fallback: expected a string, chat message array, or object with a template')
@@ -85,6 +150,7 @@ class ManagedPrompt {
85
150
  version: String(promptLike && value.version ? value.version : 'fallback'),
86
151
  source: 'fallback',
87
152
  template,
153
+ config: promptLike ? value.config : undefined,
88
154
  })
89
155
  }
90
156
  }
@@ -505,10 +505,13 @@ class LLMObsTagger {
505
505
  }
506
506
 
507
507
  if (Array.isArray(template)) {
508
- for (const message of template) {
509
- if (typeof message !== 'object' || !message.role || !message.content) {
508
+ for (const item of template) {
509
+ const valid = item?.type === 'placeholder'
510
+ ? typeof item.name === 'string'
511
+ : typeof item?.role === 'string' && typeof item?.content === 'string'
512
+ if (!valid) {
510
513
  this.#handleFailure(
511
- 'Prompt chat template must be an array of objects with role and content properties.', 'invalid_prompt'
514
+ 'Prompt chat template must contain messages or message placeholders.', 'invalid_prompt'
512
515
  )
513
516
  return
514
517
  }
@@ -532,7 +535,9 @@ class LLMObsTagger {
532
535
  if (typeof template === 'string') {
533
536
  finalTemplate = template
534
537
  } else if (Array.isArray(template)) {
535
- finalChatTemplate = template.map(message => ({ role: message.role, content: message.content }))
538
+ finalChatTemplate = template.map(item => item.type === 'placeholder'
539
+ ? { type: 'placeholder', name: item.name }
540
+ : { role: item.role, content: item.content })
536
541
  }
537
542
 
538
543
  const validatedPrompt = {}
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { createSiteUrl } = require('../exporters/common/url')
3
4
  const log = require('../log')
4
5
 
5
6
  const DEFAULT_AGENTLESS_PATH = '/api/v2/feature-flagging/config/rules-based/server'
@@ -60,7 +61,9 @@ function endpoint (config, configuredBaseUrl) {
60
61
  const configured = configuredBaseUrl?.trim()
61
62
 
62
63
  if (!configured) {
63
- const url = new URL(`https://ufc-server.ff-cdn.${config.site.toLowerCase()}${DEFAULT_AGENTLESS_PATH}`)
64
+ const url = createSiteUrl(config.site, 'ufc-server.ff-cdn')
65
+ if (url === undefined) throw new Error('Invalid DD_SITE for Feature Flagging agentless configuration')
66
+ url.pathname = DEFAULT_AGENTLESS_PATH
64
67
  if (config.env) url.searchParams.set('dd_env', config.env)
65
68
  return url
66
69
  }
@@ -27,12 +27,12 @@ class FlaggingProvider extends DatadogNodeServerProvider {
27
27
  constructor (tracer, config) {
28
28
  super({
29
29
  exposureChannel: channel(EXPOSURE_CHANNEL),
30
- initializationTimeoutMs: config.experimental.flaggingProvider.initializationTimeoutMs,
30
+ initializationTimeoutMs: config.featureFlags.DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS,
31
31
  })
32
32
 
33
33
  this.hooks.push(new EvalMetricsHook(config))
34
34
 
35
- if (config.experimental.flaggingProvider.spanEnrichment?.enabled) {
35
+ if (config.featureFlags.DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED) {
36
36
  this.#spanEnrichmentHook = new SpanEnrichmentHook(tracer)
37
37
  // @ts-expect-error The upstream constructor always initializes its optional hooks property.
38
38
  this.hooks.push(this.#spanEnrichmentHook)
@@ -42,7 +42,7 @@ class FlaggingProvider extends DatadogNodeServerProvider {
42
42
  }
43
43
 
44
44
  log.debug('%s created with timeout: %dms', this.constructor.name,
45
- config.experimental.flaggingProvider.initializationTimeoutMs)
45
+ config.featureFlags.DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS)
46
46
 
47
47
  this.#configurationSource = configurationSource.create(config, this.setConfiguration.bind(this))
48
48
  this.#configurationSource?.start()
@@ -3,12 +3,13 @@
3
3
  const { channel } = require('dc-polyfill')
4
4
  const log = require('../log')
5
5
  const ExposuresWriter = require('./writers/exposures')
6
- const { setExposureDeliveryStrategy } = require('./writers/util')
6
+ const { setEventDeliveryStrategy } = require('./writers/util')
7
7
 
8
8
  const exposureSubmitCh = channel('ffe:exposure:submit')
9
9
  const flushCh = channel('ffe:writers:flush')
10
10
 
11
11
  let exposuresWriter = null
12
+ let stopEventDeliveryStrategy
12
13
 
13
14
  /**
14
15
  * @private
@@ -42,7 +43,7 @@ function enable (config) {
42
43
  exposureSubmitCh.subscribe(_handleExposureSubmit)
43
44
  flushCh.subscribe(_handleFlush)
44
45
 
45
- setExposureDeliveryStrategy(config, (enabled, route) => {
46
+ stopEventDeliveryStrategy = setEventDeliveryStrategy(config, (enabled, route) => {
46
47
  if (exposuresWriter !== writer) return
47
48
 
48
49
  writer.setEnabled(enabled, route)
@@ -55,6 +56,9 @@ function enable (config) {
55
56
  function disable () {
56
57
  if (!exposuresWriter) return
57
58
 
59
+ stopEventDeliveryStrategy?.()
60
+ stopEventDeliveryStrategy = undefined
61
+
58
62
  if (exposureSubmitCh.hasSubscribers) {
59
63
  exposureSubmitCh.unsubscribe(_handleExposureSubmit)
60
64
  }
@@ -1,9 +1,14 @@
1
1
  'use strict'
2
2
 
3
3
  const request = require('../../exporters/common/request')
4
- const { safeJSONStringify } = require('../../exporters/common/util')
5
4
 
6
5
  const log = require('../../log')
6
+ const tracerVersion = require('../../../../../package.json').version
7
+
8
+ const EVP_ORIGIN_HEADERS = {
9
+ 'DD-EVP-ORIGIN': 'dd-trace-js',
10
+ 'DD-EVP-ORIGIN-VERSION': tracerVersion,
11
+ }
7
12
 
8
13
  /**
9
14
  * @typedef {object} BaseFFEWriterOptions
@@ -23,6 +28,8 @@ const log = require('../../log')
23
28
  * @property {string} endpoint - Route endpoint
24
29
  * @property {object} headers - Route-specific headers
25
30
  * @property {import('node:https').Agent} [agent] - Optional HTTPS proxy agent
31
+ * @property {Function} [onFallback] - Called after direct fallback becomes active
32
+ * @property {Function} [onUnavailable] - Called after the local route becomes unavailable
26
33
  */
27
34
 
28
35
  /**
@@ -30,6 +37,8 @@ const log = require('../../log')
30
37
  * @property {URL} url - Route base URL
31
38
  * @property {string} endpoint - Route endpoint
32
39
  * @property {object} requestOptions - HTTP request options
40
+ * @property {Function} [onFallback] - Called after direct fallback becomes active
41
+ * @property {Function} [onUnavailable] - Called after the local route becomes unavailable
33
42
  */
34
43
 
35
44
  /**
@@ -38,19 +47,31 @@ const log = require('../../log')
38
47
  * @param {Error | null} error - Request error
39
48
  * @param {number | undefined} statusCode - HTTP response status
40
49
  */
41
- function isDefinitiveRejection (error, statusCode) {
50
+ function isSafeToReplay (error, statusCode) {
42
51
  return error?.code === 'EAI_AGAIN' || error?.code === 'ECONNREFUSED' ||
43
52
  error?.code === 'ENOENT' || error?.code === 'ENOTFOUND' ||
44
- statusCode === 403 || statusCode === 404 || statusCode === 405
53
+ statusCode === 404 || statusCode === 405
45
54
  }
46
55
 
47
56
  /**
48
- * Tests whether a local route can have accepted an event batch before failing.
57
+ * Tests whether a local route failed without an authoritative HTTP response.
49
58
  *
50
59
  * @param {Error | null} error - Request error
60
+ * @param {number | undefined} statusCode - HTTP response status
61
+ */
62
+ function isTransportFailure (error, statusCode) {
63
+ // SDK backpressure says nothing about the local receiver's health.
64
+ return error !== null && error !== undefined && statusCode === undefined &&
65
+ error.code !== 'ERR_DD_REQUEST_BUFFER_FULL'
66
+ }
67
+
68
+ /**
69
+ * Tests whether an HTTP response should move only future Agentless batches.
70
+ *
71
+ * @param {number | undefined} statusCode - HTTP response status
51
72
  */
52
- function isAmbiguousNetworkFailure (error) {
53
- return error?.code === 'ECONNRESET' || error?.code === 'EPIPE' || error?.code === 'ETIMEDOUT'
73
+ function shouldSwitchFutureRoute (statusCode) {
74
+ return statusCode === 403 || statusCode === 429 || statusCode >= 500 && statusCode < 600
54
75
  }
55
76
 
56
77
  /**
@@ -81,10 +102,11 @@ class BaseFFEWriter {
81
102
  this._requestOptions = {
82
103
  headers: {
83
104
  ...this._headers,
105
+ ...EVP_ORIGIN_HEADERS,
84
106
  'Content-Type': 'application/json',
85
107
  },
86
108
  method: 'POST',
87
- retry: true,
109
+ retry: false,
88
110
  timeout: this._timeout,
89
111
  url: this._baseUrl,
90
112
  path: this._endpoint,
@@ -150,11 +172,10 @@ class BaseFFEWriter {
150
172
 
151
173
  const payload = this._encode(this.makePayload(events))
152
174
 
175
+ // Keep byte counting behind the debug callback because payloads can be several megabytes.
153
176
  // eslint-disable-next-line eslint-rules/eslint-log-printf-style
154
- log.debug(() => `${this.constructor.name} flushing payload: ${safeJSONStringify(payload)}`)
155
-
156
- const route = this.#createActiveRoute()
157
- this.#sendRequest(payload, events.length, route, this._fallbackRoute)
177
+ log.debug(() => `${this.constructor.name} flushing ${events.length} events (${Buffer.byteLength(payload)} bytes)`)
178
+ this._sendPayload(payload, events.length)
158
179
  }
159
180
 
160
181
  /**
@@ -192,6 +213,18 @@ class BaseFFEWriter {
192
213
  return JSON.stringify(payload)
193
214
  }
194
215
 
216
+ /**
217
+ * Sends one encoded event batch through a snapshot of the selected routes.
218
+ *
219
+ * @protected
220
+ * @param {string} payload - Encoded event batch
221
+ * @param {number} eventCount - Event count
222
+ */
223
+ _sendPayload (payload, eventCount) {
224
+ const route = this.#createActiveRoute()
225
+ this.#sendRequest(payload, eventCount, route, this._fallbackRoute)
226
+ }
227
+
195
228
  /**
196
229
  * Applies the active route and an optional direct fallback route.
197
230
  *
@@ -213,10 +246,11 @@ class BaseFFEWriter {
213
246
  const requestOptions = {
214
247
  headers: {
215
248
  ...route.headers,
249
+ ...EVP_ORIGIN_HEADERS,
216
250
  'Content-Type': 'application/json',
217
251
  },
218
252
  method: 'POST',
219
- retry: true,
253
+ retry: false,
220
254
  timeout: this._timeout,
221
255
  url: route.url,
222
256
  path: route.endpoint,
@@ -227,6 +261,8 @@ class BaseFFEWriter {
227
261
  url: route.url,
228
262
  endpoint: route.endpoint,
229
263
  requestOptions,
264
+ onFallback: route.onFallback,
265
+ onUnavailable: route.onUnavailable,
230
266
  }
231
267
  }
232
268
 
@@ -240,6 +276,8 @@ class BaseFFEWriter {
240
276
  url: this._baseUrl,
241
277
  endpoint: this._endpoint,
242
278
  requestOptions: this._requestOptions,
279
+ onFallback: this._onFallback,
280
+ onUnavailable: this._onUnavailable,
243
281
  }
244
282
  }
245
283
 
@@ -252,10 +290,12 @@ class BaseFFEWriter {
252
290
  this._baseUrl = route.url
253
291
  this._endpoint = route.endpoint
254
292
  this._requestOptions = route.requestOptions
293
+ this._onFallback = route.onFallback
294
+ this._onUnavailable = route.onUnavailable
255
295
  }
256
296
 
257
297
  /**
258
- * Sends an encoded batch and retries it through direct intake after a local route failure.
298
+ * Sends an encoded batch once per route and applies safe local fallback semantics.
259
299
  *
260
300
  * @param {string} payload - Encoded event batch
261
301
  * @param {number} eventCount - Event count
@@ -264,7 +304,7 @@ class BaseFFEWriter {
264
304
  */
265
305
  #sendRequest (payload, eventCount, route, fallbackRoute) {
266
306
  request(payload, route.requestOptions, (error, response, statusCode) => {
267
- if (fallbackRoute && isDefinitiveRejection(error, statusCode)) {
307
+ if (fallbackRoute && isSafeToReplay(error, statusCode)) {
268
308
  log.debug(
269
309
  '%s switching from %s%s to direct intake after definitive rejection',
270
310
  this.constructor.name,
@@ -273,20 +313,53 @@ class BaseFFEWriter {
273
313
  )
274
314
  this.#activateRoute(fallbackRoute)
275
315
  this._fallbackRoute = undefined
316
+ route.onFallback?.()
276
317
  this.#sendRequest(payload, eventCount, fallbackRoute)
277
318
  return
278
319
  }
279
320
 
280
- if (fallbackRoute && isAmbiguousNetworkFailure(error)) {
321
+ if (fallbackRoute && isTransportFailure(error, statusCode)) {
281
322
  log.debug(
282
- '%s retrying through direct intake and switching future batches from %s%s after ambiguous failure',
323
+ '%s switching future batches from %s%s to direct intake after an ambiguous failure without replay',
283
324
  this.constructor.name,
284
325
  route.url.href,
285
326
  route.endpoint
286
327
  )
287
328
  this.#activateRoute(fallbackRoute)
288
329
  this._fallbackRoute = undefined
289
- this.#sendRequest(payload, eventCount, fallbackRoute)
330
+ route.onFallback?.()
331
+ log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, error.message)
332
+ return
333
+ }
334
+
335
+ if (fallbackRoute && shouldSwitchFutureRoute(statusCode)) {
336
+ log.debug(
337
+ '%s switching future batches from %s%s to direct intake after status %d without replay',
338
+ this.constructor.name,
339
+ route.url.href,
340
+ route.endpoint,
341
+ statusCode
342
+ )
343
+ this.#activateRoute(fallbackRoute)
344
+ this._fallbackRoute = undefined
345
+ route.onFallback?.()
346
+ log.warn('Events request returned status %d', statusCode)
347
+ return
348
+ }
349
+
350
+ if (
351
+ !fallbackRoute &&
352
+ route.onUnavailable &&
353
+ (isSafeToReplay(error, statusCode) ||
354
+ isTransportFailure(error, statusCode) ||
355
+ shouldSwitchFutureRoute(statusCode))
356
+ ) {
357
+ route.onUnavailable()
358
+ if (error) {
359
+ log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, error.message)
360
+ } else {
361
+ log.warn('Events request returned status %d', statusCode)
362
+ }
290
363
  return
291
364
  }
292
365
 
@@ -26,6 +26,8 @@ const PENDING_MAX_EVENTS = 1000
26
26
  * @property {object} [headers] - Route-specific headers
27
27
  * @property {import('node:https').Agent} [agent] - Optional HTTPS proxy agent
28
28
  * @property {ExposureRoute} [fallback] - Optional direct fallback route
29
+ * @property {Function} [onFallback] - Called after direct fallback becomes active
30
+ * @property {Function} [onUnavailable] - Called after the local route becomes unavailable
29
31
  */
30
32
 
31
33
  /**
@@ -150,6 +152,8 @@ class ExposuresWriter extends BaseFFEWriter {
150
152
  endpoint: joinEVPProxyPath(route.basePath, EXPOSURES_ENDPOINT),
151
153
  headers,
152
154
  agent: route.agent,
155
+ onFallback: route.onFallback,
156
+ onUnavailable: route.onUnavailable,
153
157
  }, fallbackRoute)
154
158
  }
155
159