dd-trace 6.18.0 → 6.19.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 (55) 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 +3 -2
  4. package/package.json +6 -6
  5. package/packages/datadog-instrumentations/src/anthropic.js +111 -9
  6. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +5 -1
  7. package/packages/datadog-instrumentations/src/cucumber.js +40 -4
  8. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +8 -0
  9. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +2 -0
  10. package/packages/datadog-instrumentations/src/jest/session-error.js +102 -0
  11. package/packages/datadog-instrumentations/src/jest.js +3 -10
  12. package/packages/datadog-instrumentations/src/playwright.js +26 -0
  13. package/packages/datadog-instrumentations/src/vitest-main.js +4 -1
  14. package/packages/datadog-instrumentations/src/vitest-worker.js +34 -3
  15. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +83 -29
  16. package/packages/datadog-plugin-cucumber/src/index.js +19 -4
  17. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +7 -0
  18. package/packages/datadog-plugin-fetch/src/index.js +3 -0
  19. package/packages/datadog-plugin-oracledb/src/connection-parser.js +3 -1
  20. package/packages/datadog-plugin-undici/src/index.js +5 -0
  21. package/packages/dd-trace/src/aiguard/client.js +38 -19
  22. package/packages/dd-trace/src/aiguard/integrations/anthropic.js +29 -4
  23. package/packages/dd-trace/src/aiguard/integrations/index.js +1 -1
  24. package/packages/dd-trace/src/aiguard/integrations/openai.js +20 -56
  25. package/packages/dd-trace/src/aiguard/integrations/stream.js +60 -0
  26. package/packages/dd-trace/src/aiguard/messages/anthropic.js +64 -0
  27. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -4
  28. package/packages/dd-trace/src/config/supported-configurations.json +5 -5
  29. package/packages/dd-trace/src/constants.js +2 -0
  30. package/packages/dd-trace/src/llmobs/constants/tags.js +31 -8
  31. package/packages/dd-trace/src/llmobs/gen-ai-tags.js +127 -0
  32. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +18 -0
  33. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +33 -8
  34. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +77 -41
  35. package/packages/dd-trace/src/llmobs/plugins/base.js +154 -15
  36. package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +162 -10
  37. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +65 -16
  38. package/packages/dd-trace/src/llmobs/plugins/genai/index.js +14 -0
  39. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +34 -35
  40. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +10 -0
  41. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +25 -2
  42. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +8 -7
  43. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +13 -0
  44. package/packages/dd-trace/src/llmobs/plugins/openai/realtime.js +5 -0
  45. package/packages/dd-trace/src/llmobs/plugins/vertexai.js +7 -0
  46. package/packages/dd-trace/src/llmobs/prompts/prompt.js +2 -1
  47. package/packages/dd-trace/src/llmobs/span_processor.js +10 -65
  48. package/packages/dd-trace/src/llmobs/tagger.js +2 -36
  49. package/packages/dd-trace/src/opentelemetry/trace/index.js +5 -0
  50. package/packages/dd-trace/src/plugins/util/test.js +4 -0
  51. package/packages/dd-trace/src/profiler.js +51 -5
  52. package/packages/dd-trace/src/profiling/profiler.js +24 -20
  53. package/packages/dd-trace/src/ritm.js +12 -9
  54. package/packages/dd-trace/src/span_processor.js +6 -1
  55. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
@@ -1,32 +1,89 @@
1
1
  'use strict'
2
2
 
3
3
  const log = require('../../log')
4
+ const { PROPAGATED_SESSION_ID_KEY } = require('../constants/tags')
5
+ const { MODEL_BACKED_SPAN_KINDS, setGenAiApmTags, updateGenAiApmTags } = require('../gen-ai-tags')
4
6
  const { storage: llmobsStorage } = require('../storage')
5
7
  const telemetry = require('../telemetry')
6
8
 
7
9
  const TracingPlugin = require('../../plugins/tracing')
8
10
  const LLMObsTagger = require('../tagger')
9
11
 
12
+ /**
13
+ * @typedef {object} LLMObsSpanRegisterOptions
14
+ * @property {string} kind LLMObs span kind
15
+ * @property {string} [name]
16
+ * @property {string} [modelName]
17
+ * @property {string} [modelProvider]
18
+ * @property {string} [mlApp]
19
+ * @property {string} [sessionId]
20
+ */
21
+
10
22
  class LLMObsPlugin extends TracingPlugin {
23
+ /**
24
+ * Whether this integration emits the `gen_ai.*` APM attributes while LLM Observability is off.
25
+ * An integration sets this to `false` when staying subscribed would cost more than the tags are
26
+ * worth, and then behaves as it did before those attributes existed: disabled outright.
27
+ */
28
+ static emitsGenAiApmTags = true
29
+
11
30
  constructor (...args) {
12
31
  super(...args)
13
32
 
14
33
  this._tagger = new LLMObsTagger(this._tracerConfig, true)
15
34
  }
16
35
 
36
+ /**
37
+ * Whether the LLMObs layer is active. When it is not, the plugin stays subscribed but only
38
+ * emits the `gen_ai.*` APM attributes.
39
+ *
40
+ */
41
+ get _llmobsEnabled () {
42
+ return this._tracerConfig.llmobs.DD_LLMOBS_ENABLED
43
+ }
44
+
45
+ /**
46
+ * The mode one operation runs in, latched on first read. `llmobs.enable()` and `llmobs.disable()`
47
+ * flip the flag while operations are in flight, and an operation that switches track halfway
48
+ * reports neither: the tagger rejects a span the start never registered, and the reduced path
49
+ * has no start tags for its end hook to update. Every hook for a given operation reads it here
50
+ * so they all take the branch its start took.
51
+ *
52
+ * @param {object} ctx
53
+ */
54
+ _llmobsEnabledFor (ctx) {
55
+ ctx.llmobsEnabled ??= this._llmobsEnabled
56
+ return ctx.llmobsEnabled
57
+ }
58
+
17
59
  setLLMObsTags (ctx) {
18
60
  throw new Error('setLLMObsTags must be implemented by the subclass')
19
61
  }
20
62
 
63
+ /**
64
+ * The `gen_ai.*` values an integration can only resolve once the operation finished, such as
65
+ * token usage or a session id the response carries. Only used while LLMObs is disabled; the
66
+ * LLMObs layer reads them off the span event instead. Fields left out keep their start value.
67
+ *
68
+ * @param {object} ctx
69
+ * @param {string} spanKind LLMObs span kind resolved at span start
70
+ * @returns {import('../gen-ai-tags').GenAiApmTags | void}
71
+ */
72
+ getGenAiApmEndTags (ctx, spanKind) {}
73
+
74
+ /**
75
+ * @param {object} ctx
76
+ * @returns {LLMObsSpanRegisterOptions | undefined}
77
+ */
21
78
  getLLMObsSpanRegisterOptions (ctx) {
22
79
  throw new Error('getLLMObsSPanRegisterOptions must be implemented by the subclass')
23
80
  }
24
81
 
25
82
  start (ctx) {
26
- // even though llmobs span events won't be enqueued if llmobs is disabled
27
- // we should avoid doing any computations here (these listeners aren't disabled)
28
- const enabled = this._tracerConfig.llmobs.DD_LLMOBS_ENABLED
29
- if (!enabled) return
83
+ if (!this._llmobsEnabledFor(ctx)) {
84
+ this.#setGenAiApmTagsFromRegisterOptions(ctx)
85
+ return
86
+ }
30
87
 
31
88
  const parentStore = llmobsStorage.getStore()
32
89
  const apmStore = ctx.currentStore
@@ -52,8 +109,7 @@ class LLMObsPlugin extends TracingPlugin {
52
109
  }
53
110
 
54
111
  end (ctx) {
55
- const enabled = this._tracerConfig.llmobs.DD_LLMOBS_ENABLED
56
- if (!enabled) return
112
+ if (!this._llmobsEnabledFor(ctx)) return
57
113
 
58
114
  // only attempt to restore the context if the current span was an LLMObs span
59
115
  const apmStore = ctx.currentStore
@@ -65,10 +121,10 @@ class LLMObsPlugin extends TracingPlugin {
65
121
  }
66
122
 
67
123
  asyncEnd (ctx) {
68
- // even though llmobs span events won't be enqueued if llmobs is disabled
69
- // we should avoid doing any computations here (these listeners aren't disabled)
70
- const enabled = this._tracerConfig.llmobs.DD_LLMOBS_ENABLED
71
- if (!enabled) return
124
+ if (!this._llmobsEnabledFor(ctx)) {
125
+ this.#setGenAiApmEndTags(ctx)
126
+ return
127
+ }
72
128
 
73
129
  const apmStore = ctx.currentStore
74
130
  const span = apmStore?.span
@@ -83,12 +139,95 @@ class LLMObsPlugin extends TracingPlugin {
83
139
  this.setLLMObsTags(ctx)
84
140
  }
85
141
 
142
+ /**
143
+ * Resolves the LLMObs annotations the `gen_ai.*` APM attributes need from the span register
144
+ * options, which every integration already builds for the LLMObs layer.
145
+ *
146
+ * @param {object} ctx
147
+ */
148
+ #setGenAiApmTagsFromRegisterOptions (ctx) {
149
+ const span = ctx.currentStore?.span
150
+ if (!span) return
151
+
152
+ try {
153
+ const registerOptions = this.getLLMObsSpanRegisterOptions(ctx)
154
+ if (!registerOptions?.kind) return
155
+
156
+ // `asyncEnd` needs these back: the kind to gate the usage metrics, and the model in case an
157
+ // integration promotes the kind to one that must always report a model
158
+ ctx.genAiApmStartTags = {
159
+ spanKind: registerOptions.kind,
160
+ modelName: registerOptions.modelName,
161
+ modelProvider: registerOptions.modelProvider,
162
+ mlApp: registerOptions.mlApp,
163
+ sessionId: registerOptions.sessionId,
164
+ }
165
+
166
+ this._setGenAiApmTags(span, ctx.genAiApmStartTags)
167
+ } catch (e) {
168
+ log.debug('Failed to set gen_ai APM tags for %s:', this.constructor.name, e.message)
169
+ }
170
+ }
171
+
172
+ /**
173
+ * @param {object} ctx
174
+ */
175
+ #setGenAiApmEndTags (ctx) {
176
+ const span = ctx.currentStore?.span
177
+ const startTags = ctx.genAiApmStartTags
178
+ if (!span || !startTags) return
179
+
180
+ const { spanKind } = startTags
181
+
182
+ try {
183
+ const endTags = this.getGenAiApmEndTags(ctx, spanKind)
184
+ if (!endTags) return
185
+
186
+ // An integration may correct the kind, the way the tagger's `changeKind` does. A correction
187
+ // into a model-backed kind has to go back through `setGenAiApmTags`, which applies the model
188
+ // and provider defaults a model-backed span always reports; an update alone would leave the
189
+ // span claiming a kind the enabled path could never emit without a model.
190
+ const promotedToModelBacked = endTags.spanKind &&
191
+ endTags.spanKind !== spanKind &&
192
+ MODEL_BACKED_SPAN_KINDS.has(endTags.spanKind)
193
+
194
+ if (promotedToModelBacked) {
195
+ this._setGenAiApmTags(span, { ...startTags, ...endTags })
196
+ } else {
197
+ updateGenAiApmTags(span, { spanKind, ...endTags })
198
+ }
199
+ } catch (e) {
200
+ log.debug('Failed to set gen_ai APM end tags for %s:', this.constructor.name, e.message)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Writes the `gen_ai.*` APM attributes.
206
+ *
207
+ * No `gen_ai.application.name`: ml_app is an LLM Observability concept, and with LLMObs off the
208
+ * only value left to report is the service name the span already carries. dd-trace-py's reduced
209
+ * path leaves it out for the same reason.
210
+ *
211
+ * @param {import('../../opentracing/span')} span
212
+ * @param {import('../gen-ai-tags').GenAiApmTags} tags
213
+ */
214
+ _setGenAiApmTags (span, tags) {
215
+ // the in-process session default is written by the tagger, which never runs on this path, so
216
+ // an inherited session can only have come from an upstream service
217
+ const propagatedSessionId = span.context()._trace.tags[PROPAGATED_SESSION_ID_KEY]
218
+
219
+ setGenAiApmTags(span, { ...tags, sessionId: tags.sessionId || propagatedSessionId })
220
+ }
221
+
86
222
  configure (config) {
87
- // we do not want to enable any LLMObs plugins if it is disabled on the tracer, or if the
88
- // integration opted out via `tracer.use(<name>, { llmobs: false })`. Opting out only disables
89
- // the LLMObs layer: the integration keeps emitting APM spans and propagating trace context.
90
- const llmobsEnabled = this._tracerConfig.llmobs.DD_LLMOBS_ENABLED
91
- if (llmobsEnabled === false || config?.llmobs === false) {
223
+ // an integration opt-out via `tracer.use(<name>, { llmobs: false })` disables the LLMObs layer
224
+ // entirely. When only LLMObs itself is disabled we stay subscribed: the handlers then emit the
225
+ // `gen_ai.*` APM attributes and skip the LLMObs payload, unless the integration opted out of
226
+ // those too.
227
+ const disabled = config?.llmobs === false ||
228
+ (!this._llmobsEnabled && !this.constructor.emitsGenAiApmTags)
229
+
230
+ if (disabled) {
92
231
  config = typeof config === 'boolean' ? false : { ...config, enabled: false } // override to false
93
232
  }
94
233
  super.configure(config)
@@ -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