dd-trace 5.123.0 → 5.124.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 (97) hide show
  1. package/ci/test-optimization-validation/framework-adapters/cypress.js +9 -11
  2. package/ci/test-optimization-validation/scenarios/ci-wiring.js +5 -4
  3. package/index.d.ts +128 -26
  4. package/package.json +10 -10
  5. package/packages/datadog-instrumentations/src/bunyan.js +4 -2
  6. package/packages/datadog-instrumentations/src/helpers/bunyan.js +12 -3
  7. package/packages/datadog-instrumentations/src/helpers/pool-acquire.js +1 -0
  8. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +28 -0
  9. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +1 -0
  10. package/packages/datadog-instrumentations/src/jest.js +140 -18
  11. package/packages/datadog-instrumentations/src/mariadb-bundle.js +1597 -0
  12. package/packages/datadog-instrumentations/src/mariadb.js +7 -0
  13. package/packages/datadog-instrumentations/src/pino.js +15 -4
  14. package/packages/datadog-instrumentations/src/playwright.js +34 -20
  15. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +7 -4
  16. package/packages/datadog-instrumentations/src/vitest-worker.js +11 -0
  17. package/packages/datadog-instrumentations/src/webdriverio.js +26 -0
  18. package/packages/datadog-instrumentations/src/winston.js +6 -3
  19. package/packages/datadog-plugin-cypress/src/support.js +1 -0
  20. package/packages/datadog-plugin-http/src/client.js +2 -15
  21. package/packages/datadog-plugin-http2/src/client.js +2 -15
  22. package/packages/datadog-plugin-openai-agents/src/integration.js +15 -11
  23. package/packages/datadog-plugin-playwright/src/index.js +9 -1
  24. package/packages/datadog-plugin-undici/src/index.js +2 -15
  25. package/packages/dd-trace/src/aiguard/evaluation.js +94 -20
  26. package/packages/dd-trace/src/aiguard/integrations/vercel-ai.js +2 -3
  27. package/packages/dd-trace/src/aiguard/messages/anthropic.js +21 -9
  28. package/packages/dd-trace/src/aiguard/noop.js +9 -1
  29. package/packages/dd-trace/src/aiguard/redaction.js +183 -0
  30. package/packages/dd-trace/src/aiguard/sdk.js +7 -2
  31. package/packages/dd-trace/src/aiguard/tags.js +2 -0
  32. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +10 -9
  33. package/packages/dd-trace/src/appsec/rule_manager.js +2 -0
  34. package/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js +51 -26
  35. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +5 -11
  36. package/packages/dd-trace/src/ci-visibility/final-flush.js +20 -0
  37. package/packages/dd-trace/src/ci-visibility/log-submission/log-submission-plugin.js +196 -29
  38. package/packages/dd-trace/src/config/generated-config-types.d.ts +6 -0
  39. package/packages/dd-trace/src/config/index.js +2 -1
  40. package/packages/dd-trace/src/config/supported-configurations.json +27 -0
  41. package/packages/dd-trace/src/debugger/devtools_client/send.js +4 -3
  42. package/packages/dd-trace/src/dogstatsd.js +45 -18
  43. package/packages/dd-trace/src/encode/agentless-json.js +5 -4
  44. package/packages/dd-trace/src/exporters/agent/index.js +22 -2
  45. package/packages/dd-trace/src/exporters/agent/writer.js +4 -4
  46. package/packages/dd-trace/src/exporters/common/writer.js +24 -2
  47. package/packages/dd-trace/src/exporters/span-stats/index.js +23 -4
  48. package/packages/dd-trace/src/external-logger/src/index.js +4 -3
  49. package/packages/dd-trace/src/flush.js +102 -0
  50. package/packages/dd-trace/src/llmobs/experiments/client.js +4 -0
  51. package/packages/dd-trace/src/llmobs/experiments/dataset.js +51 -2
  52. package/packages/dd-trace/src/llmobs/experiments/experiment.js +420 -105
  53. package/packages/dd-trace/src/llmobs/experiments/index.js +54 -42
  54. package/packages/dd-trace/src/llmobs/experiments/noop.js +39 -8
  55. package/packages/dd-trace/src/llmobs/experiments/result.js +1 -0
  56. package/packages/dd-trace/src/llmobs/experiments/util.js +21 -0
  57. package/packages/dd-trace/src/llmobs/index.js +80 -17
  58. package/packages/dd-trace/src/llmobs/noop.js +2 -1
  59. package/packages/dd-trace/src/llmobs/plugins/anthropic/util.js +8 -4
  60. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +6 -3
  61. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +13 -6
  62. package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +5 -7
  63. package/packages/dd-trace/src/llmobs/writers/base.js +60 -24
  64. package/packages/dd-trace/src/noop/span.js +5 -0
  65. package/packages/dd-trace/src/openfeature/writers/exposures.js +8 -1
  66. package/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +45 -3
  67. package/packages/dd-trace/src/opentelemetry/logs/index.js +4 -1
  68. package/packages/dd-trace/src/opentelemetry/logs/logger_provider.js +7 -4
  69. package/packages/dd-trace/src/opentelemetry/metrics/index.js +3 -1
  70. package/packages/dd-trace/src/opentelemetry/metrics/meter_provider.js +8 -0
  71. package/packages/dd-trace/src/opentelemetry/metrics/otlp_http_metric_exporter.js +4 -2
  72. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js +4 -2
  73. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +25 -5
  74. package/packages/dd-trace/src/opentelemetry/otlp/otlp_http_exporter_base.js +61 -25
  75. package/packages/dd-trace/src/opentelemetry/tracer.js +26 -2
  76. package/packages/dd-trace/src/opentracing/propagation/text_map.js +410 -342
  77. package/packages/dd-trace/src/opentracing/span.js +28 -0
  78. package/packages/dd-trace/src/otel-thread-ctx.js +23 -5
  79. package/packages/dd-trace/src/plugin_manager.js +2 -0
  80. package/packages/dd-trace/src/plugins/util/status-validator.js +98 -0
  81. package/packages/dd-trace/src/plugins/util/test.js +74 -21
  82. package/packages/dd-trace/src/plugins/util/web.js +2 -65
  83. package/packages/dd-trace/src/profiling/oom.js +4 -3
  84. package/packages/dd-trace/src/profiling/profiler.js +7 -0
  85. package/packages/dd-trace/src/profiling/profilers/events.js +3 -3
  86. package/packages/dd-trace/src/profiling/profilers/wall.js +14 -14
  87. package/packages/dd-trace/src/proxy.js +36 -8
  88. package/packages/dd-trace/src/runtime_metrics/index.js +5 -0
  89. package/packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js +5 -0
  90. package/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +17 -6
  91. package/packages/dd-trace/src/serverless/telemetry-delivery-tracker.js +55 -0
  92. package/packages/dd-trace/src/serverless/vercel.js +122 -0
  93. package/packages/dd-trace/src/serverless.js +37 -20
  94. package/packages/dd-trace/src/span_stats.js +40 -3
  95. package/packages/dd-trace/src/tracer.js +22 -0
  96. package/packages/dd-trace/src/web-tags-cache.js +67 -13
  97. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
@@ -2,11 +2,13 @@
2
2
 
3
3
  const log = require('../../log')
4
4
  const { ExperimentsClient } = require('./client')
5
- const { Dataset, DatasetRecord } = require('./dataset')
5
+ const { Dataset } = require('./dataset')
6
6
  const { Experiment, ExternalExperiment } = require('./experiment')
7
7
  const { validateTagsList } = require('./util')
8
8
  const NoopExperiments = require('./noop')
9
9
 
10
+ const DEFAULT_PROJECT_NAME = 'default-project'
11
+
10
12
  // Poll `attempt` with exponential backoff until it returns true or the time
11
13
  // budget is spent. Used for eventually-consistent reads (pullDataset).
12
14
  async function retryWithBackoff (attempt, { maxTotalMs = 30_000, baseDelayMs = 250, maxDelayMs = 8000 } = {}) {
@@ -33,8 +35,8 @@ class Experiments {
33
35
 
34
36
  constructor (config, llmobs) {
35
37
  this.#config = config
36
- this.#llmobs = llmobs
37
- this.#projectName = config.llmobs?.mlApp || config.service
38
+ this.#llmobs = config.llmobs?.mlApp || config.service ? llmobs : undefined
39
+ this.#projectName = config.llmobs?.projectName || DEFAULT_PROJECT_NAME
38
40
  this.#client = this.#clientForProject(this.#projectName)
39
41
  }
40
42
 
@@ -51,27 +53,26 @@ class Experiments {
51
53
  })
52
54
  }
53
55
 
56
+ /**
57
+ * @param {string | undefined} projectName
58
+ * @returns {ExperimentsClient}
59
+ */
60
+ #clientForOperation (projectName) {
61
+ if (projectName !== undefined && projectName !== this.#projectName) {
62
+ return this.#clientForProject(projectName)
63
+ }
64
+ if (this.#client === undefined) this.#client = this.#clientForProject(projectName)
65
+ return this.#client
66
+ }
67
+
54
68
  // Create a local dataset buffer. Pushed remotely on first experiment run.
55
69
  createDataset (name, descriptionOrOptions = '') {
56
70
  const options = typeof descriptionOrOptions === 'string'
57
71
  ? { description: descriptionOrOptions }
58
72
  : (descriptionOrOptions ?? {})
59
- const dataset = new Dataset(this.#client, name, options.description ?? '')
60
- const recordIds = new Set()
61
- if ((options.records) != null) {
62
- for (const record of options.records) {
63
- if (record.id !== undefined && (typeof record.id !== 'string' || record.id.length === 0)) {
64
- throw new Error('record id must be a non-empty string')
65
- }
66
- if (record.id !== undefined) {
67
- if (recordIds.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
68
- recordIds.add(record.id)
69
- }
70
- dataset.addRecord(
71
- new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id, record.tags)
72
- )
73
- }
74
- }
73
+ const client = this.#clientForOperation(options.projectName)
74
+ const dataset = new Dataset(client, name, options.description ?? '')
75
+ if ((options.records) != null) dataset.addRecords(options.records)
75
76
  return dataset
76
77
  }
77
78
 
@@ -80,9 +81,11 @@ class Experiments {
80
81
  // wait until that many records are readable. Pass `tags` to filter records by
81
82
  // dataset record tags.
82
83
  async pullDataset (name, options = {}) {
83
- const { expectedRecordCount, maxWaitMs = 30_000, tags, version } = options
84
+ const { expectedRecordCount, maxWaitMs = 30_000, projectName, tags, version } = options
84
85
  const filterTags = validateTagsList(tags)
85
- const projectId = await this.#client.ensureProjectId()
86
+ const client = this.#clientForOperation(projectName)
87
+ const resolvedProjectName = projectName ?? this.#projectName
88
+ const projectId = await client.ensureProjectId()
86
89
 
87
90
  let pulledDataset = null
88
91
  let records = []
@@ -93,7 +96,7 @@ class Experiments {
93
96
  const succeeded = await retryWithBackoff(async () => {
94
97
  try {
95
98
  if (pulledDataset === null) {
96
- const datasets = await this.#client.listDatasets(projectId, { name })
99
+ const datasets = await client.listDatasets(projectId, { name })
97
100
  for (const dataset of datasets) {
98
101
  if (dataset.name() === name) {
99
102
  pulledDataset = dataset
@@ -109,7 +112,7 @@ class Experiments {
109
112
  // Follow the meta.after / page[cursor] pagination until the last page.
110
113
  for (;;) {
111
114
  // eslint-disable-next-line no-await-in-loop
112
- const page = await this.#client.listDatasetRecords(projectId, pulledDataset.id(), {
115
+ const page = await client.listDatasetRecords(projectId, pulledDataset.id(), {
113
116
  cursor,
114
117
  tags: filterTags,
115
118
  version: datasetVersion,
@@ -129,13 +132,13 @@ class Experiments {
129
132
  }, { maxTotalMs: maxWaitMs })
130
133
 
131
134
  if (pulledDataset === null && lastError) {
132
- throw new Error(`Failed to list datasets in project '${this.#projectName}': ${lastError}`)
135
+ throw new Error(`Failed to list datasets in project '${resolvedProjectName}': ${lastError}`)
133
136
  }
134
137
  if (pulledDataset === null) {
135
- throw new Error(`Dataset '${name}' not found in project '${this.#projectName}' (after ${maxWaitMs}ms)`)
138
+ throw new Error(`Dataset '${name}' not found in project '${resolvedProjectName}' (after ${maxWaitMs}ms)`)
136
139
  }
137
140
  if (!succeeded && lastError) {
138
- throw new Error(`Failed to fetch records for dataset '${name}' in project '${this.#projectName}': ${lastError}`)
141
+ throw new Error(`Failed to fetch records for dataset '${name}' in project '${resolvedProjectName}': ${lastError}`)
139
142
  }
140
143
  if (!succeeded && expectedRecordCount != null) {
141
144
  throw new Error(
@@ -151,7 +154,7 @@ class Experiments {
151
154
  }
152
155
 
153
156
  return Dataset.fromExisting(
154
- this.#client,
157
+ client,
155
158
  name,
156
159
  pulledDataset.description(),
157
160
  pulledDataset.id(),
@@ -163,9 +166,26 @@ class Experiments {
163
166
  )
164
167
  }
165
168
 
166
- // Build an experiment: { name, dataset, task, evaluators, description?, config?, tags? }.
169
+ // Build an experiment with a dataset, task, evaluators, and optional project/config/tags.
167
170
  experiment (options) {
168
- return new Experiment(this.#client, options, this.#llmobs)
171
+ const datasetProjectName = options?.dataset?.projectName?.()
172
+ if (options?.projectName !== undefined &&
173
+ datasetProjectName !== undefined &&
174
+ options.projectName !== datasetProjectName) {
175
+ throw new Error(
176
+ `Experiment project '${options.projectName}' does not match dataset project '${datasetProjectName}'`
177
+ )
178
+ }
179
+ const projectName = options?.projectName ?? datasetProjectName
180
+ const client = this.#clientForOperation(projectName)
181
+ const usesDatasetOverride = datasetProjectName !== undefined && datasetProjectName !== this.#projectName
182
+ const resolvedProjectName = projectName ?? this.#config.llmobs?.projectName
183
+ const experimentOptions = options?.projectName === undefined &&
184
+ (usesDatasetOverride || this.#config.llmobs?.projectName !== undefined) &&
185
+ resolvedProjectName !== undefined
186
+ ? { ...options, projectName: resolvedProjectName }
187
+ : options
188
+ return new Experiment(client, experimentOptions, this.#llmobs)
169
189
  }
170
190
 
171
191
  /**
@@ -177,10 +197,11 @@ class Experiments {
177
197
  * @returns {Promise<ExternalExperiment>}
178
198
  */
179
199
  startExperiment (options) {
180
- const client = options?.projectName === undefined || options.projectName === this.#projectName
181
- ? this.#client
182
- : this.#clientForProject(options.projectName)
183
- return new Experiment(client, { ...options, external: true }).start()
200
+ const client = this.#clientForOperation(options?.projectName)
201
+ const experimentOptions = options?.projectName === undefined && this.#config.llmobs?.projectName !== undefined
202
+ ? { ...options, projectName: this.#config.llmobs.projectName }
203
+ : options
204
+ return new Experiment(client, { ...experimentOptions, external: true }).start()
184
205
  .then(experiment => new ExternalExperiment(experiment))
185
206
  }
186
207
  }
@@ -195,15 +216,6 @@ function createExperiments (config, llmobs) {
195
216
  log.warn('LLMObs experiments: missing api and/or app keys, set DD_API_KEY and DD_APP_KEY')
196
217
  return new NoopExperiments('DD_API_KEY and DD_APP_KEY are required for experiments')
197
218
  }
198
- if (!config.llmobs?.mlApp && !config.service) {
199
- const reason = 'no project name configured; set the DD_LLMOBS_ML_APP environment variable (or llmobs.mlApp in ' +
200
- 'tracer.init()) to name the LLM Obs project, or DD_SERVICE (or service in tracer.init()) as a fallback, ' +
201
- 'then retry'
202
- const experiments = new Experiments(config, llmobs)
203
- return new NoopExperiments(reason, {
204
- startExperiment: (options) => experiments.startExperiment(options),
205
- })
206
- }
207
219
  return new Experiments(config, llmobs)
208
220
  }
209
221
 
@@ -7,6 +7,15 @@ const NOOP_EXPERIMENT_ID = '00000000-0000-0000-0000-000000000000'
7
7
  const NOOP_SPAN_ID = '0000000000000000'
8
8
  const NOOP_TRACE_ID = '00000000000000000000000000000000'
9
9
 
10
+ /**
11
+ * @typedef {object} DatasetRecordNew
12
+ * @property {string} [id]
13
+ * @property {unknown} inputData
14
+ * @property {unknown} [expectedOutput]
15
+ * @property {Record<string, unknown>} [metadata]
16
+ * @property {string[]} [tags]
17
+ */
18
+
10
19
  class NoopDataset {
11
20
  #name
12
21
  #description
@@ -37,6 +46,24 @@ class NoopDataset {
37
46
  return this
38
47
  }
39
48
 
49
+ /**
50
+ * Add multiple records to a dataset.
51
+ * @param {DatasetRecordNew[]} records
52
+ * @returns {NoopDataset} This dataset for chaining.
53
+ */
54
+ addRecords (records) {
55
+ for (const record of records) {
56
+ this.#records.push({
57
+ id: record.id ?? null,
58
+ input: record.inputData,
59
+ expectedOutput: record.expectedOutput ?? null,
60
+ metadata: record.metadata ?? {},
61
+ tags: [...(record.tags ?? [])],
62
+ })
63
+ }
64
+ return this
65
+ }
66
+
40
67
  update (index, fields) {
41
68
  const record = this.#records[index]
42
69
  if (record == null) return this
@@ -114,6 +141,10 @@ class NoopDataset {
114
141
  return null
115
142
  }
116
143
 
144
+ projectName () {
145
+ return null
146
+ }
147
+
117
148
  version () {
118
149
  return null
119
150
  }
@@ -165,7 +196,13 @@ class NoopExperiment {
165
196
  }
166
197
 
167
198
  run () {
168
- return Promise.resolve({ experimentId: null, rows: [], url: null })
199
+ return Promise.resolve({
200
+ experimentId: null,
201
+ rows: [],
202
+ summaryEvaluations: {},
203
+ runs: [],
204
+ url: null,
205
+ })
169
206
  }
170
207
 
171
208
  /**
@@ -200,11 +237,9 @@ class NoopExperiment {
200
237
  // throwing, so intentionally disabled experiments remain graceful.
201
238
  class NoopExperiments {
202
239
  #reason
203
- #startExperiment
204
240
 
205
- constructor (reason, options = {}) {
241
+ constructor (reason) {
206
242
  this.#reason = reason || 'LLMObs experiments are not available'
207
- this.#startExperiment = options.startExperiment
208
243
  }
209
244
 
210
245
  #warn () {
@@ -231,10 +266,6 @@ class NoopExperiments {
231
266
  * @returns {Promise<ExternalExperiment>}
232
267
  */
233
268
  startExperiment (options = {}) {
234
- if (this.#startExperiment !== undefined && options.projectName) {
235
- return this.#startExperiment(options)
236
- }
237
-
238
269
  this.#warn()
239
270
  return Promise.resolve(new ExternalExperiment(new NoopExperiment(options.name, true)))
240
271
  }
@@ -27,6 +27,7 @@ class ExperimentRun {
27
27
  constructor (fields) {
28
28
  this.runId = fields.runId
29
29
  this.runIteration = fields.runIteration
30
+ this.hasError = fields.hasError
30
31
  this.rows = fields.rows
31
32
  this.summaryEvaluations = fields.summaryEvaluations
32
33
  }
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { randomUUID } = require('node:crypto')
4
+
3
5
  const log = require('../../log')
4
6
 
5
7
  // Matches the backend and dd-trace-py evaluator metric label contract.
@@ -42,6 +44,23 @@ function tagOperationsAreEmpty (operations) {
42
44
  )
43
45
  }
44
46
 
47
+ /**
48
+ * @param {unknown} value
49
+ * @param {string} name
50
+ * @returns {number}
51
+ */
52
+ function normalizePositiveInteger (value, name) {
53
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`)
54
+ return value
55
+ }
56
+
57
+ /**
58
+ * @returns {string}
59
+ */
60
+ function generateRunId () {
61
+ return randomUUID()
62
+ }
63
+
45
64
  /**
46
65
  * @param {string} name
47
66
  */
@@ -297,11 +316,13 @@ module.exports = {
297
316
  buildSpanMetadata,
298
317
  buildTags,
299
318
  durationNs,
319
+ generateRunId,
300
320
  hasEntries,
301
321
  inferMetricType,
302
322
  mergeTags,
303
323
  normalizeEvaluators,
304
324
  normalizeJsonMetricValue,
325
+ normalizePositiveInteger,
305
326
  recordTagsToObject,
306
327
  sleep,
307
328
  stringify,
@@ -3,7 +3,9 @@
3
3
  const { channel } = require('dc-polyfill')
4
4
 
5
5
  const { readDatadogTags, writeDatadogTags } = require('../carrier')
6
+ const { registerTelemetryFlusher } = require('../flush')
6
7
  const log = require('../log')
8
+ const { createServerlessDeliveryTracker } = require('../serverless')
7
9
  const { DD_MAJOR } = require('../../../../version')
8
10
  const startupLogs = require('../startup-log')
9
11
  const {
@@ -57,6 +59,8 @@ let spanWriter
57
59
  /** @type {LLMObsEvalMetricsWriter | null} */
58
60
  let evalWriter
59
61
 
62
+ let unregisterTelemetryFlusher
63
+
60
64
  /** @type {import('../config/config-base')} */
61
65
  let globalTracerConfig
62
66
 
@@ -66,23 +70,36 @@ let globalTracerConfig
66
70
  function enable (config) {
67
71
  globalTracerConfig = config
68
72
 
73
+ const retiredSpanWriter = spanWriter
74
+ const retiredEvalWriter = evalWriter
75
+ const isReinitializing = Boolean(retiredSpanWriter || retiredEvalWriter)
76
+ unregisterTelemetryFlusher?.()
77
+ retireWriters(retiredSpanWriter, retiredEvalWriter)
78
+
69
79
  const startTime = performance.now()
70
80
  // create writers and eval writer append and flush channels
71
81
  // span writer append is handled by the span processor
72
82
  evalWriter = new LLMObsEvalMetricsWriter(config)
73
83
  spanWriter = new LLMObsSpanWriter(config)
84
+ const currentEvalWriter = evalWriter
85
+ const currentSpanWriter = spanWriter
86
+ unregisterTelemetryFlusher = registerTelemetryFlusher(done => {
87
+ flushWriters(done, currentSpanWriter, currentEvalWriter)
88
+ })
74
89
 
75
- evalMetricAppendCh.subscribe(handleEvalMetricAppend)
76
- flushCh.subscribe(handleFlush)
77
- registerUserSpanProcessorCh.subscribe(handleRegisterProcessor)
90
+ if (!isReinitializing) {
91
+ evalMetricAppendCh.subscribe(handleEvalMetricAppend)
92
+ flushCh.subscribe(handleFlush)
93
+ registerUserSpanProcessorCh.subscribe(handleRegisterProcessor)
94
+ }
78
95
 
79
96
  // span processing
80
97
  spanProcessor = new LLMObsSpanProcessor(config)
81
98
  spanProcessor.setWriter(spanWriter)
82
- spanFinishCh.subscribe(handleSpanProcess)
99
+ if (!isReinitializing) spanFinishCh.subscribe(handleSpanProcess)
83
100
 
84
101
  // distributed tracing for llmobs
85
- injectCh.subscribe(handleLLMObsInjection)
102
+ if (!isReinitializing) injectCh.subscribe(handleLLMObsInjection)
86
103
 
87
104
  setAgentStrategy(config, useAgentless => {
88
105
  if (useAgentless && !(config.DD_API_KEY && config.site)) {
@@ -94,8 +111,10 @@ function enable (config) {
94
111
  }
95
112
  }
96
113
 
97
- evalWriter?.setAgentless(useAgentless)
98
- spanWriter?.setAgentless(useAgentless)
114
+ // A disable can happen while transport selection is still pending. Keep
115
+ // configuring these writers so their queued lifecycle flushes can drain.
116
+ currentEvalWriter.setAgentless(useAgentless)
117
+ currentSpanWriter.setAgentless(useAgentless)
99
118
 
100
119
  telemetry.recordLLMObsEnabled(startTime, config)
101
120
  log.debug('[LLMObs] Enabled LLM Observability with configuration: %o', config.llmobs)
@@ -109,16 +128,39 @@ function disable () {
109
128
  if (injectCh.hasSubscribers) injectCh.unsubscribe(handleLLMObsInjection)
110
129
  if (registerUserSpanProcessorCh.hasSubscribers) registerUserSpanProcessorCh.unsubscribe(handleRegisterProcessor)
111
130
 
112
- spanWriter?.destroy()
113
- evalWriter?.destroy()
131
+ const retiredSpanWriter = spanWriter
132
+ const retiredEvalWriter = evalWriter
114
133
  spanProcessor?.setWriter(null)
134
+ unregisterTelemetryFlusher?.()
135
+ unregisterTelemetryFlusher = undefined
115
136
 
116
137
  spanWriter = null
117
138
  evalWriter = null
118
139
 
140
+ retireWriters(retiredSpanWriter, retiredEvalWriter)
141
+
119
142
  log.debug('[LLMObs] Disabled LLM Observability')
120
143
  }
121
144
 
145
+ /**
146
+ * Keeps retired writers reachable until their destroy-triggered deliveries complete.
147
+ * @param {LLMObsSpanWriter | null} retiredSpanWriter
148
+ * @param {LLMObsEvalMetricsWriter | null} retiredEvalWriter
149
+ * @returns {void}
150
+ */
151
+ function retireWriters (retiredSpanWriter, retiredEvalWriter) {
152
+ const retiredWriters = [retiredSpanWriter, retiredEvalWriter].filter(Boolean)
153
+ if (retiredWriters.length === 0) return
154
+ let remainingWriters = retiredWriters.length
155
+ const unregisterRetiredFlusher = registerTelemetryFlusher(done => {
156
+ flushWriters(done, retiredSpanWriter, retiredEvalWriter)
157
+ })
158
+ function onWriterDestroyed () {
159
+ if (--remainingWriters === 0) unregisterRetiredFlusher?.()
160
+ }
161
+ for (const writer of retiredWriters) writer.destroy(onWriterDestroyed)
162
+ }
163
+
122
164
  // since LLMObs traces can extend between services and be the same trace,
123
165
  // we need to propagate the parent id, mlApp, session id, and sampling rate/decision.
124
166
  function handleLLMObsInjection ({ carrier }) {
@@ -192,15 +234,36 @@ function handleLLMObsInjection ({ carrier }) {
192
234
  if (tags !== existing) writeDatadogTags(carrier, tags)
193
235
  }
194
236
 
195
- function handleFlush () {
196
- let err = ''
197
- try {
198
- spanWriter.flush()
199
- evalWriter.flush()
200
- } catch (e) {
201
- err = 'writer_flush_error'
202
- log.warn('Failed to flush LLMObs spans and evaluation metrics:', e.message)
237
+ /**
238
+ * Flushes the specified LLMObs writers and joins deliveries active at the boundary.
239
+ * @param {Function} [done]
240
+ * @param {LLMObsSpanWriter | null} [currentSpanWriter]
241
+ * @param {LLMObsEvalMetricsWriter | null} [currentEvalWriter]
242
+ * @returns {boolean} `true` when a writer throws synchronously.
243
+ */
244
+ function flushWriters (done, currentSpanWriter = spanWriter, currentEvalWriter = evalWriter) {
245
+ let failed = false
246
+ const deliveryTracker = createServerlessDeliveryTracker()
247
+ const flush = writer => {
248
+ try {
249
+ if (deliveryTracker && writer) deliveryTracker.track(complete => writer.flush(complete))
250
+ // Non-serverless flushes retain the existing writer behavior.
251
+ else writer?.flush()
252
+ } catch (error) {
253
+ failed = true
254
+ log.warn('Failed to flush LLMObs writer:', error.message)
255
+ }
203
256
  }
257
+
258
+ flush(currentSpanWriter)
259
+ flush(currentEvalWriter)
260
+ deliveryTracker?.waitForIdle(done)
261
+ if (!deliveryTracker) done?.()
262
+ return failed
263
+ }
264
+
265
+ function handleFlush () {
266
+ const err = flushWriters() ? 'writer_flush_error' : ''
204
267
  telemetry.recordUserFlush(err)
205
268
  }
206
269
 
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const NoopExperiments = require('./experiments/noop')
3
+ let NoopExperiments
4
4
 
5
5
  class NoopLLMObs {
6
6
  constructor (noopTracer) {
@@ -12,6 +12,7 @@ class NoopLLMObs {
12
12
  }
13
13
 
14
14
  get experiments () {
15
+ NoopExperiments ??= require('./experiments/noop')
15
16
  return new NoopExperiments('LLM Observability is not enabled')
16
17
  }
17
18
 
@@ -38,16 +38,20 @@ function formatAnthropicToolResultContent (content) {
38
38
  if (typeof content === 'string') {
39
39
  return content
40
40
  } else if (Array.isArray(content)) {
41
- const formattedContent = []
41
+ let formattedContent = ''
42
42
  for (const toolResultBlock of content) {
43
+ let part
43
44
  if (toolResultBlock.text) {
44
- formattedContent.push(toolResultBlock.text)
45
+ part = toolResultBlock.text
45
46
  } else if (toolResultBlock.type === 'image') {
46
- formattedContent.push('([IMAGE DETECTED])')
47
+ part = '([IMAGE DETECTED])'
47
48
  }
49
+ if (part === undefined) continue
50
+ if (formattedContent) formattedContent += ','
51
+ formattedContent += part
48
52
  }
49
53
 
50
- return formattedContent.join(',')
54
+ return formattedContent
51
55
  }
52
56
  return JSON.stringify(content)
53
57
  }
@@ -18,12 +18,15 @@ function getToolOutputText (raw) {
18
18
  if (raw == null) return
19
19
 
20
20
  if (Array.isArray(raw)) {
21
- const output = []
21
+ let output = ''
22
+ let separator = ''
22
23
  for (const block of raw) {
23
24
  const text = getToolOutputText(block)
24
- if (text) output.push(text)
25
+ if (!text) continue
26
+ output += separator + text
27
+ separator = '\n'
25
28
  }
26
- return output.join('\n') || undefined
29
+ return output
27
30
  }
28
31
 
29
32
  if (raw.type === 'tool_result') return getToolOutputText(raw.content)
@@ -134,15 +134,17 @@ function hasMultimodalInputs (variables) {
134
134
  * @returns {{ content: string, audioParts: Array<{ mimeType: string, content: string }> }}
135
135
  */
136
136
  function extractContentParts (parts) {
137
- const extracted = []
137
+ let content = ''
138
+ let hasContent = false
138
139
  const audioParts = []
139
140
 
140
141
  for (const part of parts) {
141
142
  const partType = part?.type ?? ''
143
+ let extracted
142
144
  if (partType === 'text') {
143
- extracted.push(part.text ?? '')
145
+ extracted = part.text ?? ''
144
146
  } else if (partType === 'image_url') {
145
- extracted.push(IMAGE_FALLBACK)
147
+ extracted = IMAGE_FALLBACK
146
148
  } else if (partType === 'input_audio') {
147
149
  const inputAudio = part.input_audio ?? {}
148
150
  const data = inputAudio.data
@@ -151,14 +153,19 @@ function extractContentParts (parts) {
151
153
  // is needed. Only fall back to "[audio]" when there's no audio to capture.
152
154
  audioParts.push(formatAudioPart(data, audioMimeTypeFromFormat(inputAudio.format, AUDIO_MIME_TYPES)))
153
155
  } else {
154
- extracted.push(AUDIO_FALLBACK)
156
+ extracted = AUDIO_FALLBACK
155
157
  }
156
158
  } else {
157
- extracted.push(`[${partType}]`)
159
+ extracted = `[${partType}]`
158
160
  }
161
+
162
+ if (extracted === undefined) continue
163
+ if (hasContent) content += '\n'
164
+ content += extracted
165
+ hasContent = true
159
166
  }
160
167
 
161
- return { content: extracted.join('\n'), audioParts }
168
+ return { content, audioParts }
162
169
  }
163
170
 
164
171
  /**
@@ -11,23 +11,23 @@ const { safeJsonParse } = require('../../util')
11
11
  * @returns {{ content: string, audioParts: Array<{ mimeType: string, content: string }> }}
12
12
  */
13
13
  function extractMessageContent (parts) {
14
- const contentParts = []
14
+ let content = ''
15
15
  const audioParts = []
16
16
 
17
17
  for (const part of parts) {
18
18
  if (!part) continue
19
19
  const text = extractTextFromContentItem(part)
20
20
  if (text) {
21
- contentParts.push(text)
21
+ content += text
22
22
  continue
23
23
  }
24
24
 
25
25
  const extracted = extractContentParts([part])
26
- if (extracted.content) contentParts.push(extracted.content)
26
+ if (extracted.content) content += extracted.content
27
27
  if (extracted.audioParts.length > 0) audioParts.push(...extracted.audioParts)
28
28
  }
29
29
 
30
- return { content: contentParts.join(''), audioParts }
30
+ return { content, audioParts }
31
31
  }
32
32
 
33
33
  /**
@@ -167,13 +167,11 @@ function extractOutputMessages (result) {
167
167
  if (item.type === 'message') {
168
168
  let content = ''
169
169
  if (Array.isArray(item.content)) {
170
- const textParts = []
171
170
  for (const contentItem of item.content) {
172
171
  if (contentItem?.type === 'output_text' && contentItem.text) {
173
- textParts.push(contentItem.text)
172
+ content += contentItem.text
174
173
  }
175
174
  }
176
- content = textParts.join('')
177
175
  } else if (typeof item.content === 'string') {
178
176
  content = item.content
179
177
  }