dd-trace 5.66.0 → 5.67.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 CHANGED
@@ -2566,6 +2566,25 @@ declare namespace tracer {
2566
2566
  annotate (options: llmobs.AnnotationOptions): void
2567
2567
  annotate (span: tracer.Span | undefined, options: llmobs.AnnotationOptions): void
2568
2568
 
2569
+ /**
2570
+ * Register a processor to be called on each LLMObs span.
2571
+ *
2572
+ * This can be used to modify the span before it is sent to LLMObs. For example, you can modify the input/output.
2573
+ * You can also return `null` to omit the span entirely from being sent to LLM Observability.
2574
+ *
2575
+ * Otherwise, if the return value from the processor is not an instance of `LLMObservabilitySpan`, the span will be dropped.
2576
+ *
2577
+ * To deregister the processor, call `llmobs.deregisterProcessor()`
2578
+ * @param processor A function that will be called for each span.
2579
+ * @throws {Error} If a processor is already registered.
2580
+ */
2581
+ registerProcessor (processor: ((span: LLMObservabilitySpan) => LLMObservabilitySpan | null)): void
2582
+
2583
+ /**
2584
+ * Deregister a processor.
2585
+ */
2586
+ deregisterProcessor (): void
2587
+
2569
2588
  /**
2570
2589
  * Submits a custom evaluation metric for a given span ID and trace ID.
2571
2590
  * @param spanContext The span context of the span to submit the evaluation metric for.
@@ -2579,6 +2598,25 @@ declare namespace tracer {
2579
2598
  flush (): void
2580
2599
  }
2581
2600
 
2601
+ interface LLMObservabilitySpan {
2602
+ /**
2603
+ * The input content associated with the span.
2604
+ */
2605
+ input: { content: string, role?: string }[]
2606
+
2607
+ /**
2608
+ * The output content associated with the span.
2609
+ */
2610
+ output: { content: string, role?: string }[]
2611
+
2612
+ /**
2613
+ * Get a tag from the span.
2614
+ * @param key The key of the tag to get.
2615
+ * @returns The value of the tag, or `undefined` if the tag does not exist.
2616
+ */
2617
+ getTag (key: string): string | undefined
2618
+ }
2619
+
2582
2620
  interface EvaluationOptions {
2583
2621
  /**
2584
2622
  * The name of the evaluation metric
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dd-trace",
3
- "version": "5.66.0",
3
+ "version": "5.67.0",
4
4
  "description": "Datadog APM tracing client for JavaScript",
5
5
  "main": "index.js",
6
6
  "typings": "index.d.ts",
@@ -16,6 +16,7 @@ const spanProcessCh = channel('dd-trace:span:process')
16
16
  const evalMetricAppendCh = channel('llmobs:eval-metric:append')
17
17
  const flushCh = channel('llmobs:writers:flush')
18
18
  const injectCh = channel('dd-trace:span:inject')
19
+ const registerUserSpanProcessorCh = channel('llmobs:register-processor')
19
20
 
20
21
  const LLMObsEvalMetricsWriter = require('./writers/evaluations')
21
22
  const LLMObsTagger = require('./tagger')
@@ -56,6 +57,7 @@ function enable (config) {
56
57
 
57
58
  evalMetricAppendCh.subscribe(handleEvalMetricAppend)
58
59
  flushCh.subscribe(handleFlush)
60
+ registerUserSpanProcessorCh.subscribe(handleRegisterProcessor)
59
61
 
60
62
  // span processing
61
63
  spanProcessor = new LLMObsSpanProcessor(config)
@@ -86,6 +88,7 @@ function disable () {
86
88
  if (flushCh.hasSubscribers) flushCh.unsubscribe(handleFlush)
87
89
  if (spanProcessCh.hasSubscribers) spanProcessCh.unsubscribe(handleSpanProcess)
88
90
  if (injectCh.hasSubscribers) injectCh.unsubscribe(handleLLMObsParentIdInjection)
91
+ if (registerUserSpanProcessorCh.hasSubscribers) registerUserSpanProcessorCh.unsubscribe(handleRegisterProcessor)
89
92
 
90
93
  spanWriter?.destroy()
91
94
  evalWriter?.destroy()
@@ -126,6 +129,10 @@ function handleFlush () {
126
129
  telemetry.recordUserFlush(err)
127
130
  }
128
131
 
132
+ function handleRegisterProcessor (userSpanProcessor) {
133
+ spanProcessor.setUserSpanProcessor(userSpanProcessor)
134
+ }
135
+
129
136
  function handleSpanProcess (data) {
130
137
  spanProcessor.process(data)
131
138
  }
@@ -23,9 +23,16 @@ const LLMObsTagger = require('./tagger')
23
23
  const { channel } = require('dc-polyfill')
24
24
  const evalMetricAppendCh = channel('llmobs:eval-metric:append')
25
25
  const flushCh = channel('llmobs:writers:flush')
26
+ const registerUserSpanProcessorCh = channel('llmobs:register-processor')
26
27
  const NoopLLMObs = require('./noop')
27
28
 
28
29
  class LLMObs extends NoopLLMObs {
30
+ /**
31
+ * flag representing if a user span processor has been registered
32
+ * @type {boolean}
33
+ */
34
+ #hasUserSpanProcessor = false
35
+
29
36
  constructor (tracer, llmobsModule, config) {
30
37
  super(tracer)
31
38
 
@@ -309,6 +316,27 @@ class LLMObs extends NoopLLMObs {
309
316
  }
310
317
  }
311
318
 
319
+ registerProcessor (processor) {
320
+ if (!this.enabled) return
321
+
322
+ if (this.#hasUserSpanProcessor) {
323
+ throw new Error(
324
+ '[LLMObs] Only one user span processor can be registered. ' +
325
+ 'To register a new processor, deregister the existing processor first using `llmobs.deregisterProcessor()`.'
326
+ )
327
+ }
328
+
329
+ this.#hasUserSpanProcessor = true
330
+ registerUserSpanProcessorCh.publish(processor)
331
+ }
332
+
333
+ deregisterProcessor () {
334
+ if (!this.enabled) return
335
+
336
+ this.#hasUserSpanProcessor = false
337
+ registerUserSpanProcessorCh.publish(null)
338
+ }
339
+
312
340
  submitEvaluation (llmobsSpanContext, options = {}) {
313
341
  if (!this.enabled) return
314
342
 
@@ -34,25 +34,55 @@ const LLMObsTagger = require('./tagger')
34
34
  const tracerVersion = require('../../../../package.json').version
35
35
  const logger = require('../log')
36
36
 
37
+ const util = require('node:util')
38
+
39
+ class LLMObservabilitySpan {
40
+ constructor () {
41
+ this.input = []
42
+ this.output = []
43
+
44
+ this._tags = {}
45
+ }
46
+
47
+ getTag (key) {
48
+ return this._tags[key]
49
+ }
50
+ }
51
+
37
52
  class LLMObsSpanProcessor {
53
+ /** @type {import('../config')} */
54
+ #config
55
+
56
+ /** @type {((span: LLMObservabilitySpan) => LLMObservabilitySpan | null) | null} */
57
+ #userSpanProcessor
58
+
59
+ /** @type {import('./writers/spans')} */
60
+ #writer
61
+
38
62
  constructor (config) {
39
- this._config = config
63
+ this.#config = config
64
+ }
65
+
66
+ setUserSpanProcessor (userSpanProcessor) {
67
+ this.#userSpanProcessor = userSpanProcessor
40
68
  }
41
69
 
42
70
  setWriter (writer) {
43
- this._writer = writer
71
+ this.#writer = writer
44
72
  }
45
73
 
46
74
  // TODO: instead of relying on the tagger's weakmap registry, can we use some namespaced storage correlation?
47
75
  process ({ span }) {
48
- if (!this._config.llmobs.enabled) return
76
+ if (!this.#config.llmobs.enabled) return
49
77
  // if the span is not in our private tagger map, it is not an llmobs span
50
78
  if (!LLMObsTagger.tagMap.has(span)) return
51
79
 
52
80
  try {
53
81
  const formattedEvent = this.format(span)
54
82
  telemetry.incrementLLMObsSpanFinishedCount(span)
55
- this._writer.append(formattedEvent)
83
+ if (formattedEvent == null) return
84
+
85
+ this.#writer.append(formattedEvent)
56
86
  } catch (e) {
57
87
  // this should be a rare case
58
88
  // we protect against unserializable properties in the format function, and in
@@ -65,6 +95,9 @@ class LLMObsSpanProcessor {
65
95
  }
66
96
 
67
97
  format (span) {
98
+ const llmObsSpan = new LLMObservabilitySpan()
99
+ let inputType, outputType
100
+
68
101
  const spanTags = span.context()._tags
69
102
  const mlObsTags = LLMObsTagger.tagMap.get(span)
70
103
 
@@ -78,26 +111,29 @@ class LLMObsSpanProcessor {
78
111
  meta.model_name = mlObsTags[MODEL_NAME] || 'custom'
79
112
  meta.model_provider = (mlObsTags[MODEL_PROVIDER] || 'custom').toLowerCase()
80
113
  }
114
+
81
115
  if (mlObsTags[METADATA]) {
82
- this._addObject(mlObsTags[METADATA], meta.metadata = {})
116
+ this.#addObject(mlObsTags[METADATA], meta.metadata = {})
83
117
  }
118
+
84
119
  if (spanKind === 'llm' && mlObsTags[INPUT_MESSAGES]) {
85
- input.messages = mlObsTags[INPUT_MESSAGES]
86
- }
87
- if (mlObsTags[INPUT_VALUE]) {
88
- input.value = mlObsTags[INPUT_VALUE]
89
- }
90
- if (spanKind === 'llm' && mlObsTags[OUTPUT_MESSAGES]) {
91
- output.messages = mlObsTags[OUTPUT_MESSAGES]
92
- }
93
- if (spanKind === 'embedding' && mlObsTags[INPUT_DOCUMENTS]) {
120
+ llmObsSpan.input = mlObsTags[INPUT_MESSAGES]
121
+ inputType = 'messages'
122
+ } else if (spanKind === 'embedding' && mlObsTags[INPUT_DOCUMENTS]) {
94
123
  input.documents = mlObsTags[INPUT_DOCUMENTS]
124
+ } else if (mlObsTags[INPUT_VALUE]) {
125
+ llmObsSpan.input = [{ role: '', content: mlObsTags[INPUT_VALUE] }]
126
+ inputType = 'value'
95
127
  }
96
- if (mlObsTags[OUTPUT_VALUE]) {
97
- output.value = mlObsTags[OUTPUT_VALUE]
98
- }
99
- if (spanKind === 'retrieval' && mlObsTags[OUTPUT_DOCUMENTS]) {
128
+
129
+ if (spanKind === 'llm' && mlObsTags[OUTPUT_MESSAGES]) {
130
+ llmObsSpan.output = mlObsTags[OUTPUT_MESSAGES]
131
+ outputType = 'messages'
132
+ } else if (spanKind === 'retrieval' && mlObsTags[OUTPUT_DOCUMENTS]) {
100
133
  output.documents = mlObsTags[OUTPUT_DOCUMENTS]
134
+ } else if (mlObsTags[OUTPUT_VALUE]) {
135
+ llmObsSpan.output = [{ role: '', content: mlObsTags[OUTPUT_VALUE] }]
136
+ outputType = 'value'
101
137
  }
102
138
 
103
139
  const error = spanTags.error || spanTags[ERROR_TYPE]
@@ -107,9 +143,6 @@ class LLMObsSpanProcessor {
107
143
  meta[ERROR_STACK] = spanTags[ERROR_STACK] || error.stack
108
144
  }
109
145
 
110
- if (input) meta.input = input
111
- if (output) meta.output = output
112
-
113
146
  const metrics = mlObsTags[METRICS] || {}
114
147
 
115
148
  const mlApp = mlObsTags[ML_APP]
@@ -118,12 +151,37 @@ class LLMObsSpanProcessor {
118
151
 
119
152
  const name = mlObsTags[NAME] || span._name
120
153
 
154
+ const tags = this.#getTags(span, mlApp, sessionId, error)
155
+ llmObsSpan._tags = tags
156
+
157
+ const processedSpan = this.#runProcessor(llmObsSpan)
158
+ if (processedSpan === null) return null
159
+
160
+ if (processedSpan.input) {
161
+ if (inputType === 'messages') {
162
+ input.messages = processedSpan.input
163
+ } else if (inputType === 'value') {
164
+ input.value = processedSpan.input[0].content
165
+ }
166
+ }
167
+
168
+ if (processedSpan.output) {
169
+ if (outputType === 'messages') {
170
+ output.messages = processedSpan.output
171
+ } else if (outputType === 'value') {
172
+ output.value = processedSpan.output[0].content
173
+ }
174
+ }
175
+
176
+ if (input) meta.input = input
177
+ if (output) meta.output = output
178
+
121
179
  const llmObsSpanEvent = {
122
180
  trace_id: span.context().toTraceId(true),
123
181
  span_id: span.context().toSpanId(),
124
182
  parent_id: parentId,
125
183
  name,
126
- tags: this._processTags(span, mlApp, sessionId, error),
184
+ tags: this.#objectTagsToStringArrayTags(tags),
127
185
  start_ns: Math.round(span._startTime * 1e6),
128
186
  duration: Math.round(span._duration * 1e6),
129
187
  status: error ? 'error' : 'ok',
@@ -144,7 +202,7 @@ class LLMObsSpanProcessor {
144
202
  // However, we want to protect against circular references or BigInts (unserializable)
145
203
  // This function can be reused for other fields if needed
146
204
  // Messages, Documents, and Metrics are safeguarded in `llmobs/tagger.js`
147
- _addObject (obj, carrier) {
205
+ #addObject (obj, carrier) {
148
206
  const seenObjects = new WeakSet()
149
207
  seenObjects.add(obj) // capture root object
150
208
 
@@ -176,12 +234,12 @@ class LLMObsSpanProcessor {
176
234
  add(obj, carrier)
177
235
  }
178
236
 
179
- _processTags (span, mlApp, sessionId, error) {
237
+ #getTags (span, mlApp, sessionId, error) {
180
238
  let tags = {
181
- ...this._config.parsedDdTags,
182
- version: this._config.version,
183
- env: this._config.env,
184
- service: this._config.service,
239
+ ...this.#config.parsedDdTags,
240
+ version: this.#config.version,
241
+ env: this.#config.env,
242
+ service: this.#config.service,
185
243
  source: 'integration',
186
244
  ml_app: mlApp,
187
245
  'ddtrace.version': tracerVersion,
@@ -191,13 +249,51 @@ class LLMObsSpanProcessor {
191
249
 
192
250
  const errType = span.context()._tags[ERROR_TYPE] || error?.name
193
251
  if (errType) tags.error_type = errType
252
+
194
253
  if (sessionId) tags.session_id = sessionId
254
+
195
255
  const integration = LLMObsTagger.tagMap.get(span)?.[INTEGRATION]
196
256
  if (integration) tags.integration = integration
257
+
197
258
  const existingTags = LLMObsTagger.tagMap.get(span)?.[TAGS] || {}
198
259
  if (existingTags) tags = { ...tags, ...existingTags }
260
+
261
+ return tags
262
+ }
263
+
264
+ #objectTagsToStringArrayTags (tags) {
199
265
  return Object.entries(tags).map(([key, value]) => `${key}:${value ?? ''}`)
200
266
  }
267
+
268
+ /**
269
+ * Runs the user span processor, emitting telemetry and adding some guardrails against invalid return types
270
+ * @param {LLMObservabilitySpan} span
271
+ * @returns {LLMObservabilitySpan | null}
272
+ */
273
+ #runProcessor (span) {
274
+ const processor = this.#userSpanProcessor
275
+ if (!processor) return span
276
+
277
+ let error = false
278
+
279
+ try {
280
+ const processedLLMObsSpan = processor(span)
281
+ if (processedLLMObsSpan === null) return null
282
+
283
+ if (!(processedLLMObsSpan instanceof LLMObservabilitySpan)) {
284
+ error = true
285
+ logger.warn('User span processor must return an instance of an LLMObservabilitySpan or null, dropping span.')
286
+ return null
287
+ }
288
+
289
+ return processedLLMObsSpan
290
+ } catch (e) {
291
+ logger.error(`[LLMObs] Error in LLMObs span processor (${util.inspect(processor)}): ${util.inspect(e)}`)
292
+ error = true
293
+ } finally {
294
+ telemetry.recordLLMObsUserProcessorCalled(error)
295
+ }
296
+ }
201
297
  }
202
298
 
203
299
  module.exports = LLMObsSpanProcessor
@@ -154,6 +154,11 @@ function recordSubmitEvaluation (options, err, value = 1) {
154
154
  llmobsMetrics.count('evals_submitted', tags).inc(value)
155
155
  }
156
156
 
157
+ function recordLLMObsUserProcessorCalled (error, value = 1) {
158
+ const tags = { error: error ? 1 : 0 }
159
+ llmobsMetrics.count('user_processor_called', tags).inc(value)
160
+ }
161
+
157
162
  module.exports = {
158
163
  recordLLMObsEnabled,
159
164
  incrementLLMObsSpanStartCount,
@@ -164,5 +169,6 @@ module.exports = {
164
169
  recordLLMObsAnnotate,
165
170
  recordUserFlush,
166
171
  recordExportSpan,
167
- recordSubmitEvaluation
172
+ recordSubmitEvaluation,
173
+ recordLLMObsUserProcessorCalled
168
174
  }