Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
49eb625
First implementation for OTel sampling support
vpellan Sep 2, 2026
5785a9b
CODEOWNERS
vpellan Sep 2, 2026
9a7dfdd
fix(otel): generate sampling state for OTel propagation
vpellan Sep 3, 2026
af0fa51
fix(sampling): preserve committed decision metadata during checks
vpellan Sep 3, 2026
1186c76
fix(standalone): mark extracted force-keeps non-probability
vpellan Sep 3, 2026
30ab81c
fix(propagation): merge matching W3C tracestate into B3 context
vpellan Sep 3, 2026
42005a6
perf(sampling): defer OTel decision metadata until propagation
vpellan Sep 3, 2026
660cb44
Merge branch 'master' into vpellan/otel-threshold-random-value
vpellan Sep 3, 2026
46548fb
fix(otel): preserve standard parent tracestate
vpellan Sep 3, 2026
e8d4fd6
fix(sampling): refresh manual decision maker
vpellan Sep 3, 2026
9c18613
fix(propagation): clear conflicting OTel thresholds
vpellan Sep 3, 2026
f9ff0ad
refactor(sampling): use kebab-case filenames
vpellan Sep 3, 2026
8fa5940
fix(otel): ignore uncommitted sampling probe rates
vpellan Sep 3, 2026
9dc3f5d
fix(otel): preserve inherited tracestate member order
vpellan Sep 3, 2026
cf02963
Merge branch 'master' into vpellan/otel-threshold-random-value
vpellan Sep 4, 2026
6c0513a
fix(propagation): inherit W3C priority when selected style omits it
vpellan Sep 7, 2026
3e895ea
fix(sampling): ignore invalid priority tag values
vpellan Sep 7, 2026
59c8dd7
fix(sampling): avoid rescanning stale legacy tags
vpellan Sep 7, 2026
c0614ce
Merge branch 'master' into vpellan/otel-threshold-random-value
vpellan Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@
/packages/dd-trace/test/baggage.spec.js @DataDog/apm-sdk-capabilities-js
/packages/dd-trace/src/carrier.js @DataDog/apm-sdk-capabilities-js
/packages/dd-trace/test/carrier.spec.js @DataDog/apm-sdk-capabilities-js
/packages/dd-trace/src/knuth_hash.js @DataDog/apm-sdk-capabilities-js
/packages/dd-trace/src/otel_sampling.js @DataDog/apm-sdk-capabilities-js
/packages/dd-trace/test/opentracing/propagation/otel_sampling.spec.js @DataDog/apm-sdk-capabilities-js
/packages/dd-trace/src/*sampler.js @DataDog/apm-sdk-capabilities-js
/packages/dd-trace/test/*sampler.spec.js @DataDog/apm-sdk-capabilities-js
/packages/dd-trace/src/sampling_rule.js @DataDog/apm-sdk-capabilities-js
Expand Down
18 changes: 18 additions & 0 deletions packages/dd-trace/src/knuth_hash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict'

const UINT64_MODULO = 2n ** 64n

// Knuth's factor for the sampling algorithm shared across Datadog tracers.
const SAMPLING_KNUTH_FACTOR = 1_111_111_111_111_111_111n

/**
* Hashes the lower 64 bits of a trace ID for deterministic trace sampling.
*
* @param {bigint} traceId
* @returns {bigint}
*/
function knuthHash (traceId) {
return (traceId * SAMPLING_KNUTH_FACTOR) % UINT64_MODULO
}

module.exports = knuthHash
8 changes: 6 additions & 2 deletions packages/dd-trace/src/opentelemetry/span_context.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

const api = require('@opentelemetry/api')
const { AUTO_KEEP } = require('../../../../ext/priority')
const { updateOtelTraceState } = require('../otel_sampling')
const DatadogSpanContext = require('../opentracing/span_context')
const TraceState = require('../opentracing/propagation/tracestate')
const id = require('../id')

function newContext () {
Expand Down Expand Up @@ -37,8 +39,10 @@ class SpanContext {
}

get traceState () {
const ts = this._ddContext._tracestate
return api.createTraceState(ts ? ts.toString() : '')
this._ddContext._ensureSamplingPriority()
const traceState = TraceState.fromString(this._ddContext._tracestate?.toString())
Comment thread
vpellan marked this conversation as resolved.
updateOtelTraceState(this._ddContext, traceState)
return api.createTraceState(traceState.toString())
}
}

Expand Down
18 changes: 11 additions & 7 deletions packages/dd-trace/src/opentracing/propagation/text_map.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const zeroTraceId = '0000000000000000'
const hex16 = /^[0-9A-Fa-f]{16}$/
const percentByte = /%([0-9A-Fa-f]{2})/g

let updateOtelTraceState

class TextMapPropagator {
/** @type {Set<string> | undefined} Cached `Set` view of `_config.baggageTagKeys`. */
#baggageTagKeysSet
Expand Down Expand Up @@ -394,6 +396,9 @@ class TextMapPropagator {

writeTraceparent(carrier, spanContext.toTraceparent())

updateOtelTraceState ??= require('../../otel_sampling').updateOtelTraceState
updateOtelTraceState(spanContext, ts)

ts.forVendor('dd', state => {
if (!spanContext._isRemote) {
// SpanContext was created by a ddtrace span.
Expand Down Expand Up @@ -441,12 +446,6 @@ class TextMapPropagator {
return this._config.tracePropagationStyle[mode].includes(name)
}

_hasTraceIdConflict (w3cSpanContext, firstSpanContext) {
return w3cSpanContext !== undefined &&
firstSpanContext.toTraceId(true) === w3cSpanContext.toTraceId(true) &&
firstSpanContext.toSpanId() !== w3cSpanContext.toSpanId()
}

_hasParentIdInTags (spanContext) {
return tags.DD_PARENT_ID in spanContext._trace.tags
}
Expand All @@ -459,9 +458,14 @@ class TextMapPropagator {
}

_resolveTraceContextConflicts (w3cSpanContext, firstSpanContext, carrier) {
if (!this._hasTraceIdConflict(w3cSpanContext, firstSpanContext)) {
if (w3cSpanContext === undefined ||
firstSpanContext.toTraceId(true) !== w3cSpanContext.toTraceId(true)) {
return firstSpanContext
}

firstSpanContext._tracestate = w3cSpanContext._tracestate
Comment thread
vpellan marked this conversation as resolved.
if (firstSpanContext.toSpanId() === w3cSpanContext.toSpanId()) return firstSpanContext

if (this._hasParentIdInTags(w3cSpanContext)) {
// tracecontext headers contain a p value, ensure this value is sent to backend
firstSpanContext._trace.tags[tags.DD_PARENT_ID] = w3cSpanContext._trace.tags[tags.DD_PARENT_ID]
Expand Down
34 changes: 33 additions & 1 deletion packages/dd-trace/src/opentracing/propagation/tracestate.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// W3C Trace Context §3.3.1.2: max 32 list-members.
// https://www.w3.org/TR/trace-context/#tracestate-header-field-values
const MAX_LIST_MEMBERS = 32
const MAX_TRACESTATE_BYTES = 512
const WHITESPACE = /[ \t]/

/**
Expand Down Expand Up @@ -56,6 +57,32 @@ function toString (map, pairSeparator, fieldSeparator) {
return result
}

/**
* Keeps complete leftmost members within the W3C count and byte limits.
*
* @param {string} value
* @returns {string}
*/
function limitTraceState (value) {
let byteLength = 0
let end = 0
let members = 0
let start = 0

while (start < value.length && members < MAX_LIST_MEMBERS) {
let next = value.indexOf(',', start)
if (next === -1) next = value.length
const memberLength = Buffer.byteLength(value.slice(start, next)) + (members === 0 ? 0 : 1)
if (byteLength + memberLength > MAX_TRACESTATE_BYTES) break
byteLength += memberLength
end = next
members++
start = next + 1
}

return value.slice(0, end)
}

class TraceStateData {
#map
changed = false
Expand Down Expand Up @@ -162,7 +189,12 @@ class TraceState {
}

toString () {
return toString(this, '=', ',')
const value = toString(this, '=', ',')
if (this.size <= MAX_LIST_MEMBERS &&
(value.length <= MAX_TRACESTATE_BYTES / 4 || Buffer.byteLength(value) <= MAX_TRACESTATE_BYTES)) {
return value
}
return limitTraceState(value)
}
}

Expand Down
11 changes: 7 additions & 4 deletions packages/dd-trace/src/opentracing/span.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ class DatadogSpan {
setTag (key, value) {
this._spanContext.setTag(key, value)

if (isSamplingPriorityTag(key) && this._spanContext._sampling.priority === undefined) {
this._prioritySampler.sample(this, false)
if (isSamplingPriorityTag(key)) {
this._prioritySampler.setPriorityFromTag(this, key, value)
Comment on lines +216 to +217

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate sampling-priority values before overriding

After sampling has already been materialized—for example by injection or reading OTel traceFlags—calling setTag('sampling.priority', 1n) or passing a Symbol now reaches Math.trunc() through this unconditional override path and throws a TypeError. Previously the defined priority made this path a no-op, so the same late tag could not crash the application; validate the value's type before attempting the override.

AGENTS.md reference: AGENTS.md:L109-L109

Useful? React with 👍 / 👎.

}

if (tagsUpdateCh.hasSubscribers) {
Expand All @@ -223,9 +223,11 @@ class DatadogSpan {
// `options.tags` callers that pass `'key:val,key:val'` strings.
const tags = this._spanContext.getTags()
let mayChangeSamplingPriority
let samplingTags

if (keyValueMap !== null && typeof keyValueMap === 'object' && !Array.isArray(keyValueMap)) {
Object.assign(tags, keyValueMap)
samplingTags = keyValueMap
mayChangeSamplingPriority =
MANUAL_KEEP in keyValueMap ||
MANUAL_DROP in keyValueMap ||
Expand All @@ -234,14 +236,15 @@ class DatadogSpan {
/* istanbul ignore if: v5 fallback, master ships 6.0.0-pre */
if (DD_MAJOR < 6 && (typeof keyValueMap === 'string' || Array.isArray(keyValueMap))) {
tagger.add(tags, keyValueMap)
samplingTags = tags
mayChangeSamplingPriority = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rescanning stale sampling tags in the v5 fallback

With DD_MAJOR < 6, every string or array passed to addTags() now marks sampling as changed and passes the entire live tag map to setPriorityFromTags(), even when the new input contains no sampling tag. For example, after addTags('manual.keep:true') and a later setTag('manual.drop', true) establish a drop, an unrelated addTags('foo:bar') rescans both stored tags, gives manual.keep precedence, and silently flips the trace back to keep. Detect sampling keys in the newly parsed input, or avoid reapplying old tags for unrelated additions.

AGENTS.md reference: AGENTS.md:L119-L122

Useful? React with 👍 / 👎.

} else {
return this
}
}

if (mayChangeSamplingPriority && this._spanContext._sampling.priority === undefined) {
this._prioritySampler.sample(this, false)
if (mayChangeSamplingPriority) {
this._prioritySampler.setPriorityFromTags(this, samplingTags)
}

if (tagsUpdateCh.hasSubscribers) {
Expand Down
198 changes: 198 additions & 0 deletions packages/dd-trace/src/otel_sampling.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
'use strict'

const { AUTO_KEEP } = require('../../../ext/priority')
const knuthHash = require('./knuth_hash')
const { SAMPLING_AGENT_DECISION, SAMPLING_RULE_DECISION } = require('./constants')

const MAX_OTEL_VALUE_BYTES = 256
const MAX_THRESHOLD = 2n ** 56n
const MAX_ENCODABLE_THRESHOLD = MAX_THRESHOLD - 1n
const UINT64_MASK = 2n ** 64n - 1n
const validRandomValue = /^[0-9a-f]{14}$/
const validThreshold = /^[0-9a-f]{1,14}$/

/**
* Derives the OTel 56-bit random value from Datadog's sampling hash.
*
* @param {bigint} traceId
* @returns {bigint}
*/
function randomValueFor (traceId) {
return ((~knuthHash(traceId)) & UINT64_MASK) >> 8n
}

/**
* Converts a sample rate to an OTel 56-bit rejection threshold.
*
* @param {number} sampleRate
* @returns {bigint}
*/
function thresholdFor (sampleRate) {
if (sampleRate === 1) return 0n
if (sampleRate === 0) return MAX_ENCODABLE_THRESHOLD

const threshold = BigInt(Math.round((1 - sampleRate) * Number(MAX_THRESHOLD)))
if (threshold < 0n) return 0n
if (threshold > MAX_ENCODABLE_THRESHOLD) return MAX_ENCODABLE_THRESHOLD
return threshold
}

/**
* Formats an OTel threshold with trailing zero nibbles removed.
*
* @param {bigint} threshold
* @returns {string}
*/
function formatThreshold (threshold) {
return threshold.toString(16).padStart(14, '0').replace(/0+$/, '') || '0'
}

/**
* Generates OTel sampling fields for a local probability decision.
*
* @param {import('./opentracing/span_context')} context
* @returns {{ randomValue: string, threshold: string } | undefined}
*/
function generateFields (context) {
const { priority } = context._sampling
const probabilityRate = getProbabilityRate(context)
if (priority === undefined || probabilityRate === undefined) return

const thresholdValue = thresholdFor(probabilityRate)
let randomValue = randomValueFor(context._traceId.toBigInt())
const kept = priority >= AUTO_KEEP

if (kept && randomValue < thresholdValue) {
randomValue = thresholdValue
} else if (!kept && randomValue >= thresholdValue) {
randomValue = thresholdValue > 0n ? thresholdValue - 1n : 0n
}

return {
randomValue: randomValue.toString(16).padStart(14, '0'),
threshold: formatThreshold(thresholdValue),
}
}

/**
* Returns the probability rate already recorded by the regular Datadog sampling path.
*
* @param {import('./opentracing/span_context')} context
* @returns {number | undefined}
*/
function getProbabilityRate (context) {
if (context._sampling.isProbabilityDecision === false) return
if (context._sampling.probabilityRate !== undefined) return context._sampling.probabilityRate
return context._trace[SAMPLING_RULE_DECISION] ?? context._trace[SAMPLING_AGENT_DECISION]
}

/**
* Adds a complete sub-field while the OTel member remains within its byte cap.
*
* @param {string[]} fields
* @param {string} field
* @param {number} byteLength
* @returns {number}
*/
function addField (fields, field, byteLength) {
const fieldLength = Buffer.byteLength(field) + (fields.length === 0 ? 0 : 1)
if (byteLength + fieldLength <= MAX_OTEL_VALUE_BYTES) {
fields.push(field)
return byteLength + fieldLength
}
return byteLength
}

/**
* Parses and rebuilds the OTel tracestate member, preserving unknown sub-fields.
*
* @param {import('./opentracing/span_context')} context
* @param {string | undefined} member
* @returns {string | undefined}
*/
function buildOtelMember (context, member) {
if (member === undefined) {
if (context._sampling.isProbabilityDecision === false) return
const generated = generateFields(context)
if (!generated) return
return `rv:${generated.randomValue};th:${generated.threshold}`
}

let randomValue
let threshold
const unknownFields = []
let start = 0

while (start <= member.length) {
let end = member.indexOf(';', start)
if (end === -1) end = member.length
const field = member.slice(start, end)
if (field) {
const separator = field.indexOf(':')
const key = separator === -1 ? field : field.slice(0, separator)
const value = separator === -1 ? undefined : field.slice(separator + 1)
if (key === 'rv') {
randomValue = value
} else if (key === 'th') {
threshold = value
} else {
unknownFields.push(field)
}
}
if (end === member.length) break
start = end + 1
}

if (!validRandomValue.test(randomValue)) randomValue = undefined
if (!validThreshold.test(threshold)) threshold = undefined

if (context._sampling.isProbabilityDecision === false) {
threshold = undefined
} else if (randomValue === undefined && threshold === undefined) {
const generated = generateFields(context)
if (generated) {
randomValue = generated.randomValue
threshold = generated.threshold
}
}

const fields = []
let byteLength = 0
if (randomValue !== undefined) byteLength = addField(fields, `rv:${randomValue}`, byteLength)
if (threshold !== undefined) byteLength = addField(fields, `th:${threshold}`, byteLength)
for (const field of unknownFields) {
byteLength = addField(fields, field, byteLength)
}

return fields.length === 0 ? undefined : fields.join(';')
}

/**
* Updates the OTel tracestate member to represent the context's sampling decision.
*
* @param {import('./opentracing/span_context')} context
* @param {import('./opentracing/propagation/tracestate')} traceState
* @returns {void}
*/
function updateOtelTraceState (context, traceState) {
const otelMember = traceState.get('ot')
if (context._sampling.isProbabilityDecision === false) {
if (otelMember === undefined) return
} else if (getProbabilityRate(context) === undefined) {
// Reinsert the inherited member to keep it leftmost without rebuilding its fields.
if (otelMember !== undefined) traceState.set('ot', otelMember)
return
}

const rebuiltOtelMember = buildOtelMember(context, otelMember)
if (rebuiltOtelMember === undefined) {
traceState.delete('ot')
} else {
traceState.set('ot', rebuiltOtelMember)
}
}

module.exports = {
buildOtelMember,
updateOtelTraceState,
}
Loading