Skip to content

Commit 02ddf32

Browse files
sabrennerBridgeAR
authored andcommitted
fix(llmobs): llm observability traces have custom trace IDs (#9460)
* separate llmobs trace ids * update exportSpan * tests * add tagger test * additional fixups * update tests from rebase
1 parent 1409b6d commit 02ddf32

11 files changed

Lines changed: 323 additions & 141 deletions

File tree

packages/dd-trace/src/llmobs/index.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const {
1616
SAMPLING_DECISION,
1717
PROPAGATED_SAMPLE_RATE_KEY,
1818
PROPAGATED_SAMPLING_DECISION_KEY,
19+
TRACE_ID,
20+
PROPAGATED_TRACE_ID_KEY,
1921
} = require('./constants/tags')
2022
const { storage } = require('./storage')
2123
const telemetry = require('./telemetry')
@@ -25,6 +27,7 @@ const LLMObsTagger = require('./tagger')
2527
const LLMObsSpanWriter = require('./writers/spans')
2628
const { setAgentStrategy } = require('./writers/util')
2729
const { INCOMPATIBLE_INITIALIZATION } = require('./constants/text')
30+
const { llmObsTraceIdToWire } = require('./util')
2831

2932
const spanFinishCh = channel('dd-trace:span:finish')
3033
const evalMetricAppendCh = channel('llmobs:eval-metric:append')
@@ -137,8 +140,12 @@ function handleLLMObsInjection ({ carrier }) {
137140
mlObsSpanTags?.[SESSION_ID] ??
138141
parentContext?._trace?.tags?.[SESSION_ID_TRACE_DEFAULT_KEY] ??
139142
parentContext?._trace?.tags?.[PROPAGATED_SESSION_ID_KEY]
143+
const llmobsTraceId = mlObsSpanTags?.[TRACE_ID]
144+
const propagatedTraceId = llmobsTraceId === undefined
145+
? parentContext?._trace?.tags?.[PROPAGATED_TRACE_ID_KEY]
146+
: llmObsTraceIdToWire(llmobsTraceId)
140147

141-
if (!parentId && !mlApp && samplingDecision == null && !sessionId) return
148+
if (!parentId && !mlApp && samplingDecision == null && !sessionId && !propagatedTraceId) return
142149

143150
// `_injectTags` only writes `x-datadog-tags` when the trace has `_dd.p.*`
144151
// tags, so it may be undefined here — coalesce before appending.
@@ -149,6 +156,7 @@ function handleLLMObsInjection ({ carrier }) {
149156
if (sessionId) tags += `${tags ? ',' : ''}${PROPAGATED_SESSION_ID_KEY}=${sessionId}`
150157
if (sampleRate != null) tags += `${tags ? ',' : ''}${PROPAGATED_SAMPLE_RATE_KEY}=${sampleRate}`
151158
if (samplingDecision != null) tags += `${tags ? ',' : ''}${PROPAGATED_SAMPLING_DECISION_KEY}=${samplingDecision}`
159+
if (propagatedTraceId != null) tags += `${tags ? ',' : ''}${PROPAGATED_TRACE_ID_KEY}=${propagatedTraceId}`
152160
if (tags !== existing) carrier['x-datadog-tags'] = tags
153161
}
154162

packages/dd-trace/src/llmobs/sdk.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
SPAN_KIND,
1212
OUTPUT_VALUE,
1313
INPUT_VALUE,
14+
TRACE_ID,
1415
} = require('./constants/tags')
1516
const {
1617
getFunctionArguments,
@@ -345,7 +346,7 @@ class LLMObs extends NoopLLMObs {
345346
}
346347
try {
347348
return {
348-
traceId: span.context().toTraceId(true),
349+
traceId: LLMObsTagger.tagMap.get(span)[TRACE_ID],
349350
spanId: span.context().toSpanId(),
350351
}
351352
} catch {

packages/dd-trace/src/llmobs/span_processor.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const {
3535
LLMOBS_SUBMITTED_TAG_KEY,
3636
SAMPLE_RATE,
3737
SAMPLING_DECISION,
38+
TRACE_ID,
3839
} = require('./constants/tags')
3940
const { UNSERIALIZABLE_VALUE_TEXT } = require('./constants/text')
4041
const telemetry = require('./telemetry')
@@ -236,8 +237,11 @@ class LLMObsSpanProcessor {
236237
meta.input.prompt = prompt
237238
}
238239

240+
const apmTraceId = span.context().toTraceId(true)
241+
const llmobsTraceId = mlObsTags[TRACE_ID] ?? apmTraceId
242+
239243
const llmObsSpanEvent = {
240-
trace_id: span.context().toTraceId(true),
244+
trace_id: llmobsTraceId,
241245
span_id: span.context().toSpanId(),
242246
parent_id: parentId,
243247
name,
@@ -249,9 +253,10 @@ class LLMObsSpanProcessor {
249253
metrics,
250254
_dd: {
251255
span_id: span.context().toSpanId(),
252-
trace_id: span.context().toTraceId(true),
256+
trace_id: apmTraceId,
253257
sample_rate: mlObsTags[SAMPLE_RATE],
254258
sampling_decision: mlObsTags[SAMPLING_DECISION],
259+
apm_trace_id: apmTraceId,
255260
},
256261
}
257262

packages/dd-trace/src/llmobs/tagger.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,18 @@ const {
5151
SAMPLING_DECISION_DROPPED,
5252
PROPAGATED_SAMPLE_RATE_KEY,
5353
PROPAGATED_SAMPLING_DECISION_KEY,
54+
TRACE_ID,
55+
PROPAGATED_TRACE_ID_KEY,
5456
} = require('./constants/tags')
5557
const { storage } = require('./storage')
56-
const { findGenAIAncestorSpanId, validateCostTags, writeBridgeTags, validateToolDefinitions } = require('./util')
58+
const {
59+
findGenAIAncestorSpanId,
60+
validateCostTags,
61+
writeBridgeTags,
62+
validateToolDefinitions,
63+
generateLlmObsTraceId,
64+
normalizeLlmObsTraceId,
65+
} = require('./util')
5766

5867
// global registry of LLMObs spans
5968
// maps LLMObs spans to their annotations
@@ -126,12 +135,20 @@ class LLMObsTagger {
126135

127136
this._register(span)
128137

138+
const traceTags = span.context()._trace.tags
139+
140+
const llmobsTraceId =
141+
registry.get(parent)?.[TRACE_ID] ??
142+
normalizeLlmObsTraceId(traceTags[PROPAGATED_TRACE_ID_KEY]) ??
143+
generateLlmObsTraceId(span._startTime)
144+
this._setTag(span, TRACE_ID, llmobsTraceId)
145+
129146
// When the registering span sits below an OTel `gen_ai.*` ancestor, use
130147
// that ancestor as the parent_id fallback and suppress the bridge
131148
// parent_id tag so the indexer doesn't invert the trace.
132149
const genAIAncestorSpanId = findGenAIAncestorSpanId(span)
133150

134-
writeBridgeTags(span, { includeParentId: genAIAncestorSpanId === null })
151+
writeBridgeTags(span, { includeParentId: genAIAncestorSpanId === null, llmobsTraceId })
135152

136153
this._setTag(span, ML_APP, spanMlApp)
137154

@@ -141,7 +158,6 @@ class LLMObsTagger {
141158
if (modelName) this.tagModelName(span, modelName)
142159
if (modelProvider) this._setTag(span, MODEL_PROVIDER, modelProvider)
143160

144-
const traceTags = span.context()._trace.tags
145161
sessionId = sessionId ||
146162
registry.get(parent)?.[SESSION_ID] ||
147163
traceTags[SESSION_ID_TRACE_DEFAULT_KEY] ||

packages/dd-trace/src/llmobs/util.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
'use strict'
22

3+
const id = require('../id')
34
const log = require('../log')
45
const {
56
LLMOBS_PARENT_ID_BRIDGE_KEY,
67
LLMOBS_TRACE_ID_BRIDGE_KEY,
78
SPAN_KINDS,
89
} = require('./constants/tags')
910

11+
const DECIMAL_TRACE_ID_REGEX = /^\d+$/
12+
const HEX_TRACE_ID_REGEX = /^[0-9a-f]{32}$/i
13+
const MAX_UINT_64 = (1n << 64n) - 1n
14+
1015
// LLM I/O is overwhelmingly ASCII (English prompts and code). Walk once
1116
// looking for the first non-ASCII char; if there is none, hand the input
1217
// straight back. Otherwise pick up the slow path from the byte that needed
@@ -312,12 +317,12 @@ function safeJsonParse (value, fallback) {
312317
// LLMObs root and hoists the gen_ai ancestors under it, inverting the trace.
313318
/**
314319
* @param {import('../opentracing/span')} span
315-
* @param {{ includeParentId?: boolean }} [opts]
320+
* @param {{ includeParentId?: boolean, llmobsTraceId?: string }} [opts]
316321
*/
317-
function writeBridgeTags (span, { includeParentId = true } = {}) {
322+
function writeBridgeTags (span, { includeParentId = true, llmobsTraceId } = {}) {
318323
const traceTags = span?.context?.()._trace?.tags
319324
if (!traceTags || traceTags[LLMOBS_TRACE_ID_BRIDGE_KEY]) return
320-
traceTags[LLMOBS_TRACE_ID_BRIDGE_KEY] = span.context().toTraceId(true)
325+
traceTags[LLMOBS_TRACE_ID_BRIDGE_KEY] = llmobsTraceId ?? span.context().toTraceId(true)
321326
if (includeParentId) {
322327
traceTags[LLMOBS_PARENT_ID_BRIDGE_KEY] = span.context().toSpanId()
323328
}
@@ -362,6 +367,51 @@ function findGenAIAncestorSpanId (span) {
362367
return null
363368
}
364369

370+
/**
371+
* Generate a 128-bit LLMObs trace ID with the span start time encoded in its high bits.
372+
* @param {number} startTime
373+
* @returns {string}
374+
*/
375+
function generateLlmObsTraceId (startTime) {
376+
const identifier = id()
377+
const traceIdHigh = Math.floor(startTime / 1000)
378+
.toString(16)
379+
.padStart(8, '0')
380+
.padEnd(16, '0')
381+
382+
return identifier.toTraceIdHex(traceIdHigh).padStart(32, '0')
383+
}
384+
385+
/**
386+
* Convert an internally stored hexadecimal LLMObs trace ID to its distributed wire representation.
387+
* @param {string | undefined} traceId
388+
* @returns {string | undefined}
389+
*/
390+
function llmObsTraceIdToWire (traceId) {
391+
if (!traceId) return
392+
if (!HEX_TRACE_ID_REGEX.test(traceId)) return traceId
393+
394+
return BigInt(`0x${traceId}`).toString(10)
395+
}
396+
397+
/**
398+
* Normalize a distributed LLMObs trace ID to the representation expected by LLMObs span events.
399+
* @param {string | undefined} traceId
400+
* @returns {string | undefined}
401+
*/
402+
function normalizeLlmObsTraceId (traceId) {
403+
if (!traceId) return
404+
405+
if (HEX_TRACE_ID_REGEX.test(traceId) && (traceId[0] === '0' || !DECIMAL_TRACE_ID_REGEX.test(traceId))) {
406+
return traceId
407+
}
408+
409+
if (!DECIMAL_TRACE_ID_REGEX.test(traceId)) return traceId
410+
411+
const identifier = BigInt(traceId)
412+
return identifier > MAX_UINT_64 ? identifier.toString(16).padStart(32, '0') : traceId
413+
}
414+
365415
// Maps an audio `format` (e.g. "wav", "mp3") to a MIME type. Defaults to `audio/wav` when the
366416
// format is missing. Provider-specific overrides (e.g. OpenAI's mp3 -> audio/mpeg) are passed in
367417
// via `mimeTypeLookup` so this stays provider-agnostic. A non-string `format` is treated as missing
@@ -396,6 +446,9 @@ module.exports = {
396446
audioMimeTypeFromFormat,
397447
encodeUnicode,
398448
findGenAIAncestorSpanId,
449+
generateLlmObsTraceId,
450+
llmObsTraceIdToWire,
451+
normalizeLlmObsTraceId,
399452
formatAudioPart,
400453
validateCostTags,
401454
validateKind,

packages/dd-trace/test/llmobs/index.spec.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ const sinon = require('sinon')
1010
const { DD_MAJOR } = require('../../../../version')
1111
const { INCOMPATIBLE_INITIALIZATION } = require('../../src/llmobs/constants/text')
1212
const LLMObsTagger = require('../../src/llmobs/tagger')
13-
const { SAMPLE_RATE, SAMPLING_DECISION, SESSION_ID } = require('../../src/llmobs/constants/tags')
13+
const {
14+
PROPAGATED_TRACE_ID_KEY,
15+
SAMPLE_RATE,
16+
SAMPLING_DECISION,
17+
SESSION_ID,
18+
TRACE_ID,
19+
} = require('../../src/llmobs/constants/tags')
1420
const { getConfigFresh } = require('../helpers/config')
1521
const { removeDestroyHandler } = require('./util')
1622

@@ -189,6 +195,51 @@ describe('module', () => {
189195
)
190196
})
191197

198+
it('converts the local LLMObs trace id to decimal for propagation', () => {
199+
llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } })
200+
store.span = {
201+
context () {
202+
return {
203+
_trace: { tags: {} },
204+
toSpanId () { return 'parent-id' },
205+
}
206+
},
207+
}
208+
LLMObsTagger.tagMap.set(store.span, {
209+
[TRACE_ID]: '6a5f76e7000000001973227978d8110b',
210+
})
211+
212+
const carrier = { 'x-datadog-tags': '' }
213+
injectCh.publish({ carrier })
214+
215+
assert.strictEqual(
216+
carrier['x-datadog-tags'],
217+
// eslint-disable-next-line @stylistic/max-len
218+
'_dd.p.llmobs_parent_id=parent-id,_dd.p.llmobs_ml_app=test,_dd.p.llmobs_trace_id=141393847380800662846519802803680448779'
219+
)
220+
})
221+
222+
it('forwards an extracted LLMObs trace id without reinterpreting it', () => {
223+
llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } })
224+
const wireTraceId = '12345678901234567890123456789012'
225+
store.span = {
226+
context () {
227+
return {
228+
_trace: { tags: { [PROPAGATED_TRACE_ID_KEY]: wireTraceId } },
229+
toSpanId () { return 'parent-id' },
230+
}
231+
},
232+
}
233+
234+
const carrier = { 'x-datadog-tags': '' }
235+
injectCh.publish({ carrier })
236+
237+
assert.strictEqual(
238+
carrier['x-datadog-tags'],
239+
`_dd.p.llmobs_parent_id=parent-id,_dd.p.llmobs_ml_app=test,_dd.p.llmobs_trace_id=${wireTraceId}`
240+
)
241+
})
242+
192243
it('does not inject LLMObs parent ID info when there is no parent LLMObs span', () => {
193244
llmobsModule.enable({ llmobs: { mlApp: 'test', agentlessEnabled: false } })
194245

0 commit comments

Comments
 (0)