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
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+
3
+ const log = require('../../log')
4
+
5
+ /**
6
+ * Splits an SDK stream, consumes one branch, and returns the other after inspection.
7
+ *
8
+ * @param {object} stream
9
+ * @param {(chunks: Array<object>) => void|Promise<void>} inspect
10
+ * @returns {object|Promise<object>}
11
+ */
12
+ function interceptStream (stream, inspect) {
13
+ if (typeof stream?.tee !== 'function') return stream
14
+
15
+ try {
16
+ const [inspectionStream, resultStream] = stream.tee()
17
+ return drainStream(inspectionStream).then(chunks => {
18
+ return Promise.resolve(inspect(chunks)).then(() => resultStream)
19
+ })
20
+ } catch {
21
+ return stream
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Buffers the whole stream. A stream that ends early still yields what it delivered: truncated
27
+ * output can carry the very violation the evaluation is looking for, so it is judged rather than
28
+ * skipped, and the read failure is logged because nothing downstream reports it.
29
+ *
30
+ * @param {object} stream
31
+ * @returns {Promise<Array<object>>}
32
+ */
33
+ function drainStream (stream) {
34
+ const chunks = []
35
+ const iterator = stream[Symbol.asyncIterator]()
36
+
37
+ function onReadError (error) {
38
+ log.error('AIGuard: the streamed response ended after %s chunks: %s', chunks.length, error)
39
+ return chunks
40
+ }
41
+
42
+ function readAll () {
43
+ let next
44
+ try {
45
+ next = iterator.next()
46
+ } catch (error) {
47
+ return Promise.resolve(onReadError(error))
48
+ }
49
+
50
+ return next.then(({ done, value }) => {
51
+ if (done) return chunks
52
+ chunks.push(value)
53
+ return readAll()
54
+ }, onReadError)
55
+ }
56
+
57
+ return readAll()
58
+ }
59
+
60
+ module.exports = { interceptStream }
@@ -431,10 +431,74 @@ function getMessagesOutputMessages (body) {
431
431
  return convertAnthropicMessage({ role, content: body.content })
432
432
  }
433
433
 
434
+ /**
435
+ * Combines Anthropic message stream events into regular output messages.
436
+ *
437
+ * Accumulates exactly the way the SDK does: every `content_block_start` appends a block, and
438
+ * deltas address blocks by position. Keying blocks by `event.index` instead would let a repeated
439
+ * or out-of-range index hide output that the caller still receives.
440
+ *
441
+ * @param {Array<object>} events
442
+ * @returns {Array<object>}
443
+ */
444
+ function getStreamedMessagesOutputMessages (events) {
445
+ let message
446
+ let contentBlocks
447
+ // Keyed by block, not by index: distinct indices can resolve to one block, and a second
448
+ // message must not inherit partial JSON accumulated for the first.
449
+ const inputJson = new Map()
450
+
451
+ for (const event of events) {
452
+ if (!event || typeof event !== 'object') continue
453
+
454
+ if (event.type === 'message_start' && event.message && typeof event.message === 'object') {
455
+ contentBlocks = Array.isArray(event.message.content)
456
+ ? event.message.content.map(block => ({ ...block }))
457
+ : []
458
+ message = { role: event.message.role || 'assistant' }
459
+ continue
460
+ }
461
+
462
+ if (event.type === 'content_block_start' && event.content_block && typeof event.content_block === 'object') {
463
+ message ??= { role: 'assistant' }
464
+ contentBlocks ??= []
465
+ contentBlocks.push({ ...event.content_block })
466
+ continue
467
+ }
468
+
469
+ if (event.type !== 'content_block_delta' || !event.delta || typeof event.delta !== 'object') continue
470
+
471
+ const block = contentBlocks?.at(event.index ?? 0)
472
+ if (!block) continue
473
+
474
+ if (event.delta.type === 'text_delta' && block.type === 'text' && typeof event.delta.text === 'string') {
475
+ block.text = (block.text || '') + event.delta.text
476
+ } else if (event.delta.type === 'input_json_delta' && typeof event.delta.partial_json === 'string') {
477
+ inputJson.set(block, (inputJson.get(block) || '') + event.delta.partial_json)
478
+ }
479
+ }
480
+
481
+ if (!message) return []
482
+
483
+ for (const [block, json] of inputJson) {
484
+ // An empty buffer is what a no-argument tool call accumulates; the SDK keeps `{}` there.
485
+ if (!json) continue
486
+ try {
487
+ block.input = JSON.parse(json)
488
+ } catch {
489
+ block.input = json
490
+ }
491
+ }
492
+
493
+ message.content = contentBlocks
494
+ return getMessagesOutputMessages(message)
495
+ }
496
+
434
497
  module.exports = {
435
498
  convertAnthropicSystem,
436
499
  convertAnthropicBlocksToContent,
437
500
  convertAnthropicMessage,
438
501
  getMessagesInputMessages,
439
502
  getMessagesOutputMessages,
503
+ getStreamedMessagesOutputMessages,
440
504
  }
@@ -574,8 +574,8 @@ export interface GeneratedConfig {
574
574
  DD_CODE_COVERAGE_FLAGS: string | undefined;
575
575
  DD_TEST_EARLY_FLAKE_DETECTION_RETRY_COUNT: number | undefined;
576
576
  DD_TEST_FAILED_TEST_REPLAY_ENABLED: boolean;
577
- DD_TEST_FAILURE_SCREENSHOTS_ENABLED: boolean | undefined;
578
- DD_TEST_FAILURE_VIDEOS_ENABLED: boolean | undefined;
577
+ DD_TEST_FAILURE_SCREENSHOTS_ENABLED: boolean;
578
+ DD_TEST_FAILURE_VIDEOS_ENABLED: boolean;
579
579
  DD_TEST_FLEET_CONFIG_PATH: string | undefined;
580
580
  DD_TEST_LOCAL_CONFIG_PATH: string | undefined;
581
581
  DD_TEST_MANAGEMENT_ATTEMPT_TO_FIX_RETRIES: number;
@@ -835,8 +835,8 @@ export interface GeneratedEnvVarConfig {
835
835
  DD_TELEMETRY_METRICS_ENABLED: boolean;
836
836
  DD_TEST_EARLY_FLAKE_DETECTION_RETRY_COUNT: number | undefined;
837
837
  DD_TEST_FAILED_TEST_REPLAY_ENABLED: boolean;
838
- DD_TEST_FAILURE_SCREENSHOTS_ENABLED: boolean | undefined;
839
- DD_TEST_FAILURE_VIDEOS_ENABLED: boolean | undefined;
838
+ DD_TEST_FAILURE_SCREENSHOTS_ENABLED: boolean;
839
+ DD_TEST_FAILURE_VIDEOS_ENABLED: boolean;
840
840
  DD_TEST_FLEET_CONFIG_PATH: string | undefined;
841
841
  DD_TEST_LOCAL_CONFIG_PATH: string | undefined;
842
842
  DD_TEST_MANAGEMENT_ATTEMPT_TO_FIX_RETRIES: number;
@@ -59,7 +59,7 @@
59
59
  "type": "boolean",
60
60
  "namespace": "aiguard",
61
61
  "default": "false",
62
- "description": "Analyze streamed OpenAI and Vercel AI responses with AI Guard After Model evaluation."
62
+ "description": "Analyze streamed Anthropic, OpenAI, and Vercel AI responses with AI Guard After Model evaluation."
63
63
  }
64
64
  ],
65
65
  "DD_AI_GUARD_BLOCK": [
@@ -2055,17 +2055,17 @@
2055
2055
  ],
2056
2056
  "DD_TEST_FAILURE_SCREENSHOTS_ENABLED": [
2057
2057
  {
2058
- "implementation": "A",
2058
+ "implementation": "B",
2059
2059
  "type": "boolean",
2060
- "default": null,
2060
+ "default": "true",
2061
2061
  "namespace": "testOptimization"
2062
2062
  }
2063
2063
  ],
2064
2064
  "DD_TEST_FAILURE_VIDEOS_ENABLED": [
2065
2065
  {
2066
- "implementation": "A",
2066
+ "implementation": "B",
2067
2067
  "type": "boolean",
2068
- "default": null,
2068
+ "default": "true",
2069
2069
  "namespace": "testOptimization"
2070
2070
  }
2071
2071
  ],
@@ -22,6 +22,8 @@ module.exports = {
22
22
  SPAN_SAMPLING_RULE_RATE: '_dd.span_sampling.rule_rate',
23
23
  SPAN_SAMPLING_MAX_PER_SECOND: '_dd.span_sampling.max_per_second',
24
24
  SVC_SRC_KEY: '_dd.svc_src',
25
+ SDK_OTLP_EXPORT_KEY: '_dd.sdk.otlp_export',
26
+ SDK_SEMANTICS_KEY: 'datadog.sdk.semantics',
25
27
  DATADOG_LAMBDA_EXTENSION_PATH: '/opt/extensions/datadog-agent',
26
28
  DATADOG_MINI_AGENT_PATH: '/tmp/datadog/mini_agent_ready',
27
29
  DECISION_MAKER_KEY: '_dd.p.dm',
@@ -1,5 +1,14 @@
1
1
  'use strict'
2
2
 
3
+ const INPUT_TOKENS_METRIC_KEY = 'input_tokens'
4
+ const OUTPUT_TOKENS_METRIC_KEY = 'output_tokens'
5
+ const TOTAL_TOKENS_METRIC_KEY = 'total_tokens'
6
+ const CACHE_READ_INPUT_TOKENS_METRIC_KEY = 'cache_read_input_tokens'
7
+ const CACHE_WRITE_INPUT_TOKENS_METRIC_KEY = 'cache_write_input_tokens'
8
+ const CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY = 'ephemeral_5m_input_tokens'
9
+ const CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY = 'ephemeral_1h_input_tokens'
10
+ const REASONING_OUTPUT_TOKENS_METRIC_KEY = 'reasoning_output_tokens'
11
+
3
12
  module.exports = {
4
13
  SPAN_KINDS: ['llm', 'agent', 'workflow', 'task', 'tool', 'embedding', 'retrieval', 'experiment'],
5
14
  SPAN_KIND: '_ml_obs.meta.span.kind',
@@ -42,6 +51,7 @@ module.exports = {
42
51
  MODEL_NAME: '_ml_obs.meta.model_name',
43
52
  MODEL_PROVIDER: '_ml_obs.meta.model_provider',
44
53
  UNKNOWN_MODEL_PROVIDER: 'unknown',
54
+ DEFAULT_MODEL: 'custom',
45
55
 
46
56
  INPUT_DOCUMENTS: '_ml_obs.meta.input.documents',
47
57
  INPUT_MESSAGES: '_ml_obs.meta.input.messages',
@@ -52,14 +62,27 @@ module.exports = {
52
62
  OUTPUT_MESSAGES: '_ml_obs.meta.output.messages',
53
63
  OUTPUT_VALUE: '_ml_obs.meta.output.value',
54
64
 
55
- INPUT_TOKENS_METRIC_KEY: 'input_tokens',
56
- OUTPUT_TOKENS_METRIC_KEY: 'output_tokens',
57
- TOTAL_TOKENS_METRIC_KEY: 'total_tokens',
58
- CACHE_READ_INPUT_TOKENS_METRIC_KEY: 'cache_read_input_tokens',
59
- CACHE_WRITE_INPUT_TOKENS_METRIC_KEY: 'cache_write_input_tokens',
60
- CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY: 'ephemeral_5m_input_tokens',
61
- CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY: 'ephemeral_1h_input_tokens',
62
- REASONING_OUTPUT_TOKENS_METRIC_KEY: 'reasoning_output_tokens',
65
+ INPUT_TOKENS_METRIC_KEY,
66
+ OUTPUT_TOKENS_METRIC_KEY,
67
+ TOTAL_TOKENS_METRIC_KEY,
68
+ CACHE_READ_INPUT_TOKENS_METRIC_KEY,
69
+ CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
70
+ CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY,
71
+ CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY,
72
+ REASONING_OUTPUT_TOKENS_METRIC_KEY,
73
+
74
+ // integrations build metric objects with these camelCase spellings. Null prototype: a metric
75
+ // named after an `Object.prototype` member must not resolve to an inherited property.
76
+ METRIC_KEY_ALIASES: Object.assign(Object.create(null), {
77
+ inputTokens: INPUT_TOKENS_METRIC_KEY,
78
+ outputTokens: OUTPUT_TOKENS_METRIC_KEY,
79
+ totalTokens: TOTAL_TOKENS_METRIC_KEY,
80
+ cacheReadTokens: CACHE_READ_INPUT_TOKENS_METRIC_KEY,
81
+ cacheWriteTokens: CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
82
+ cacheWrite5mTokens: CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY,
83
+ cacheWrite1hTokens: CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY,
84
+ reasoningOutputTokens: REASONING_OUTPUT_TOKENS_METRIC_KEY,
85
+ }),
63
86
 
64
87
  DROPPED_IO_COLLECTION_ERROR: 'dropped_io',
65
88
 
@@ -0,0 +1,127 @@
1
+ 'use strict'
2
+
3
+ const {
4
+ ARTIFICIAL_GEN_AI_TAGS,
5
+ CACHE_READ_INPUT_TOKENS_METRIC_KEY,
6
+ CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
7
+ DEFAULT_MODEL,
8
+ GEN_AI_APPLICATION_NAME,
9
+ GEN_AI_CONVERSATION_ID,
10
+ GEN_AI_OPERATION_NAME,
11
+ GEN_AI_PROVIDER_NAME,
12
+ GEN_AI_REQUEST_MODEL,
13
+ GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_METRIC_KEY,
14
+ GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
15
+ GEN_AI_USAGE_INPUT_TOKENS_METRIC_KEY,
16
+ GEN_AI_USAGE_OUTPUT_TOKENS_METRIC_KEY,
17
+ GEN_AI_USAGE_REASONING_OUTPUT_TOKENS_METRIC_KEY,
18
+ GEN_AI_USAGE_TOTAL_TOKENS_METRIC_KEY,
19
+ INPUT_TOKENS_METRIC_KEY,
20
+ METRIC_KEY_ALIASES,
21
+ OUTPUT_TOKENS_METRIC_KEY,
22
+ REASONING_OUTPUT_TOKENS_METRIC_KEY,
23
+ TOTAL_TOKENS_METRIC_KEY,
24
+ } = require('./constants/tags')
25
+
26
+ /** @type {Set<string | undefined>} */
27
+ const MODEL_BACKED_SPAN_KINDS = new Set(['llm', 'embedding'])
28
+
29
+ // null prototype: a metric named after an `Object.prototype` member must not resolve to an
30
+ // inherited property
31
+ const GEN_AI_USAGE_METRIC_KEYS = Object.assign(Object.create(null), {
32
+ [INPUT_TOKENS_METRIC_KEY]: GEN_AI_USAGE_INPUT_TOKENS_METRIC_KEY,
33
+ [OUTPUT_TOKENS_METRIC_KEY]: GEN_AI_USAGE_OUTPUT_TOKENS_METRIC_KEY,
34
+ [TOTAL_TOKENS_METRIC_KEY]: GEN_AI_USAGE_TOTAL_TOKENS_METRIC_KEY,
35
+ [CACHE_READ_INPUT_TOKENS_METRIC_KEY]: GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_METRIC_KEY,
36
+ [CACHE_WRITE_INPUT_TOKENS_METRIC_KEY]: GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
37
+ [REASONING_OUTPUT_TOKENS_METRIC_KEY]: GEN_AI_USAGE_REASONING_OUTPUT_TOKENS_METRIC_KEY,
38
+ })
39
+
40
+ /**
41
+ * @typedef {object} GenAiApmTags
42
+ * @property {string} [spanKind] LLMObs span kind
43
+ * @property {string} [modelName]
44
+ * @property {string} [modelProvider]
45
+ * @property {string} [mlApp]
46
+ * @property {string} [sessionId]
47
+ * @property {Record<string, unknown>} [metrics] LLMObs metrics, in either spelling
48
+ */
49
+
50
+ /**
51
+ * Writes the scalar `gen_ai.*` attributes onto the APM span, so model, provider, application,
52
+ * conversation and token usage are searchable in APM. Message bodies stay off the APM span.
53
+ *
54
+ * @param {import('../opentracing/span')} span
55
+ * @param {GenAiApmTags} tags
56
+ */
57
+ function setGenAiApmTags (span, tags) {
58
+ // mirrors the LLMObs span event: a model-backed span always reports a model and provider
59
+ updateGenAiApmTags(span, MODEL_BACKED_SPAN_KINDS.has(tags.spanKind)
60
+ ? { ...tags, modelName: tags.modelName || DEFAULT_MODEL, modelProvider: tags.modelProvider || DEFAULT_MODEL }
61
+ : tags)
62
+ }
63
+
64
+ /**
65
+ * Writes only the `gen_ai.*` attributes present in `tags`, for values an integration resolves
66
+ * after the span started. Absent fields keep whatever the span already carries.
67
+ *
68
+ * @param {import('../opentracing/span')} span
69
+ * @param {GenAiApmTags} tags
70
+ */
71
+ function updateGenAiApmTags (span, { spanKind, modelName, modelProvider, mlApp, sessionId, metrics }) {
72
+ const spanContext = span.context()
73
+
74
+ // written ahead of the attributes it covers: a throw partway through would otherwise leave
75
+ // `gen_ai.*` tags behind with nothing marking them as tracer-emitted
76
+ if (spanKind || modelName || modelProvider || mlApp || sessionId) {
77
+ markArtificialGenAiTags(spanContext)
78
+ }
79
+
80
+ if (spanKind) spanContext.setTag(GEN_AI_OPERATION_NAME, spanKind)
81
+ if (modelName) spanContext.setTag(GEN_AI_REQUEST_MODEL, modelName)
82
+ if (modelProvider) spanContext.setTag(GEN_AI_PROVIDER_NAME, modelProvider.toLowerCase())
83
+ if (mlApp) spanContext.setTag(GEN_AI_APPLICATION_NAME, mlApp)
84
+ if (sessionId) spanContext.setTag(GEN_AI_CONVERSATION_ID, sessionId)
85
+ if (metrics) setGenAiApmUsageMetrics(span, spanKind, metrics)
86
+ }
87
+
88
+ /**
89
+ * @param {import('../opentracing/span_context')} spanContext
90
+ */
91
+ function markArtificialGenAiTags (spanContext) {
92
+ spanContext.setTag(ARTIFICIAL_GEN_AI_TAGS, 'true')
93
+ }
94
+
95
+ /**
96
+ * Writes the `gen_ai.usage.*` metrics onto the APM span. Accepts both the LLMObs metric keys and
97
+ * the camelCase spellings integrations extract before the tagger normalizes them.
98
+ *
99
+ * @param {import('../opentracing/span')} span
100
+ * @param {string | undefined} spanKind LLMObs span kind
101
+ * @param {Record<string, unknown>} metrics
102
+ */
103
+ function setGenAiApmUsageMetrics (span, spanKind, metrics) {
104
+ // Other kinds carry unrelated metrics that would be misleading under a `gen_ai.usage.*` key.
105
+ if (!MODEL_BACKED_SPAN_KINDS.has(spanKind)) return
106
+
107
+ const spanContext = span.context()
108
+
109
+ // ahead of the metrics, for the same reason `updateGenAiApmTags` marks before its scalars
110
+ markArtificialGenAiTags(spanContext)
111
+
112
+ for (const [key, value] of Object.entries(metrics)) {
113
+ if (typeof value !== 'number') continue
114
+
115
+ const genAiKey = GEN_AI_USAGE_METRIC_KEYS[METRIC_KEY_ALIASES[key] ?? key]
116
+ if (!genAiKey) continue
117
+
118
+ spanContext.setTag(genAiKey, value)
119
+ }
120
+ }
121
+
122
+ module.exports = {
123
+ MODEL_BACKED_SPAN_KINDS,
124
+ setGenAiApmTags,
125
+ setGenAiApmUsageMetrics,
126
+ updateGenAiApmTags,
127
+ }
@@ -89,6 +89,24 @@ class DdTelemetryPlugin extends BaseLLMObsPlugin {
89
89
  return { kind, name: getLlmObsSpanName(operation, ctx.attributes['ai.telemetry.functionId']) }
90
90
  }
91
91
 
92
+ /**
93
+ * @override
94
+ */
95
+ getGenAiApmEndTags (ctx, spanKind) {
96
+ if (spanKind !== 'llm' && spanKind !== 'embedding') return
97
+
98
+ const tags = /** @type {Record<string, string>} */ (getSpanTags(ctx))
99
+ const embeddingUsage = tags['ai.usage.tokens']
100
+
101
+ return {
102
+ modelName: tags['ai.model.id'],
103
+ modelProvider: getModelProvider(tags),
104
+ metrics: spanKind === 'embedding'
105
+ ? { inputTokens: embeddingUsage, totalTokens: embeddingUsage }
106
+ : getUsage(tags),
107
+ }
108
+ }
109
+
92
110
  /**
93
111
  * @override
94
112
  */
@@ -140,6 +140,20 @@ function formatLanguageModelOutputMessages (content) {
140
140
  return outputMessages
141
141
  }
142
142
 
143
+ /**
144
+ * @param {object} [usage] AI SDK usage, from the result or the stream's `finish` chunk
145
+ * @returns {Record<string, number | undefined>}
146
+ */
147
+ function extractUsageMetrics (usage) {
148
+ return {
149
+ inputTokens: usage?.inputTokens?.total,
150
+ cacheWriteTokens: usage?.inputTokens?.cacheWrite ?? 0,
151
+ cacheReadTokens: usage?.inputTokens?.cacheRead ?? 0,
152
+ outputTokens: usage?.outputTokens?.total,
153
+ reasoningOutputTokens: usage?.outputTokens?.reasoning ?? 0,
154
+ }
155
+ }
156
+
143
157
  class VercelAiTelemetryPlugin extends BaseLLMObsPlugin {
144
158
  static id = 'ai_llmobs_vercel_telemetry'
145
159
  static integration = 'ai'
@@ -152,6 +166,13 @@ class VercelAiTelemetryPlugin extends BaseLLMObsPlugin {
152
166
  super(...arguments)
153
167
 
154
168
  this.addSub('dd-trace:vercel-ai:chunk', ({ ctx, chunk, done }) => {
169
+ if (!this._llmobsEnabledFor(ctx)) {
170
+ // only the token usage is needed, for the `gen_ai.usage.*` metrics; the message bodies and
171
+ // `ctx.result` are left to the LLMObs path
172
+ if (chunk?.type === 'finish') ctx.streamedUsage = chunk.usage
173
+ return
174
+ }
175
+
155
176
  ctx.chunks ??= []
156
177
  const chunks = ctx.chunks
157
178
  if (chunk) chunks.push(chunk)
@@ -202,6 +223,17 @@ class VercelAiTelemetryPlugin extends BaseLLMObsPlugin {
202
223
  super.asyncEnd(ctx)
203
224
  }
204
225
 
226
+ /**
227
+ * @override
228
+ */
229
+ getGenAiApmEndTags (ctx, spanKind) {
230
+ const usage = ctx.result?.usage ?? ctx.streamedUsage
231
+ if (!usage) return {}
232
+
233
+ // `embed` reports a single token count, the generation operations a structured breakdown
234
+ return { metrics: spanKind === 'embedding' ? { inputTokens: usage.tokens } : extractUsageMetrics(usage) }
235
+ }
236
+
205
237
  /**
206
238
  * @override
207
239
  */
@@ -367,14 +399,7 @@ class VercelAiTelemetryPlugin extends BaseLLMObsPlugin {
367
399
  if (!result) return
368
400
 
369
401
  // metrics
370
- const { usage } = result
371
- this._tagger.tagMetrics(span, {
372
- inputTokens: usage?.inputTokens?.total,
373
- cacheWriteTokens: usage?.inputTokens?.cacheWrite ?? 0,
374
- cacheReadTokens: usage?.inputTokens?.cacheRead ?? 0,
375
- outputTokens: usage?.outputTokens?.total,
376
- reasoningOutputTokens: usage?.outputTokens?.reasoning ?? 0,
377
- })
402
+ this._tagger.tagMetrics(span, extractUsageMetrics(result.usage))
378
403
  }
379
404
 
380
405
  setToolTags (span, ctx) {
@@ -22,6 +22,13 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
22
22
  super(...arguments)
23
23
 
24
24
  this.addSub('apm:anthropic:request:chunk', ({ ctx, chunk, done }) => {
25
+ if (!this._llmobsEnabledFor(ctx)) {
26
+ // only the token usage is needed, for the `gen_ai.usage.*` metrics; the message bodies
27
+ // and the aggregated response are left to the LLMObs path
28
+ if (chunk) ctx.streamedUsage = mergeChunkUsage(ctx.streamedUsage, chunk)
29
+ return
30
+ }
31
+
25
32
  ctx.chunks ??= []
26
33
  const chunks = ctx.chunks
27
34
  if (chunk) chunks.push(chunk)
@@ -36,9 +43,8 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
36
43
  const { message } = chunk
37
44
  if (!message) continue
38
45
 
39
- const { role, usage } = message
40
- if (role) response.role = role
41
- if (usage) response.usage = usage
46
+ if (message.role) response.role = message.role
47
+ response.usage = mergeChunkUsage(response.usage, chunk)
42
48
  break
43
49
  }
44
50
  case 'content_block_start': {
@@ -86,22 +92,10 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
86
92
  break
87
93
  }
88
94
  case 'message_delta': {
89
- const { delta } = chunk
90
-
91
- const finishReason = delta?.stop_reason
95
+ const finishReason = chunk.delta?.stop_reason
92
96
  if (finishReason) response.finish_reason = finishReason
93
97
 
94
- const { usage } = chunk
95
- if (usage) {
96
- const responseUsage = (response.usage ??= { input_tokens: 0, output_tokens: 0 })
97
- responseUsage.output_tokens = usage.output_tokens
98
-
99
- const cacheCreationTokens = usage.cache_creation_input_tokens
100
- const cacheReadTokens = usage.cache_read_input_tokens
101
- if (cacheCreationTokens) responseUsage.cache_creation_input_tokens = cacheCreationTokens
102
- if (cacheReadTokens) responseUsage.cache_read_input_tokens = cacheReadTokens
103
- }
104
-
98
+ response.usage = mergeChunkUsage(response.usage, chunk)
105
99
  break
106
100
  }
107
101
  case 'error': {
@@ -140,6 +134,13 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
140
134
  return UNKNOWN_MODEL_PROVIDER
141
135
  }
142
136
 
137
+ /**
138
+ * @override
139
+ */
140
+ getGenAiApmEndTags (ctx) {
141
+ return { metrics: extractUsage(ctx.result ?? { usage: ctx.streamedUsage }) }
142
+ }
143
+
143
144
  setLLMObsTags (ctx) {
144
145
  const span = ctx.currentStore?.span
145
146
  if (!span) return
@@ -213,38 +214,73 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
213
214
  }
214
215
 
215
216
  #tagAnthropicUsage (span, result) {
216
- if (!result) return
217
+ const metrics = extractUsage(result)
218
+ if (metrics) this._tagger.tagMetrics(span, metrics)
219
+ }
220
+ }
217
221
 
218
- const { usage } = result
219
- if (!usage) return
222
+ /**
223
+ * Merges the token usage a streamed chunk carries into the usage accumulated so far.
224
+ *
225
+ * @param {Record<string, number> | undefined} usage
226
+ * @param {object} chunk
227
+ * @returns {Record<string, number> | undefined}
228
+ */
229
+ function mergeChunkUsage (usage, chunk) {
230
+ if (chunk.type === 'message_start') {
231
+ // copied: the `message_delta` counts are merged into this object, and the chunk is handed back
232
+ // to the application
233
+ const startUsage = chunk.message?.usage
234
+ return startUsage ? { ...startUsage } : usage
235
+ }
220
236
 
221
- const inputTokens = usage.input_tokens
222
- const outputTokens = usage.output_tokens
223
- const cacheWriteTokens = usage.cache_creation_input_tokens
224
- const cacheReadTokens = usage.cache_read_input_tokens
237
+ if (chunk.type !== 'message_delta' || !chunk.usage) return usage
225
238
 
226
- const metrics = {
227
- inputTokens: (inputTokens ?? 0) + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0),
228
- }
239
+ const mergedUsage = usage ?? { input_tokens: 0, output_tokens: 0 }
240
+ mergedUsage.output_tokens = chunk.usage.output_tokens
241
+
242
+ const cacheCreationTokens = chunk.usage.cache_creation_input_tokens
243
+ const cacheReadTokens = chunk.usage.cache_read_input_tokens
244
+ if (cacheCreationTokens) mergedUsage.cache_creation_input_tokens = cacheCreationTokens
245
+ if (cacheReadTokens) mergedUsage.cache_read_input_tokens = cacheReadTokens
229
246
 
230
- if (outputTokens) metrics.outputTokens = outputTokens
231
- const totalTokens = metrics.inputTokens + (outputTokens ?? 0)
232
- if (totalTokens) metrics.totalTokens = totalTokens
247
+ return mergedUsage
248
+ }
233
249
 
234
- if (cacheWriteTokens != null) metrics.cacheWriteTokens = cacheWriteTokens
235
- if (cacheReadTokens != null) metrics.cacheReadTokens = cacheReadTokens
250
+ /**
251
+ * @param {object} [result]
252
+ * @returns {Record<string, number> | undefined}
253
+ */
254
+ function extractUsage (result) {
255
+ const usage = result?.usage
256
+ if (!usage) return
257
+
258
+ const inputTokens = usage.input_tokens
259
+ const outputTokens = usage.output_tokens
260
+ const cacheWriteTokens = usage.cache_creation_input_tokens
261
+ const cacheReadTokens = usage.cache_read_input_tokens
262
+
263
+ const metrics = {
264
+ inputTokens: (inputTokens ?? 0) + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0),
265
+ }
236
266
 
237
- const cacheCreation = usage.cache_creation
238
- if (cacheCreation) {
239
- metrics.cacheWrite5mTokens = cacheCreation.ephemeral_5m_input_tokens ?? 0
240
- metrics.cacheWrite1hTokens = cacheCreation.ephemeral_1h_input_tokens ?? 0
241
- } else if (cacheWriteTokens != null) {
242
- metrics.cacheWrite5mTokens = cacheWriteTokens
243
- metrics.cacheWrite1hTokens = 0
244
- }
267
+ if (outputTokens) metrics.outputTokens = outputTokens
268
+ const totalTokens = metrics.inputTokens + (outputTokens ?? 0)
269
+ if (totalTokens) metrics.totalTokens = totalTokens
245
270
 
246
- this._tagger.tagMetrics(span, metrics)
271
+ if (cacheWriteTokens != null) metrics.cacheWriteTokens = cacheWriteTokens
272
+ if (cacheReadTokens != null) metrics.cacheReadTokens = cacheReadTokens
273
+
274
+ const cacheCreation = usage.cache_creation
275
+ if (cacheCreation) {
276
+ metrics.cacheWrite5mTokens = cacheCreation.ephemeral_5m_input_tokens ?? 0
277
+ metrics.cacheWrite1hTokens = cacheCreation.ephemeral_1h_input_tokens ?? 0
278
+ } else if (cacheWriteTokens != null) {
279
+ metrics.cacheWrite5mTokens = cacheWriteTokens
280
+ metrics.cacheWrite1hTokens = 0
247
281
  }
282
+
283
+ return metrics
248
284
  }
249
285
 
250
286
  module.exports = AnthropicLLMObsPlugin