dd-trace 6.20.0 → 6.21.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 (45) hide show
  1. package/index.d.ts +91 -3
  2. package/package.json +6 -7
  3. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +57 -4
  4. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +1 -0
  5. package/packages/datadog-instrumentations/src/jest.js +119 -1
  6. package/packages/datadog-instrumentations/src/mocha/utils.js +124 -36
  7. package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +61 -34
  8. package/packages/datadog-instrumentations/src/mocha/worker.js +6 -3
  9. package/packages/datadog-instrumentations/src/playwright.js +3 -0
  10. package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
  11. package/packages/datadog-instrumentations/src/webdriverio.js +115 -15
  12. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +17 -0
  13. package/packages/datadog-plugin-mocha/src/index.js +216 -29
  14. package/packages/datadog-plugin-mocha/src/webdriverio-video.js +449 -0
  15. package/packages/dd-trace/src/config/supported-configurations.json +2 -2
  16. package/packages/dd-trace/src/debugger/constants.js +1 -0
  17. package/packages/dd-trace/src/debugger/devtools_client/index.js +5 -3
  18. package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +17 -12
  19. package/packages/dd-trace/src/debugger/devtools_client/send.js +6 -2
  20. package/packages/dd-trace/src/debugger/devtools_client/worker-error.js +21 -0
  21. package/packages/dd-trace/src/debugger/index.js +24 -2
  22. package/packages/dd-trace/src/debugger/probe_sampler.js +108 -11
  23. package/packages/dd-trace/src/exporters/common/request.js +1 -0
  24. package/packages/dd-trace/src/exporters/common/url.js +1 -1
  25. package/packages/dd-trace/src/id.js +1 -4
  26. package/packages/dd-trace/src/llmobs/agent-manifest.js +296 -0
  27. package/packages/dd-trace/src/llmobs/constants/tags.js +1 -0
  28. package/packages/dd-trace/src/llmobs/sdk.js +23 -6
  29. package/packages/dd-trace/src/llmobs/span_processor.js +17 -6
  30. package/packages/dd-trace/src/llmobs/tagger.js +54 -1
  31. package/packages/dd-trace/src/llmobs/writers/base.js +3 -1
  32. package/packages/dd-trace/src/llmobs/writers/util.js +3 -1
  33. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +30 -1
  34. package/packages/dd-trace/src/openfeature/configuration_source.js +17 -2
  35. package/packages/dd-trace/src/openfeature/debug-logging-hook.js +25 -0
  36. package/packages/dd-trace/src/openfeature/flagging_provider.js +29 -0
  37. package/packages/dd-trace/src/openfeature/remote_config.js +9 -0
  38. package/packages/dd-trace/src/openfeature/writers/util.js +43 -4
  39. package/packages/dd-trace/src/opentelemetry/resource-attributes.js +1 -1
  40. package/packages/dd-trace/src/otel-thread-ctx.js +2 -2
  41. package/packages/dd-trace/src/plugins/util/url.js +6 -1
  42. package/packages/dd-trace/src/profiling/profilers/wall.js +4 -10
  43. package/packages/dd-trace/src/proxy.js +2 -0
  44. package/packages/dd-trace/src/serverless.js +5 -0
  45. 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
- * Apply the per-probe and global rate limits and store the sampled probe index for the debugger worker.
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 false
325
+ return true
238
326
  }
239
327
 
240
- let shouldResetGlobalSnapshotRateWindow = false
241
- if (isSnapshotProducingProbe === true) {
242
- if (now - globalSnapshotSamplingRateWindowStart > oneSecondNs) {
243
- shouldResetGlobalSnapshotRateWindow = true
244
- } else if (snapshotsSampledWithinTheLastSecond >= MAX_SNAPSHOTS_PER_SECOND_GLOBALLY) {
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 (shouldResetGlobalSnapshotRateWindow === true) {
350
+ if (now - globalSnapshotSamplingRateWindowStart > oneSecondNs) {
254
351
  snapshotsSampledWithinTheLastSecond = 1
255
352
  globalSnapshotSamplingRateWindowStart = now
256
353
  } else {
@@ -6,6 +6,7 @@
6
6
  const { Readable } = require('stream')
7
7
  const http = require('http')
8
8
  const https = require('https')
9
+ const { URL } = require('url')
9
10
  const zlib = require('zlib')
10
11
 
11
12
  const { storage } = require('../../../../datadog-core')
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const net = require('node:net')
4
- const { format } = require('node:url')
4
+ const { format, URL } = require('node:url')
5
5
 
6
6
  const { urlToHttpOptions } = require('./url-to-http-options-polyfill')
7
7
 
@@ -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
- ...currentStore?.annotationContext,
611
- ...options,
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
- if (mlObsTags[METADATA] || mlObsTags[COST_TAGS]) {
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 (currently cost_tags). Mirrors
159
- // dd-trace-py and the cross-language wire format enforced by system-tests — metadata-only
160
- // spans must not carry an empty `_dd: {}` block.
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