dd-trace 5.131.1 → 5.132.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/index.d.ts +91 -3
- package/package.json +2 -2
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +57 -4
- package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +1 -0
- package/packages/datadog-instrumentations/src/jest.js +119 -1
- package/packages/datadog-instrumentations/src/mocha/utils.js +124 -36
- package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +61 -34
- package/packages/datadog-instrumentations/src/mocha/worker.js +6 -3
- package/packages/datadog-instrumentations/src/playwright.js +3 -0
- package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
- package/packages/datadog-instrumentations/src/webdriverio.js +115 -15
- package/packages/datadog-plugin-cypress/src/cypress-plugin.js +17 -0
- package/packages/datadog-plugin-mocha/src/index.js +216 -29
- package/packages/datadog-plugin-mocha/src/webdriverio-video.js +449 -0
- package/packages/dd-trace/src/debugger/constants.js +1 -0
- package/packages/dd-trace/src/debugger/devtools_client/index.js +5 -3
- package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +17 -12
- package/packages/dd-trace/src/debugger/devtools_client/send.js +6 -2
- package/packages/dd-trace/src/debugger/devtools_client/worker-error.js +21 -0
- package/packages/dd-trace/src/debugger/index.js +24 -2
- package/packages/dd-trace/src/debugger/probe_sampler.js +108 -11
- package/packages/dd-trace/src/exporters/common/request.js +1 -0
- package/packages/dd-trace/src/exporters/common/url.js +1 -1
- package/packages/dd-trace/src/id.js +1 -4
- package/packages/dd-trace/src/llmobs/agent-manifest.js +296 -0
- package/packages/dd-trace/src/llmobs/constants/tags.js +1 -0
- package/packages/dd-trace/src/llmobs/sdk.js +23 -6
- package/packages/dd-trace/src/llmobs/span_processor.js +17 -6
- package/packages/dd-trace/src/llmobs/tagger.js +54 -1
- package/packages/dd-trace/src/llmobs/writers/base.js +3 -1
- package/packages/dd-trace/src/llmobs/writers/util.js +3 -1
- package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +30 -1
- package/packages/dd-trace/src/openfeature/configuration_source.js +17 -2
- package/packages/dd-trace/src/openfeature/debug-logging-hook.js +25 -0
- package/packages/dd-trace/src/openfeature/flagging_provider.js +29 -0
- package/packages/dd-trace/src/openfeature/remote_config.js +9 -0
- package/packages/dd-trace/src/openfeature/writers/util.js +43 -4
- package/packages/dd-trace/src/opentelemetry/resource-attributes.js +1 -1
- package/packages/dd-trace/src/otel-thread-ctx.js +2 -2
- package/packages/dd-trace/src/profiling/profilers/wall.js +4 -10
- package/packages/dd-trace/src/proxy.js +2 -0
- package/packages/dd-trace/src/serverless.js +5 -0
- package/packages/dd-trace/src/storage-channels.js +112 -46
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { types } = require('node:util')
|
|
4
4
|
|
|
5
|
+
const { storage } = require('../../../datadog-core')
|
|
5
6
|
const { MAX_SNAPSHOTS_PER_SECOND_GLOBALLY } = require('./devtools_client/defaults')
|
|
6
7
|
const { MAX_MESSAGE_LENGTH } = require('./constants')
|
|
7
8
|
const { EVENT_TYPE, SKIPPED_REASON } = require('./guardrail-metrics')
|
|
@@ -32,6 +33,20 @@ const ddTraceGlobal = /** @type {Record<symbol, SharedArrayBuffer | object | und
|
|
|
32
33
|
* @property {{ DD_DYNAMIC_INSTRUMENTATION_EVALUATION_TIMEOUT_MS: number }} dynamicInstrumentation
|
|
33
34
|
*/
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The trace object shared by all spans of a trace in this process (`DatadogSpanContext#_trace`).
|
|
38
|
+
*
|
|
39
|
+
* @typedef {{ started: object[] }} LocalTrace
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The span active in the current async context, as stored in the legacy storage by the tracer.
|
|
44
|
+
*
|
|
45
|
+
* @typedef {{ context: () => { _trace: LocalTrace } }} ActiveSpan
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const legacyStorage = storage('legacy')
|
|
49
|
+
|
|
35
50
|
let evaluationTimeoutNs = 0n
|
|
36
51
|
|
|
37
52
|
module.exports = {
|
|
@@ -63,6 +78,14 @@ function installProbeSampler (guardrailMetrics, config) {
|
|
|
63
78
|
const buffer = createProbeSamplerBuffer()
|
|
64
79
|
|
|
65
80
|
const lastCaptureNsByProbeId = new Map()
|
|
81
|
+
/**
|
|
82
|
+
* The coordinated sampling decision of each trace in which a snapshot-producing probe was hit: `false` if the trace
|
|
83
|
+
* was dropped, otherwise the sampling indexes of the probes that already emitted a snapshot in it. Keyed weakly, so
|
|
84
|
+
* a decision is released together with its trace.
|
|
85
|
+
*
|
|
86
|
+
* @type {WeakMap<LocalTrace, Set<number> | false>}
|
|
87
|
+
*/
|
|
88
|
+
const emittedProbesByTrace = new WeakMap()
|
|
66
89
|
/**
|
|
67
90
|
* Probes that are skipped at entry because a recent evaluation failed or exceeded its time budget. One error result
|
|
68
91
|
* is reported per throttle window, so the probe stays visible without repeatedly paying for the evaluation.
|
|
@@ -219,7 +242,10 @@ function installProbeSampler (guardrailMetrics, config) {
|
|
|
219
242
|
}
|
|
220
243
|
|
|
221
244
|
/**
|
|
222
|
-
*
|
|
245
|
+
* Decide if a probe hit should be sampled and store the sampled probe index for the debugger worker.
|
|
246
|
+
*
|
|
247
|
+
* Snapshot-producing probes hit within an active trace share one sampling decision per trace, so a trace emits the
|
|
248
|
+
* snapshots of all of its probes or none of them. Other hits are sampled independently.
|
|
223
249
|
*
|
|
224
250
|
* @param {number} probeIndex - The worker-side probe sampling index.
|
|
225
251
|
* @param {string} probeId - The probe id.
|
|
@@ -228,29 +254,100 @@ function installProbeSampler (guardrailMetrics, config) {
|
|
|
228
254
|
* @param {boolean} isSnapshotProducingProbe - Whether this probe counts toward the global snapshot sample limit.
|
|
229
255
|
*/
|
|
230
256
|
function sample (probeIndex, probeId, now, nsBetweenSampling, isSnapshotProducingProbe) {
|
|
257
|
+
if (isSnapshotProducingProbe === true) {
|
|
258
|
+
const span = /** @type {ActiveSpan | null | undefined} */ (legacyStorage.getStore()?.span)
|
|
259
|
+
const trace = span?.context()._trace
|
|
260
|
+
// The span processor empties `started` once all spans of a trace have finished. A probe hit under such a trace
|
|
261
|
+
// runs in a context that outlived it, e.g. a connection pool or event emitter callback bound to a request that
|
|
262
|
+
// has since ended. Coordinating that hit would let a decision made for the old request silence the probe, or
|
|
263
|
+
// limit it to a single snapshot, for as long as the context lives, so it's sampled independently instead.
|
|
264
|
+
if (trace !== undefined && trace.started.length !== 0) {
|
|
265
|
+
return sampleInTrace(trace, probeIndex, probeId, now, nsBetweenSampling)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (isRateLimited(probeId, now, nsBetweenSampling, isSnapshotProducingProbe)) return false
|
|
270
|
+
return commitSample(probeIndex, probeId, now, isSnapshotProducingProbe)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Sample a snapshot-producing probe hit using the sampling decision of its trace. The first such probe hit in the
|
|
275
|
+
* trace makes the decision for all of them, subject to its per-probe rate limit and the global snapshot rate limit.
|
|
276
|
+
* If the trace is sampled, each probe then emits once in it without being subject to the rate limits, so the trace
|
|
277
|
+
* emits a complete set of snapshots. Those snapshots still count toward the rate limits, which keeps the overall
|
|
278
|
+
* volume close to the limits by making later traces less likely to be sampled.
|
|
279
|
+
*
|
|
280
|
+
* @param {LocalTrace} trace - The trace the probe was hit in.
|
|
281
|
+
* @param {number} probeIndex - The worker-side probe sampling index.
|
|
282
|
+
* @param {string} probeId - The probe id.
|
|
283
|
+
* @param {bigint} now - The current time.
|
|
284
|
+
* @param {bigint} nsBetweenSampling - Minimum nanoseconds between samples for this probe.
|
|
285
|
+
*/
|
|
286
|
+
function sampleInTrace (trace, probeIndex, probeId, now, nsBetweenSampling) {
|
|
287
|
+
const emittedProbes = emittedProbesByTrace.get(trace)
|
|
288
|
+
|
|
289
|
+
if (emittedProbes === undefined) {
|
|
290
|
+
if (isRateLimited(probeId, now, nsBetweenSampling, true)) {
|
|
291
|
+
emittedProbesByTrace.set(trace, false)
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
// If the sampled probe can't be handed over, no decision is made, so the next probe hit in the trace makes it
|
|
295
|
+
if (!commitSample(probeIndex, probeId, now, true)) return false
|
|
296
|
+
emittedProbesByTrace.set(trace, new Set([probeIndex]))
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (emittedProbes === false || emittedProbes.has(probeIndex)) {
|
|
301
|
+
guardrailMetrics.eventSkipped(SKIPPED_REASON.RATE_LIMIT_PROBE, EVENT_TYPE.SNAPSHOT)
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!commitSample(probeIndex, probeId, now, true)) return false
|
|
306
|
+
emittedProbes.add(probeIndex)
|
|
307
|
+
return true
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Check the per-probe and global rate limits, recording the skip if a limit is reached.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} probeId - The probe id.
|
|
314
|
+
* @param {bigint} now - The current time.
|
|
315
|
+
* @param {bigint} nsBetweenSampling - Minimum nanoseconds between samples for this probe.
|
|
316
|
+
* @param {boolean} isSnapshotProducingProbe - Whether this probe is subject to the global snapshot sample limit.
|
|
317
|
+
*/
|
|
318
|
+
function isRateLimited (probeId, now, nsBetweenSampling, isSnapshotProducingProbe) {
|
|
231
319
|
const lastCaptureNs = lastCaptureNsByProbeId.get(probeId)
|
|
232
320
|
if (lastCaptureNs !== undefined && now - lastCaptureNs < nsBetweenSampling) {
|
|
233
321
|
guardrailMetrics.eventSkipped(
|
|
234
322
|
SKIPPED_REASON.RATE_LIMIT_PROBE,
|
|
235
323
|
isSnapshotProducingProbe === true ? EVENT_TYPE.SNAPSHOT : EVENT_TYPE.LOG
|
|
236
324
|
)
|
|
237
|
-
return
|
|
325
|
+
return true
|
|
238
326
|
}
|
|
239
327
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
guardrailMetrics.eventSkipped(SKIPPED_REASON.RATE_LIMIT_GLOBAL, EVENT_TYPE.SNAPSHOT)
|
|
246
|
-
return false
|
|
247
|
-
}
|
|
328
|
+
if (isSnapshotProducingProbe === true &&
|
|
329
|
+
now - globalSnapshotSamplingRateWindowStart <= oneSecondNs &&
|
|
330
|
+
snapshotsSampledWithinTheLastSecond >= MAX_SNAPSHOTS_PER_SECOND_GLOBALLY) {
|
|
331
|
+
guardrailMetrics.eventSkipped(SKIPPED_REASON.RATE_LIMIT_GLOBAL, EVENT_TYPE.SNAPSHOT)
|
|
332
|
+
return true
|
|
248
333
|
}
|
|
249
334
|
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Store the sampled probe index for the debugger worker and count the sample toward the rate limits.
|
|
340
|
+
*
|
|
341
|
+
* @param {number} probeIndex - The worker-side probe sampling index.
|
|
342
|
+
* @param {string} probeId - The probe id.
|
|
343
|
+
* @param {bigint} now - The current time.
|
|
344
|
+
* @param {boolean} isSnapshotProducingProbe - Whether this probe counts toward the global snapshot sample limit.
|
|
345
|
+
*/
|
|
346
|
+
function commitSample (probeIndex, probeId, now, isSnapshotProducingProbe) {
|
|
250
347
|
if (!storeSampledProbeIndex(probeIndex)) return false
|
|
251
348
|
|
|
252
349
|
if (isSnapshotProducingProbe === true) {
|
|
253
|
-
if (
|
|
350
|
+
if (now - globalSnapshotSamplingRateWindowStart > oneSecondNs) {
|
|
254
351
|
snapshotsSampledWithinTheLastSecond = 1
|
|
255
352
|
globalSnapshotSamplingRateWindowStart = now
|
|
256
353
|
} else {
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
const { randomFillSync } = require('crypto')
|
|
4
4
|
|
|
5
|
-
const { channel } = require('dc-polyfill')
|
|
6
|
-
|
|
7
5
|
const UINT_MAX = 4_294_967_296
|
|
8
6
|
|
|
9
7
|
const data = new Uint8Array(8 * 8192)
|
|
@@ -11,8 +9,6 @@ const zeroId = new Uint8Array(8)
|
|
|
11
9
|
|
|
12
10
|
let batch = 0
|
|
13
11
|
|
|
14
|
-
channel('datadog:identity:update').subscribe(reseed)
|
|
15
|
-
|
|
16
12
|
// Internal representation of a trace or span ID.
|
|
17
13
|
class Identifier {
|
|
18
14
|
/** @type {number[] | Uint8Array} */
|
|
@@ -267,3 +263,4 @@ module.exports = function createIdentifier (value, radix) {
|
|
|
267
263
|
}
|
|
268
264
|
|
|
269
265
|
module.exports.Identifier = Identifier
|
|
266
|
+
module.exports.reseed = reseed
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const log = require('../log')
|
|
4
|
+
|
|
5
|
+
// Set by the SDK so a consumer can tell a hand-declared manifest from one read off a framework object.
|
|
6
|
+
const MANUAL_FRAMEWORK_NAME = 'manual'
|
|
7
|
+
|
|
8
|
+
// Provider-specific keys such as `extra_headers` can carry secrets, so only these are reported.
|
|
9
|
+
const ALLOWED_MODEL_SETTINGS_KEYS = new Set([
|
|
10
|
+
'frequency_penalty',
|
|
11
|
+
'logit_bias',
|
|
12
|
+
'logprobs',
|
|
13
|
+
'max_tokens',
|
|
14
|
+
'parallel_tool_calls',
|
|
15
|
+
'presence_penalty',
|
|
16
|
+
'seed',
|
|
17
|
+
'stop_sequences',
|
|
18
|
+
'temperature',
|
|
19
|
+
'timeout',
|
|
20
|
+
'tool_choice',
|
|
21
|
+
'top_k',
|
|
22
|
+
'top_logprobs',
|
|
23
|
+
'top_p',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const LABEL_FIELDS = ['name', 'instructions', 'model']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {{ type?: string, required?: true }} AgentToolParameter
|
|
30
|
+
* @typedef {{ name: string, description?: string, parameters?: Record<string, AgentToolParameter> }} AgentTool
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* name?: string,
|
|
33
|
+
* instructions?: string,
|
|
34
|
+
* model?: string,
|
|
35
|
+
* model_settings?: Record<string, unknown>,
|
|
36
|
+
* tools?: AgentTool[],
|
|
37
|
+
* }} AgentManifestFields
|
|
38
|
+
* @typedef {{ manifest: AgentManifestFields }} AgentDeclaration
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reads the agent a caller declared through `annotate({ agent })` or `annotationContext({ agent })` into an
|
|
43
|
+
* immutable snapshot. Never throws, since the caller's object may carry throwing getters or be a revoked Proxy,
|
|
44
|
+
* and this runs while an LLMObs span is being registered.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} agent
|
|
47
|
+
* @returns {AgentDeclaration | undefined} undefined when the agent declares nothing reportable
|
|
48
|
+
*/
|
|
49
|
+
function buildAgentDeclaration (agent) {
|
|
50
|
+
let declaration
|
|
51
|
+
try {
|
|
52
|
+
if (isPlainObject(agent)) {
|
|
53
|
+
const manifest = buildAgentManifest(agent)
|
|
54
|
+
if (manifest !== undefined) declaration = { manifest }
|
|
55
|
+
} else {
|
|
56
|
+
log.warn('Dropping agent annotation, the agent must be a plain object.')
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
log.warn('Dropping agent annotation, its fields could not be read.')
|
|
60
|
+
}
|
|
61
|
+
return declaration
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validates each manifest field on its own, so one bad field cannot blank the rest. Unset values (`undefined`,
|
|
66
|
+
* `null`, `''`, `[]`, `{}`) are omitted so they declare nothing rather than erase an earlier declaration. Fields
|
|
67
|
+
* that are set but unreportable are dropped and logged by name, never by value.
|
|
68
|
+
*
|
|
69
|
+
* @param {Record<string, unknown>} agent
|
|
70
|
+
* @returns {AgentManifestFields | undefined}
|
|
71
|
+
*/
|
|
72
|
+
function buildAgentManifest (agent) {
|
|
73
|
+
/** @type {string[]} */
|
|
74
|
+
const dropped = []
|
|
75
|
+
/** @type {AgentManifestFields | undefined} */
|
|
76
|
+
let manifest
|
|
77
|
+
|
|
78
|
+
for (const key of LABEL_FIELDS) {
|
|
79
|
+
const value = agent[key]
|
|
80
|
+
if (isUnset(value)) continue
|
|
81
|
+
if (typeof value === 'string') {
|
|
82
|
+
manifest ??= {}
|
|
83
|
+
manifest[key] = value
|
|
84
|
+
} else {
|
|
85
|
+
dropped.push(key)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const modelSettings = buildModelSettings(agent.modelSettings, dropped)
|
|
90
|
+
if (modelSettings) {
|
|
91
|
+
manifest ??= {}
|
|
92
|
+
manifest.model_settings = modelSettings
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tools = buildTools(agent.tools, dropped)
|
|
96
|
+
if (tools) {
|
|
97
|
+
manifest ??= {}
|
|
98
|
+
manifest.tools = tools
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (dropped.length > 0) {
|
|
102
|
+
log.warn('Dropping unsupported agent manifest fields: %s', dropped.join(', '))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return manifest
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {unknown} settings
|
|
110
|
+
* @param {string[]} dropped
|
|
111
|
+
* @returns {Record<string, unknown> | undefined}
|
|
112
|
+
*/
|
|
113
|
+
function buildModelSettings (settings, dropped) {
|
|
114
|
+
if (isUnset(settings)) return
|
|
115
|
+
if (!isPlainObject(settings)) {
|
|
116
|
+
dropped.push('modelSettings')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let allowed
|
|
121
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
122
|
+
const wireKey = toSnakeCase(key)
|
|
123
|
+
if (!ALLOWED_MODEL_SETTINGS_KEYS.has(wireKey)) {
|
|
124
|
+
dropped.push(`modelSettings.${key}`)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (isUnset(value)) continue
|
|
128
|
+
const wireValue = toFlatScalarValue(value)
|
|
129
|
+
if (wireValue === null) continue
|
|
130
|
+
if (wireValue === undefined) {
|
|
131
|
+
dropped.push(`modelSettings.${key}`)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
allowed ??= {}
|
|
135
|
+
allowed[wireKey] = wireValue
|
|
136
|
+
}
|
|
137
|
+
return allowed
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A JSON scalar, a flat array of scalars, or a flat object of numbers (`logit_bias`). No allowed setting nests, so
|
|
142
|
+
* anything deeper is dropped rather than coerced. Returns undefined for a value that cannot be reported, and null
|
|
143
|
+
* for an empty object, which declares nothing.
|
|
144
|
+
*
|
|
145
|
+
* @param {unknown} value
|
|
146
|
+
*/
|
|
147
|
+
function toFlatScalarValue (value) {
|
|
148
|
+
if (isScalar(value)) return value
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
const items = [...value]
|
|
151
|
+
for (const item of items) {
|
|
152
|
+
if (!isScalar(item)) return
|
|
153
|
+
}
|
|
154
|
+
return items
|
|
155
|
+
}
|
|
156
|
+
if (!isPlainObject(value)) return
|
|
157
|
+
|
|
158
|
+
const entries = Object.entries(value)
|
|
159
|
+
if (entries.length === 0) return null
|
|
160
|
+
for (const [, item] of entries) {
|
|
161
|
+
if (!isFiniteNumber(item)) return
|
|
162
|
+
}
|
|
163
|
+
// Built from entries so a key such as `__proto__` becomes an own property rather than setting the prototype.
|
|
164
|
+
return Object.fromEntries(entries)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {unknown} declared
|
|
169
|
+
* @param {string[]} dropped
|
|
170
|
+
* @returns {AgentTool[] | undefined}
|
|
171
|
+
*/
|
|
172
|
+
function buildTools (declared, dropped) {
|
|
173
|
+
if (isUnset(declared)) return
|
|
174
|
+
if (!Array.isArray(declared)) {
|
|
175
|
+
dropped.push('tools')
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const tools = []
|
|
180
|
+
for (let index = 0; index < declared.length; index++) {
|
|
181
|
+
const tool = declared[index]
|
|
182
|
+
const name = isPlainObject(tool) ? tool.name : undefined
|
|
183
|
+
// An unnamed tool is unidentifiable once it ships.
|
|
184
|
+
if (typeof name !== 'string' || name === '') {
|
|
185
|
+
dropped.push(`tools[${index}]`)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @type {AgentTool} */
|
|
190
|
+
const built = { name }
|
|
191
|
+
const description = tool.description
|
|
192
|
+
if (typeof description === 'string' && description !== '') built.description = description
|
|
193
|
+
const parameters = buildToolParameters(tool.parameters)
|
|
194
|
+
if (parameters === null) {
|
|
195
|
+
dropped.push(`tools[${index}].parameters`)
|
|
196
|
+
} else if (parameters) {
|
|
197
|
+
built.parameters = parameters
|
|
198
|
+
}
|
|
199
|
+
tools.push(built)
|
|
200
|
+
}
|
|
201
|
+
return tools.length > 0 ? tools : undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Flattens tool parameters to `{ param: { type?, required? } }`, the shape the Python framework integrations emit.
|
|
206
|
+
* `required` is omitted rather than reported false. Returns null for parameters that are set but not a plain
|
|
207
|
+
* object, such as a schema-library instance, so the caller can report them as dropped.
|
|
208
|
+
*
|
|
209
|
+
* @param {unknown} parameters
|
|
210
|
+
* @returns {Record<string, AgentToolParameter> | null | undefined}
|
|
211
|
+
*/
|
|
212
|
+
function buildToolParameters (parameters) {
|
|
213
|
+
if (isUnset(parameters)) return
|
|
214
|
+
if (!isPlainObject(parameters)) return null
|
|
215
|
+
|
|
216
|
+
const { specs, required } = toolParameterSpecs(parameters)
|
|
217
|
+
const flattened = []
|
|
218
|
+
for (const [param, spec] of Object.entries(specs)) {
|
|
219
|
+
/** @type {AgentToolParameter} */
|
|
220
|
+
const entry = {}
|
|
221
|
+
if (isPlainObject(spec)) {
|
|
222
|
+
const { type } = spec
|
|
223
|
+
if (typeof type === 'string' && type !== '') entry.type = type
|
|
224
|
+
if (spec.required === true) entry.required = true
|
|
225
|
+
}
|
|
226
|
+
if (required?.has(param)) entry.required = true
|
|
227
|
+
if (entry.type === undefined && entry.required === undefined) continue
|
|
228
|
+
flattened.push([param, entry])
|
|
229
|
+
}
|
|
230
|
+
// Built from entries so a parameter named `__proto__` becomes an own property rather than setting the prototype.
|
|
231
|
+
return flattened.length > 0 ? Object.fromEntries(flattened) : undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Accepts a JSON Schema object as well as the documented `{ param: { type, required } }` mapping, since a JSON
|
|
236
|
+
* Schema is what OpenAI, Gemini and MCP tool definitions carry. Two markers are required rather than `properties`
|
|
237
|
+
* alone, so a mapping that declares a parameter named `properties` is still read as a mapping.
|
|
238
|
+
*
|
|
239
|
+
* @param {Record<string, unknown>} parameters
|
|
240
|
+
* @returns {{ specs: Record<string, unknown>, required?: Set<unknown> }}
|
|
241
|
+
*/
|
|
242
|
+
function toolParameterSpecs (parameters) {
|
|
243
|
+
const { properties, required } = parameters
|
|
244
|
+
const hasRequiredList = Array.isArray(required)
|
|
245
|
+
if (!isPlainObject(properties) || !(parameters.type === 'object' || hasRequiredList)) {
|
|
246
|
+
return { specs: parameters }
|
|
247
|
+
}
|
|
248
|
+
return { specs: properties, required: hasRequiredList ? new Set(required) : undefined }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {string} key
|
|
253
|
+
*/
|
|
254
|
+
function toSnakeCase (key) {
|
|
255
|
+
return key.replaceAll(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Class instances are rejected so a schema-library object (Zod, the AI SDK `jsonSchema()` wrapper) is dropped rather
|
|
260
|
+
* than read field by field into a misleading manifest.
|
|
261
|
+
*
|
|
262
|
+
* @param {unknown} value
|
|
263
|
+
* @returns {value is Record<string, unknown>}
|
|
264
|
+
*/
|
|
265
|
+
function isPlainObject (value) {
|
|
266
|
+
if (value === null || typeof value !== 'object') return false
|
|
267
|
+
const prototype = Object.getPrototypeOf(value)
|
|
268
|
+
return prototype === Object.prototype || prototype === null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @param {unknown} value
|
|
273
|
+
*/
|
|
274
|
+
function isUnset (value) {
|
|
275
|
+
if (value === undefined || value === null || value === '') return true
|
|
276
|
+
return Array.isArray(value) && value.length === 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {unknown} value
|
|
281
|
+
*/
|
|
282
|
+
function isFiniteNumber (value) {
|
|
283
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {unknown} value
|
|
288
|
+
*/
|
|
289
|
+
function isScalar (value) {
|
|
290
|
+
return typeof value === 'string' || typeof value === 'boolean' || isFiniteNumber(value)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
MANUAL_FRAMEWORK_NAME,
|
|
295
|
+
buildAgentDeclaration,
|
|
296
|
+
}
|
|
@@ -18,6 +18,7 @@ module.exports = {
|
|
|
18
18
|
INTEGRATION: '_ml_obs.integration',
|
|
19
19
|
METADATA: '_ml_obs.meta.metadata',
|
|
20
20
|
COST_TAGS: '_ml_obs.meta.metadata._dd.cost_tags',
|
|
21
|
+
AGENT_MANIFEST: '_ml_obs.meta.metadata._dd.agent_manifest',
|
|
21
22
|
METRICS: '_ml_obs.metrics',
|
|
22
23
|
TOOL_DEFINITIONS: '_ml_obs.meta.tool_definitions',
|
|
23
24
|
ML_APP: '_ml_obs.meta.ml_app',
|
|
@@ -29,6 +29,8 @@ const {
|
|
|
29
29
|
getFunctionArguments,
|
|
30
30
|
validateKind,
|
|
31
31
|
} = require('./util')
|
|
32
|
+
const { buildAgentDeclaration } = require('./agent-manifest')
|
|
33
|
+
/** @typedef {import('./agent-manifest').AgentDeclaration} AgentDeclaration */
|
|
32
34
|
const { storage } = require('./storage')
|
|
33
35
|
const telemetry = require('./telemetry')
|
|
34
36
|
const LLMObsTagger = require('./tagger')
|
|
@@ -300,7 +302,7 @@ class LLMObs extends NoopLLMObs {
|
|
|
300
302
|
throw new Error('LLMObs span must have a span kind specified')
|
|
301
303
|
}
|
|
302
304
|
|
|
303
|
-
const { inputData, outputData, metadata, metrics, tags, prompt, costTags, toolDefinitions } = options
|
|
305
|
+
const { inputData, outputData, metadata, metrics, tags, prompt, costTags, toolDefinitions, agent } = options
|
|
304
306
|
|
|
305
307
|
const hasInputOrOutput = spanKind === 'experiment'
|
|
306
308
|
? inputData !== undefined || outputData !== undefined
|
|
@@ -338,6 +340,15 @@ class LLMObs extends NoopLLMObs {
|
|
|
338
340
|
if (toolDefinitions != null) {
|
|
339
341
|
this._tagger.tagToolDefinitions(span, toolDefinitions)
|
|
340
342
|
}
|
|
343
|
+
if (agent != null) {
|
|
344
|
+
if (spanKind === 'agent') {
|
|
345
|
+
this._tagger.tagAgent(span, agent)
|
|
346
|
+
} else {
|
|
347
|
+
logger.warn(
|
|
348
|
+
'Dropping agent annotation on non-agent span kind, annotating agents is only supported for agent spans.'
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
341
352
|
} catch (e) {
|
|
342
353
|
if (e.ddErrorTag) {
|
|
343
354
|
err = e.ddErrorTag
|
|
@@ -602,14 +613,20 @@ class LLMObs extends NoopLLMObs {
|
|
|
602
613
|
annotationContext (options, fn) {
|
|
603
614
|
if (!this.enabled) return fn()
|
|
604
615
|
|
|
616
|
+
/** @type {{ annotationContext?: object, agentDeclarations?: AgentDeclaration[] } | undefined} */
|
|
605
617
|
const currentStore = storage.getStore()
|
|
606
|
-
|
|
618
|
+
const { agent, ...contextOptions } = options ?? {}
|
|
619
|
+
/** @type {{ annotationContext: object, agentDeclarations?: AgentDeclaration[] }} */
|
|
607
620
|
const store = {
|
|
608
621
|
...currentStore,
|
|
609
|
-
annotationContext: {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
622
|
+
annotationContext: { ...currentStore?.annotationContext, ...contextOptions },
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (agent != null) {
|
|
626
|
+
const declaration = buildAgentDeclaration(agent)
|
|
627
|
+
// Outside `annotationContext`, whose keys come from the caller. Outermost first, so nested declarations fold
|
|
628
|
+
// onto the outer ones.
|
|
629
|
+
if (declaration) store.agentDeclarations = [...(currentStore?.agentDeclarations ?? []), declaration]
|
|
613
630
|
}
|
|
614
631
|
|
|
615
632
|
return storage.run(store, fn)
|
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
MODEL_PROVIDER,
|
|
16
16
|
METADATA,
|
|
17
17
|
COST_TAGS,
|
|
18
|
+
AGENT_MANIFEST,
|
|
18
19
|
TOOL_DEFINITIONS,
|
|
19
20
|
EXPERIMENT_INPUT,
|
|
20
21
|
EXPERIMENT_OUTPUT,
|
|
@@ -43,6 +44,7 @@ const {
|
|
|
43
44
|
DEFAULT_MODEL,
|
|
44
45
|
} = require('./constants/tags')
|
|
45
46
|
const { UNSERIALIZABLE_VALUE_TEXT } = require('./constants/text')
|
|
47
|
+
const { MANUAL_FRAMEWORK_NAME } = require('./agent-manifest')
|
|
46
48
|
const { setGenAiApmTags } = require('./gen-ai-tags')
|
|
47
49
|
const telemetry = require('./telemetry')
|
|
48
50
|
const LLMObsTagger = require('./tagger')
|
|
@@ -152,15 +154,26 @@ class LLMObsSpanProcessor {
|
|
|
152
154
|
meta.model_provider = (mlObsTags[MODEL_PROVIDER] || DEFAULT_MODEL).toLowerCase()
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
|
|
157
|
+
const name = mlObsTags[NAME] || span._name
|
|
158
|
+
const agentManifest = spanKind === 'agent' ? mlObsTags[AGENT_MANIFEST] : undefined
|
|
159
|
+
|
|
160
|
+
if (mlObsTags[METADATA] || mlObsTags[COST_TAGS] || agentManifest) {
|
|
161
|
+
/** @type {Record<string, unknown>} */
|
|
156
162
|
const metadata = {}
|
|
157
163
|
if (mlObsTags[METADATA]) this.#addObject(mlObsTags[METADATA], metadata)
|
|
158
|
-
// Only seed `metadata._dd` when there's something to put in it
|
|
159
|
-
//
|
|
160
|
-
//
|
|
164
|
+
// Only seed `metadata._dd` when there's something to put in it. Mirrors dd-trace-py and the
|
|
165
|
+
// cross-language wire format enforced by system-tests, so metadata-only spans must not carry
|
|
166
|
+
// an empty `_dd: {}` block.
|
|
161
167
|
if (mlObsTags[COST_TAGS]) {
|
|
162
168
|
this.#getDdMetadata(metadata).cost_tags = mlObsTags[COST_TAGS]
|
|
163
169
|
}
|
|
170
|
+
if (agentManifest) {
|
|
171
|
+
this.#getDdMetadata(metadata).agent_manifest = {
|
|
172
|
+
framework: MANUAL_FRAMEWORK_NAME,
|
|
173
|
+
...agentManifest,
|
|
174
|
+
name: agentManifest.name ?? name,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
164
177
|
meta.metadata = metadata
|
|
165
178
|
}
|
|
166
179
|
|
|
@@ -227,8 +240,6 @@ class LLMObsSpanProcessor {
|
|
|
227
240
|
const sessionId = mlObsTags[SESSION_ID]
|
|
228
241
|
const parentId = mlObsTags[PARENT_ID_KEY]
|
|
229
242
|
|
|
230
|
-
const name = mlObsTags[NAME] || span._name
|
|
231
|
-
|
|
232
243
|
const tags = this.#getTags(span, mlApp, sessionId, error)
|
|
233
244
|
llmObsSpan._tags = tags
|
|
234
245
|
|