dd-trace 5.105.0 → 5.106.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.
@@ -131,6 +131,7 @@ class Generation {
131
131
  outputTokens,
132
132
  cacheReadTokens,
133
133
  cacheWriteTokens,
134
+ messages,
134
135
  } = {}) {
135
136
  // stringify message as it could be a single generated message as well as a list of embeddings
136
137
  this.message = typeof message === 'string' ? message : JSON.stringify(message) || ''
@@ -143,6 +144,7 @@ class Generation {
143
144
  cacheReadTokens,
144
145
  cacheWriteTokens,
145
146
  }
147
+ this.messages = messages ?? [{ content: this.message, role: this.role }]
146
148
  }
147
149
  }
148
150
 
@@ -401,10 +403,7 @@ function extractTextAndResponseReason (response, provider, modelName) {
401
403
  message: output.message?.content[0]?.text ?? 'Unsupported content type',
402
404
  finishReason: body.stopReason,
403
405
  role: output.message?.role,
404
- inputTokens: body.usage?.inputTokens,
405
- outputTokens: body.usage?.outputTokens,
406
- cacheReadInputTokenCount: body.usage?.cacheReadInputTokenCount,
407
- cacheWriteInputTokenCount: body.usage?.cacheWriteInputTokenCount,
406
+ ...buildUsage(body.usage),
408
407
  })
409
408
  }
410
409
  break
@@ -476,6 +475,216 @@ function extractTextAndResponseReason (response, provider, modelName) {
476
475
  return new Generation()
477
476
  }
478
477
 
478
+ /**
479
+ * Convert a Converse content-block array to an LLMObs message array.
480
+ *
481
+ * @param {string} role
482
+ * @param {Array<object>} contentBlocks
483
+ * @returns {{ content?: string, role: string, toolCalls?: Array, toolResults?: Array } | undefined}
484
+ */
485
+ function extractMessagesFromConverseContent (role, contentBlocks) {
486
+ let content = ''
487
+ const toolCalls = []
488
+ const toolResults = []
489
+
490
+ for (const block of contentBlocks || []) {
491
+ if (block == null || typeof block !== 'object') continue
492
+ if (typeof block.text === 'string') {
493
+ content += block.text
494
+ } else if (block.toolUse) {
495
+ toolCalls.push(buildToolCall(block.toolUse))
496
+ } else if (block.toolResult) {
497
+ toolResults.push(buildToolResult(block.toolResult))
498
+ } else {
499
+ content += `[Unsupported content type: ${getContentBlockType(block)}]`
500
+ }
501
+ }
502
+
503
+ if (!content && toolCalls.length === 0 && toolResults.length === 0) return
504
+
505
+ const message = { role }
506
+ if (content) message.content = content
507
+ if (toolCalls.length > 0) message.toolCalls = toolCalls
508
+ if (toolResults.length > 0) message.toolResults = toolResults
509
+ return message
510
+ }
511
+
512
+ /**
513
+ * Resolve a Converse `ContentBlock`'s member type. The block is a key-presence
514
+ * tagged union (no `type` discriminator), so the active member is its sole own
515
+ * key. For forward-compat `$unknown` members the real type is the first element
516
+ * of the `[name, value]` tuple.
517
+ *
518
+ * @param {object} block
519
+ * @returns {string}
520
+ */
521
+ function getContentBlockType (block) {
522
+ const key = Object.keys(block)[0]
523
+ if (key === '$unknown') return block.$unknown?.[0] ?? 'unknown'
524
+ return key ?? 'unknown'
525
+ }
526
+
527
+ // Always emit at least one output message so downstream tagging has a role to attach to.
528
+ function toOutputMessages (role, contentBlocks) {
529
+ const message = extractMessagesFromConverseContent(role, contentBlocks)
530
+ return message ? [message] : [{ role, content: '' }]
531
+ }
532
+
533
+ function buildToolCall ({ name, input, toolUseId }) {
534
+ return { name: name ?? '', arguments: input ?? {}, toolId: toolUseId ?? '', type: 'toolUse' }
535
+ }
536
+
537
+ function parseToolInput (inputStr) {
538
+ try {
539
+ return JSON.parse(inputStr)
540
+ } catch {
541
+ log.warn('Failed to parse Converse stream toolUse.input JSON; emitting empty arguments')
542
+ return {}
543
+ }
544
+ }
545
+
546
+ function buildToolResult ({ toolUseId, content }) {
547
+ const result = (content || []).map(resolveToolResultItem).join('')
548
+ return { name: '', result, toolId: toolUseId ?? '', type: 'tool_result' }
549
+ }
550
+
551
+ function resolveToolResultItem (item) {
552
+ if (typeof item.text === 'string') return item.text
553
+ if (item.json != null) return JSON.stringify(item.json)
554
+ return `[Unsupported content type(s): ${getContentBlockType(item)}]`
555
+ }
556
+
557
+ function buildUsage (usage = {}) {
558
+ return {
559
+ inputTokens: usage.inputTokens,
560
+ outputTokens: usage.outputTokens,
561
+ cacheReadTokens: usage.cacheReadInputTokens ?? usage.cacheReadInputTokenCount,
562
+ cacheWriteTokens: usage.cacheWriteInputTokens ?? usage.cacheWriteInputTokenCount,
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Extract tool definitions from a Converse request's `toolConfig.tools`,
568
+ * mapping Bedrock's `toolSpec` shape to LLMObs `ToolDefinition` shape.
569
+ *
570
+ * @param {object} params - Converse request params with optional `toolConfig.tools[].toolSpec`.
571
+ * @returns {Array<{ name: string, description: string, schema: object }>}
572
+ */
573
+ function extractConverseToolDefinitions (params) {
574
+ const toolDefinitions = []
575
+ for (const tool of params.toolConfig?.tools || []) {
576
+ const toolSpec = tool?.toolSpec
577
+ if (!toolSpec?.name) continue
578
+ toolDefinitions.push({
579
+ name: toolSpec.name,
580
+ description: toolSpec.description ?? '',
581
+ schema: toolSpec.inputSchema ?? {},
582
+ })
583
+ }
584
+ return toolDefinitions
585
+ }
586
+
587
+ /**
588
+ * Extract request metadata + rendered input messages from a Converse /
589
+ * ConverseStream request.
590
+ *
591
+ * @param {{ modelId?: string, messages?: Array, system?: Array, inferenceConfig?: object, toolConfig?: object }} params
592
+ * @returns {RequestParams}
593
+ */
594
+ function extractRequestParamsConverse (params) {
595
+ const prompt = []
596
+ for (const block of params.system || []) {
597
+ if (typeof block?.text === 'string') prompt.push({ content: block.text, role: 'system' })
598
+ }
599
+ for (const msg of params.messages || []) {
600
+ if (msg == null || typeof msg !== 'object') continue
601
+ const message = extractMessagesFromConverseContent(msg.role || 'user', msg.content)
602
+ if (message) prompt.push(message)
603
+ }
604
+
605
+ const { temperature, topP, maxTokens, stopSequences } = params.inferenceConfig || {}
606
+ return new RequestParams({ prompt, temperature, topP, maxTokens, stopSequences })
607
+ }
608
+
609
+ /**
610
+ * Extract output messages + usage from a non-stream Converse response.
611
+ *
612
+ * @param {{ output?: { message?: { role?: string, content?: Array } }, stopReason?: string, usage?: object }} response
613
+ * @returns {Generation}
614
+ */
615
+ function extractTextAndResponseReasonConverse (response) {
616
+ const outputMessage = response?.output?.message
617
+ const role = outputMessage?.role || 'assistant'
618
+
619
+ return new Generation({
620
+ role,
621
+ finishReason: response?.stopReason || '',
622
+ ...buildUsage(response?.usage),
623
+ messages: toOutputMessages(role, outputMessage?.content),
624
+ })
625
+ }
626
+
627
+ /**
628
+ * Aggregate Converse stream events into a single output message + usage.
629
+ * One messageStart / messageStop pair per response, so one message out.
630
+ *
631
+ * Stream events describe the same content-block structure as the non-stream
632
+ * response, spread across start/delta chunks. We reassemble those chunks
633
+ * into a normalized content-block array and reuse the non-stream extractor.
634
+ *
635
+ * @param {Array<object>} chunks - Ordered ConverseStreamOutput events.
636
+ * @returns {Generation}
637
+ */
638
+ function extractTextAndResponseReasonConverseFromStream (chunks) {
639
+ let role = 'assistant'
640
+ let stopReason = ''
641
+ let usage = {}
642
+ const blocksByIdx = new Map()
643
+
644
+ for (const chunk of chunks || []) {
645
+ if (chunk.messageStart?.role) {
646
+ role = chunk.messageStart.role
647
+ } else if (chunk.messageStop?.stopReason) {
648
+ stopReason = chunk.messageStop.stopReason
649
+ } else if (chunk.metadata?.usage) {
650
+ usage = chunk.metadata.usage
651
+ } else if (chunk.contentBlockStart?.start?.toolUse) {
652
+ const { contentBlockIndex, start: { toolUse } } = chunk.contentBlockStart
653
+ blocksByIdx.set(contentBlockIndex, {
654
+ toolUse: { toolUseId: toolUse.toolUseId, name: toolUse.name, inputStr: '' },
655
+ })
656
+ } else if (chunk.contentBlockDelta) {
657
+ const { contentBlockIndex, delta } = chunk.contentBlockDelta
658
+ if (typeof delta?.text === 'string') {
659
+ const block = blocksByIdx.get(contentBlockIndex) ?? {}
660
+ block.text = (block.text ?? '') + delta.text
661
+ blocksByIdx.set(contentBlockIndex, block)
662
+ } else if (typeof delta?.toolUse?.input === 'string') {
663
+ const block = blocksByIdx.get(contentBlockIndex) ?? { toolUse: { inputStr: '' } }
664
+ block.toolUse ??= { inputStr: '' }
665
+ block.toolUse.inputStr += delta.toolUse.input
666
+ blocksByIdx.set(contentBlockIndex, block)
667
+ }
668
+ }
669
+ }
670
+
671
+ const contentBlocks = [...blocksByIdx.keys()].sort((a, b) => a - b).map(i => {
672
+ const block = blocksByIdx.get(i)
673
+ if (block.toolUse) {
674
+ const { toolUseId, name, inputStr } = block.toolUse
675
+ block.toolUse = { toolUseId, name, input: parseToolInput(inputStr) }
676
+ }
677
+ return block
678
+ })
679
+
680
+ return new Generation({
681
+ role,
682
+ finishReason: stopReason,
683
+ ...buildUsage(usage),
684
+ messages: toOutputMessages(role, contentBlocks),
685
+ })
686
+ }
687
+
479
688
  module.exports = {
480
689
  Generation,
481
690
  RequestParams,
@@ -483,5 +692,10 @@ module.exports = {
483
692
  parseModelId,
484
693
  extractRequestParams,
485
694
  extractTextAndResponseReason,
695
+ extractMessagesFromConverseContent,
696
+ extractConverseToolDefinitions,
697
+ extractRequestParamsConverse,
698
+ extractTextAndResponseReasonConverse,
699
+ extractTextAndResponseReasonConverseFromStream,
486
700
  PROVIDER,
487
701
  }
@@ -78,6 +78,21 @@ class Identifier {
78
78
  return this.toString()
79
79
  }
80
80
 
81
+ /**
82
+ * Returns the full hex trace ID. When this is a 64-bit identifier and `traceIdHigh`
83
+ * is provided, prepends it to form the 128-bit trace ID. Otherwise returns
84
+ * only this identifier's hex representation.
85
+ *
86
+ * @param {string | undefined} traceIdHigh - 16-char hex of the upper 64 bits, or undefined
87
+ * @returns {string}
88
+ */
89
+ toTraceIdHex (traceIdHigh) {
90
+ if (traceIdHigh && this.#buffer.length <= 8) {
91
+ return traceIdHigh + this.toString(16)
92
+ }
93
+ return this.toString(16)
94
+ }
95
+
81
96
  /**
82
97
  * @param {Identifier} other
83
98
  * @returns {boolean}
@@ -66,11 +66,22 @@ function getOperation (span) {
66
66
  }
67
67
 
68
68
  /**
69
- * Get the LLM token usage from the span tags
70
- * Supports both AI SDK v4 (promptTokens/completionTokens) and v5 (inputTokens/outputTokens)
71
- * @template T extends {inputTokens: number, outputTokens: number, totalTokens: number}
72
- * @param {T} tags
73
- * @returns {Pick<T, 'inputTokens' | 'outputTokens' | 'totalTokens'>}
69
+ * Get the LLM token usage from the span tags.
70
+ *
71
+ * Supports both AI SDK v4 (promptTokens/completionTokens) and v5+
72
+ * (inputTokens/outputTokens), and surfaces prompt-cache metrics for providers
73
+ * that report them. The AI SDK convention is that `inputTokens` already
74
+ * includes cached tokens, so cache reads are reported as a subset of input
75
+ * tokens rather than added on top.
76
+ *
77
+ * @param {SpanTags} tags
78
+ * @returns {{
79
+ * inputTokens?: number,
80
+ * outputTokens?: number,
81
+ * totalTokens?: number,
82
+ * cacheReadTokens?: number,
83
+ * cacheWriteTokens?: number
84
+ * }}
74
85
  */
75
86
  function getUsage (tags) {
76
87
  const usage = {}
@@ -87,9 +98,84 @@ function getUsage (tags) {
87
98
  const totalTokens = tags['ai.usage.totalTokens'] ?? (inputTokens + outputTokens)
88
99
  if (!Number.isNaN(totalTokens)) usage.totalTokens = totalTokens
89
100
 
101
+ // Prompt-cache metrics. AI SDK v6 standardizes cache READ tokens via
102
+ // `ai.usage.cachedInputTokens`; cache WRITE tokens (and earlier AI SDK
103
+ // versions / providers that don't fill `cachedInputTokens`) are only
104
+ // available through provider-specific `ai.response.providerMetadata`.
105
+ // Skip zero values: the AI SDK sets `cachedInputTokens=0` on every span
106
+ // regardless of provider, so emitting it would add noise to spans that
107
+ // don't actually use prompt caching (e.g. OpenAI).
108
+ const providerCache = getProviderCacheTokens(tags['ai.response.providerMetadata'])
109
+
110
+ const cacheReadTokens = tags['ai.usage.cachedInputTokens'] ?? providerCache.cacheReadTokens
111
+ if (cacheReadTokens) usage.cacheReadTokens = cacheReadTokens
112
+
113
+ if (providerCache.cacheWriteTokens) usage.cacheWriteTokens = providerCache.cacheWriteTokens
114
+
115
+ // Normalize `inputTokens` to the sum convention used by `bedrockruntime.js`.
116
+ // Some SDK combinations (e.g. `ai@5` + `@ai-sdk/amazon-bedrock@3`) pass the
117
+ // raw fresh count through, which makes `nonCached = input - cacheRead -
118
+ // cacheWrite` go negative downstream.
119
+ //
120
+ // Detection: if `inputTokens < cacheSum`, the value cannot already be a sum
121
+ // that includes them (non-negative arithmetic). This is provider/version
122
+ // agnostic and won't double-count on stacks where the SDK already
123
+ // normalized (`ai@6` + `bedrock@4` / `anthropic@3`, OpenAI, Google).
124
+ if (usage.inputTokens != null) {
125
+ const cacheSum = (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0)
126
+ if (usage.inputTokens < cacheSum) {
127
+ usage.inputTokens += cacheSum
128
+ if (usage.totalTokens != null) {
129
+ usage.totalTokens = usage.inputTokens + (usage.outputTokens || 0)
130
+ }
131
+ }
132
+ }
133
+
90
134
  return usage
91
135
  }
92
136
 
137
+ /**
138
+ * Extract prompt-cache token counts from the stringified
139
+ * `ai.response.providerMetadata` attribute.
140
+ *
141
+ * The AI SDK does not standardize cache WRITE tokens on the usage object, and
142
+ * earlier versions / providers may also omit `ai.usage.cachedInputTokens`, so
143
+ * we read the provider-specific shape directly. Only Bedrock and Anthropic
144
+ * are handled here as they are the providers that report cache writes today.
145
+ *
146
+ * @see https://ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock#cache-points
147
+ * @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control
148
+ *
149
+ * @param {string | undefined} providerMetadataJson
150
+ * @returns {{ cacheReadTokens?: number, cacheWriteTokens?: number }}
151
+ */
152
+ function getProviderCacheTokens (providerMetadataJson) {
153
+ if (!providerMetadataJson) return {}
154
+
155
+ const metadata = getJsonStringValue(providerMetadataJson, null)
156
+ if (!metadata || typeof metadata !== 'object') return {}
157
+
158
+ const result = {}
159
+
160
+ const bedrockUsage = metadata.bedrock?.usage
161
+ if (bedrockUsage) {
162
+ if (bedrockUsage.cacheReadInputTokens != null) result.cacheReadTokens = bedrockUsage.cacheReadInputTokens
163
+ if (bedrockUsage.cacheWriteInputTokens != null) result.cacheWriteTokens = bedrockUsage.cacheWriteInputTokens
164
+ }
165
+
166
+ const anthropic = metadata.anthropic
167
+ if (anthropic) {
168
+ if (result.cacheReadTokens == null && anthropic.cacheReadInputTokens != null) {
169
+ result.cacheReadTokens = anthropic.cacheReadInputTokens
170
+ }
171
+ if (result.cacheWriteTokens == null && anthropic.cacheCreationInputTokens != null) {
172
+ result.cacheWriteTokens = anthropic.cacheCreationInputTokens
173
+ }
174
+ }
175
+
176
+ return result
177
+ }
178
+
93
179
  /**
94
180
  * Safely JSON parses a string value with a default fallback
95
181
  * @template T typeof defaultValue
@@ -7,12 +7,22 @@ const {
7
7
  extractTextAndResponseReason,
8
8
  parseModelId,
9
9
  extractTextAndResponseReasonFromStream,
10
+ extractConverseToolDefinitions,
11
+ extractRequestParamsConverse,
12
+ extractTextAndResponseReasonConverse,
13
+ extractTextAndResponseReasonConverseFromStream,
10
14
  } = require('../../../../datadog-plugin-aws-sdk/src/services/bedrockruntime/utils')
11
15
  const BaseLLMObsPlugin = require('./base')
12
16
 
13
17
  const llmobsStore = storage('llmobs')
14
18
 
15
- const ENABLED_OPERATIONS = new Set(['invokeModel', 'invokeModelWithResponseStream'])
19
+ const ENABLED_OPERATIONS = new Set([
20
+ 'invokeModel',
21
+ 'invokeModelWithResponseStream',
22
+ 'converse',
23
+ 'converseStream',
24
+ ])
25
+ const CONVERSE_OPERATIONS = new Set(['converse', 'converseStream'])
16
26
 
17
27
  /**
18
28
  * @typedef {{
@@ -79,10 +89,18 @@ class BedrockRuntimeLLMObsPlugin extends BaseLLMObsPlugin {
79
89
  setLLMObsTags ({ ctx, request, span, response, modelProvider, modelName, tokensFromHeaders }) {
80
90
  const isStream = request?.operation?.toLowerCase().includes('stream')
81
91
  telemetry.incrementLLMObsSpanStartCount({ autoinstrumented: true, integration: 'bedrock' })
92
+ this.#registerSpan(span, request)
82
93
 
94
+ if (CONVERSE_OPERATIONS.has(request?.operation)) {
95
+ this.#tagConverseSpan({ ctx, request, span, response, tokensFromHeaders, isStream })
96
+ } else {
97
+ this.#tagInvokeModelSpan({ ctx, request, span, response, modelProvider, modelName, tokensFromHeaders, isStream })
98
+ }
99
+ }
100
+
101
+ #registerSpan (span, request) {
83
102
  const parent = llmobsStore.getStore()?.span
84
103
  // Use full modelId and unified provider for LLMObs (required for backend cost estimation).
85
- // Split modelProvider/modelName from parseModelId() are still used below for response parsing.
86
104
  this._tagger.registerLLMObsSpan(span, {
87
105
  parent,
88
106
  modelName: request.params.modelId.toLowerCase(),
@@ -91,38 +109,42 @@ class BedrockRuntimeLLMObsPlugin extends BaseLLMObsPlugin {
91
109
  name: 'bedrock-runtime.command',
92
110
  integration: 'bedrock',
93
111
  })
112
+ }
113
+
114
+ #tagConverseSpan ({ ctx, request, span, response, tokensFromHeaders, isStream }) {
115
+ const requestParams = extractRequestParamsConverse(request.params)
116
+ const textAndResponseReason = isStream
117
+ ? extractTextAndResponseReasonConverseFromStream(ctx.chunks)
118
+ : extractTextAndResponseReasonConverse(response)
119
+
120
+ const toolDefinitions = extractConverseToolDefinitions(request.params)
121
+ if (toolDefinitions.length > 0) this._tagger.tagToolDefinitions(span, toolDefinitions)
122
+ if (textAndResponseReason.finishReason) {
123
+ this._tagger.tagMetadata(span, { stop_reason: textAndResponseReason.finishReason })
124
+ }
125
+ this.#tagCommon({ span, requestParams, textAndResponseReason, tokensFromHeaders })
126
+ }
94
127
 
128
+ #tagInvokeModelSpan ({ ctx, request, span, response, modelProvider, modelName, tokensFromHeaders, isStream }) {
95
129
  const requestParams = extractRequestParams(request.params, modelProvider)
96
130
  // for streamed responses, we'll use the coerced response object we formed in the stream handler
97
131
  const textAndResponseReason = isStream
98
132
  ? extractTextAndResponseReasonFromStream(ctx.chunks, modelProvider, modelName)
99
133
  : extractTextAndResponseReason(response, modelProvider, modelName)
100
134
 
101
- // add metadata tags
135
+ this.#tagCommon({ span, requestParams, textAndResponseReason, tokensFromHeaders })
136
+ }
137
+
138
+ #tagCommon ({ span, requestParams, textAndResponseReason, tokensFromHeaders }) {
102
139
  this._tagger.tagMetadata(span, {
103
140
  temperature: Number.parseFloat(requestParams.temperature) || 0,
104
141
  max_tokens: Number.parseInt(requestParams.maxTokens) || 0,
105
142
  })
106
-
107
- // add I/O tags
108
- this._tagger.tagLLMIO(
109
- span,
110
- requestParams.prompt,
111
- [{ content: textAndResponseReason.message, role: textAndResponseReason.role }]
112
- )
113
-
114
- // add token metrics
115
- const { inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens } = extractTokens({
143
+ this._tagger.tagLLMIO(span, requestParams.prompt, textAndResponseReason.messages)
144
+ this._tagger.tagMetrics(span, extractTokens({
116
145
  tokensFromHeaders,
117
146
  usage: textAndResponseReason.usage,
118
- })
119
- this._tagger.tagMetrics(span, {
120
- inputTokens,
121
- outputTokens,
122
- totalTokens,
123
- cacheReadTokens,
124
- cacheWriteTokens,
125
- })
147
+ }))
126
148
  }
127
149
  }
128
150
 
@@ -16,6 +16,11 @@ const SPAN_KIND_CONSUMER = protoSpanKind.values.SPAN_KIND_CONSUMER
16
16
  // Cached zero Identifier used to detect zero IDs without re-allocating per span.
17
17
  const ZERO_ID = id('0')
18
18
 
19
+ // DD propagation tag carrying the upper 64 bits of a 128-bit trace ID as 16 hex chars.
20
+ // span_format.js#extractChunkTags only copies this onto the first-in-chunk span, so the
21
+ // transformer scans the batch to find it and applies it to every span's traceId.
22
+ const TRACE_ID_128 = '_dd.p.tid'
23
+
19
24
  /**
20
25
  * @typedef {import('../../id').Identifier} Identifier
21
26
  *
@@ -65,6 +70,7 @@ const STATUS_CODE_ERROR = 2
65
70
  const EXCLUDED_META_KEYS = new Set([
66
71
  '_dd.span_links',
67
72
  'span.kind',
73
+ TRACE_ID_128,
68
74
  ])
69
75
 
70
76
  /**
@@ -113,6 +119,18 @@ class OtlpTraceTransformer extends OtlpTransformerBase {
113
119
  * @returns {object[]} Array of scope span objects
114
120
  */
115
121
  #transformScopeSpans (spans) {
122
+ let traceKey
123
+ let traceIdHigh
124
+ const otlpSpans = spans.map((span) => {
125
+ // `_dd.p.tid` lives only on the first-in-chunk span of each trace.
126
+ // Reset at each trace boundary for batching of multiple traces.
127
+ const key = span.trace_id.toString(16)
128
+ if (key !== traceKey) {
129
+ traceKey = key
130
+ traceIdHigh = span.meta?.[TRACE_ID_128]?.toLowerCase()
131
+ }
132
+ return this.#transformSpan(span, traceIdHigh)
133
+ })
116
134
  return [{
117
135
  scope: {
118
136
  name: 'dd-trace-js',
@@ -121,7 +139,7 @@ class OtlpTraceTransformer extends OtlpTransformerBase {
121
139
  droppedAttributesCount: 0,
122
140
  },
123
141
  schemaUrl: '',
124
- spans: spans.map(span => this.#transformSpan(span)),
142
+ spans: otlpSpans,
125
143
  }]
126
144
  }
127
145
 
@@ -129,14 +147,15 @@ class OtlpTraceTransformer extends OtlpTransformerBase {
129
147
  * Transforms a single DD-formatted span to an OTLP Span object.
130
148
  *
131
149
  * @param {DDFormattedSpan} span - DD-formatted span to transform
150
+ * @param {string | undefined} traceIdHigh - 16-char hex of the upper 64 bits of the trace ID
132
151
  * @returns {object} OTLP Span object
133
152
  */
134
- #transformSpan (span) {
153
+ #transformSpan (span, traceIdHigh) {
135
154
  const parentId = span.parent_id
136
155
  const links = this.#extractLinks(span.meta?.['_dd.span_links'])
137
156
 
138
157
  return {
139
- traceId: this.#idToBytes(span.trace_id, 16),
158
+ traceId: span.trace_id.toTraceIdHex(traceIdHigh).padStart(32, '0'),
140
159
  spanId: this.#idToBytes(span.span_id, 8),
141
160
  parentSpanId: (parentId && !parentId.equals(ZERO_ID)) ? this.#idToBytes(parentId, 8) : undefined,
142
161
  name: span.resource,
@@ -284,7 +284,7 @@ class TextMapPropagator {
284
284
  (DD_MAJOR < 6 && this._hasPropagationStyle('inject', 'b3'))
285
285
  if (!hasB3multi) return
286
286
 
287
- carrier[b3TraceKey] = this._getB3TraceId(spanContext)
287
+ carrier[b3TraceKey] = spanContext._traceId.toTraceIdHex(spanContext._trace.tags['_dd.p.tid'])
288
288
  carrier[b3SpanKey] = spanContext._spanId.toString(16)
289
289
  carrier[b3SampledKey] = spanContext._sampling.priority >= AUTO_KEEP ? '1' : '0'
290
290
 
@@ -303,7 +303,7 @@ class TextMapPropagator {
303
303
  (DD_MAJOR >= 6 && this._hasPropagationStyle('inject', 'b3'))
304
304
  if (!hasB3SingleHeader) return
305
305
 
306
- const traceId = this._getB3TraceId(spanContext)
306
+ const traceId = spanContext._traceId.toTraceIdHex(spanContext._trace.tags['_dd.p.tid'])
307
307
  const spanId = spanContext._spanId.toString(16)
308
308
  const sampled = spanContext._sampling.priority >= AUTO_KEEP ? '1' : '0'
309
309
 
@@ -859,14 +859,6 @@ class TextMapPropagator {
859
859
  }
860
860
  }
861
861
 
862
- _getB3TraceId (spanContext) {
863
- if (spanContext._traceId.toBuffer().length <= 8 && spanContext._trace.tags['_dd.p.tid']) {
864
- return spanContext._trace.tags['_dd.p.tid'] + spanContext._traceId.toString(16)
865
- }
866
-
867
- return spanContext._traceId.toString(16)
868
- }
869
-
870
862
  /**
871
863
  * @param {number} traceparentSampled
872
864
  * @param {number|undefined} tracestateSamplingPriority
@@ -46,9 +46,7 @@ class DatadogSpanContext {
46
46
 
47
47
  toTraceId (get128bitId = false) {
48
48
  if (get128bitId) {
49
- return this._traceId.toBuffer().length <= 8 && this._trace.tags[TRACE_ID_128]
50
- ? this._trace.tags[TRACE_ID_128] + this._traceId.toString(16).padStart(16, '0')
51
- : this._traceId.toString(16).padStart(32, '0')
49
+ return this._traceId.toTraceIdHex(this._trace.tags[TRACE_ID_128]).padStart(32, '0')
52
50
  }
53
51
  return this._traceId.toString(10)
54
52
  }