dd-trace 6.18.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 (123) 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 +166 -24
  4. package/package.json +7 -7
  5. package/packages/datadog-esbuild/index.js +9 -6
  6. package/packages/datadog-instrumentations/src/ai.js +26 -0
  7. package/packages/datadog-instrumentations/src/anthropic.js +111 -9
  8. package/packages/datadog-instrumentations/src/aws-sdk.js +2 -2
  9. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +5 -1
  10. package/packages/datadog-instrumentations/src/cucumber.js +40 -4
  11. package/packages/datadog-instrumentations/src/helpers/hooks.js +0 -7
  12. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +2 -2
  13. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/ai.js +25 -0
  14. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +8 -0
  15. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +2 -0
  16. package/packages/datadog-instrumentations/src/jest/session-error.js +102 -0
  17. package/packages/datadog-instrumentations/src/jest.js +3 -10
  18. package/packages/datadog-instrumentations/src/playwright.js +26 -0
  19. package/packages/datadog-instrumentations/src/vitest-main.js +4 -1
  20. package/packages/datadog-instrumentations/src/vitest-worker.js +34 -3
  21. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +83 -29
  22. package/packages/datadog-plugin-cucumber/src/index.js +19 -4
  23. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +7 -0
  24. package/packages/datadog-plugin-fetch/src/index.js +3 -0
  25. package/packages/datadog-plugin-openai/src/stream-helpers.js +50 -1
  26. package/packages/datadog-plugin-oracledb/src/connection-parser.js +3 -1
  27. package/packages/datadog-plugin-undici/src/index.js +5 -0
  28. package/packages/dd-trace/src/aiguard/client.js +38 -19
  29. package/packages/dd-trace/src/aiguard/integrations/anthropic.js +29 -4
  30. package/packages/dd-trace/src/aiguard/integrations/index.js +1 -1
  31. package/packages/dd-trace/src/aiguard/integrations/openai.js +20 -56
  32. package/packages/dd-trace/src/aiguard/integrations/stream.js +60 -0
  33. package/packages/dd-trace/src/aiguard/messages/anthropic.js +64 -0
  34. package/packages/dd-trace/src/config/generated-config-types.d.ts +8 -4
  35. package/packages/dd-trace/src/config/remote_config.js +12 -0
  36. package/packages/dd-trace/src/config/supported-configurations.json +26 -5
  37. package/packages/dd-trace/src/constants.js +2 -0
  38. package/packages/dd-trace/src/debugger/constants.js +13 -4
  39. package/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +14 -1
  40. package/packages/dd-trace/src/debugger/devtools_client/condition.js +109 -6
  41. package/packages/dd-trace/src/debugger/devtools_client/config.js +2 -0
  42. package/packages/dd-trace/src/debugger/devtools_client/index.js +65 -6
  43. package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +22 -10
  44. package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +12 -8
  45. package/packages/dd-trace/src/debugger/devtools_client/send.js +14 -0
  46. package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +43 -5
  47. package/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js +3 -8
  48. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +2 -122
  49. package/packages/dd-trace/src/debugger/guardrail-metrics.js +0 -2
  50. package/packages/dd-trace/src/debugger/index.js +60 -28
  51. package/packages/dd-trace/src/debugger/inspect-segment.js +92 -23
  52. package/packages/dd-trace/src/debugger/pause-duration-histogram.js +74 -0
  53. package/packages/dd-trace/src/debugger/probe_sampler.js +192 -52
  54. package/packages/dd-trace/src/debugger/redaction.js +139 -0
  55. package/packages/dd-trace/src/knuth-hash.js +17 -0
  56. package/packages/dd-trace/src/llmobs/constants/tags.js +33 -8
  57. package/packages/dd-trace/src/llmobs/experiments/client.js +66 -2
  58. package/packages/dd-trace/src/llmobs/experiments/evaluator.js +138 -0
  59. package/packages/dd-trace/src/llmobs/experiments/experiment.js +213 -90
  60. package/packages/dd-trace/src/llmobs/experiments/index.js +40 -1
  61. package/packages/dd-trace/src/llmobs/experiments/noop.js +53 -2
  62. package/packages/dd-trace/src/llmobs/experiments/remote-evaluator.js +110 -0
  63. package/packages/dd-trace/src/llmobs/experiments/util.js +34 -6
  64. package/packages/dd-trace/src/llmobs/gen-ai-tags.js +127 -0
  65. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +18 -0
  66. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +33 -8
  67. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +77 -41
  68. package/packages/dd-trace/src/llmobs/plugins/base.js +154 -15
  69. package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +162 -10
  70. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +65 -16
  71. package/packages/dd-trace/src/llmobs/plugins/genai/index.js +14 -0
  72. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +34 -35
  73. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +10 -0
  74. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +25 -2
  75. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +8 -7
  76. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +15 -2
  77. package/packages/dd-trace/src/llmobs/plugins/openai/realtime.js +5 -0
  78. package/packages/dd-trace/src/llmobs/plugins/vertexai.js +7 -0
  79. package/packages/dd-trace/src/llmobs/prompts/prompt.js +2 -1
  80. package/packages/dd-trace/src/llmobs/sdk.js +19 -3
  81. package/packages/dd-trace/src/llmobs/span_processor.js +52 -91
  82. package/packages/dd-trace/src/llmobs/tagger.js +37 -36
  83. package/packages/dd-trace/src/llmobs/writers/base.js +1 -6
  84. package/packages/dd-trace/src/openfeature/constants/constants.js +10 -0
  85. package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -1
  86. package/packages/dd-trace/src/openfeature/writers/base.js +39 -18
  87. package/packages/dd-trace/src/openfeature/writers/flag-eval-evp-hook.js +105 -0
  88. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-aggregation.js +190 -0
  89. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-consumer.js +233 -0
  90. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-context.js +262 -0
  91. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-payload.js +181 -0
  92. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-pii.js +108 -0
  93. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-telemetry.js +154 -0
  94. package/packages/dd-trace/src/openfeature/writers/flag-evaluation-worker.js +77 -0
  95. package/packages/dd-trace/src/openfeature/writers/flag-evaluations.js +398 -0
  96. package/packages/dd-trace/src/openfeature/writers/util.js +5 -4
  97. package/packages/dd-trace/src/opentelemetry/context_manager.js +3 -0
  98. package/packages/dd-trace/src/opentelemetry/span_context.js +6 -2
  99. package/packages/dd-trace/src/opentelemetry/trace/index.js +5 -0
  100. package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +6 -0
  101. package/packages/dd-trace/src/opentelemetry/tracer.js +16 -18
  102. package/packages/dd-trace/src/opentracing/propagation/text_map.js +116 -25
  103. package/packages/dd-trace/src/opentracing/propagation/tracestate.js +80 -16
  104. package/packages/dd-trace/src/opentracing/span.js +14 -9
  105. package/packages/dd-trace/src/opentracing/tracer.js +10 -3
  106. package/packages/dd-trace/src/otel-sampling.js +171 -0
  107. package/packages/dd-trace/src/plugins/index.js +0 -1
  108. package/packages/dd-trace/src/plugins/util/test.js +4 -0
  109. package/packages/dd-trace/src/priority_sampler.js +155 -33
  110. package/packages/dd-trace/src/profiler.js +51 -5
  111. package/packages/dd-trace/src/profiling/profiler.js +24 -20
  112. package/packages/dd-trace/src/remote_config/capabilities.js +9 -0
  113. package/packages/dd-trace/src/ritm.js +12 -9
  114. package/packages/dd-trace/src/sampler.js +2 -5
  115. package/packages/dd-trace/src/sampling_rule.js +4 -8
  116. package/packages/dd-trace/src/span_format.js +6 -0
  117. package/packages/dd-trace/src/span_processor.js +32 -3
  118. package/packages/dd-trace/src/standalone/index.js +5 -4
  119. package/packages/dd-trace/src/standalone/tracesource_priority_sampler.js +25 -5
  120. package/packages/dd-trace/src/telemetry/metrics.js +4 -3
  121. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
  122. package/packages/datadog-instrumentations/src/postgres.js +0 -7
  123. package/packages/datadog-instrumentations/src/supabase.js +0 -15
@@ -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,
@@ -38,41 +40,13 @@ const {
38
40
  SAMPLE_RATE,
39
41
  SAMPLING_DECISION,
40
42
  TRACE_ID,
41
- INPUT_TOKENS_METRIC_KEY,
42
- OUTPUT_TOKENS_METRIC_KEY,
43
- TOTAL_TOKENS_METRIC_KEY,
44
- CACHE_READ_INPUT_TOKENS_METRIC_KEY,
45
- CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
46
- REASONING_OUTPUT_TOKENS_METRIC_KEY,
47
- GEN_AI_OPERATION_NAME,
48
- GEN_AI_REQUEST_MODEL,
49
- GEN_AI_PROVIDER_NAME,
50
- GEN_AI_APPLICATION_NAME,
51
- GEN_AI_CONVERSATION_ID,
52
- GEN_AI_USAGE_INPUT_TOKENS_METRIC_KEY,
53
- GEN_AI_USAGE_OUTPUT_TOKENS_METRIC_KEY,
54
- GEN_AI_USAGE_TOTAL_TOKENS_METRIC_KEY,
55
- GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_METRIC_KEY,
56
- GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
57
- GEN_AI_USAGE_REASONING_OUTPUT_TOKENS_METRIC_KEY,
58
- ARTIFICIAL_GEN_AI_TAGS,
43
+ DEFAULT_MODEL,
59
44
  } = require('./constants/tags')
60
45
  const { UNSERIALIZABLE_VALUE_TEXT } = require('./constants/text')
46
+ const { setGenAiApmTags } = require('./gen-ai-tags')
61
47
  const telemetry = require('./telemetry')
62
48
  const LLMObsTagger = require('./tagger')
63
49
 
64
- const DEFAULT_MODEL = 'custom'
65
- const MODEL_BACKED_SPAN_KINDS = new Set(['llm', 'embedding'])
66
-
67
- const GEN_AI_TOKEN_METRIC_KEYS = [
68
- [INPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_INPUT_TOKENS_METRIC_KEY],
69
- [OUTPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_OUTPUT_TOKENS_METRIC_KEY],
70
- [TOTAL_TOKENS_METRIC_KEY, GEN_AI_USAGE_TOTAL_TOKENS_METRIC_KEY],
71
- [CACHE_READ_INPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_METRIC_KEY],
72
- [CACHE_WRITE_INPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_METRIC_KEY],
73
- [REASONING_OUTPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_REASONING_OUTPUT_TOKENS_METRIC_KEY],
74
- ]
75
-
76
50
  class LLMObservabilitySpan {
77
51
  /**
78
52
  * @param {string} kind span kind
@@ -210,8 +184,13 @@ class LLMObsSpanProcessor {
210
184
  }
211
185
 
212
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)
213
190
 
214
- 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]) {
215
194
  llmObsSpan.input = mlObsTags[INPUT_MESSAGES]
216
195
  inputType = 'messages'
217
196
  } else if (spanKind === 'embedding' && mlObsTags[INPUT_DOCUMENTS]) {
@@ -222,7 +201,9 @@ class LLMObsSpanProcessor {
222
201
  inputType = 'value'
223
202
  }
224
203
 
225
- 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]) {
226
207
  llmObsSpan.output = mlObsTags[OUTPUT_MESSAGES]
227
208
  outputType = 'messages'
228
209
  } else if (spanKind === 'retrieval' && mlObsTags[OUTPUT_DOCUMENTS]) {
@@ -254,34 +235,41 @@ class LLMObsSpanProcessor {
254
235
  const processedSpan = this.#runProcessor(llmObsSpan)
255
236
  if (processedSpan === undefined) return null
256
237
 
257
- if (processedSpan.input) {
258
- if (inputType === 'messages') {
259
- input.messages = processedSpan.input
260
- } else if (inputType === 'value') {
261
- input.value = processedSpan.input[0].content
262
- } else if (inputType === 'documents') {
263
- input.documents = processedSpan.input.map((processedDocument, processedDocumentIdx) => ({
264
- ...mlObsTags[INPUT_DOCUMENTS][processedDocumentIdx],
265
- text: processedDocument.content,
266
- }))
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
+ }
267
255
  }
268
- }
269
256
 
270
- if (processedSpan.output) {
271
- if (outputType === 'messages') {
272
- output.messages = processedSpan.output
273
- } else if (outputType === 'value') {
274
- output.value = processedSpan.output[0].content
275
- } else if (outputType === 'documents') {
276
- output.documents = processedSpan.output.map((processedDocument, processedDocumentIdx) => ({
277
- ...mlObsTags[OUTPUT_DOCUMENTS][processedDocumentIdx],
278
- text: processedDocument.content,
279
- }))
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
+ }
280
268
  }
281
- }
282
269
 
283
- if (input) meta.input = input
284
- if (output) meta.output = output
270
+ meta.input = input
271
+ meta.output = output
272
+ }
285
273
 
286
274
  const prompt = mlObsTags[INPUT_PROMPT]
287
275
  if (prompt && spanKind === 'llm') {
@@ -320,46 +308,19 @@ class LLMObsSpanProcessor {
320
308
  }
321
309
 
322
310
  /**
323
- * Writes the scalar `gen_ai.*` attributes onto the APM span, so model, provider, application,
324
- * conversation and token usage are searchable in APM. Message bodies stay off the APM span.
325
- *
326
311
  * @param {import('../opentracing/span')} span
327
312
  */
328
313
  #setGenAiApmTags (span) {
329
314
  const mlObsTags = LLMObsTagger.tagMap.get(span)
330
- const spanContext = span.context()
331
- const spanKind = mlObsTags[SPAN_KIND]
332
-
333
- if (spanKind) spanContext.setTag(GEN_AI_OPERATION_NAME, spanKind)
334
-
335
- const modelName = mlObsTags[MODEL_NAME]
336
- const modelProvider = mlObsTags[MODEL_PROVIDER]
337
- const modelBacked = MODEL_BACKED_SPAN_KINDS.has(spanKind)
338
- if (modelBacked) {
339
- spanContext.setTag(GEN_AI_REQUEST_MODEL, modelName || DEFAULT_MODEL)
340
- spanContext.setTag(GEN_AI_PROVIDER_NAME, (modelProvider || DEFAULT_MODEL).toLowerCase())
341
- } else {
342
- if (modelName) spanContext.setTag(GEN_AI_REQUEST_MODEL, modelName)
343
- if (modelProvider) spanContext.setTag(GEN_AI_PROVIDER_NAME, modelProvider.toLowerCase())
344
- }
345
-
346
- const mlApp = mlObsTags[ML_APP]
347
- if (mlApp) spanContext.setTag(GEN_AI_APPLICATION_NAME, mlApp)
348
-
349
- const sessionId = mlObsTags[SESSION_ID]
350
- if (sessionId) spanContext.setTag(GEN_AI_CONVERSATION_ID, sessionId)
351
-
352
- const metrics = mlObsTags[METRICS]
353
- // Other kinds carry unrelated metrics that would be misleading under a `gen_ai.usage.*` key.
354
- if (modelBacked && metrics) {
355
- for (const [metricKey, genAiKey] of GEN_AI_TOKEN_METRIC_KEYS) {
356
- const value = metrics[metricKey]
357
- if (value != null) spanContext.setTag(genAiKey, value)
358
- }
359
- }
360
315
 
361
- // matches the value dd-trace-py writes
362
- spanContext.setTag(ARTIFICIAL_GEN_AI_TAGS, 'true')
316
+ setGenAiApmTags(span, {
317
+ spanKind: mlObsTags[SPAN_KIND],
318
+ modelName: mlObsTags[MODEL_NAME],
319
+ modelProvider: mlObsTags[MODEL_PROVIDER],
320
+ mlApp: mlObsTags[ML_APP],
321
+ sessionId: mlObsTags[SESSION_ID],
322
+ metrics: mlObsTags[METRICS],
323
+ })
363
324
  }
364
325
 
365
326
  // For now, this only applies to metadata, as we let users annotate this field with any object
@@ -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,
@@ -29,14 +31,7 @@ const {
29
31
  PROPAGATED_PARENT_AGENT_ID_KEY,
30
32
  PROPAGATED_PARENT_AGENT_NAME_KEY,
31
33
  ROOT_PARENT_ID,
32
- CACHE_READ_INPUT_TOKENS_METRIC_KEY,
33
- CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
34
- CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY,
35
- CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY,
36
- INPUT_TOKENS_METRIC_KEY,
37
- OUTPUT_TOKENS_METRIC_KEY,
38
- TOTAL_TOKENS_METRIC_KEY,
39
- REASONING_OUTPUT_TOKENS_METRIC_KEY,
34
+ METRIC_KEY_ALIASES,
40
35
  INTEGRATION,
41
36
  DECORATOR,
42
37
  PROPAGATED_ML_APP_KEY,
@@ -280,6 +275,18 @@ class LLMObsTagger {
280
275
  this.#tagDocuments(span, outputData, OUTPUT_DOCUMENTS)
281
276
  }
282
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
+
283
290
  tagTextIO (span, inputData, outputData) {
284
291
  this.#tagText(span, inputData, INPUT_VALUE)
285
292
  this.#tagText(span, outputData, OUTPUT_VALUE)
@@ -307,35 +314,8 @@ class LLMObsTagger {
307
314
  tagMetrics (span, metrics) {
308
315
  const filterdMetrics = {}
309
316
  for (const [key, value] of Object.entries(metrics)) {
310
- let processedKey = key
311
-
312
317
  // processing these specifically for our metrics ingestion
313
- switch (key) {
314
- case 'inputTokens':
315
- processedKey = INPUT_TOKENS_METRIC_KEY
316
- break
317
- case 'outputTokens':
318
- processedKey = OUTPUT_TOKENS_METRIC_KEY
319
- break
320
- case 'totalTokens':
321
- processedKey = TOTAL_TOKENS_METRIC_KEY
322
- break
323
- case 'cacheReadTokens':
324
- processedKey = CACHE_READ_INPUT_TOKENS_METRIC_KEY
325
- break
326
- case 'cacheWriteTokens':
327
- processedKey = CACHE_WRITE_INPUT_TOKENS_METRIC_KEY
328
- break
329
- case 'cacheWrite5mTokens':
330
- processedKey = CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY
331
- break
332
- case 'cacheWrite1hTokens':
333
- processedKey = CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY
334
- break
335
- case 'reasoningOutputTokens':
336
- processedKey = REASONING_OUTPUT_TOKENS_METRIC_KEY
337
- break
338
- }
318
+ const processedKey = METRIC_KEY_ALIASES[key] ?? key
339
319
 
340
320
  if (typeof value === 'number') {
341
321
  filterdMetrics[processedKey] = value
@@ -596,6 +576,27 @@ class LLMObsTagger {
596
576
  }
597
577
  }
598
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
+
599
600
  #tagDocuments (span, data, key) {
600
601
  if (!data) {
601
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
  }
@@ -0,0 +1,105 @@
1
+ 'use strict'
2
+
3
+ const { MAX_EVALUATION_TIMESTAMP_MS } = require('../constants/constants')
4
+ const { snapshotEvaluationContext } = require('./flag-evaluation-context')
5
+ const {
6
+ recordContextTruncated, recordDropped, recordHookError, recordTargetingKeyOmitted,
7
+ } = require('./flag-evaluation-telemetry')
8
+ const FlagEvaluationsWriter = require('./flag-evaluations')
9
+ const { setExposureDeliveryStrategy } = require('./util')
10
+
11
+ /** Captures terminal SDK results; the writer owns deferred aggregation and delivery. */
12
+ class FlagEvalEVPHook {
13
+ /** @type {FlagEvaluationsWriter} */
14
+ #writer
15
+ #closed = false
16
+ #stopDeliveryStrategy
17
+
18
+ /**
19
+ * The provider only constructs this hook when evaluation counts are enabled.
20
+ *
21
+ * @param {import('../../config/config-base')} config
22
+ */
23
+ constructor (config) {
24
+ const writer = new FlagEvaluationsWriter(config)
25
+ this.#writer = writer
26
+ this.#stopDeliveryStrategy = setExposureDeliveryStrategy(config, (enabled, route) => {
27
+ if (this.#closed) return
28
+ writer.setEnabled(enabled, route)
29
+ })
30
+ }
31
+
32
+ /**
33
+ * Provider hooks run in finally even when the SDK short-circuits before resolution.
34
+ * Consent belongs to the captured result, never the provider's current configuration.
35
+ *
36
+ * @param {import('@openfeature/core').HookContext} hookContext
37
+ * @param {import('@openfeature/core').EvaluationDetails<import('@openfeature/core').FlagValue>} evaluationDetails
38
+ */
39
+ finally (hookContext, evaluationDetails) {
40
+ try {
41
+ const unavailableReason = this.#closed ? 'closed' : this.#writer.getUnavailableReason()
42
+ if (unavailableReason !== undefined) {
43
+ recordDropped(unavailableReason)
44
+ return
45
+ }
46
+ if (!this.#writer.hasCapacity()) {
47
+ recordDropped('pre_queue_overflow')
48
+ return
49
+ }
50
+
51
+ const metadata = evaluationDetails.flagMetadata
52
+ const consent = metadata?.__dd_observe_full_evaluation_data === true
53
+ const capturedTime = metadata?.__dd_eval_timestamp_ms
54
+ const timestamp = typeof capturedTime === 'number' && Number.isSafeInteger(capturedTime) &&
55
+ Math.abs(capturedTime) <= MAX_EVALUATION_TIMESTAMP_MS
56
+ ? capturedTime
57
+ : Date.now()
58
+ const context = hookContext.context
59
+ let targetingKey
60
+ try {
61
+ targetingKey = context?.targetingKey
62
+ } catch {
63
+ // An unreadable identity must not discard an otherwise valid evaluation count.
64
+ recordTargetingKeyOmitted()
65
+ }
66
+ let attrs
67
+ if (consent) {
68
+ let snapshot
69
+ try {
70
+ snapshot = snapshotEvaluationContext(context)
71
+ } catch {
72
+ // Preserve the evaluation count without the failed context. Never log caller-controlled errors.
73
+ recordContextTruncated('snapshot_error')
74
+ }
75
+ if (snapshot !== undefined) {
76
+ attrs = snapshot.attrs
77
+ for (const reason of snapshot.reasons) recordContextTruncated(reason)
78
+ }
79
+ }
80
+
81
+ this.#writer.enqueue({
82
+ flagKey: hookContext.flagKey,
83
+ variant: evaluationDetails.variant,
84
+ allocationKey: typeof metadata?.__dd_allocation_key === 'string' ? metadata.__dd_allocation_key : undefined,
85
+ runtimeDefault: evaluationDetails.variant === undefined,
86
+ errorCode: evaluationDetails.errorCode,
87
+ targetingKey,
88
+ attrs,
89
+ observeFullEvaluationData: consent,
90
+ timestamp,
91
+ })
92
+ } catch {
93
+ recordHookError()
94
+ }
95
+ }
96
+
97
+ destroy () {
98
+ if (this.#closed) return
99
+ this.#closed = true
100
+ this.#stopDeliveryStrategy?.()
101
+ this.#writer.destroy()
102
+ }
103
+ }
104
+
105
+ module.exports = FlagEvalEVPHook