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
@@ -1,10 +1,14 @@
1
1
  'use strict'
2
2
 
3
3
  const { storage } = require('../../../../datadog-core')
4
+ const log = require('../../log')
4
5
  const telemetry = require('../telemetry')
6
+ const { safeJsonParse } = require('../util')
5
7
  const {
8
+ buildUsage,
6
9
  extractRequestParams,
7
10
  extractTextAndResponseReason,
11
+ mergeStreamedUsage,
8
12
  parseModelId,
9
13
  extractTextAndResponseReasonFromStream,
10
14
  extractConverseToolDefinitions,
@@ -52,12 +56,45 @@ class BedrockRuntimeLLMObsPlugin extends BaseLLMObsPlugin {
52
56
  // avoids instrumenting other non supported runtime operations
53
57
  if (!ENABLED_OPERATIONS.has(operation)) return
54
58
 
55
- const { modelProvider, modelName } = parseModelId(request.params.modelId)
59
+ // the SDK rejects a request with no model id, and the parser assumes a string
60
+ const modelId = request.params?.modelId
61
+ if (typeof modelId !== 'string') return
62
+
63
+ const { modelProvider, modelName } = parseModelId(modelId)
56
64
 
57
65
  // avoids instrumenting non llm type
58
66
  if (modelName.includes('embed')) return
59
67
 
60
68
  const span = ctx.currentStore?.span
69
+ if (!span) return
70
+
71
+ if (!this._llmobsEnabledFor(ctx)) {
72
+ // no LLMObs payload to build, so the usage comes from the response headers and, where
73
+ // those are absent, from whatever reported it
74
+ let usage
75
+ if (CONVERSE_OPERATIONS.has(operation)) {
76
+ // a non-streamed Converse puts it on the response, a streamed one on a metadata event
77
+ usage = buildUsage(response.usage) ?? ctx.streamedUsage
78
+ } else if (operation.toLowerCase().includes('stream')) {
79
+ // every streamed frame was folded into the running totals as it arrived
80
+ usage = ctx.streamedUsage
81
+ } else {
82
+ // headers can report some counts and the body others, so both are read and
83
+ // `extractTokens` merges them field by field
84
+ usage = responseBodyUsage(response, modelProvider, modelName)
85
+ }
86
+
87
+ this._setGenAiApmTags(span, {
88
+ spanKind: 'llm',
89
+ modelName: modelId.toLowerCase(),
90
+ modelProvider: 'amazon_bedrock',
91
+ // a count no source reported comes back undefined and is left off the span: reporting
92
+ // zeros for every metric would be worse than reporting none
93
+ metrics: extractTokens({ tokensFromHeaders, usage: usage ?? {} }),
94
+ })
95
+ return
96
+ }
97
+
61
98
  this.setLLMObsTags({ ctx, request, span, response, modelProvider, modelName, tokensFromHeaders })
62
99
  })
63
100
 
@@ -71,6 +108,10 @@ class BedrockRuntimeLLMObsPlugin extends BaseLLMObsPlugin {
71
108
  const cacheReadTokenCount = headers['x-amzn-bedrock-cache-read-input-token-count']
72
109
  const cacheWriteTokenCount = headers['x-amzn-bedrock-cache-write-input-token-count']
73
110
 
111
+ // Responses that report no counts at all, error responses included, would otherwise cache a
112
+ // record of undefined fields that reads as a measurement of zero.
113
+ if (!inputTokenCount && !outputTokenCount && !cacheReadTokenCount && !cacheWriteTokenCount) return
114
+
74
115
  pendingTokenHeaders.set(requestId, {
75
116
  inputTokensFromHeaders: inputTokenCount && Number.parseInt(inputTokenCount, 10),
76
117
  outputTokensFromHeaders: outputTokenCount && Number.parseInt(outputTokenCount, 10),
@@ -80,6 +121,13 @@ class BedrockRuntimeLLMObsPlugin extends BaseLLMObsPlugin {
80
121
  })
81
122
 
82
123
  this.addSub('apm:aws:response:streamed-chunk:bedrockruntime', ({ ctx, chunk }) => {
124
+ if (!this._llmobsEnabledFor(ctx)) {
125
+ // only the token counts are needed, for the `gen_ai.usage.*` metrics; the generated
126
+ // content is left to the LLMObs path, so nothing is retained past the running totals
127
+ ctx.streamedUsage = mergeChunkUsage(ctx, chunk)
128
+ return
129
+ }
130
+
83
131
  if (!ctx.chunks) ctx.chunks = []
84
132
 
85
133
  if (chunk) ctx.chunks.push(chunk)
@@ -141,10 +189,10 @@ class BedrockRuntimeLLMObsPlugin extends BaseLLMObsPlugin {
141
189
  max_tokens: Number.parseInt(requestParams.maxTokens, 10) || 0,
142
190
  })
143
191
  this._tagger.tagLLMIO(span, requestParams.prompt, textAndResponseReason.messages)
144
- this._tagger.tagMetrics(span, extractTokens({
192
+ this._tagger.tagMetrics(span, zeroFilled(extractTokens({
145
193
  tokensFromHeaders,
146
194
  usage: textAndResponseReason.usage,
147
- }))
195
+ })))
148
196
  }
149
197
  }
150
198
 
@@ -159,9 +207,74 @@ function consumeTokenHeaders (requestId) {
159
207
  }
160
208
 
161
209
  /**
162
- * Combine response-body usage with header-derived counts, preferring the body.
210
+ * Fold one streamed frame's token counts into the totals on `ctx`. Converse reports them on a
211
+ * metadata event; `invokeModel` reports them in the frame body, in a shape that varies by
212
+ * provider, so the body is read through the same table the LLMObs path uses.
213
+ *
214
+ * @param {object} ctx
215
+ * @param {object} [chunk]
216
+ * @returns {import('../../../../datadog-plugin-aws-sdk/src/services/bedrockruntime/utils')
217
+ * .StreamedUsage | undefined}
218
+ */
219
+ function mergeChunkUsage (ctx, chunk) {
220
+ const metadataUsage = chunk?.metadata?.usage
221
+ if (metadataUsage) return buildUsage(metadataUsage) ?? ctx.streamedUsage
222
+
223
+ const bytes = chunk?.chunk?.bytes
224
+ if (!ArrayBuffer.isView(bytes)) return ctx.streamedUsage
225
+
226
+ // a view, not a copy: this runs on every frame of every streamed response
227
+ const text = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8')
228
+ const body = safeJsonParse(text, null)
229
+ // a frame the model filled with generated text rather than JSON must not reach the application
230
+ if (typeof body !== 'object' || body === null) return ctx.streamedUsage
231
+
232
+ return mergeStreamedUsage(ctx.streamedUsage, body, streamModelProvider(ctx))
233
+ }
234
+
235
+ /**
236
+ * Token usage a non-streamed `invokeModel` reports in its own response body, which several
237
+ * providers carry and the headers do not always correlate. Read through the same extractor the
238
+ * LLMObs path uses, which parses this body on every request anyway.
239
+ *
240
+ * @param {{ body?: Uint8Array }} response
241
+ * @param {string} modelProvider
242
+ * @param {string} modelName
243
+ * @returns {Record<string, number | undefined> | undefined}
244
+ */
245
+ function responseBodyUsage (response, modelProvider, modelName) {
246
+ if (!response?.body) return
247
+
248
+ try {
249
+ return extractTextAndResponseReason(response, modelProvider, modelName).usage
250
+ } catch (e) {
251
+ // the extractor parses the body itself; a malformed one must not disable the plugin
252
+ log.debug('Failed to read Bedrock response usage: %s', e.message)
253
+ }
254
+ }
255
+
256
+ /**
257
+ * The provider is fixed for the life of the stream, so it is parsed off the request once.
258
+ *
259
+ * @param {object} ctx
260
+ */
261
+ function streamModelProvider (ctx) {
262
+ if (ctx.streamModelProvider === undefined) {
263
+ const modelId = (ctx.request ?? ctx.response?.request)?.params?.modelId
264
+ ctx.streamModelProvider = typeof modelId === 'string'
265
+ ? parseModelId(modelId).modelProvider.toUpperCase()
266
+ : ''
267
+ }
268
+
269
+ return ctx.streamModelProvider
270
+ }
271
+
272
+ /**
273
+ * Combine response-body usage with header-derived counts, preferring the body. A count no source
274
+ * reported stays undefined rather than becoming a zero that reads as a measurement.
163
275
  *
164
276
  * @param {{ tokensFromHeaders: HeaderTokens | undefined, usage: Record<string, number | undefined> }} options
277
+ * @returns {Record<string, number | undefined>}
165
278
  */
166
279
  function extractTokens ({ tokensFromHeaders, usage }) {
167
280
  const {
@@ -171,21 +284,60 @@ function extractTokens ({ tokensFromHeaders, usage }) {
171
284
  cacheWriteTokensFromHeaders,
172
285
  } = tokensFromHeaders ?? {}
173
286
 
174
- const inputTokens = usage.inputTokens || inputTokensFromHeaders || 0
175
- const outputTokens = usage.outputTokens || outputTokensFromHeaders || 0
176
- const cacheReadTokens = usage.cacheReadTokens || cacheReadTokensFromHeaders || 0
177
- const cacheWriteTokens = usage.cacheWriteTokens || cacheWriteTokensFromHeaders || 0
287
+ const inputTokens = resolveCount(usage.inputTokens, inputTokensFromHeaders)
288
+ const outputTokens = resolveCount(usage.outputTokens, outputTokensFromHeaders)
289
+ const cacheReadTokens = resolveCount(usage.cacheReadTokens, cacheReadTokensFromHeaders)
290
+ const cacheWriteTokens = resolveCount(usage.cacheWriteTokens, cacheWriteTokensFromHeaders)
178
291
 
179
292
  // adjust for the fact that bedrock input tokens only count non-cached tokens
180
- const normalizedInputTokens = inputTokens + cacheReadTokens + cacheWriteTokens
293
+ const normalizedInputTokens = inputTokens === undefined &&
294
+ cacheReadTokens === undefined &&
295
+ cacheWriteTokens === undefined
296
+ ? undefined
297
+ : (inputTokens ?? 0) + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0)
298
+
299
+ const totalTokens = normalizedInputTokens === undefined && outputTokens === undefined
300
+ ? undefined
301
+ : (normalizedInputTokens ?? 0) + (outputTokens ?? 0)
181
302
 
182
303
  return {
183
304
  inputTokens: normalizedInputTokens,
184
305
  outputTokens,
185
- totalTokens: normalizedInputTokens + outputTokens,
306
+ totalTokens,
186
307
  cacheReadTokens,
187
308
  cacheWriteTokens,
188
309
  }
189
310
  }
190
311
 
312
+ /**
313
+ * The body wins over the headers whenever it reported a count, a measured zero included, and a
314
+ * value neither reported as a number is left undefined: header counts are parsed from strings
315
+ * and can arrive empty.
316
+ *
317
+ * @param {unknown} fromBody
318
+ * @param {unknown} fromHeaders
319
+ * @returns {number | undefined}
320
+ */
321
+ function resolveCount (fromBody, fromHeaders) {
322
+ const value = typeof fromBody === 'number' ? fromBody : fromHeaders
323
+ return typeof value === 'number' && !Number.isNaN(value) ? value : undefined
324
+ }
325
+
326
+ /**
327
+ * The LLMObs metrics contract reports an unmeasured count as zero, where the `gen_ai.*` APM
328
+ * attributes leave it off the span entirely.
329
+ *
330
+ * @param {Record<string, number | undefined>} tokens
331
+ * @returns {Record<string, number>}
332
+ */
333
+ function zeroFilled (tokens) {
334
+ return {
335
+ inputTokens: tokens.inputTokens ?? 0,
336
+ outputTokens: tokens.outputTokens ?? 0,
337
+ totalTokens: tokens.totalTokens ?? 0,
338
+ cacheReadTokens: tokens.cacheReadTokens ?? 0,
339
+ cacheWriteTokens: tokens.cacheWriteTokens ?? 0,
340
+ }
341
+ }
342
+
191
343
  module.exports = BedrockRuntimeLLMObsPlugin
@@ -5,6 +5,7 @@ const { storage: llmobsStorage } = require('../../storage')
5
5
  const { NAME, SESSION_ID } = require('../../constants/tags')
6
6
  const { splitModel } = require('../../../../../datadog-plugin-claude-agent-sdk/src/util')
7
7
 
8
+ const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
8
9
  const subagentToolIds = new Set()
9
10
 
10
11
  function normalizeToolOutputString (raw) {
@@ -39,6 +40,27 @@ function getToolOutputText (raw) {
39
40
  return JSON.stringify(raw)
40
41
  }
41
42
 
43
+ /**
44
+ * @param {object} [usage]
45
+ * @returns {Record<string, number> | undefined}
46
+ */
47
+ function extractUsageMetrics (usage) {
48
+ if (!usage) return
49
+
50
+ const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0
51
+ const cacheReadTokens = usage.cache_read_input_tokens ?? 0
52
+ const inputTokens = (usage.input_tokens ?? 0) + cacheWriteTokens + cacheReadTokens
53
+ const outputTokens = usage.output_tokens ?? 0
54
+
55
+ return {
56
+ input_tokens: inputTokens,
57
+ output_tokens: outputTokens,
58
+ cache_read_input_tokens: cacheReadTokens,
59
+ cache_write_input_tokens: cacheWriteTokens,
60
+ total_tokens: inputTokens + outputTokens,
61
+ }
62
+ }
63
+
42
64
  function buildOutputMessages (chunks, llmStartIdx, llmEndIdx) {
43
65
  let thinking = ''
44
66
  let text = ''
@@ -85,6 +107,13 @@ class QueryLLMObsPlugin extends LLMObsPlugin {
85
107
  super.asyncEnd(ctx)
86
108
  }
87
109
 
110
+ /**
111
+ * @override
112
+ */
113
+ getGenAiApmEndTags (ctx) {
114
+ return { sessionId: ctx.session_id }
115
+ }
116
+
88
117
  setLLMObsTags (ctx) {
89
118
  const span = ctx.currentStore?.span
90
119
  if (!span) return
@@ -99,6 +128,11 @@ class QueryLLMObsPlugin extends LLMObsPlugin {
99
128
 
100
129
  if (cwd) metadata.cwd = cwd
101
130
  if (permissionMode) metadata.permissionMode = permissionMode
131
+ const systemPrompt = ctx.arguments?.[0]?.options?.systemPrompt
132
+ if (systemPrompt?.type === 'preset') {
133
+ if (typeof systemPrompt.preset === 'string') metadata.systemPromptPreset = systemPrompt.preset
134
+ if (typeof systemPrompt.append === 'string') metadata.systemPromptAppend = systemPrompt.append
135
+ }
102
136
 
103
137
  this._tagger.tagMetadata(span, metadata)
104
138
  }
@@ -149,6 +183,13 @@ class LlmLlmObsPlugin extends LLMObsPlugin {
149
183
  return { kind: 'llm', name: ctx.model, modelName, modelProvider, sessionId: ctx.sessionId }
150
184
  }
151
185
 
186
+ /**
187
+ * @override
188
+ */
189
+ getGenAiApmEndTags (ctx) {
190
+ return { metrics: extractUsageMetrics(ctx.usage) }
191
+ }
192
+
152
193
  end (ctx) {
153
194
  super.end(ctx)
154
195
  super.asyncEnd(ctx)
@@ -158,31 +199,32 @@ class LlmLlmObsPlugin extends LLMObsPlugin {
158
199
  const span = ctx.currentStore?.span
159
200
  if (!span) return
160
201
 
161
- const { chunks, llmStartIdx, llmEndIdx, parentToolUseId, initialPrompt, usage } = ctx
202
+ const { chunks, llmStartIdx, llmEndIdx, parentToolUseId, initialPrompt, systemPrompt, usage } = ctx
162
203
 
163
204
  if (chunks) {
164
- const inputMessages = this.#buildInputMessages(chunks, llmStartIdx, parentToolUseId, initialPrompt)
205
+ const inputMessages = this.#buildInputMessages(chunks, llmStartIdx, parentToolUseId, initialPrompt, systemPrompt)
165
206
  const outputMessages = buildOutputMessages(chunks, llmStartIdx, llmEndIdx)
166
207
  this._tagger.tagLLMIO(span, inputMessages, outputMessages)
167
208
  }
168
209
 
169
- if (usage) {
170
- const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0
171
- const cacheReadTokens = usage.cache_read_input_tokens ?? 0
172
- const inputTokens = (usage.input_tokens ?? 0) + cacheWriteTokens + cacheReadTokens
173
- const outputTokens = usage.output_tokens ?? 0
174
- this._tagger.tagMetrics(span, {
175
- input_tokens: inputTokens,
176
- output_tokens: outputTokens,
177
- cache_read_input_tokens: cacheReadTokens,
178
- cache_write_input_tokens: cacheWriteTokens,
179
- total_tokens: inputTokens + outputTokens,
180
- })
181
- }
210
+ const metrics = extractUsageMetrics(usage)
211
+ if (metrics) this._tagger.tagMetrics(span, metrics)
182
212
  }
183
213
 
184
- #buildInputMessages (chunks, llmStartIdx, parentToolUseId, initialPrompt) {
214
+ #buildInputMessages (chunks, llmStartIdx, parentToolUseId, initialPrompt, systemPrompt) {
185
215
  const messages = []
216
+ let configuredPrompt = systemPrompt
217
+ if (systemPrompt?.type === 'custom') configuredPrompt = systemPrompt.prompt
218
+ else if (systemPrompt?.type === 'preset') configuredPrompt = systemPrompt.append
219
+ if (typeof configuredPrompt === 'string') {
220
+ if (configuredPrompt) messages.push({ role: 'system', content: configuredPrompt })
221
+ } else if (Array.isArray(configuredPrompt)) {
222
+ for (const part of configuredPrompt) {
223
+ if (typeof part === 'string' && part && part !== SYSTEM_PROMPT_DYNAMIC_BOUNDARY) {
224
+ messages.push({ role: 'system', content: part })
225
+ }
226
+ }
227
+ }
186
228
  if (initialPrompt) messages.push({ role: 'user', content: initialPrompt })
187
229
  const seenIds = new Set()
188
230
 
@@ -245,6 +287,13 @@ class ToolLlmObsPlugin extends LLMObsPlugin {
245
287
  super.asyncEnd(ctx)
246
288
  }
247
289
 
290
+ /**
291
+ * @override
292
+ */
293
+ getGenAiApmEndTags (ctx) {
294
+ return subagentToolIds.delete(ctx.id) ? { spanKind: 'agent' } : {}
295
+ }
296
+
248
297
  setLLMObsTags (ctx) {
249
298
  const span = ctx.currentStore?.span
250
299
  if (!span) return
@@ -23,6 +23,13 @@ class GenAiLLMObsPlugin extends LLMObsPlugin {
23
23
 
24
24
  // Subscribe to streaming chunk events
25
25
  this.addSub('apm:google:genai:request:chunk', ({ ctx, chunk, done }) => {
26
+ if (!this._llmobsEnabledFor(ctx)) {
27
+ // only the token usage is needed, for the `gen_ai.usage.*` metrics. The aggregated
28
+ // response is left alone: it feeds the LLMObs payload and `google_genai.response.model`.
29
+ if (chunk?.usageMetadata) ctx.streamedUsageMetadata = chunk.usageMetadata
30
+ return
31
+ }
32
+
26
33
  ctx.isStreaming = true
27
34
  ctx.chunks ||= []
28
35
 
@@ -49,6 +56,13 @@ class GenAiLLMObsPlugin extends LLMObsPlugin {
49
56
  }
50
57
  }
51
58
 
59
+ /**
60
+ * @override
61
+ */
62
+ getGenAiApmEndTags (ctx) {
63
+ return { metrics: extractMetrics(ctx.result ?? { usageMetadata: ctx.streamedUsageMetadata }) }
64
+ }
65
+
52
66
  setLLMObsTags (ctx) {
53
67
  const { args, methodName } = ctx
54
68
  const span = ctx.currentStore?.span
@@ -38,19 +38,6 @@ class LangChainLLMObsChatModelHandler extends LangChainLLMObsHandler {
38
38
  }
39
39
 
40
40
  const outputMessages = []
41
- let inputTokens = 0
42
- let outputTokens = 0
43
- let totalTokens = 0
44
- let tokensSetTopLevel = false
45
- const tokensPerRunId = {}
46
-
47
- if (!isWorkflow) {
48
- const tokens = this.checkTokenUsageChatOrLLMResult(results)
49
- inputTokens = tokens.inputTokens
50
- outputTokens = tokens.outputTokens
51
- totalTokens = tokens.totalTokens
52
- tokensSetTopLevel = totalTokens > 0
53
- }
54
41
 
55
42
  for (const messageSet of results.generations) {
56
43
  for (const chatCompletion of messageSet) {
@@ -59,35 +46,47 @@ class LangChainLLMObsChatModelHandler extends LangChainLLMObsHandler {
59
46
  const content = chatCompletionMessage.text || ''
60
47
  const toolCalls = this.extractToolCalls(chatCompletionMessage)
61
48
  outputMessages.push({ content, role, toolCalls })
62
-
63
- if (!isWorkflow && !tokensSetTopLevel) {
64
- const { tokens, runId } = this.checkTokenUsageFromAIMessage(chatCompletionMessage)
65
- if (tokensPerRunId[runId]) {
66
- tokensPerRunId[runId].inputTokens += tokens.inputTokens
67
- tokensPerRunId[runId].outputTokens += tokens.outputTokens
68
- tokensPerRunId[runId].totalTokens += tokens.totalTokens
69
- } else {
70
- tokensPerRunId[runId] = tokens
71
- }
72
- }
73
49
  }
74
50
  }
75
51
 
76
- if (!isWorkflow && !tokensSetTopLevel) {
77
- inputTokens = Object.values(tokensPerRunId).reduce((acc, val) => acc + val.inputTokens, 0)
78
- outputTokens = Object.values(tokensPerRunId).reduce((acc, val) => acc + val.outputTokens, 0)
79
- totalTokens = Object.values(tokensPerRunId).reduce((acc, val) => acc + val.totalTokens, 0)
80
- }
81
-
82
52
  if (isWorkflow) {
83
53
  this._tagger.tagTextIO(span, inputMessages, outputMessages)
84
54
  } else {
85
55
  this._tagger.tagLLMIO(span, inputMessages, outputMessages)
86
- this._tagger.tagMetrics(span, {
87
- inputTokens,
88
- outputTokens,
89
- totalTokens,
90
- })
56
+ this._tagger.tagMetrics(span, this.getTokenUsage(results))
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @override
62
+ */
63
+ getTokenUsage (results) {
64
+ const tokens = this.checkTokenUsageChatOrLLMResult(results)
65
+ if (tokens.totalTokens > 0) return tokens
66
+
67
+ // providers that report usage on each generated message instead of on `llmOutput`; counts are
68
+ // summed per run so a run split across generations is totalled once
69
+ if (!results.generations) return tokens
70
+
71
+ const tokensPerRunId = {}
72
+ for (const messageSet of results.generations) {
73
+ for (const chatCompletion of messageSet) {
74
+ const { tokens: messageTokens, runId } = this.checkTokenUsageFromAIMessage(chatCompletion.message)
75
+ if (tokensPerRunId[runId]) {
76
+ tokensPerRunId[runId].inputTokens += messageTokens.inputTokens
77
+ tokensPerRunId[runId].outputTokens += messageTokens.outputTokens
78
+ tokensPerRunId[runId].totalTokens += messageTokens.totalTokens
79
+ } else {
80
+ tokensPerRunId[runId] = messageTokens
81
+ }
82
+ }
83
+ }
84
+
85
+ const perRun = Object.values(tokensPerRunId)
86
+ return {
87
+ inputTokens: perRun.reduce((acc, val) => acc + val.inputTokens, 0),
88
+ outputTokens: perRun.reduce((acc, val) => acc + val.outputTokens, 0),
89
+ totalTokens: perRun.reduce((acc, val) => acc + val.totalTokens, 0),
91
90
  }
92
91
  }
93
92
 
@@ -12,6 +12,16 @@ class LangChainLLMObsHandler {
12
12
 
13
13
  setMetaTags () {}
14
14
 
15
+ /**
16
+ * Token usage for a chat or LLM result. Subclasses widen this where the provider reports usage
17
+ * somewhere other than `llmOutput`.
18
+ *
19
+ * @param {object} results
20
+ */
21
+ getTokenUsage (results) {
22
+ return this.checkTokenUsageChatOrLLMResult(results)
23
+ }
24
+
15
25
  checkTokenUsageChatOrLLMResult (results) {
16
26
  const llmOutput = results.llmOutput
17
27
  const tokens = {
@@ -3,8 +3,6 @@
3
3
  const log = require('../../../log')
4
4
  const LLMObsPlugin = require('../base')
5
5
 
6
- const pluginManager = require('../../../../../..')._pluginManager
7
-
8
6
  const ANTHROPIC_PROVIDER_NAME = 'anthropic'
9
7
  const BEDROCK_PROVIDER_NAME = 'amazon_bedrock'
10
8
  const OPENAI_PROVIDER_NAME = 'openai'
@@ -76,6 +74,28 @@ class BaseLangChainLLMObsPlugin extends LLMObsPlugin {
76
74
  }
77
75
  }
78
76
 
77
+ /**
78
+ * @override
79
+ */
80
+ getGenAiApmEndTags (ctx, spanKind) {
81
+ // the helper reports zeros when the result carries no usage, and zeroed token metrics would
82
+ // read as a real measurement
83
+ const tokens = this._handlers[ctx.type]?.getTokenUsage(ctx.result ?? {})
84
+ const metrics = tokens?.totalTokens ? tokens : undefined
85
+
86
+ // langchain-openai calls an untraced beta client when `response_format` is set, so this span is
87
+ // the only model span for the call. `handlers/chat_model.js` applies the same correction through
88
+ // `changeKind` while building the LLMObs payload.
89
+ if (spanKind !== WORKFLOW || ctx.type !== 'chat_model' || !ctx.arguments?.[1]?.response_format) {
90
+ return { metrics }
91
+ }
92
+
93
+ const provider = ctx.currentStore?.span?.context().getTags()['langchain.request.provider']
94
+ const isOpenAI = this.getIntegrationName(ctx.type, provider) === OPENAI_PROVIDER_NAME
95
+
96
+ return isOpenAI ? { spanKind: LLM, metrics } : { metrics }
97
+ }
98
+
79
99
  setLLMObsTags (ctx) {
80
100
  ctx.args = ctx.arguments
81
101
  ctx.instance = ctx.self
@@ -158,6 +178,9 @@ class BaseLangChainLLMObsPlugin extends LLMObsPlugin {
158
178
  }
159
179
 
160
180
  isLLMIntegrationEnabled (integration) {
181
+ // read off the owning manager rather than a module-scope capture: the tracer, and with it the
182
+ // plugin manager, can be rebuilt after this module is first loaded
183
+ const pluginManager = this._tracer?._pluginManager
161
184
  return SUPPORTED_INTEGRATIONS.has(integration) && pluginManager?._pluginsByName[integration]?.llmobs?._enabled
162
185
  }
163
186
  }
@@ -14,15 +14,16 @@ class PregelStreamLLMObsPlugin extends LLMObsPlugin {
14
14
  getLLMObsSpanRegisterOptions (ctx) {
15
15
  const name = ctx.self.name || 'LangGraph'
16
16
 
17
- const enabled = this._tracerConfig.llmobs.DD_LLMOBS_ENABLED
18
- if (!enabled) return
19
-
20
17
  const span = ctx.currentStore?.span
21
18
  if (!span) return
22
- streamDataMap.set(span, {
23
- streamInputs: ctx.arguments?.[0],
24
- chunks: [],
25
- })
19
+
20
+ // only the LLMObs payload consumes the accumulated stream
21
+ if (this._llmobsEnabledFor(ctx)) {
22
+ streamDataMap.set(span, {
23
+ streamInputs: ctx.arguments?.[0],
24
+ chunks: [],
25
+ })
26
+ }
26
27
 
27
28
  return {
28
29
  kind: 'workflow',
@@ -74,6 +74,19 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
74
74
  }
75
75
  }
76
76
 
77
+ /**
78
+ * @override
79
+ */
80
+ getGenAiApmEndTags (ctx) {
81
+ const response = ctx.result?.data
82
+
83
+ return {
84
+ // the response model is the resolved one, e.g. the dated version behind an alias
85
+ modelName: response?.model,
86
+ metrics: response && this._extractMetrics(response),
87
+ }
88
+ }
89
+
77
90
  setLLMObsTags (ctx) {
78
91
  const span = ctx.currentStore?.span
79
92
  const resource = ctx.methodName
@@ -81,8 +94,8 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
81
94
  if (!methodName) return // we will not trace all openai methods for llmobs
82
95
 
83
96
  const inputs = ctx.args[0] // completion, chat completion, and embeddings take one argument
84
- const response = ctx.result?.data // no result if error
85
- const error = !!span.context().getTag('error')
97
+ const response = ctx.result?.data // no result if error, or if a stream ended before any response arrived
98
+ const error = !!span.context().getTag('error') || response == null
86
99
 
87
100
  const operation = getOperation(methodName)
88
101
 
@@ -155,6 +155,11 @@ class RealtimeLLMObsPlugin extends LLMObsPlugin {
155
155
  static integration = 'openai'
156
156
  static system = 'openai'
157
157
 
158
+ // The instrumentation retains a turn's audio only while these plugins are subscribed, and only
159
+ // the LLM Observability payload reads those bytes. Staying subscribed for the `gen_ai.*` tags
160
+ // alone would buffer megabytes per turn for no consumer, so realtime opts out.
161
+ static emitsGenAiApmTags = false
162
+
158
163
  /**
159
164
  * The instrumentation replays the turn with `traceSync`, which never publishes `asyncEnd`, so tag
160
165
  * on `end` instead — before the sibling tracing plugin's `end` finishes the span, since the LLM
@@ -23,6 +23,13 @@ class VertexAILLMObsPlugin extends LLMObsPlugin {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * @override
28
+ */
29
+ getGenAiApmEndTags (ctx) {
30
+ return { metrics: extractMetrics(ctx.result) }
31
+ }
32
+
26
33
  setLLMObsTags (ctx) {
27
34
  const span = ctx.currentStore?.span
28
35
  if (!span) return
@@ -2,7 +2,8 @@
2
2
 
3
3
  /** @typedef {import('../../../../../index').llmobs.FormattedPromptMessage} FormattedPromptMessage */
4
4
 
5
- const VARIABLE_PATTERN = /(?<!\{)(?:\{\{\s*(\w+)\s*\}\}(?!\})|\{\s*(\w+)\s*\}(?!\}))/g
5
+ // Match double braces first; preserve surrounding braces such as the closing object in {"age": {age}}.
6
+ const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}|\{\s*(\w+)\s*\}/g
6
7
 
7
8
  function isMessage (value) {
8
9
  return typeof value?.role === 'string' &&