dd-trace 6.19.0 → 6.20.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 (77) hide show
  1. package/index.d.ts +163 -22
  2. package/package.json +2 -2
  3. package/packages/datadog-esbuild/index.js +9 -6
  4. package/packages/datadog-instrumentations/src/ai.js +26 -0
  5. package/packages/datadog-instrumentations/src/aws-sdk.js +2 -2
  6. package/packages/datadog-instrumentations/src/helpers/hooks.js +0 -7
  7. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +2 -2
  8. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/ai.js +25 -0
  9. package/packages/datadog-plugin-openai/src/stream-helpers.js +50 -1
  10. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -0
  11. package/packages/dd-trace/src/config/remote_config.js +12 -0
  12. package/packages/dd-trace/src/config/supported-configurations.json +21 -0
  13. package/packages/dd-trace/src/debugger/constants.js +13 -4
  14. package/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +14 -1
  15. package/packages/dd-trace/src/debugger/devtools_client/condition.js +109 -6
  16. package/packages/dd-trace/src/debugger/devtools_client/config.js +2 -0
  17. package/packages/dd-trace/src/debugger/devtools_client/index.js +65 -6
  18. package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +22 -10
  19. package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +12 -8
  20. package/packages/dd-trace/src/debugger/devtools_client/send.js +14 -0
  21. package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +43 -5
  22. package/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js +3 -8
  23. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +2 -122
  24. package/packages/dd-trace/src/debugger/guardrail-metrics.js +0 -2
  25. package/packages/dd-trace/src/debugger/index.js +60 -28
  26. package/packages/dd-trace/src/debugger/inspect-segment.js +92 -23
  27. package/packages/dd-trace/src/debugger/pause-duration-histogram.js +74 -0
  28. package/packages/dd-trace/src/debugger/probe_sampler.js +192 -52
  29. package/packages/dd-trace/src/debugger/redaction.js +139 -0
  30. package/packages/dd-trace/src/knuth-hash.js +17 -0
  31. package/packages/dd-trace/src/llmobs/constants/tags.js +2 -0
  32. package/packages/dd-trace/src/llmobs/experiments/client.js +66 -2
  33. package/packages/dd-trace/src/llmobs/experiments/evaluator.js +138 -0
  34. package/packages/dd-trace/src/llmobs/experiments/experiment.js +213 -90
  35. package/packages/dd-trace/src/llmobs/experiments/index.js +40 -1
  36. package/packages/dd-trace/src/llmobs/experiments/noop.js +53 -2
  37. package/packages/dd-trace/src/llmobs/experiments/remote-evaluator.js +110 -0
  38. package/packages/dd-trace/src/llmobs/experiments/util.js +34 -6
  39. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +2 -2
  40. package/packages/dd-trace/src/llmobs/sdk.js +19 -3
  41. package/packages/dd-trace/src/llmobs/span_processor.js +42 -26
  42. package/packages/dd-trace/src/llmobs/tagger.js +35 -0
  43. package/packages/dd-trace/src/llmobs/writers/base.js +1 -6
  44. package/packages/dd-trace/src/openfeature/constants/constants.js +10 -0
  45. package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -1
  46. package/packages/dd-trace/src/openfeature/writers/base.js +39 -18
  47. package/packages/dd-trace/src/openfeature/writers/flag-eval-evp-hook.js +105 -0
  48. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-aggregation.js +190 -0
  49. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-consumer.js +233 -0
  50. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-context.js +262 -0
  51. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-payload.js +181 -0
  52. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-pii.js +108 -0
  53. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-telemetry.js +154 -0
  54. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-worker.js +77 -0
  55. package/packages/dd-trace/src/openfeature/writers/flag-evaluations.js +398 -0
  56. package/packages/dd-trace/src/openfeature/writers/util.js +5 -4
  57. package/packages/dd-trace/src/opentelemetry/context_manager.js +3 -0
  58. package/packages/dd-trace/src/opentelemetry/span_context.js +6 -2
  59. package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +6 -0
  60. package/packages/dd-trace/src/opentelemetry/tracer.js +16 -18
  61. package/packages/dd-trace/src/opentracing/propagation/text_map.js +116 -25
  62. package/packages/dd-trace/src/opentracing/propagation/tracestate.js +80 -16
  63. package/packages/dd-trace/src/opentracing/span.js +14 -9
  64. package/packages/dd-trace/src/opentracing/tracer.js +10 -3
  65. package/packages/dd-trace/src/otel-sampling.js +171 -0
  66. package/packages/dd-trace/src/plugins/index.js +0 -1
  67. package/packages/dd-trace/src/priority_sampler.js +155 -33
  68. package/packages/dd-trace/src/remote_config/capabilities.js +9 -0
  69. package/packages/dd-trace/src/sampler.js +2 -5
  70. package/packages/dd-trace/src/sampling_rule.js +4 -8
  71. package/packages/dd-trace/src/span_format.js +6 -0
  72. package/packages/dd-trace/src/span_processor.js +26 -2
  73. package/packages/dd-trace/src/standalone/index.js +5 -4
  74. package/packages/dd-trace/src/standalone/tracesource_priority_sampler.js +25 -5
  75. package/packages/dd-trace/src/telemetry/metrics.js +4 -3
  76. package/packages/datadog-instrumentations/src/postgres.js +0 -7
  77. package/packages/datadog-instrumentations/src/supabase.js +0 -15
@@ -0,0 +1,110 @@
1
+ 'use strict'
2
+
3
+ const { BaseEvaluator, EvaluatorResult } = require('./evaluator')
4
+ const { hasEntries } = require('./util')
5
+
6
+ /**
7
+ * @typedef {{value?: unknown, reasoning?: string, assessment?: string, status?: string}} RemoteEvaluatorResponse
8
+ * @typedef {{evaluatorInfer: (evalName: string, context: object) => Promise<RemoteEvaluatorResponse>}}
9
+ * RemoteEvaluatorClient
10
+ */
11
+
12
+ /**
13
+ * @param {import('./evaluator').EvaluatorContext} context
14
+ */
15
+ function defaultContextTransform (context) {
16
+ const transformed = {
17
+ span_input: context.inputData,
18
+ span_output: context.outputData,
19
+ }
20
+
21
+ const meta = {}
22
+ let hasMeta = false
23
+ if (context.expectedOutput != null) {
24
+ meta.expected_output = context.expectedOutput
25
+ hasMeta = true
26
+ }
27
+ if (hasEntries(context.metadata)) {
28
+ meta.metadata = context.metadata
29
+ hasMeta = true
30
+ }
31
+ if (hasMeta) transformed.meta = meta
32
+
33
+ if (context.spanId) transformed.span_id = context.spanId
34
+ if (context.traceId) transformed.trace_id = context.traceId
35
+
36
+ return transformed
37
+ }
38
+
39
+ class RemoteEvaluatorResult extends EvaluatorResult {
40
+ /**
41
+ * @param {unknown} value
42
+ * @param {{reasoning?: string, assessment?: string, status?: string}} [options]
43
+ */
44
+ constructor (value, { reasoning, assessment, status } = {}) {
45
+ super(value, { reasoning, assessment })
46
+ this.status = status
47
+ this.evalSourceType = 'managed'
48
+ }
49
+ }
50
+
51
+ class RemoteEvaluatorError extends Error {
52
+ /**
53
+ * @param {string} message
54
+ * @param {{status?: string, backendError?: object}} [options]
55
+ */
56
+ constructor (message, { status = 'ERROR', backendError = {} } = {}) {
57
+ super(message)
58
+ this.name = 'RemoteEvaluatorError'
59
+ this.status = status
60
+ this.backendError = backendError
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Evaluator that references an LLM-as-a-judge evaluator configured in Datadog.
66
+ */
67
+ class RemoteEvaluator extends BaseEvaluator {
68
+ #evalName
69
+ #transformFn
70
+
71
+ /**
72
+ * @param {{evalName: string, transformFn?: (context: import('./evaluator').EvaluatorContext) => object}} options
73
+ */
74
+ constructor (options) {
75
+ super()
76
+ const { evalName, transformFn } = options ?? {}
77
+ if (typeof evalName !== 'string' || evalName.trim() === '') {
78
+ throw new TypeError('evalName must be a non-empty string')
79
+ }
80
+ if (transformFn !== undefined && typeof transformFn !== 'function') {
81
+ throw new TypeError('transformFn must be a function')
82
+ }
83
+
84
+ this.name = evalName.trim()
85
+ this.#evalName = this.name
86
+ this.#transformFn = transformFn ?? defaultContextTransform
87
+ }
88
+
89
+ /**
90
+ * @param {import('./evaluator').EvaluatorContext} context
91
+ * @param {RemoteEvaluatorClient} [client]
92
+ */
93
+ evaluate (context, client) {
94
+ if (client === undefined || typeof client.evaluatorInfer !== 'function') {
95
+ throw new Error('RemoteEvaluator can only be evaluated as part of an experiment')
96
+ }
97
+
98
+ const transformed = this.#transformFn(context)
99
+ return client.evaluatorInfer(this.#evalName, transformed).then(result => new RemoteEvaluatorResult(
100
+ result?.value,
101
+ {
102
+ reasoning: result?.reasoning,
103
+ assessment: result?.assessment,
104
+ status: result?.status,
105
+ }
106
+ ))
107
+ }
108
+ }
109
+
110
+ module.exports = { RemoteEvaluator, RemoteEvaluatorError }
@@ -62,6 +62,7 @@ function generateRunId () {
62
62
  function validateEvaluatorName (name) {
63
63
  if (typeof name !== 'string') throw new TypeError('Evaluator name must be a string')
64
64
  if (name.length === 0) throw new Error('Evaluator name cannot be empty')
65
+ if (name === '__proto__') throw new Error("Evaluator name '__proto__' is reserved")
65
66
  if (!EVALUATOR_NAME_PATTERN.test(name)) {
66
67
  throw new Error(
67
68
  `Evaluator name '${name}' is invalid. Name must contain only alphanumeric characters, underscores, and hyphens.`
@@ -77,10 +78,34 @@ function functionName (fn, fallback) {
77
78
  return typeof fn.name === 'string' && fn.name.length > 0 ? fn.name : fallback
78
79
  }
79
80
 
81
+ /**
82
+ * @param {unknown} evaluator
83
+ * @param {string} kind
84
+ */
85
+ function isClassEvaluator (evaluator, kind) {
86
+ // Lazy loading avoids a cycle: evaluator.js uses validateEvaluatorName from this module.
87
+ const { BaseEvaluator, BaseSummaryEvaluator } = require('./evaluator')
88
+ return kind === 'summary'
89
+ ? evaluator instanceof BaseSummaryEvaluator
90
+ : evaluator instanceof BaseEvaluator
91
+ }
92
+
93
+ /**
94
+ * @param {unknown} evaluator
95
+ * @param {string} kind
96
+ * @param {number} index
97
+ */
98
+ function evaluatorName (evaluator, kind, index) {
99
+ if (isClassEvaluator(evaluator, kind)) return evaluator.name
100
+ if (typeof evaluator === 'function') return functionName(evaluator, `${kind}_evaluator_${index}`)
101
+ const baseName = kind === 'summary' ? 'BaseSummaryEvaluator' : 'BaseEvaluator'
102
+ throw new TypeError(`${kind} evaluator must be a function or a ${baseName} instance`)
103
+ }
104
+
80
105
  /**
81
106
  * @param {unknown} evaluators
82
107
  * @param {string} kind
83
- * @returns {Array<[string, (...args: unknown[]) => unknown]>}
108
+ * @returns {Array<[string, Function | object]>}
84
109
  */
85
110
  function normalizeEvaluators (evaluators, kind) {
86
111
  if (evaluators == null) return []
@@ -90,8 +115,7 @@ function normalizeEvaluators (evaluators, kind) {
90
115
  const indexesByName = new Map()
91
116
  for (let i = 0; i < evaluators.length; i++) {
92
117
  const evaluator = evaluators[i]
93
- if (typeof evaluator !== 'function') throw new TypeError(`${kind} evaluator must be a function`)
94
- const name = functionName(evaluator, `${kind}_evaluator_${i}`)
118
+ const name = evaluatorName(evaluator, kind, i)
95
119
  validateEvaluatorName(name)
96
120
  if (indexesByName.has(name)) {
97
121
  log.warn('Duplicate %s evaluator name %s; previous evaluator will be overwritten', kind, name)
@@ -104,13 +128,17 @@ function normalizeEvaluators (evaluators, kind) {
104
128
  return normalized
105
129
  }
106
130
 
107
- if (typeof evaluators !== 'object') {
108
- throw new TypeError(`${kind} evaluators must be an array of functions or an object keyed by evaluator name`)
131
+ if (typeof evaluators !== 'object' || evaluators === null) {
132
+ throw new TypeError(
133
+ `${kind} evaluators must be an array of functions or class instances, or an object keyed by evaluator name`
134
+ )
109
135
  }
110
136
 
111
137
  for (const [name, evaluator] of Object.entries(evaluators)) {
112
138
  validateEvaluatorName(name)
113
- if (typeof evaluator !== 'function') throw new TypeError(`${kind} evaluator '${name}' must be a function`)
139
+ if (!isClassEvaluator(evaluator, kind) && typeof evaluator !== 'function') {
140
+ throw new TypeError(`${kind} evaluator '${name}' must be a function or a class instance`)
141
+ }
114
142
  normalized.push([name, evaluator])
115
143
  }
116
144
  return normalized
@@ -94,8 +94,8 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
94
94
  if (!methodName) return // we will not trace all openai methods for llmobs
95
95
 
96
96
  const inputs = ctx.args[0] // completion, chat completion, and embeddings take one argument
97
- const response = ctx.result?.data // no result if error
98
- const error = !!span.context().getTag('error')
97
+ const response = ctx.result?.data // no result if error, or if a stream ended before any response arrived
98
+ const error = !!span.context().getTag('error') || response == null
99
99
 
100
100
  const operation = getOperation(methodName)
101
101
 
@@ -7,6 +7,8 @@ const logger = require('../log')
7
7
  const { getValueFromEnvSources } = require('../config/helper')
8
8
  const Span = require('../opentracing/span')
9
9
  const {
10
+ EXPERIMENT_INPUT,
11
+ EXPERIMENT_OUTPUT,
10
12
  SPAN_KIND,
11
13
  OUTPUT_VALUE,
12
14
  INPUT_VALUE,
@@ -300,13 +302,18 @@ class LLMObs extends NoopLLMObs {
300
302
 
301
303
  const { inputData, outputData, metadata, metrics, tags, prompt, costTags, toolDefinitions } = options
302
304
 
303
- if (inputData || outputData) {
305
+ const hasInputOrOutput = spanKind === 'experiment'
306
+ ? inputData !== undefined || outputData !== undefined
307
+ : inputData || outputData
308
+ if (hasInputOrOutput) {
304
309
  if (spanKind === 'llm') {
305
310
  this._tagger.tagLLMIO(span, inputData, outputData)
306
311
  } else if (spanKind === 'embedding') {
307
312
  this._tagger.tagEmbeddingIO(span, inputData, outputData)
308
313
  } else if (spanKind === 'retrieval') {
309
314
  this._tagger.tagRetrievalIO(span, inputData, outputData)
315
+ } else if (spanKind === 'experiment') {
316
+ this._tagger.tagExperimentIO(span, inputData, outputData)
310
317
  } else {
311
318
  this._tagger.tagTextIO(span, inputData, outputData)
312
319
  }
@@ -638,11 +645,20 @@ class LLMObs extends NoopLLMObs {
638
645
 
639
646
  #autoAnnotate (span, kind, input, output) {
640
647
  const annotations = {}
641
- if (input && !['llm', 'embedding'].includes(kind) && !LLMObsTagger.tagMap.get(span)?.[INPUT_VALUE]) {
648
+ const spanTags = LLMObsTagger.tagMap.get(span)
649
+ const isExperiment = kind === 'experiment'
650
+ const inputKey = isExperiment ? EXPERIMENT_INPUT : INPUT_VALUE
651
+ const outputKey = isExperiment ? EXPERIMENT_OUTPUT : OUTPUT_VALUE
652
+ const hasInput = isExperiment ? input !== undefined : input
653
+ const hasOutput = isExperiment ? output !== undefined : output
654
+ const hasInputTag = spanTags !== undefined && Object.hasOwn(spanTags, inputKey)
655
+ const hasOutputTag = spanTags !== undefined && Object.hasOwn(spanTags, outputKey)
656
+
657
+ if (hasInput && !['llm', 'embedding'].includes(kind) && !hasInputTag) {
642
658
  annotations.inputData = input
643
659
  }
644
660
 
645
- if (output && !['llm', 'retrieval'].includes(kind) && !LLMObsTagger.tagMap.get(span)?.[OUTPUT_VALUE]) {
661
+ if (hasOutput && !['llm', 'retrieval'].includes(kind) && !hasOutputTag) {
646
662
  annotations.outputData = output
647
663
  }
648
664
 
@@ -16,6 +16,8 @@ const {
16
16
  METADATA,
17
17
  COST_TAGS,
18
18
  TOOL_DEFINITIONS,
19
+ EXPERIMENT_INPUT,
20
+ EXPERIMENT_OUTPUT,
19
21
  INPUT_MESSAGES,
20
22
  INPUT_VALUE,
21
23
  INTEGRATION,
@@ -182,8 +184,13 @@ class LLMObsSpanProcessor {
182
184
  }
183
185
 
184
186
  const llmObsSpan = new LLMObservabilitySpan(spanKind)
187
+ const isExperiment = spanKind === 'experiment'
188
+ const hasExperimentInput = isExperiment && Object.hasOwn(mlObsTags, EXPERIMENT_INPUT)
189
+ const hasExperimentOutput = isExperiment && Object.hasOwn(mlObsTags, EXPERIMENT_OUTPUT)
185
190
 
186
- if (spanKind === 'llm' && mlObsTags[INPUT_MESSAGES]) {
191
+ if (hasExperimentInput) {
192
+ llmObsSpan.input = [{ role: '', content: mlObsTags[EXPERIMENT_INPUT] }]
193
+ } else if (spanKind === 'llm' && mlObsTags[INPUT_MESSAGES]) {
187
194
  llmObsSpan.input = mlObsTags[INPUT_MESSAGES]
188
195
  inputType = 'messages'
189
196
  } else if (spanKind === 'embedding' && mlObsTags[INPUT_DOCUMENTS]) {
@@ -194,7 +201,9 @@ class LLMObsSpanProcessor {
194
201
  inputType = 'value'
195
202
  }
196
203
 
197
- if (spanKind === 'llm' && mlObsTags[OUTPUT_MESSAGES]) {
204
+ if (hasExperimentOutput) {
205
+ llmObsSpan.output = [{ role: '', content: mlObsTags[EXPERIMENT_OUTPUT] }]
206
+ } else if (spanKind === 'llm' && mlObsTags[OUTPUT_MESSAGES]) {
198
207
  llmObsSpan.output = mlObsTags[OUTPUT_MESSAGES]
199
208
  outputType = 'messages'
200
209
  } else if (spanKind === 'retrieval' && mlObsTags[OUTPUT_DOCUMENTS]) {
@@ -226,34 +235,41 @@ class LLMObsSpanProcessor {
226
235
  const processedSpan = this.#runProcessor(llmObsSpan)
227
236
  if (processedSpan === undefined) return null
228
237
 
229
- if (processedSpan.input) {
230
- if (inputType === 'messages') {
231
- input.messages = processedSpan.input
232
- } else if (inputType === 'value') {
233
- input.value = processedSpan.input[0].content
234
- } else if (inputType === 'documents') {
235
- input.documents = processedSpan.input.map((processedDocument, processedDocumentIdx) => ({
236
- ...mlObsTags[INPUT_DOCUMENTS][processedDocumentIdx],
237
- text: processedDocument.content,
238
- }))
238
+ if (isExperiment) {
239
+ const [processedInput] = processedSpan.input
240
+ const [processedOutput] = processedSpan.output
241
+ if (hasExperimentInput && processedInput !== undefined) meta.input = processedInput.content
242
+ if (hasExperimentOutput && processedOutput !== undefined) meta.output = processedOutput.content
243
+ } else {
244
+ if (processedSpan.input) {
245
+ if (inputType === 'messages') {
246
+ input.messages = processedSpan.input
247
+ } else if (inputType === 'value') {
248
+ input.value = processedSpan.input[0].content
249
+ } else if (inputType === 'documents') {
250
+ input.documents = processedSpan.input.map((processedDocument, processedDocumentIdx) => ({
251
+ ...mlObsTags[INPUT_DOCUMENTS][processedDocumentIdx],
252
+ text: processedDocument.content,
253
+ }))
254
+ }
239
255
  }
240
- }
241
256
 
242
- if (processedSpan.output) {
243
- if (outputType === 'messages') {
244
- output.messages = processedSpan.output
245
- } else if (outputType === 'value') {
246
- output.value = processedSpan.output[0].content
247
- } else if (outputType === 'documents') {
248
- output.documents = processedSpan.output.map((processedDocument, processedDocumentIdx) => ({
249
- ...mlObsTags[OUTPUT_DOCUMENTS][processedDocumentIdx],
250
- text: processedDocument.content,
251
- }))
257
+ if (processedSpan.output) {
258
+ if (outputType === 'messages') {
259
+ output.messages = processedSpan.output
260
+ } else if (outputType === 'value') {
261
+ output.value = processedSpan.output[0].content
262
+ } else if (outputType === 'documents') {
263
+ output.documents = processedSpan.output.map((processedDocument, processedDocumentIdx) => ({
264
+ ...mlObsTags[OUTPUT_DOCUMENTS][processedDocumentIdx],
265
+ text: processedDocument.content,
266
+ }))
267
+ }
252
268
  }
253
- }
254
269
 
255
- if (input) meta.input = input
256
- if (output) meta.output = output
270
+ meta.input = input
271
+ meta.output = output
272
+ }
257
273
 
258
274
  const prompt = mlObsTags[INPUT_PROMPT]
259
275
  if (prompt && spanKind === 'llm') {
@@ -10,6 +10,8 @@ const {
10
10
  SESSION_ID_TRACE_DEFAULT_KEY,
11
11
  ML_APP,
12
12
  SPAN_KIND,
13
+ EXPERIMENT_INPUT,
14
+ EXPERIMENT_OUTPUT,
13
15
  INPUT_VALUE,
14
16
  OUTPUT_DOCUMENTS,
15
17
  INPUT_DOCUMENTS,
@@ -273,6 +275,18 @@ class LLMObsTagger {
273
275
  this.#tagDocuments(span, outputData, OUTPUT_DOCUMENTS)
274
276
  }
275
277
 
278
+ /**
279
+ * Tags arbitrary JSON-compatible experiment input and output without converting structured values to text.
280
+ *
281
+ * @param {import('../opentracing/span')} span
282
+ * @param {unknown} inputData
283
+ * @param {unknown} outputData
284
+ */
285
+ tagExperimentIO (span, inputData, outputData) {
286
+ this.#tagExperimentValue(span, inputData, EXPERIMENT_INPUT, 'input')
287
+ this.#tagExperimentValue(span, outputData, EXPERIMENT_OUTPUT, 'output')
288
+ }
289
+
276
290
  tagTextIO (span, inputData, outputData) {
277
291
  this.#tagText(span, inputData, INPUT_VALUE)
278
292
  this.#tagText(span, outputData, OUTPUT_VALUE)
@@ -562,6 +576,27 @@ class LLMObsTagger {
562
576
  }
563
577
  }
564
578
 
579
+ /**
580
+ * Validates and stores one free-form experiment I/O value.
581
+ *
582
+ * @param {import('../opentracing/span')} span
583
+ * @param {unknown} data
584
+ * @param {string} key
585
+ * @param {string} type
586
+ */
587
+ #tagExperimentValue (span, data, key, type) {
588
+ if (data === undefined) return
589
+
590
+ try {
591
+ if (JSON.stringify(data) !== undefined) {
592
+ this._setTag(span, key, data)
593
+ return
594
+ }
595
+ } catch {}
596
+
597
+ this.#handleFailure(`Failed to parse ${type} value, must be JSON serializable.`, 'invalid_io_text')
598
+ }
599
+
565
600
  #tagDocuments (span, data, key) {
566
601
  if (!data) {
567
602
  return
@@ -293,12 +293,7 @@ class BaseLLMObsWriter {
293
293
  }
294
294
 
295
295
  _encode (payload) {
296
- return JSON.stringify(payload, (key, value) => {
297
- if (typeof value === 'string') {
298
- return encodeUnicode(value) // serialize unicode characters
299
- }
300
- return value
301
- }).replaceAll(String.raw`\\u`, String.raw`\u`) // remove double escaping
296
+ return encodeUnicode(JSON.stringify(payload))
302
297
  }
303
298
  }
304
299
 
@@ -19,6 +19,16 @@ module.exports = {
19
19
  */
20
20
  EVP_EVENT_SIZE_LIMIT: (1 << 20) - 1024,
21
21
 
22
+ FLAG_EVALUATION_ENDPOINT: '/api/v2/flagevaluation',
23
+ FLAG_EVALUATION_FLUSH_INTERVAL: 10_000,
24
+ FLAG_EVALUATION_QUEUE_CAP: 4096,
25
+ FLAG_EVALUATION_GLOBAL_CAP: 131_072,
26
+ FLAG_EVALUATION_PER_FLAG_CAP: 10_000,
27
+ FLAG_EVALUATION_DEGRADED_CAP: 32_768,
28
+
29
+ // ECMAScript Date's maximum absolute time value, in milliseconds.
30
+ MAX_EVALUATION_TIMESTAMP_MS: 8_640_000_000_000_000,
31
+
22
32
  /**
23
33
  * @constant
24
34
  * @type {string} Channel name for exposure event submission
@@ -8,6 +8,7 @@ const configurationSource = require('./configuration_source')
8
8
  const { EXPOSURE_CHANNEL } = require('./constants/constants')
9
9
  const EvalMetricsHook = require('./eval-metrics-hook')
10
10
  const SpanEnrichmentHook = require('./span-enrichment-hook')
11
+ const FlagEvalEVPHook = require('./writers/flag-eval-evp-hook')
11
12
 
12
13
  /**
13
14
  * OpenFeature provider that integrates with Datadog's feature flagging system.
@@ -17,6 +18,9 @@ class FlaggingProvider extends DatadogNodeServerProvider {
17
18
  /** @type {SpanEnrichmentHook | undefined} */
18
19
  #spanEnrichmentHook
19
20
 
21
+ /** @type {FlagEvalEVPHook | undefined} */
22
+ #flagEvalEVPHook
23
+
20
24
  /** @type {{ start: Function, stop: Function } | undefined} */
21
25
  #configurationSource
22
26
 
@@ -30,7 +34,9 @@ class FlaggingProvider extends DatadogNodeServerProvider {
30
34
  initializationTimeoutMs: config.featureFlags.DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS,
31
35
  })
32
36
 
33
- this.hooks.push(new EvalMetricsHook(config))
37
+ if (config.DD_METRICS_OTEL_ENABLED === true) {
38
+ this.hooks.push(new EvalMetricsHook(config))
39
+ }
34
40
 
35
41
  if (config.featureFlags.DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED) {
36
42
  this.#spanEnrichmentHook = new SpanEnrichmentHook(tracer)
@@ -44,6 +50,11 @@ class FlaggingProvider extends DatadogNodeServerProvider {
44
50
  log.debug('%s created with timeout: %dms', this.constructor.name,
45
51
  config.featureFlags.DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS)
46
52
 
53
+ if (config.featureFlags?.DD_FLAGGING_EVALUATION_COUNTS_ENABLED !== false) {
54
+ this.#flagEvalEVPHook = new FlagEvalEVPHook(config)
55
+ this.hooks.push(this.#flagEvalEVPHook)
56
+ }
57
+
47
58
  this.#configurationSource = configurationSource.create(config, this.setConfiguration.bind(this))
48
59
  this.#configurationSource?.start()
49
60
  }
@@ -73,6 +84,8 @@ class FlaggingProvider extends DatadogNodeServerProvider {
73
84
  this.#configurationSource = undefined
74
85
  this.#spanEnrichmentHook?.destroy()
75
86
  this.#spanEnrichmentHook = undefined
87
+ this.#flagEvalEVPHook?.destroy()
88
+ this.#flagEvalEVPHook = undefined
76
89
  }
77
90
  }
78
91
 
@@ -20,6 +20,7 @@ const EVP_ORIGIN_HEADERS = {
20
20
  * @property {number} [payloadSizeLimit] - Maximum payload size in bytes
21
21
  * @property {number} [eventSizeLimit] - Maximum individual event size in bytes
22
22
  * @property {object} [headers] - Additional HTTP headers
23
+ * @property {(error: Error, statusCode?: number) => string} [formatError] - Optional delivery error redaction
23
24
  */
24
25
 
25
26
  /**
@@ -80,10 +81,14 @@ function shouldSwitchFutureRoute (statusCode) {
80
81
  */
81
82
  class BaseFFEWriter {
82
83
  #destroyer
84
+ #formatError
83
85
  /**
84
86
  * @param {BaseFFEWriterOptions} options - Writer configuration options
85
87
  */
86
- constructor ({ interval, timeout, config, endpoint, agentUrl, payloadSizeLimit, eventSizeLimit, headers }) {
88
+ constructor ({
89
+ interval, timeout, config, endpoint, agentUrl, payloadSizeLimit, eventSizeLimit, headers, formatError,
90
+ }) {
91
+ this.#formatError = formatError
87
92
  this._interval = interval ?? 1000
88
93
  this._timeout = timeout ?? 5000
89
94
 
@@ -219,10 +224,11 @@ class BaseFFEWriter {
219
224
  * @protected
220
225
  * @param {string} payload - Encoded event batch
221
226
  * @param {number} eventCount - Event count
227
+ * @param {(delivered: boolean) => void} [onComplete] - Final outcome after any safe fallback attempt
222
228
  */
223
- _sendPayload (payload, eventCount) {
229
+ _sendPayload (payload, eventCount, onComplete) {
224
230
  const route = this.#createActiveRoute()
225
- this.#sendRequest(payload, eventCount, route, this._fallbackRoute)
231
+ this.#sendRequest(payload, eventCount, route, this._fallbackRoute, onComplete)
226
232
  }
227
233
 
228
234
  /**
@@ -301,9 +307,13 @@ class BaseFFEWriter {
301
307
  * @param {number} eventCount - Event count
302
308
  * @param {ActiveWriterRoute} route - Selected route
303
309
  * @param {ActiveWriterRoute} [fallbackRoute] - Direct fallback route
310
+ * @param {(delivered: boolean) => void} [onComplete] - True only for a successful final response
304
311
  */
305
- #sendRequest (payload, eventCount, route, fallbackRoute) {
306
- request(payload, route.requestOptions, (error, response, statusCode) => {
312
+ #sendRequest (payload, eventCount, route, fallbackRoute, onComplete) {
313
+ // The request helper mutates headers. Concurrent envelopes must not share them.
314
+ const requestOptions = { ...route.requestOptions, headers: { ...route.requestOptions.headers } }
315
+ request(payload, requestOptions, (error, response, statusCode) => {
316
+ const errorMessage = error && (this.#formatError ? this.#formatError(error, statusCode) : error.message)
307
317
  if (fallbackRoute && isSafeToReplay(error, statusCode)) {
308
318
  log.debug(
309
319
  '%s switching from %s%s to direct intake after definitive rejection',
@@ -311,10 +321,12 @@ class BaseFFEWriter {
311
321
  route.url.href,
312
322
  route.endpoint
313
323
  )
314
- this.#activateRoute(fallbackRoute)
315
- this._fallbackRoute = undefined
316
- route.onFallback?.()
317
- this.#sendRequest(payload, eventCount, fallbackRoute)
324
+ if (this._requestOptions === route.requestOptions) {
325
+ this.#activateRoute(fallbackRoute)
326
+ this._fallbackRoute = undefined
327
+ route.onFallback?.()
328
+ }
329
+ this.#sendRequest(payload, eventCount, fallbackRoute, undefined, onComplete)
318
330
  return
319
331
  }
320
332
 
@@ -325,10 +337,13 @@ class BaseFFEWriter {
325
337
  route.url.href,
326
338
  route.endpoint
327
339
  )
328
- this.#activateRoute(fallbackRoute)
329
- this._fallbackRoute = undefined
330
- route.onFallback?.()
331
- log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, error.message)
340
+ if (this._requestOptions === route.requestOptions) {
341
+ this.#activateRoute(fallbackRoute)
342
+ this._fallbackRoute = undefined
343
+ route.onFallback?.()
344
+ }
345
+ log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, errorMessage)
346
+ onComplete?.(false)
332
347
  return
333
348
  }
334
349
 
@@ -340,15 +355,19 @@ class BaseFFEWriter {
340
355
  route.endpoint,
341
356
  statusCode
342
357
  )
343
- this.#activateRoute(fallbackRoute)
344
- this._fallbackRoute = undefined
345
- route.onFallback?.()
358
+ if (this._requestOptions === route.requestOptions) {
359
+ this.#activateRoute(fallbackRoute)
360
+ this._fallbackRoute = undefined
361
+ route.onFallback?.()
362
+ }
346
363
  log.warn('Events request returned status %d', statusCode)
364
+ onComplete?.(false)
347
365
  return
348
366
  }
349
367
 
350
368
  if (
351
369
  !fallbackRoute &&
370
+ this._requestOptions === route.requestOptions &&
352
371
  route.onUnavailable &&
353
372
  (isSafeToReplay(error, statusCode) ||
354
373
  isTransportFailure(error, statusCode) ||
@@ -356,20 +375,22 @@ class BaseFFEWriter {
356
375
  ) {
357
376
  route.onUnavailable()
358
377
  if (error) {
359
- log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, error.message)
378
+ log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, errorMessage)
360
379
  } else {
361
380
  log.warn('Events request returned status %d', statusCode)
362
381
  }
382
+ onComplete?.(false)
363
383
  return
364
384
  }
365
385
 
366
386
  if (error) {
367
- log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, error.message)
387
+ log.error('Failed to send events to %s%s: %s', route.url.href, route.endpoint, errorMessage)
368
388
  } else if (statusCode >= 200 && statusCode < 300) {
369
389
  log.debug('Successfully sent %d events', eventCount)
370
390
  } else {
371
391
  log.warn('Events request returned status %d', statusCode)
372
392
  }
393
+ onComplete?.(!error && statusCode >= 200 && statusCode < 300)
373
394
  })
374
395
  }
375
396
  }