dd-trace 5.75.0 → 5.76.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.
package/ci/init.js CHANGED
@@ -29,6 +29,7 @@ function detectTestWorkerType () {
29
29
  if (getEnvironmentVariable('MOCHA_WORKER_ID')) return 'mocha'
30
30
  if (getEnvironmentVariable('DD_PLAYWRIGHT_WORKER')) return 'playwright'
31
31
  if (getEnvironmentVariable('TINYPOOL_WORKER_ID')) return 'vitest'
32
+ if (getEnvironmentVariable('DD_VITEST_WORKER')) return 'vitest'
32
33
  return null
33
34
  }
34
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dd-trace",
3
- "version": "5.75.0",
3
+ "version": "5.76.0",
4
4
  "description": "Datadog APM tracing client for JavaScript",
5
5
  "main": "index.js",
6
6
  "typings": "index.d.ts",
@@ -125,8 +125,8 @@
125
125
  "@datadog/native-appsec": "10.3.0",
126
126
  "@datadog/native-iast-taint-tracking": "4.0.0",
127
127
  "@datadog/native-metrics": "3.1.1",
128
- "@datadog/openfeature-node-server": "0.1.0-preview.12",
129
- "@datadog/pprof": "5.11.1",
128
+ "@datadog/openfeature-node-server": "0.1.0-preview.13",
129
+ "@datadog/pprof": "5.12.0",
130
130
  "@datadog/sketches-js": "2.1.1",
131
131
  "@datadog/wasm-js-rewriter": "4.0.1",
132
132
  "@isaacs/ttlcache": "^1.4.1",
@@ -22,6 +22,14 @@ const V4_PACKAGE_SHIMS = [
22
22
  methods: ['create'],
23
23
  streamedResponse: true
24
24
  },
25
+ {
26
+ file: 'resources/responses/responses',
27
+ targetClass: 'Responses',
28
+ baseResource: 'responses',
29
+ methods: ['create'],
30
+ streamedResponse: true,
31
+ versions: ['>=4.87.0']
32
+ },
25
33
  {
26
34
  file: 'resources/embeddings',
27
35
  targetClass: 'Embeddings',
@@ -68,6 +68,8 @@ let modifiedFiles = {}
68
68
  const quarantinedOrDisabledTestsAttemptToFix = []
69
69
  let quarantinedButNotAttemptToFixFqns = new Set()
70
70
  let rootDir = ''
71
+ let sessionProjects = []
72
+
71
73
  const MINIMUM_SUPPORTED_VERSION_RANGE_EFD = '>=1.38.0' // TODO: remove this once we drop support for v5
72
74
 
73
75
  function isValidKnownTests (receivedKnownTests) {
@@ -495,6 +497,7 @@ function dispatcherHook (dispatcherExport) {
495
497
  const dispatcher = this
496
498
  const worker = createWorker.apply(this, arguments)
497
499
  const projects = getProjectsFromDispatcher(dispatcher)
500
+ sessionProjects = projects
498
501
 
499
502
  // for older versions of playwright, `shouldCreateTestSpan` should always be true,
500
503
  // since the `_runTest` function wrapper is not available for older versions
@@ -535,6 +538,7 @@ function dispatcherHookNew (dispatcherExport, runWrapper) {
535
538
  const dispatcher = this
536
539
  const worker = createWorker.apply(this, arguments)
537
540
  const projects = getProjectsFromDispatcher(dispatcher)
541
+ sessionProjects = projects
538
542
 
539
543
  worker.on('testBegin', ({ testId }) => {
540
544
  const test = getTestByTestId(dispatcher, testId)
@@ -1255,3 +1259,46 @@ addHook({
1255
1259
 
1256
1260
  return workerPackage
1257
1261
  })
1262
+
1263
+ function generateSummaryWrapper (generateSummary) {
1264
+ return function () {
1265
+ for (const test of this.suite.allTests()) {
1266
+ // https://github.com/microsoft/playwright/blob/bf92ffecff6f30a292b53430dbaee0207e0c61ad/packages/playwright/src/reporters/base.ts#L279
1267
+ const didNotRun = test.outcome() === 'skipped' &&
1268
+ (!test.results.length || test.expectedStatus !== 'skipped')
1269
+ if (didNotRun) {
1270
+ const {
1271
+ _requireFile: testSuiteAbsolutePath,
1272
+ location: { line: testSourceLine },
1273
+ } = test
1274
+ const browserName = getBrowserNameFromProjects(sessionProjects, test)
1275
+
1276
+ testSkipCh.publish({
1277
+ testName: getTestFullname(test),
1278
+ testSuiteAbsolutePath,
1279
+ testSourceLine,
1280
+ browserName,
1281
+ })
1282
+ }
1283
+ }
1284
+ return generateSummary.apply(this, arguments)
1285
+ }
1286
+ }
1287
+
1288
+ // If a playwright project B has a dependency on project A,
1289
+ // and project A fails, the tests in project B will not run.
1290
+ // This hook is used to report tests that did not run as skipped.
1291
+ // Note: this is different from tests skipped via test.skip() or test.fixme()
1292
+ addHook({
1293
+ name: 'playwright',
1294
+ file: 'lib/reporters/base.js',
1295
+ versions: ['>=1.38.0']
1296
+ }, (reportersPackage) => {
1297
+ // v1.50.0 changed the name of the base reporter from BaseReporter to TerminalReporter
1298
+ if (reportersPackage.TerminalReporter) {
1299
+ shimmer.wrap(reportersPackage.TerminalReporter.prototype, 'generateSummary', generateSummaryWrapper)
1300
+ } else if (reportersPackage.BaseReporter) {
1301
+ shimmer.wrap(reportersPackage.BaseReporter.prototype, 'generateSummary', generateSummaryWrapper)
1302
+ }
1303
+ return reportersPackage
1304
+ })
@@ -60,6 +60,7 @@ let testManagementAttemptToFixRetries = 0
60
60
  let isDiEnabled = false
61
61
  let testCodeCoverageLinesTotal
62
62
  let isSessionStarted = false
63
+ let vitestPool = null
63
64
 
64
65
  const BREAKPOINT_HIT_GRACE_PERIOD_MS = 400
65
66
 
@@ -157,6 +158,14 @@ function isTestPackage (testPackage) {
157
158
  return testPackage.V?.name === 'VitestTestRunner'
158
159
  }
159
160
 
161
+ function hasForksPoolWorker (vitestPackage) {
162
+ return vitestPackage.f?.name === 'ForksPoolWorker'
163
+ }
164
+
165
+ function hasThreadsPoolWorker (vitestPackage) {
166
+ return vitestPackage.T?.name === 'ThreadsPoolWorker'
167
+ }
168
+
160
169
  function getSessionStatus (state) {
161
170
  if (state.getCountOfFailedTests() > 0) {
162
171
  return 'fail'
@@ -389,6 +398,7 @@ function getFinishWrapper (exitOrClose) {
389
398
  isEarlyFlakeDetectionEnabled,
390
399
  isEarlyFlakeDetectionFaulty,
391
400
  isTestManagementTestsEnabled,
401
+ vitestPool,
392
402
  onFinish
393
403
  })
394
404
 
@@ -418,11 +428,27 @@ function getCreateCliWrapper (vitestPackage, frameworkVersion) {
418
428
  }
419
429
 
420
430
  function threadHandler (thread) {
421
- if (workerProcesses.has(thread.process)) {
431
+ const { runtime } = thread
432
+ let workerProcess
433
+ if (runtime === 'child_process') {
434
+ vitestPool = 'child_process'
435
+ workerProcess = thread.process
436
+ } else if (runtime === 'worker_threads') {
437
+ vitestPool = 'worker_threads'
438
+ workerProcess = thread.thread
439
+ } else {
440
+ vitestPool = 'unknown'
441
+ }
442
+ if (!workerProcess) {
443
+ log.error('Vitest error: could not get process or thread from TinyPool#run')
422
444
  return
423
445
  }
424
- workerProcesses.add(thread.process)
425
- thread.process.on('message', (message) => {
446
+
447
+ if (workerProcesses.has(workerProcess)) {
448
+ return
449
+ }
450
+ workerProcesses.add(workerProcess)
451
+ workerProcess.on('message', (message) => {
426
452
  if (message.__tinypool_worker_message__ && message.data) {
427
453
  if (message.interprocessCode === VITEST_WORKER_TRACE_PAYLOAD_CODE) {
428
454
  workerReportTraceCh.publish(message.data)
@@ -433,11 +459,7 @@ function threadHandler (thread) {
433
459
  })
434
460
  }
435
461
 
436
- addHook({
437
- name: 'tinypool',
438
- versions: ['>=1.0.0'],
439
- file: 'dist/index.js'
440
- }, (TinyPool) => {
462
+ function wrapTinyPoolRun (TinyPool) {
441
463
  shimmer.wrap(TinyPool.prototype, 'run', run => async function () {
442
464
  // We have to do this before and after because the threads list gets recycled, that is, the processes are re-created
443
465
  this.threads.forEach(threadHandler)
@@ -445,15 +467,79 @@ addHook({
445
467
  this.threads.forEach(threadHandler)
446
468
  return runResult
447
469
  })
470
+ }
471
+
472
+ addHook({
473
+ name: 'tinypool',
474
+ // version from tinypool@0.8 was used in vitest@1.6.0
475
+ versions: ['>=0.8.0 <1.0.0'],
476
+ file: 'dist/esm/index.js'
477
+ }, (TinyPool) => {
478
+ wrapTinyPoolRun(TinyPool)
479
+ return TinyPool
480
+ })
481
+
482
+ addHook({
483
+ name: 'tinypool',
484
+ versions: ['>=1.0.0'],
485
+ file: 'dist/index.js'
486
+ }, (TinyPool) => {
487
+ wrapTinyPoolRun(TinyPool)
448
488
 
449
489
  return TinyPool
450
490
  })
451
491
 
492
+ function getWrappedOn (on) {
493
+ return function (event, callback) {
494
+ if (event !== 'message') {
495
+ return on.apply(this, arguments)
496
+ }
497
+ // `arguments[1]` is the callback function, which
498
+ // we modify to intercept our messages to not interfere
499
+ // with vitest's own messages
500
+ arguments[1] = shimmer.wrapFunction(callback, callback => function (message) {
501
+ if (message.type !== 'Buffer' && Array.isArray(message)) {
502
+ const [interprocessCode, data] = message
503
+ if (interprocessCode === VITEST_WORKER_TRACE_PAYLOAD_CODE) {
504
+ workerReportTraceCh.publish(data)
505
+ } else if (interprocessCode === VITEST_WORKER_LOGS_PAYLOAD_CODE) {
506
+ workerReportLogsCh.publish(data)
507
+ }
508
+ // If we execute the callback vitest crashes, as the message is not supported
509
+ return
510
+ }
511
+ return callback.apply(this, arguments)
512
+ })
513
+ return on.apply(this, arguments)
514
+ }
515
+ }
516
+
452
517
  function getStartVitestWrapper (cliApiPackage, frameworkVersion) {
453
518
  if (!isCliApiPackage(cliApiPackage)) {
454
519
  return cliApiPackage
455
520
  }
456
521
  shimmer.wrap(cliApiPackage, 's', getCliOrStartVitestWrapper(frameworkVersion))
522
+
523
+ if (hasForksPoolWorker(cliApiPackage)) {
524
+ // function is async
525
+ shimmer.wrap(cliApiPackage.f.prototype, 'start', start => function () {
526
+ vitestPool = 'child_process'
527
+ this.env.DD_VITEST_WORKER = '1'
528
+
529
+ return start.apply(this, arguments)
530
+ })
531
+ shimmer.wrap(cliApiPackage.f.prototype, 'on', getWrappedOn)
532
+ }
533
+
534
+ if (hasThreadsPoolWorker(cliApiPackage)) {
535
+ // function is async
536
+ shimmer.wrap(cliApiPackage.T.prototype, 'start', start => function () {
537
+ vitestPool = 'worker_threads'
538
+ this.env.DD_VITEST_WORKER = '1'
539
+ return start.apply(this, arguments)
540
+ })
541
+ shimmer.wrap(cliApiPackage.T.prototype, 'on', getWrappedOn)
542
+ }
457
543
  return cliApiPackage
458
544
  }
459
545
 
@@ -107,8 +107,33 @@ function constructChatCompletionResponseFromStreamedChunks (chunks, n) {
107
107
  })
108
108
  }
109
109
 
110
+ /**
111
+ * Constructs the entire response from a stream of OpenAI responses chunks.
112
+ * The responses API uses event-based streaming with delta chunks.
113
+ * @param {Array<Record<string, any>>} chunks
114
+ * @returns {Record<string, any>}
115
+ */
116
+ function constructResponseResponseFromStreamedChunks (chunks) {
117
+ // The responses API streams events with different types:
118
+ // - response.output_text.delta: incremental text deltas
119
+ // - response.output_text.done: complete text for a content part
120
+ // - response.output_item.done: complete output item with role
121
+ // - response.done/response.incomplete/response.completed: final response with output array and usage
122
+
123
+ // Find the last chunk with a complete response object (status: done, incomplete, or completed)
124
+ const responseStatusSet = new Set(['done', 'incomplete', 'completed'])
125
+
126
+ for (let i = chunks.length - 1; i >= 0; i--) {
127
+ const chunk = chunks[i]
128
+ if (chunk.response && responseStatusSet.has(chunk.response.status)) {
129
+ return chunk.response
130
+ }
131
+ }
132
+ }
133
+
110
134
  module.exports = {
111
135
  convertBuffersToObjects,
112
136
  constructCompletionResponseFromStreamedChunks,
113
- constructChatCompletionResponseFromStreamedChunks
137
+ constructChatCompletionResponseFromStreamedChunks,
138
+ constructResponseResponseFromStreamedChunks
114
139
  }
@@ -11,7 +11,8 @@ const { MEASURED } = require('../../../ext/tags')
11
11
  const {
12
12
  convertBuffersToObjects,
13
13
  constructCompletionResponseFromStreamedChunks,
14
- constructChatCompletionResponseFromStreamedChunks
14
+ constructChatCompletionResponseFromStreamedChunks,
15
+ constructResponseResponseFromStreamedChunks
15
16
  } = require('./stream-helpers')
16
17
 
17
18
  const { DD_MAJOR } = require('../../../version')
@@ -59,6 +60,8 @@ class OpenAiTracingPlugin extends TracingPlugin {
59
60
  response = constructCompletionResponseFromStreamedChunks(chunks, n)
60
61
  } else if (methodName === 'createChatCompletion') {
61
62
  response = constructChatCompletionResponseFromStreamedChunks(chunks, n)
63
+ } else if (methodName === 'createResponse') {
64
+ response = constructResponseResponseFromStreamedChunks(chunks)
62
65
  }
63
66
 
64
67
  ctx.result = { data: response }
@@ -134,6 +137,10 @@ class OpenAiTracingPlugin extends TracingPlugin {
134
137
  case 'createEdit':
135
138
  createEditRequestExtraction(tags, payload, openaiStore)
136
139
  break
140
+
141
+ case 'createResponse':
142
+ createResponseRequestExtraction(tags, payload, openaiStore)
143
+ break
137
144
  }
138
145
 
139
146
  span.addTags(tags)
@@ -313,6 +320,10 @@ function normalizeMethodName (methodName) {
313
320
  case 'embeddings.create':
314
321
  return 'createEmbedding'
315
322
 
323
+ // responses
324
+ case 'responses.create':
325
+ return 'createResponse'
326
+
316
327
  // files
317
328
  case 'files.create':
318
329
  return 'createFile'
@@ -376,6 +387,16 @@ function createEditRequestExtraction (tags, payload, openaiStore) {
376
387
  openaiStore.instruction = instruction
377
388
  }
378
389
 
390
+ function createResponseRequestExtraction (tags, payload, openaiStore) {
391
+ // Extract model information
392
+ if (payload.model) {
393
+ tags['openai.request.model'] = payload.model
394
+ }
395
+
396
+ // Store the full payload for response extraction
397
+ openaiStore.responseData = payload
398
+ }
399
+
379
400
  function retrieveModelRequestExtraction (tags, payload) {
380
401
  tags['openai.request.id'] = payload.id
381
402
  }
@@ -410,6 +431,10 @@ function responseDataExtractionByMethod (methodName, tags, body, openaiStore) {
410
431
  commonCreateResponseExtraction(tags, body, openaiStore, methodName)
411
432
  break
412
433
 
434
+ case 'createResponse':
435
+ createResponseResponseExtraction(tags, body, openaiStore)
436
+ break
437
+
413
438
  case 'listFiles':
414
439
  case 'listFineTunes':
415
440
  case 'listFineTuneEvents':
@@ -513,6 +538,26 @@ function commonCreateResponseExtraction (tags, body, openaiStore, methodName) {
513
538
  openaiStore.choices = body.choices
514
539
  }
515
540
 
541
+ function createResponseResponseExtraction (tags, body, openaiStore) {
542
+ // Extract response ID if available
543
+ if (body.id) {
544
+ tags['openai.response.id'] = body.id
545
+ }
546
+
547
+ // Extract status if available
548
+ if (body.status) {
549
+ tags['openai.response.status'] = body.status
550
+ }
551
+
552
+ // Extract model from response if available
553
+ if (body.model) {
554
+ tags['openai.response.model'] = body.model
555
+ }
556
+
557
+ // Store the full response for potential future use
558
+ openaiStore.response = body
559
+ }
560
+
516
561
  // The server almost always responds with JSON
517
562
  function coerceResponseBody (body, methodName) {
518
563
  switch (methodName) {
@@ -6,6 +6,7 @@ const { getEnvironmentVariable } = require('../../dd-trace/src/config-helper')
6
6
 
7
7
  const {
8
8
  TEST_STATUS,
9
+ VITEST_POOL,
9
10
  finishAllTraceSpans,
10
11
  getTestSuitePath,
11
12
  getTestSuiteCommonTags,
@@ -344,7 +345,6 @@ class VitestPlugin extends CiPlugin {
344
345
  finishAllTraceSpans(testSuiteSpan)
345
346
  }
346
347
  this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
347
- // TODO: too frequent flush - find for method in worker to decrease frequency
348
348
  this.tracer._exporter.flush(onFinish)
349
349
  if (this.runningTestProbe) {
350
350
  this.removeDiProbe(this.runningTestProbe)
@@ -373,6 +373,7 @@ class VitestPlugin extends CiPlugin {
373
373
  isEarlyFlakeDetectionEnabled,
374
374
  isEarlyFlakeDetectionFaulty,
375
375
  isTestManagementTestsEnabled,
376
+ vitestPool,
376
377
  onFinish
377
378
  }) => {
378
379
  this.testSessionSpan.setTag(TEST_STATUS, status)
@@ -394,6 +395,9 @@ class VitestPlugin extends CiPlugin {
394
395
  if (isTestManagementTestsEnabled) {
395
396
  this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true')
396
397
  }
398
+ if (vitestPool) {
399
+ this.testSessionSpan.setTag(VITEST_POOL, vitestPool)
400
+ }
397
401
  this.testModuleSpan.finish()
398
402
  this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module')
399
403
  this.testSessionSpan.finish()
@@ -29,6 +29,9 @@ function getInterprocessTraceCode () {
29
29
  if (getEnvironmentVariable('TINYPOOL_WORKER_ID')) {
30
30
  return VITEST_WORKER_TRACE_PAYLOAD_CODE
31
31
  }
32
+ if (getEnvironmentVariable('DD_VITEST_WORKER')) {
33
+ return VITEST_WORKER_TRACE_PAYLOAD_CODE
34
+ }
32
35
  return null
33
36
  }
34
37
 
@@ -47,6 +50,9 @@ function getInterprocessLogsCode () {
47
50
  if (getEnvironmentVariable('TINYPOOL_WORKER_ID')) {
48
51
  return VITEST_WORKER_LOGS_PAYLOAD_CODE
49
52
  }
53
+ if (getEnvironmentVariable('DD_VITEST_WORKER')) {
54
+ return VITEST_WORKER_LOGS_PAYLOAD_CODE
55
+ }
50
56
  return null
51
57
  }
52
58
 
@@ -1,6 +1,11 @@
1
1
  'use strict'
2
2
  const { JSONEncoder } = require('../../encode/json-encoder')
3
3
  const { getEnvironmentVariable } = require('../../../config-helper')
4
+ const log = require('../../../log')
5
+ const {
6
+ VITEST_WORKER_TRACE_PAYLOAD_CODE,
7
+ VITEST_WORKER_LOGS_PAYLOAD_CODE
8
+ } = require('../../../plugins/util/test')
4
9
 
5
10
  class Writer {
6
11
  constructor (interprocessCode) {
@@ -26,24 +31,42 @@ class Writer {
26
31
  _sendPayload (data, onDone = () => {}) {
27
32
  // ## Jest
28
33
  // Only available when `child_process` is used for the jest worker.
29
- // https://github.com/facebook/jest/blob/bb39cb2c617a3334bf18daeca66bd87b7ccab28b/packages/jest-worker/README.md#experimental-worker
30
34
  // If worker_threads is used, this will not work
31
- // TODO: make it compatible with worker_threads
35
+ // TODO: make `jest` instrumentation compatible with worker_threads
36
+ // https://github.com/facebook/jest/blob/bb39cb2c617a3334bf18daeca66bd87b7ccab28b/packages/jest-worker/README.md#experimental-worker
32
37
 
33
38
  // ## Cucumber
34
39
  // This reports to the test's main process the same way test data is reported by Cucumber
35
40
  // See cucumber code:
36
41
  // https://github.com/cucumber/cucumber-js/blob/5ce371870b677fe3d1a14915dc535688946f734c/src/runtime/parallel/run_worker.ts#L13
37
- if (process.send) { // it only works if process.send is available
38
- const isVitestWorker = !!getEnvironmentVariable('TINYPOOL_WORKER_ID')
39
42
 
40
- const payload = isVitestWorker
41
- ? { __tinypool_worker_message__: true, interprocessCode: this._interprocessCode, data }
42
- : [this._interprocessCode, data]
43
+ // Old because vitest@>=4 uses `DD_VITEST_WORKER` and reports arrays just like other frameworks
44
+ // Before vitest@>=4, we need the `__tinypool_worker_message__` property, or tinypool will crash
45
+ const isVitestWorkerOld = !!getEnvironmentVariable('TINYPOOL_WORKER_ID')
46
+ const payload = isVitestWorkerOld
47
+ ? { __tinypool_worker_message__: true, interprocessCode: this._interprocessCode, data }
48
+ : [this._interprocessCode, data]
43
49
 
50
+ const isVitestTestWorker =
51
+ this._interprocessCode === VITEST_WORKER_TRACE_PAYLOAD_CODE ||
52
+ this._interprocessCode === VITEST_WORKER_LOGS_PAYLOAD_CODE
53
+
54
+ if (process.send) {
44
55
  process.send(payload, () => {
45
56
  onDone()
46
57
  })
58
+ } else if (isVitestTestWorker) { // TODO: worker_threads are only supported in vitest right now
59
+ const { isMainThread, parentPort } = require('worker_threads')
60
+ if (isMainThread) {
61
+ return onDone()
62
+ }
63
+ try {
64
+ parentPort.postMessage(payload)
65
+ } catch (error) {
66
+ log.error('Error posting message to parent port', error)
67
+ } finally {
68
+ onDone()
69
+ }
47
70
  } else {
48
71
  onDone()
49
72
  }
@@ -2,6 +2,13 @@
2
2
 
3
3
  const LLMObsPlugin = require('./base')
4
4
 
5
+ const allowedParamKeys = new Set([
6
+ 'max_output_tokens',
7
+ 'temperature',
8
+ 'stream',
9
+ 'reasoning'
10
+ ])
11
+
5
12
  function isIterable (obj) {
6
13
  if (obj == null) {
7
14
  return false
@@ -19,7 +26,7 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
19
26
  const methodName = gateResource(normalizeOpenAIResourceName(resource))
20
27
  if (!methodName) return // we will not trace all openai methods for llmobs
21
28
 
22
- const inputs = ctx.args[0] // completion, chat completion, and embeddings take one argument
29
+ const inputs = ctx.args[0] // completion, chat completion, embeddings, and responses take one argument
23
30
  const operation = getOperation(methodName)
24
31
  const kind = operation === 'embedding' ? 'embedding' : 'llm'
25
32
 
@@ -53,6 +60,8 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
53
60
  this._tagChatCompletion(span, inputs, response, error)
54
61
  } else if (operation === 'embedding') {
55
62
  this._tagEmbedding(span, inputs, response, error)
63
+ } else if (operation === 'response') {
64
+ this.#tagResponse(span, inputs, response, error)
56
65
  }
57
66
 
58
67
  if (!error) {
@@ -75,19 +84,30 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
75
84
  const tokenUsage = response.usage
76
85
 
77
86
  if (tokenUsage) {
78
- const inputTokens = tokenUsage.prompt_tokens
79
- if (inputTokens) metrics.inputTokens = inputTokens
87
+ // Responses API uses input_tokens, Chat/Completions use prompt_tokens
88
+ const inputTokens = tokenUsage.input_tokens ?? tokenUsage.prompt_tokens
89
+ if (inputTokens !== undefined) metrics.inputTokens = inputTokens
80
90
 
81
- const outputTokens = tokenUsage.completion_tokens
82
- if (outputTokens) metrics.outputTokens = outputTokens
91
+ // Responses API uses output_tokens, Chat/Completions use completion_tokens
92
+ const outputTokens = tokenUsage.output_tokens ?? tokenUsage.completion_tokens
93
+ if (outputTokens !== undefined) metrics.outputTokens = outputTokens
83
94
 
84
95
  const totalTokens = tokenUsage.total_tokens || (inputTokens + outputTokens)
85
- if (totalTokens) metrics.totalTokens = totalTokens
86
-
87
- const promptTokensDetails = tokenUsage.prompt_tokens_details
88
- if (promptTokensDetails) {
89
- const cacheReadTokens = promptTokensDetails.cached_tokens
90
- if (cacheReadTokens) metrics.cacheReadTokens = cacheReadTokens
96
+ if (totalTokens !== undefined) metrics.totalTokens = totalTokens
97
+
98
+ // Cache tokens - Responses API uses input_tokens_details, Chat/Completions use prompt_tokens_details
99
+ // For Responses API, always include cache tokens (even if 0)
100
+ // For Chat API, only include if > 0
101
+ if (tokenUsage.input_tokens_details) {
102
+ // Responses API - always include
103
+ const cacheReadTokens = tokenUsage.input_tokens_details.cached_tokens
104
+ if (cacheReadTokens !== undefined) metrics.cacheReadTokens = cacheReadTokens
105
+ } else if (tokenUsage.prompt_tokens_details) {
106
+ // Chat/Completions API - only include if > 0
107
+ const cacheReadTokens = tokenUsage.prompt_tokens_details.cached_tokens
108
+ if (cacheReadTokens) {
109
+ metrics.cacheReadTokens = cacheReadTokens
110
+ }
91
111
  }
92
112
  }
93
113
 
@@ -191,6 +211,183 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
191
211
 
192
212
  this._tagger.tagMetadata(span, metadata)
193
213
  }
214
+
215
+ #tagResponse (span, inputs, response, error) {
216
+ // Tag metadata - use allowlist approach for request parameters
217
+
218
+ const { input, model, ...parameters } = inputs
219
+
220
+ // Create input messages
221
+ const inputMessages = []
222
+
223
+ // Add system message if instructions exist
224
+ if (inputs.instructions) {
225
+ inputMessages.push({ role: 'system', content: inputs.instructions })
226
+ }
227
+
228
+ // Handle input - can be string or array of mixed messages
229
+ if (Array.isArray(input)) {
230
+ for (const item of input) {
231
+ if (item.type === 'function_call') {
232
+ // Function call: convert to message with tool_calls
233
+ // Parse arguments if it's a JSON string
234
+ let parsedArgs = item.arguments
235
+ if (typeof parsedArgs === 'string') {
236
+ try {
237
+ parsedArgs = JSON.parse(parsedArgs)
238
+ } catch {
239
+ parsedArgs = {}
240
+ }
241
+ }
242
+ inputMessages.push({
243
+ role: 'assistant',
244
+ toolCalls: [{
245
+ toolId: item.call_id,
246
+ name: item.name,
247
+ arguments: parsedArgs,
248
+ type: item.type
249
+ }]
250
+ })
251
+ } else if (item.type === 'function_call_output') {
252
+ // Function output: convert to user message with tool_results
253
+ inputMessages.push({
254
+ role: 'user',
255
+ toolResults: [{
256
+ toolId: item.call_id,
257
+ result: item.output,
258
+ name: item.name || '',
259
+ type: item.type
260
+ }]
261
+ })
262
+ } else if (item.role && item.content) {
263
+ // Regular message
264
+ inputMessages.push({ role: item.role, content: item.content })
265
+ }
266
+ }
267
+ } else {
268
+ // Simple string input
269
+ inputMessages.push({ role: 'user', content: input })
270
+ }
271
+
272
+ if (error) {
273
+ this._tagger.tagLLMIO(span, inputMessages, [{ content: '' }])
274
+ return
275
+ }
276
+
277
+ // Create output messages
278
+ const outputMessages = []
279
+
280
+ // Handle output - can be string (streaming) or array of message objects (non-streaming)
281
+ if (typeof response.output === 'string') {
282
+ // Simple text output (streaming)
283
+ outputMessages.push({ role: 'assistant', content: response.output })
284
+ } else if (Array.isArray(response.output)) {
285
+ // Array output - process all items to extract reasoning, messages, and tool calls
286
+ // Non-streaming: array of items (messages, function_calls, or reasoning)
287
+ for (const item of response.output) {
288
+ // Handle reasoning type (reasoning responses)
289
+ if (item.type === 'reasoning') {
290
+ // Extract reasoning text from summary
291
+ let reasoningText = ''
292
+ if (Array.isArray(item.summary) && item.summary.length > 0) {
293
+ const summaryItem = item.summary[0]
294
+ if (summaryItem.type === 'summary_text' && summaryItem.text) {
295
+ reasoningText = summaryItem.text
296
+ }
297
+ }
298
+ outputMessages.push({
299
+ role: 'reasoning',
300
+ content: reasoningText
301
+ })
302
+ } else if (item.type === 'function_call') {
303
+ // Handle function_call type (responses API tool calls)
304
+ let args = item.arguments
305
+ // Parse arguments if it's a JSON string
306
+ if (typeof args === 'string') {
307
+ try {
308
+ args = JSON.parse(args)
309
+ } catch {
310
+ args = {}
311
+ }
312
+ }
313
+ outputMessages.push({
314
+ role: 'assistant',
315
+ toolCalls: [{
316
+ toolId: item.call_id,
317
+ name: item.name,
318
+ arguments: args,
319
+ type: item.type
320
+ }]
321
+ })
322
+ } else {
323
+ // Handle regular message objects
324
+ const outputMsg = { role: item.role || 'assistant', content: '' }
325
+
326
+ // Extract content from message
327
+ if (Array.isArray(item.content)) {
328
+ // Content is array of content parts
329
+ // For responses API, text content has type 'output_text', not 'text'
330
+ const textParts = item.content
331
+ .filter(c => c.type === 'output_text')
332
+ .map(c => c.text)
333
+ outputMsg.content = textParts.join('')
334
+ } else if (typeof item.content === 'string') {
335
+ outputMsg.content = item.content
336
+ }
337
+
338
+ // Extract tool calls if present in message.tool_calls
339
+ if (Array.isArray(item.tool_calls)) {
340
+ outputMsg.toolCalls = item.tool_calls.map(tc => {
341
+ let args = tc.function?.arguments || tc.arguments
342
+ // Parse arguments if it's a JSON string
343
+ if (typeof args === 'string') {
344
+ try {
345
+ args = JSON.parse(args)
346
+ } catch {
347
+ args = {}
348
+ }
349
+ }
350
+ return {
351
+ toolId: tc.id,
352
+ name: tc.function?.name || tc.name,
353
+ arguments: args,
354
+ type: tc.type || 'function_call'
355
+ }
356
+ })
357
+ }
358
+
359
+ outputMessages.push(outputMsg)
360
+ }
361
+ }
362
+ } else if (response.output_text) {
363
+ // Fallback: use output_text if available (for simple non-streaming responses without reasoning/tools)
364
+ outputMessages.push({ role: 'assistant', content: response.output_text })
365
+ } else {
366
+ // No output
367
+ outputMessages.push({ role: 'assistant', content: '' })
368
+ }
369
+
370
+ this._tagger.tagLLMIO(span, inputMessages, outputMessages)
371
+
372
+ const metadata = Object.entries(parameters).reduce((obj, [key, value]) => {
373
+ if (allowedParamKeys.has(key)) {
374
+ obj[key] = value
375
+ }
376
+ return obj
377
+ }, {})
378
+
379
+ // Add fields from response object (convert numbers to floats)
380
+ if (response.temperature !== undefined) metadata.temperature = Number(response.temperature)
381
+ if (response.top_p !== undefined) metadata.top_p = Number(response.top_p)
382
+ if (response.tool_choice !== undefined) metadata.tool_choice = response.tool_choice
383
+ if (response.truncation !== undefined) metadata.truncation = response.truncation
384
+ if (response.text !== undefined) metadata.text = response.text
385
+ if (response.usage?.output_tokens_details?.reasoning_tokens !== undefined) {
386
+ metadata.reasoning_tokens = response.usage.output_tokens_details.reasoning_tokens
387
+ }
388
+
389
+ this._tagger.tagMetadata(span, metadata)
390
+ }
194
391
  }
195
392
 
196
393
  // TODO: this will be moved to the APM integration
@@ -207,13 +404,18 @@ function normalizeOpenAIResourceName (resource) {
207
404
  // embeddings
208
405
  case 'embeddings.create':
209
406
  return 'createEmbedding'
407
+
408
+ // responses
409
+ case 'responses.create':
410
+ return 'createResponse'
411
+
210
412
  default:
211
413
  return resource
212
414
  }
213
415
  }
214
416
 
215
417
  function gateResource (resource) {
216
- return ['createCompletion', 'createChatCompletion', 'createEmbedding'].includes(resource)
418
+ return ['createCompletion', 'createChatCompletion', 'createEmbedding', 'createResponse'].includes(resource)
217
419
  ? resource
218
420
  : undefined
219
421
  }
@@ -226,6 +428,8 @@ function getOperation (resource) {
226
428
  return 'chat'
227
429
  case 'createEmbedding':
228
430
  return 'embedding'
431
+ case 'createResponse':
432
+ return 'response'
229
433
  default:
230
434
  // should never happen
231
435
  return 'unknown'
@@ -224,7 +224,8 @@ class LLMObsSpanProcessor {
224
224
  continue
225
225
  }
226
226
  if (value !== null && typeof value === 'object') {
227
- add(value, carrier[key] = {})
227
+ carrier[key] = Array.isArray(value) ? [] : {}
228
+ add(value, carrier[key])
228
229
  } else {
229
230
  carrier[key] = value
230
231
  }
@@ -292,11 +292,17 @@ class LLMObsTagger {
292
292
  continue
293
293
  }
294
294
 
295
- const { result, toolId, type } = toolResult
295
+ const { result, toolId, name = '', type } = toolResult
296
296
  const toolResultObj = {}
297
297
 
298
298
  const condition1 = this.#tagConditionalString(result, 'Tool result', toolResultObj, 'result')
299
299
  const condition2 = this.#tagConditionalString(toolId, 'Tool ID', toolResultObj, 'tool_id')
300
+ // name can be empty string, so always include it
301
+ if (typeof name === 'string') {
302
+ toolResultObj.name = name
303
+ } else {
304
+ this.#handleFailure(`[LLMObs] Expected tool result name to be a string, instead got "${typeof name}"`)
305
+ }
300
306
  const condition3 = this.#tagConditionalString(type, 'Tool type', toolResultObj, 'type')
301
307
 
302
308
  if (condition1 && condition2 && condition3) {
@@ -332,13 +338,13 @@ class LLMObsTagger {
332
338
  const toolId = message.toolId
333
339
  const messageObj = { content }
334
340
 
341
+ let condition = this.#tagConditionalString(role, 'Message role', messageObj, 'role')
342
+
335
343
  const valid = typeof content === 'string'
336
344
  if (!valid) {
337
345
  this.#handleFailure('Message content must be a string.', 'invalid_io_messages')
338
346
  }
339
347
 
340
- let condition = this.#tagConditionalString(role, 'Message role', messageObj, 'role')
341
-
342
348
  if (toolCalls) {
343
349
  const filteredToolCalls = this.#filterToolCalls(toolCalls)
344
350
 
@@ -34,7 +34,8 @@ const {
34
34
  getLibraryCapabilitiesTags,
35
35
  getPullRequestDiff,
36
36
  getModifiedFilesFromDiff,
37
- getPullRequestBaseBranch
37
+ getPullRequestBaseBranch,
38
+ TEST_IS_TEST_FRAMEWORK_WORKER
38
39
  } = require('./util/test')
39
40
  const { getRepositoryRoot } = require('./util/git')
40
41
  const Plugin = require('./plugin')
@@ -311,6 +312,7 @@ module.exports = class CiPlugin extends Plugin {
311
312
  span.parent_id = id(span.parent_id)
312
313
 
313
314
  if (span.name?.startsWith(`${this.constructor.id}.`)) {
315
+ span.meta[TEST_IS_TEST_FRAMEWORK_WORKER] = 'true'
314
316
  // augment with git information (since it will not be available in the worker)
315
317
  for (const key in this.testEnvironmentMetadata) {
316
318
  // CAREFUL: this bypasses the metadata/metrics distinction
@@ -92,6 +92,8 @@ const CI_APP_ORIGIN = 'ciapp-test'
92
92
  const JEST_TEST_RUNNER = 'test.jest.test_runner'
93
93
  const JEST_DISPLAY_NAME = 'test.jest.display_name'
94
94
 
95
+ const VITEST_POOL = 'test.vitest.pool'
96
+
95
97
  const CUCUMBER_IS_PARALLEL = 'test.cucumber.is_parallel'
96
98
  const MOCHA_IS_PARALLEL = 'test.mocha.is_parallel'
97
99
 
@@ -130,6 +132,8 @@ const PLAYWRIGHT_WORKER_TRACE_PAYLOAD_CODE = 90
130
132
  const VITEST_WORKER_TRACE_PAYLOAD_CODE = 100
131
133
  const VITEST_WORKER_LOGS_PAYLOAD_CODE = 102
132
134
 
135
+ const TEST_IS_TEST_FRAMEWORK_WORKER = 'test.is_test_framework_worker'
136
+
133
137
  // Library Capabilities Tagging
134
138
  const DD_CAPABILITIES_TEST_IMPACT_ANALYSIS = '_dd.library_capabilities.test_impact_analysis'
135
139
  const DD_CAPABILITIES_EARLY_FLAKE_DETECTION = '_dd.library_capabilities.early_flake_detection'
@@ -203,6 +207,7 @@ module.exports = {
203
207
  TEST_FRAMEWORK_VERSION,
204
208
  JEST_TEST_RUNNER,
205
209
  JEST_DISPLAY_NAME,
210
+ VITEST_POOL,
206
211
  CUCUMBER_IS_PARALLEL,
207
212
  MOCHA_IS_PARALLEL,
208
213
  TEST_TYPE,
@@ -223,6 +228,7 @@ module.exports = {
223
228
  PLAYWRIGHT_WORKER_TRACE_PAYLOAD_CODE,
224
229
  VITEST_WORKER_TRACE_PAYLOAD_CODE,
225
230
  VITEST_WORKER_LOGS_PAYLOAD_CODE,
231
+ TEST_IS_TEST_FRAMEWORK_WORKER,
226
232
  TEST_SOURCE_START,
227
233
  TEST_SKIPPED_BY_ITR,
228
234
  TEST_IS_NEW,
@@ -269,9 +269,27 @@ module.exports = { Config }
269
269
  function getProfilers ({
270
270
  DD_PROFILING_HEAP_ENABLED, DD_PROFILING_WALLTIME_ENABLED, DD_PROFILING_PROFILERS
271
271
  }) {
272
- // First consider "legacy" DD_PROFILING_PROFILERS env variable, defaulting to wall + space
272
+ // First consider "legacy" DD_PROFILING_PROFILERS env variable, defaulting to space + wall
273
273
  // Use a Set to avoid duplicates
274
- const profilers = new Set((DD_PROFILING_PROFILERS ?? 'wall,space').split(','))
274
+ // NOTE: space profiler is very deliberately in the first position. This way
275
+ // when profilers are stopped sequentially one after the other to create
276
+ // snapshots the space profile won't include memory taken by profiles created
277
+ // before it in the sequence. That memory is ultimately transient and will be
278
+ // released when all profiles are subsequently encoded.
279
+ const profilers = new Set((DD_PROFILING_PROFILERS ?? 'space,wall').split(','))
280
+
281
+ let spaceExplicitlyEnabled = false
282
+ // Add/remove space depending on the value of DD_PROFILING_HEAP_ENABLED
283
+ if (DD_PROFILING_HEAP_ENABLED != null) {
284
+ if (isTrue(DD_PROFILING_HEAP_ENABLED)) {
285
+ if (!profilers.has('space')) {
286
+ profilers.add('space')
287
+ spaceExplicitlyEnabled = true
288
+ }
289
+ } else if (isFalse(DD_PROFILING_HEAP_ENABLED)) {
290
+ profilers.delete('space')
291
+ }
292
+ }
275
293
 
276
294
  // Add/remove wall depending on the value of DD_PROFILING_WALLTIME_ENABLED
277
295
  if (DD_PROFILING_WALLTIME_ENABLED != null) {
@@ -279,19 +297,23 @@ function getProfilers ({
279
297
  profilers.add('wall')
280
298
  } else if (isFalse(DD_PROFILING_WALLTIME_ENABLED)) {
281
299
  profilers.delete('wall')
300
+ profilers.delete('cpu') // remove alias too
282
301
  }
283
302
  }
284
303
 
285
- // Add/remove wall depending on the value of DD_PROFILING_HEAP_ENABLED
286
- if (DD_PROFILING_HEAP_ENABLED != null) {
287
- if (isTrue(DD_PROFILING_HEAP_ENABLED)) {
288
- profilers.add('space')
289
- } else if (isFalse(DD_PROFILING_HEAP_ENABLED)) {
290
- profilers.delete('space')
304
+ const profilersArray = [...profilers]
305
+ // If space was added through DD_PROFILING_HEAP_ENABLED, ensure it is in the
306
+ // first position. Basically, the only way for it not to be in the first
307
+ // position is if it was explicitly specified in a different position in
308
+ // DD_PROFILING_PROFILERS.
309
+ if (spaceExplicitlyEnabled) {
310
+ const spaceIdx = profilersArray.indexOf('space')
311
+ if (spaceIdx > 0) {
312
+ profilersArray.splice(spaceIdx, 1)
313
+ profilersArray.unshift('space')
291
314
  }
292
315
  }
293
-
294
- return [...profilers]
316
+ return profilersArray
295
317
  }
296
318
 
297
319
  function getExportStrategy (name, options) {
@@ -16,6 +16,8 @@ module.exports = {
16
16
  APM_TRACING_LOGS_INJECTION: 1n << 13n,
17
17
  APM_TRACING_HTTP_HEADER_TAGS: 1n << 14n,
18
18
  APM_TRACING_CUSTOM_TAGS: 1n << 15n,
19
+ ASM_PROCESSOR_OVERRIDES: 1n << 16n,
20
+ ASM_CUSTOM_DATA_SCANNERS: 1n << 17n,
19
21
  ASM_EXCLUSION_DATA: 1n << 18n,
20
22
  APM_TRACING_ENABLED: 1n << 19n,
21
23
  ASM_RASP_SQLI: 1n << 21n,
@@ -90,6 +90,8 @@ function enableWafUpdate (appsecConfig) {
90
90
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_RULES, true)
91
91
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_BLOCKING_RESPONSE, true)
92
92
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_TRUSTED_IPS, true)
93
+ rc.updateCapabilities(RemoteConfigCapabilities.ASM_PROCESSOR_OVERRIDES, true)
94
+ rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_DATA_SCANNERS, true)
93
95
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_EXCLUSION_DATA, true)
94
96
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_ENDPOINT_FINGERPRINT, true)
95
97
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_SESSION_FINGERPRINT, true)
@@ -129,6 +131,8 @@ function disableWafUpdate () {
129
131
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_RULES, false)
130
132
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_BLOCKING_RESPONSE, false)
131
133
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_TRUSTED_IPS, false)
134
+ rc.updateCapabilities(RemoteConfigCapabilities.ASM_PROCESSOR_OVERRIDES, false)
135
+ rc.updateCapabilities(RemoteConfigCapabilities.ASM_CUSTOM_DATA_SCANNERS, false)
132
136
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_EXCLUSION_DATA, false)
133
137
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_ENDPOINT_FINGERPRINT, false)
134
138
  rc.updateCapabilities(RemoteConfigCapabilities.ASM_SESSION_FINGERPRINT, false)
@@ -444,6 +444,7 @@
444
444
  "DD_VERSION": ["A"],
445
445
  "DD_VERTEXAI_SPAN_CHAR_LIMIT": ["A"],
446
446
  "DD_VERTEXAI_SPAN_PROMPT_COMPLETION_SAMPLE_RATE": ["A"],
447
+ "DD_VITEST_WORKER": ["A"],
447
448
  "OTEL_LOG_LEVEL": ["A"],
448
449
  "OTEL_LOGS_EXPORTER": ["A"],
449
450
  "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": ["A"],