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.
- package/LICENSE-3rdparty.csv +1 -0
- package/ci/vitest-no-worker-init-setup.mjs +22 -12
- package/index.d.ts +3 -2
- package/package.json +6 -6
- package/packages/datadog-instrumentations/src/anthropic.js +111 -9
- package/packages/datadog-instrumentations/src/claude-agent-sdk.js +5 -1
- package/packages/datadog-instrumentations/src/cucumber.js +40 -4
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +8 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +2 -0
- package/packages/datadog-instrumentations/src/jest/session-error.js +102 -0
- package/packages/datadog-instrumentations/src/jest.js +3 -10
- package/packages/datadog-instrumentations/src/playwright.js +26 -0
- package/packages/datadog-instrumentations/src/vitest-main.js +4 -1
- package/packages/datadog-instrumentations/src/vitest-worker.js +34 -3
- package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +83 -29
- package/packages/datadog-plugin-cucumber/src/index.js +19 -4
- package/packages/datadog-plugin-cypress/src/cypress-plugin.js +7 -0
- package/packages/datadog-plugin-fetch/src/index.js +3 -0
- package/packages/datadog-plugin-oracledb/src/connection-parser.js +3 -1
- package/packages/datadog-plugin-undici/src/index.js +5 -0
- package/packages/dd-trace/src/aiguard/client.js +38 -19
- package/packages/dd-trace/src/aiguard/integrations/anthropic.js +29 -4
- package/packages/dd-trace/src/aiguard/integrations/index.js +1 -1
- package/packages/dd-trace/src/aiguard/integrations/openai.js +20 -56
- package/packages/dd-trace/src/aiguard/integrations/stream.js +60 -0
- package/packages/dd-trace/src/aiguard/messages/anthropic.js +64 -0
- package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -4
- package/packages/dd-trace/src/config/supported-configurations.json +5 -5
- package/packages/dd-trace/src/constants.js +2 -0
- package/packages/dd-trace/src/llmobs/constants/tags.js +31 -8
- package/packages/dd-trace/src/llmobs/gen-ai-tags.js +127 -0
- package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +18 -0
- package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +33 -8
- package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +77 -41
- package/packages/dd-trace/src/llmobs/plugins/base.js +154 -15
- package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +162 -10
- package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +65 -16
- package/packages/dd-trace/src/llmobs/plugins/genai/index.js +14 -0
- package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +34 -35
- package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +10 -0
- package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +25 -2
- package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +8 -7
- package/packages/dd-trace/src/llmobs/plugins/openai/index.js +13 -0
- package/packages/dd-trace/src/llmobs/plugins/openai/realtime.js +5 -0
- package/packages/dd-trace/src/llmobs/plugins/vertexai.js +7 -0
- package/packages/dd-trace/src/llmobs/prompts/prompt.js +2 -1
- package/packages/dd-trace/src/llmobs/span_processor.js +10 -65
- package/packages/dd-trace/src/llmobs/tagger.js +2 -36
- package/packages/dd-trace/src/opentelemetry/trace/index.js +5 -0
- package/packages/dd-trace/src/plugins/util/test.js +4 -0
- package/packages/dd-trace/src/profiler.js +51 -5
- package/packages/dd-trace/src/profiling/profiler.js +24 -20
- package/packages/dd-trace/src/ritm.js +12 -9
- package/packages/dd-trace/src/span_processor.js +6 -1
- 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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
175
|
-
const outputTokens = usage.outputTokens
|
|
176
|
-
const cacheReadTokens = usage.cacheReadTokens
|
|
177
|
-
const cacheWriteTokens = usage.cacheWriteTokens
|
|
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
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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
|