Repository: DataDog/dd-trace-js
Pull request #5712: feat: add deterministic sampling with knuth based on TraceId
Author: login: genesor
Labels: name: core
name: semver-minor
Created: 2025-05-13T09:30:51Z | Merged: 2025-05-28T22:32:32Z

### What does this PR do?

The PR changes the sampling logic from a random one to a deterministic method to align the JS tracer with others.
This new sampling is based on the TraceID as opposed to a random based one. The current one has been backported to `RandomSampler` to use whenever a Trace/Span is not available (or the item to be sampled is not based on one)

Some tags applied to ML Obs plugins are dependent on sampling, the are now applied **after** span creation as the span is now needed to get a sampling decision.

### Motivation

We want to have a consistent sampling logic amongst all tracers in order to control better what is kept or dropped. This will also ensure that logs related a trace are kept and help customer down the line.

### Plugin Checklist
<!-- Fill this section if adding or updating a plugin. Remove otherwise. -->

- [ ] Unit tests.
- [ ] Integration tests.
- [ ] Benchmarks.
- [ ] TypeScript [definitions][1].
- [ ] TypeScript [tests][2].
- [ ] API [documentation][3].
- [ ] CI [jobs/workflows][4].

[1]: https://github.com/DataDog/dd-trace-js/blob/master/index.d.ts
[2]: https://github.com/DataDog/dd-trace-js/blob/master/docs/test.ts
[3]: https://github.com/DataDog/documentation/blob/master/content/en/tracing/trace_collection/library_config/nodejs.md
[4]: https://github.com/DataDog/dd-trace-js/blob/master/.github/workflows/plugins.yml

### Additional Notes
<!-- Anything else we should know when reviewing? -->





Diff:
diff --git a/benchmark/core.js b/benchmark/core.js
index 1617f290e7..7b6ffe7c9b 100644
--- a/benchmark/core.js
+++ b/benchmark/core.js
@@ -93,7 +93,7 @@ suite
       sampler = new Sampler(0.5)
     },
     fn () {
-      sampler.isSampled()
+      sampler.isSampled(span)
     }
   })
   .add('format', {
diff --git a/packages/datadog-plugin-google-cloud-vertexai/src/tracing.js b/packages/datadog-plugin-google-cloud-vertexai/src/tracing.js
index 41533fdd5a..2b2297a433 100644
--- a/packages/datadog-plugin-google-cloud-vertexai/src/tracing.js
+++ b/packages/datadog-plugin-google-cloud-vertexai/src/tracing.js
@@ -25,18 +25,18 @@ class GoogleCloudVertexAITracingPlugin extends TracingPlugin {
   bindStart (ctx) {
     const { instance, request, resource, stream } = ctx
 
-    const tags = this.tagRequest(request, instance, stream)
-
     const span = this.startSpan('vertexai.request', {
       service: this.config.service,
       resource,
       kind: 'client',
       meta: {
-        [MEASURED]: 1,
-        ...tags
+        [MEASURED]: 1
       }
     }, false)
 
+    const tags = this.tagRequest(request, instance, stream, span)
+    span.addTags(tags)
+
     const store = storage('legacy').getStore() || {}
     ctx.currentStore = { ...store, span }
 
@@ -51,14 +51,23 @@ class GoogleCloudVertexAITracingPlugin extends TracingPlugin {
 
     const response = result?.response
     if (response) {
-      const tags = this.tagResponse(response)
+      const tags = this.tagResponse(response, span)
       span.addTags(tags)
     }
 
     span.finish()
   }
 
-  tagRequest (request, instance, stream) {
+  /**
+   * Generate the request tags.
+   *
+   * @param {Object} request
+   * @param {Object} instance
+   * @param {boolean} stream
+   * @param {Span} span
+   * @returns {Object}
+   */
+  tagRequest (request, instance, stream, span) {
     const model = extractModel(instance)
     const tags = {
       'vertexai.request.model': model
@@ -81,7 +90,7 @@ class GoogleCloudVertexAITracingPlugin extends TracingPlugin {
       tags['vertexai.request.stream'] = true
     }
 
-    if (!this.isPromptCompletionSampled()) return tags
+    if (!this.isPromptCompletionSampled(span)) return tags
 
     const systemInstructions = extractSystemInstructions(instance)
 
@@ -141,8 +150,16 @@ class GoogleCloudVertexAITracingPlugin extends TracingPlugin {
     }
   }
 
-  tagResponse (response) {
+  /**
+   * Generate the response tags.
+   *
+   * @param {Object} response
+   * @param {Span} span
+   * @returns {Object}
+   */
+  tagResponse (response, span) {
     const tags = {}
+    const isSampled = this.isPromptCompletionSampled(span)
 
     const candidates = response.candidates
     for (const [candidateIdx, candidate] of candidates.entries()) {
@@ -154,7 +171,7 @@ class GoogleCloudVertexAITracingPlugin extends TracingPlugin {
       const role = candidateContent.role
       tags[`vertexai.response.candidates.${candidateIdx}.content.role`] = role
 
-      if (!this.isPromptCompletionSampled()) continue
+      if (!isSampled) continue
 
       const parts = candidateContent.parts
       for (const [partIdx, part] of parts.entries()) {
diff --git a/packages/datadog-plugin-langchain/src/handlers/chain.js b/packages/datadog-plugin-langchain/src/handlers/chain.js
index 81374587cc..fc0f787d99 100644
--- a/packages/datadog-plugin-langchain/src/handlers/chain.js
+++ b/packages/datadog-plugin-langchain/src/handlers/chain.js
@@ -3,10 +3,10 @@
 const LangChainHandler = require('./default')
 
 class LangChainChainHandler extends LangChainHandler {
-  getSpanStartTags (ctx) {
+  getSpanStartTags (ctx, provider, span) {
     const tags = {}
 
-    if (!this.isPromptCompletionSampled()) return tags
+    if (!this.isPromptCompletionSampled(span)) return tags
 
     let inputs = ctx.args?.[0]
     inputs = Array.isArray(inputs) ? inputs : [inputs]
@@ -28,10 +28,10 @@ class LangChainChainHandler extends LangChainHandler {
     return tags
   }
 
-  getSpanEndTags (ctx) {
+  getSpanEndTags (ctx, span) {
     const tags = {}
 
-    if (!this.isPromptCompletionSampled()) return tags
+    if (!this.isPromptCompletionSampled(span)) return tags
 
     let outputs = ctx.result
     outputs = Array.isArray(outputs) ? outputs : [outputs]
diff --git a/packages/datadog-plugin-langchain/src/handlers/embedding.js b/packages/datadog-plugin-langchain/src/handlers/embedding.js
index 05d27ec1c6..ebd7477383 100644
--- a/packages/datadog-plugin-langchain/src/handlers/embedding.js
+++ b/packages/datadog-plugin-langchain/src/handlers/embedding.js
@@ -3,12 +3,12 @@
 const LangChainHandler = require('./default')
 
 class LangChainEmbeddingHandler extends LangChainHandler {
-  getSpanStartTags (ctx) {
+  getSpanStartTags (ctx, provider, span) {
     const tags = {}
 
     const inputTexts = ctx.args?.[0]
 
-    const sampled = this.isPromptCompletionSampled()
+    const sampled = this.isPromptCompletionSampled(span)
     if (typeof inputTexts === 'string') {
       // embed query
       if (sampled) {
diff --git a/packages/datadog-plugin-langchain/src/handlers/language_models/chat_model.js b/packages/datadog-plugin-langchain/src/handlers/language_models/chat_model.js
index 56fabeecfc..c05f40d3d4 100644
--- a/packages/datadog-plugin-langchain/src/handlers/language_models/chat_model.js
+++ b/packages/datadog-plugin-langchain/src/handlers/language_models/chat_model.js
@@ -5,7 +5,7 @@ const LangChainLanguageModelHandler = require('.')
 const COMPLETIONS = 'langchain.response.completions'
 
 class LangChainChatModelHandler extends LangChainLanguageModelHandler {
-  getSpanStartTags (ctx, provider) {
+  getSpanStartTags (ctx, provider, span) {
     const tags = {}
 
     const inputs = ctx.args?.[0]
@@ -15,7 +15,7 @@ class LangChainChatModelHandler extends LangChainLanguageModelHandler {
 
       for (const messageIndex in messageSet) {
         const message = messageSet[messageIndex]
-        if (this.isPromptCompletionSampled()) {
+        if (this.isPromptCompletionSampled(span)) {
           tags[`langchain.request.messages.${messageSetIndex}.${messageIndex}.content`] =
             this.normalize(message.content) || ''
         }
@@ -39,11 +39,13 @@ class LangChainChatModelHandler extends LangChainLanguageModelHandler {
     return tags
   }
 
-  getSpanEndTags (ctx) {
+  getSpanEndTags (ctx, span) {
     const { result } = ctx
 
     const tags = {}
 
+    const sampled = this.isPromptCompletionSampled(span)
+
     this.extractTokenMetrics(ctx.currentStore?.span, result)
 
     for (const messageSetIdx in result?.generations) {
@@ -56,7 +58,7 @@ class LangChainChatModelHandler extends LangChainLanguageModelHandler {
         const message = chatCompletion.message
         let toolCalls = message.tool_calls
 
-        if (text && this.isPromptCompletionSampled()) {
+        if (text && sampled) {
           tags[
           `${COMPLETIONS}.${messageSetIdx}.${chatCompletionIdx}.content`
           ] = this.normalize(text)
diff --git a/packages/datadog-plugin-langchain/src/handlers/language_models/llm.js b/packages/datadog-plugin-langchain/src/handlers/language_models/llm.js
index d7c489bbc0..a819c7817e 100644
--- a/packages/datadog-plugin-langchain/src/handlers/language_models/llm.js
+++ b/packages/datadog-plugin-langchain/src/handlers/language_models/llm.js
@@ -3,12 +3,12 @@
 const LangChainLanguageModelHandler = require('.')
 
 class LangChainLLMHandler extends LangChainLanguageModelHandler {
-  getSpanStartTags (ctx, provider) {
+  getSpanStartTags (ctx, provider, span) {
     const tags = {}
 
     const prompts = ctx.args?.[0]
     for (const promptIdx in prompts) {
-      if (!this.isPromptCompletionSampled()) continue
+      if (!this.isPromptCompletionSampled(span)) continue
 
       const prompt = prompts[promptIdx]
       tags[`langchain.request.prompts.${promptIdx}.content`] = this.normalize(prompt) || ''
@@ -30,16 +30,17 @@ class LangChainLLMHandler extends LangChainLanguageModelHandler {
     return tags
   }
 
-  getSpanEndTags (ctx) {
+  getSpanEndTags (ctx, span) {
     const { result } = ctx
 
     const tags = {}
+    const sampled = this.isPromptCompletionSampled(span)
 
     this.extractTokenMetrics(ctx.currentStore?.span, result)
 
     for (const completionIdx in result?.generations) {
       const completion = result.generations[completionIdx]
-      if (this.isPromptCompletionSampled()) {
+      if (sampled) {
         tags[`langchain.response.completions.${completionIdx}.text`] = this.normalize(completion[0].text) || ''
       }
 
diff --git a/packages/datadog-plugin-langchain/src/tracing.js b/packages/datadog-plugin-langchain/src/tracing.js
index fa608a474a..62e3841d5a 100644
--- a/packages/datadog-plugin-langchain/src/tracing.js
+++ b/packages/datadog-plugin-langchain/src/tracing.js
@@ -49,23 +49,24 @@ class BaseLangChainTracingPlugin extends TracingPlugin {
     const provider = handler.extractProvider(instance)
     const model = handler.extractModel(instance)
 
-    const tags = handler.getSpanStartTags(ctx, provider) || []
-
-    if (apiKey) tags[API_KEY] = apiKey
-    if (provider) tags[PROVIDER] = provider
-    if (model) tags[MODEL] = model
-    if (type) tags[TYPE] = type
-
     const span = this.startSpan('langchain.request', {
       service: this.config.service,
       resource,
       kind: 'client',
       meta: {
-        [MEASURED]: 1,
-        ...tags
+        [MEASURED]: 1
       }
     }, false)
 
+    const tags = handler.getSpanStartTags(ctx, provider, span) || []
+
+    if (apiKey) tags[API_KEY] = apiKey
+    if (provider) tags[PROVIDER] = provider
+    if (model) tags[MODEL] = model
+    if (type) tags[TYPE] = type
+
+    span.addTags(tags)
+
     const store = storage('legacy').getStore() || {}
     ctx.currentStore = { ...store, span }
 
@@ -78,7 +79,7 @@ class BaseLangChainTracingPlugin extends TracingPlugin {
     const { type } = ctx
 
     const handler = this.handlers[type]
-    const tags = handler.getSpanEndTags(ctx) || {}
+    const tags = handler.getSpanEndTags(ctx, span) || {}
 
     span.addTags(tags)
 
diff --git a/packages/datadog-plugin-openai/src/tracing.js b/packages/datadog-plugin-openai/src/tracing.js
index f8ccaae5d8..3f3fbad0ad 100644
--- a/packages/datadog-plugin-openai/src/tracing.js
+++ b/packages/datadog-plugin-openai/src/tracing.js
@@ -326,7 +326,7 @@ class OpenAiTracingPlugin extends TracingPlugin {
   sendLog (methodName, span, tags, openaiStore, error) {
     if (!openaiStore) return
     if (!Object.keys(openaiStore).length) return
-    if (!this.sampler.isSampled()) return
+    if (!this.sampler.isSampled(span)) return
 
     const log = {
       status: error ? 'error' : 'info',
diff --git a/packages/dd-trace/src/id.js b/packages/dd-trace/src/id.js
index 76d631643d..c42e62e1e2 100644
--- a/packages/dd-trace/src/id.js
+++ b/packages/dd-trace/src/id.js
@@ -26,6 +26,10 @@ class Identifier {
       : toNumberString(this._buffer, radix)
   }
 
+  toBigInt () {
+    return Buffer.from(this._buffer).readBigUInt64BE(0)
+  }
+
   toBuffer () {
     return this._buffer
   }
diff --git a/packages/dd-trace/src/opentracing/tracer.js b/packages/dd-trace/src/opentracing/tracer.js
index 569eef03c6..2c6a07e17d 100644
--- a/packages/dd-trace/src/opentracing/tracer.js
+++ b/packages/dd-trace/src/opentracing/tracer.js
@@ -107,6 +107,12 @@ class DatadogTracer {
   }
 }
 
+/**
+ * Get the span context from a span or a span context.
+ *
+ * @param {Span|SpanContext} spanContext
+ * @returns {SpanContext}
+ */
 function getContext (spanContext) {
   if (spanContext instanceof Span) {
     spanContext = spanContext.context()
diff --git a/packages/dd-trace/src/plugins/util/llm.js b/packages/dd-trace/src/plugins/util/llm.js
index 1ab65fcc9e..e891059bda 100644
--- a/packages/dd-trace/src/plugins/util/llm.js
+++ b/packages/dd-trace/src/plugins/util/llm.js
@@ -18,8 +18,15 @@ function normalize (text, limit = 128) {
   return text
 }
 
-function isPromptCompletionSampled (sampler) {
-  return sampler.isSampled()
+/**
+ * Determines whether a prompt completion should be sampled based on the configured sampling rate.
+ *
+ * @param {Sampler} sampler
+ * @param {Span|SpanContext} spanContext
+ * @returns {boolean} `true` if the prompt completion should be sampled, otherwise `false`.
+ */
+function isPromptCompletionSampled (sampler, span) {
+  return sampler.isSampled(span)
 }
 
 module.exports = function (integrationName, tracerConfig) {
@@ -30,6 +37,12 @@ module.exports = function (integrationName, tracerConfig) {
 
   return {
     normalize: str => normalize(str, spanCharLimit),
-    isPromptCompletionSampled: () => isPromptCompletionSampled(sampler)
+    /**
+     * Determines whether a prompt completion should be sampled based on the configured sampling rate.
+     *
+     * @param {Span|SpanContext} span
+     * @returns {boolean} `true` if the prompt completion should be sampled, otherwise `false`.
+     */
+    isPromptCompletionSampled: (span) => isPromptCompletionSampled(sampler, span)
   }
 }
diff --git a/packages/dd-trace/src/priority_sampler.js b/packages/dd-trace/src/priority_sampler.js
index 038f7bc262..467e92fb46 100644
--- a/packages/dd-trace/src/priority_sampler.js
+++ b/packages/dd-trace/src/priority_sampler.js
@@ -235,7 +235,7 @@ class PrioritySampler {
     if (rule.provenance === 'customer') context._sampling.mechanism = SAMPLING_MECHANISM_REMOTE_USER
     if (rule.provenance === 'dynamic') context._sampling.mechanism = SAMPLING_MECHANISM_REMOTE_DYNAMIC
 
-    return rule.sample() && this._isSampledByRateLimit(context)
+    return rule.sample(context) && this._isSampledByRateLimit(context)
       ? USER_KEEP
       : USER_REJECT
   }
diff --git a/packages/dd-trace/src/random_sampler.js b/packages/dd-trace/src/random_sampler.js
new file mode 100644
index 0000000000..a3299d973b
--- /dev/null
+++ b/packages/dd-trace/src/random_sampler.js
@@ -0,0 +1,40 @@
+'use strict'
+
+/**
+ * `RandomSampler` determines whether or not to sample an operation based on random chance.
+ *
+ * Use this class **only** when the operation you are sampling does **not have access**
+ * to a `Span` or its `SpanContext`.
+ *
+ * If a `Span` or its `SpanContext` **is** available, use the `Sampler` class instead as
+ * it uses a deterministic sampling algorithm consistent across all languages.
+ */
+class RandomSampler {
+  /**
+   * @param {number} rate
+   */
+  constructor (rate) {
+    this._rate = rate
+  }
+
+  /**
+   * @returns {number}
+   */
+  rate () {
+    return this._rate
+  }
+
+  /**
+   * Determines whether an operation should be sampled based on the configured sampling rate.
+   *
+   * Returns `true` if the sampling decision passes (i.e., the operation should be sampled).
+   * This happens if the sampling rate is `1` (i.e., always sample) or if a random value falls below the rate.
+   *
+   * @returns {boolean} `true` if the operation should be sampled, otherwise `false`.
+   */
+  isSampled () {
+    return this._rate === 1 || Math.random() < this._rate
+  }
+}
+
+module.exports = RandomSampler
diff --git a/packages/dd-trace/src/sampler.js b/packages/dd-trace/src/sampler.js
index c7525f637a..948bdf4b8f 100644
--- a/packages/dd-trace/src/sampler.js
+++ b/packages/dd-trace/src/sampler.js
@@ -1,11 +1,27 @@
 'use strict'
 
+// Maximum trace ID value is the maximum value for a 64-bit unsigned integer.
+// Javascript cannot handle such large numbers, we will loose precision but it's fine
+// as it is cast into a float64 when computing the threshold
+const MAX_TRACE_ID = 2 ** 64 - 1
+
+const UINT64_MODULO = 2n ** 64n
+
+// Knuth's factor for the sampling algorithm
+const SAMPLING_KNUTH_FACTOR = 1111111111111111111n
+
+/**
+ * `Sampler` determines whether or not to sample a trace/span based on the trace ID.
+ *
+ * This class uses a deterministic sampling algorithm that is consistent across all languages.
+ */
 class Sampler {
   /**
-   * @param rate {number}
+   * @param {number} rate
    */
   constructor (rate) {
     this._rate = rate
+    this._threshold = BigInt(Math.floor(rate * MAX_TRACE_ID))
   }
 
   /**
@@ -16,10 +32,23 @@ class Sampler {
   }
 
   /**
-   * @returns {boolean}
+   * Determines whether a trace/span should be sampled based on the configured sampling rate.
+   *
+   * @param {Span|SpanContext} span - The span or span context to evaluate.
+   * @returns {boolean} `true` if the trace/span should be sampled, otherwise `false`.
    */
-  isSampled () {
-    return this._rate === 1 || Math.random() < this._rate
+  isSampled (span) {
+    if (this._rate === 1) {
+      return true
+    }
+
+    if (this._rate === 0) {
+      return false
+    }
+
+    span = typeof span.context === 'function' ? span.context() : span
+
+    return (span._traceId.toBigInt() * SAMPLING_KNUTH_FACTOR) % UINT64_MODULO <= this._threshold
   }
 }
 
diff --git a/packages/dd-trace/src/sampling_rule.js b/packages/dd-trace/src/sampling_rule.js
index 3ac6bc76fc..e5b03cb7d9 100644
--- a/packages/dd-trace/src/sampling_rule.js
+++ b/packages/dd-trace/src/sampling_rule.js
@@ -123,8 +123,14 @@ class SamplingRule {
     return true
   }
 
-  sample () {
-    if (!this._sampler.isSampled()) {
+  /**
+   * Determines whether a span should be sampled based on the configured sampling rule.
+   *
+   * @param {Span|SpanContext} span - The span or span context to evaluate.
+   * @returns {boolean} `true` if the span should be sampled, otherwise `false`.
+   */
+  sample (span) {
+    if (!this._sampler.isSampled(span)) {
       return false
     }
 
diff --git a/packages/dd-trace/src/span_sampler.js b/packages/dd-trace/src/span_sampler.js
index 452d3cd474..d39ceac6b5 100644
--- a/packages/dd-trace/src/span_sampler.js
+++ b/packages/dd-trace/src/span_sampler.js
@@ -26,7 +26,7 @@ class SpanSampler {
     const { started } = spanContext._trace
     for (const span of started) {
       const rule = this.findRule(span)
-      if (rule && rule.sample()) {
+      if (rule && rule.sample(spanContext)) {
         span.context()._spanSampling = {
           sampleRate: rule.sampleRate,
           maxPerSecond: rule.maxPerSecond
diff --git a/packages/dd-trace/test/id.spec.js b/packages/dd-trace/test/id.spec.js
index 61d4dd5e05..4dd60a6449 100644
--- a/packages/dd-trace/test/id.spec.js
+++ b/packages/dd-trace/test/id.spec.js
@@ -79,4 +79,18 @@ describe('id', () => {
 
     expect(spanId.toString(10)).to.equal('1234')
   })
+
+  it('should return the ID as BigInt', () => {
+    const ids = [
+      ['13835058055282163712', 13835058055282163712n],
+      ['10', 10n],
+      ['9007199254740991', 9007199254740991n]
+    ]
+
+    for (const [tid, expected] of ids) {
+      const spanId = id(tid, 10)
+
+      expect(spanId.toBigInt()).to.equal(expected)
+    }
+  })
 })
diff --git a/packages/dd-trace/test/plugins/util/llm.spec.js b/packages/dd-trace/test/plugins/util/llm.spec.js
index 933ee0653b..235fdbff24 100644
--- a/packages/dd-trace/test/plugins/util/llm.spec.js
+++ b/packages/dd-trace/test/plugins/util/llm.spec.js
@@ -3,6 +3,8 @@
 require('../../setup/tap')
 
 const makeUtilities = require('../../../src/plugins/util/llm')
+const SpanContext = require('../../../src/opentracing/span_context')
+const id = require('../../../src/id')
 
 describe('llm utils', () => {
   let utils
@@ -30,7 +32,7 @@ describe('llm utils', () => {
     })
 
     it('should always sample prompt completion', () => {
-      expect(utils.isPromptCompletionSampled()).to.be.true
+      expect(utils.isPromptCompletionSampled(new SpanContext({ traceId: id() }))).to.be.true
     })
   })
 
@@ -49,31 +51,13 @@ describe('llm utils', () => {
       expect(utils.normalize(text)).to.equal('a'.repeat(100) + '...')
     })
 
-    describe('with a random value greater than 0.6', () => {
-      beforeEach(() => {
-        sinon.stub(Math, 'random').returns(0.7)
-      })
-
-      afterEach(() => {
-        Math.random.restore()
-      })
-
+    describe('with sampling rate 0.6', () => {
       it('should not sample prompt completion', () => {
-        expect(utils.isPromptCompletionSampled()).to.be.false
-      })
-    })
-
-    describe('with a random value less than 0.6', () => {
-      beforeEach(() => {
-        sinon.stub(Math, 'random').returns(0.5)
-      })
-
-      afterEach(() => {
-        Math.random.restore()
+        expect(utils.isPromptCompletionSampled(new SpanContext({ traceId: id('8081965455359722133', 10) }))).to.be.false
       })
 
       it('should sample prompt completion', () => {
-        expect(utils.isPromptCompletionSampled()).to.be.true
+        expect(utils.isPromptCompletionSampled(new SpanContext({ traceId: id('5533085789307409170', 10) }))).to.be.true
       })
     })
   })
diff --git a/packages/dd-trace/test/ramdom_sampler.spec.js b/packages/dd-trace/test/ramdom_sampler.spec.js
new file mode 100644
index 0000000000..3445feab41
--- /dev/null
+++ b/packages/dd-trace/test/ramdom_sampler.spec.js
@@ -0,0 +1,55 @@
+'use strict'
+
+require('./setup/tap')
+
+describe('RandomSampler', () => {
+  let RandomSampler
+  let sampler
+
+  beforeEach(() => {
+    sinon.stub(Math, 'random')
+    RandomSampler = require('../src/random_sampler')
+  })
+
+  afterEach(() => {
+    Math.random.restore()
+  })
+
+  describe('rate', () => {
+    it('should return the sample rate', () => {
+      sampler = new RandomSampler(0.5)
+
+      expect(sampler.rate()).to.equal(0.5)
+    })
+  })
+
+  describe('isSampled', () => {
+    it('should always sample when rate is 1', () => {
+      sampler = new RandomSampler(1)
+
+      Math.random.returns(0.9999999999999999)
+
+      expect(sampler.isSampled()).to.be.true
+    })
+
+    it('should never sample when rate is 0', () => {
+      sampler = new RandomSampler(0)
+
+      Math.random.returns(0)
+
+      expect(sampler.isSampled()).to.be.false
+    })
+
+    it('should sample according to the rate', () => {
+      sampler = new RandomSampler(0.1234)
+
+      Math.random.returns(0.1233999999999999)
+
+      expect(sampler.isSampled()).to.be.true
+
+      Math.random.returns(0.1234)
+
+      expect(sampler.isSampled()).to.be.false
+    })
+  })
+})
diff --git a/packages/dd-trace/test/sampler.spec.js b/packages/dd-trace/test/sampler.spec.js
index ea82bfeeb9..5db554e1d4 100644
--- a/packages/dd-trace/test/sampler.spec.js
+++ b/packages/dd-trace/test/sampler.spec.js
@@ -2,6 +2,9 @@
 
 require('./setup/tap')
 
+const SpanContext = require('../src/opentracing/span_context')
+const id = require('../src/id')
+
 describe('Sampler', () => {
   let Sampler
   let sampler
@@ -23,33 +26,82 @@ describe('Sampler', () => {
     })
   })
 
+  describe('threshold', () => {
+    it('should calculate the correct threshold for a given rate', () => {
+      const rates = [
+        [0.2, 3689348814741910528n],
+        [0.25, 4611686018427387904n],
+        [0.3333, 6148299799767393280n],
+        [0.5, 9223372036854775808n],
+        [0.75, 13835058055282163712n],
+        [0.9, 16602069666338596864n],
+        [0.95, 17524406870024073216n]
+      ]
+
+      rates.forEach(([rate, expected]) => {
+        sampler = new Sampler(rate)
+        expect(sampler._threshold).to.equal(expected)
+      })
+    })
+  })
+
   describe('isSampled', () => {
     it('should always sample when rate is 1', () => {
       sampler = new Sampler(1)
 
-      Math.random.returns(0.9999999999999999)
-
-      expect(sampler.isSampled()).to.be.true
+      expect(sampler.isSampled(new SpanContext({ traceId: id() }))).to.be.true
     })
 
     it('should never sample when rate is 0', () => {
       sampler = new Sampler(0)
 
-      Math.random.returns(0)
-
-      expect(sampler.isSampled()).to.be.false
+      expect(sampler.isSampled(new SpanContext({ traceId: id() }))).to.be.false
     })
 
     it('should sample according to the rate', () => {
       sampler = new Sampler(0.1234)
 
-      Math.random.returns(0.1233999999999999)
-
-      expect(sampler.isSampled()).to.be.true
-
-      Math.random.returns(0.1234)
+      expect(sampler.isSampled(new SpanContext({ traceId: id('8135292307740797052', 10) }))).to.be.true
+      expect(sampler.isSampled(new SpanContext({ traceId: id('2263640730249415707', 10) }))).to.be.false
+    })
 
-      expect(sampler.isSampled()).to.be.false
+    it('should sample according to different rates', () => {
+      const idsAndRates = [
+        [id('9223372036854775808', 10), 0.5, true],
+        [id('9223372036854775808', 10), 0.25, false],
+        [id('6148299799767393280', 10), 0.3333, false],
+        [id('2986627970102095326', 10), 0.3333, true],
+        [id('12078589664685934330', 10), 0.5, false],
+        [id('13835058055282163712', 10), 0.75, true],
+        [id('13835058055282163712', 10), 1, true],
+        // Test random very large traceIDs
+        [id('18444899399302180860', 10), 0.5, false],
+        [id('18444899399302180861', 10), 0.5, false],
+        [id('18444899399302180862', 10), 0.5, true],
+        [id('18444899399302180863', 10), 0.5, true],
+        // Test boundary values
+        [id('18446744073709551615', 10), 0.5, false], // 2**64-1
+        [id('9223372036854775809', 10), 0.5, false], // 2**63+1
+        [id('9223372036854775807', 10), 0.5, true], // 2**63-1
+        [id('4611686018427387905', 10), 0.5, false], // 2**62+1
+        [id('4611686018427387903', 10), 0.5, false], // 2**62-1
+        // Random traceIDs
+        [id('646771306295669658', 10), 0.5, true],
+        [id('1882305164521835798', 10), 0.5, true],
+        [id('5198373796167680436', 10), 0.5, false],
+        [id('6272545487220484606', 10), 0.5, true],
+        [id('8696342848850656916', 10), 0.5, true],
+        [id('10197320802478874805', 10), 0.5, true],
+        [id('10350218024687037124', 10), 0.5, true],
+        [id('12078589664685934330', 10), 0.5, false],
+        [id('13794769880582338323', 10), 0.5, true],
+        [id('14629469446186818297', 10), 0.5, false]
+      ]
+
+      idsAndRates.forEach(([id, rate, expected]) => {
+        const sampler = new Sampler(rate)
+        expect(sampler.isSampled(new SpanContext({ traceId: id }))).to.equal(expected)
+      })
     })
   })
 })
diff --git a/packages/dd-trace/test/sampling_rule.spec.js b/packages/dd-trace/test/sampling_rule.spec.js
index 609afe385e..4769480d3d 100644
--- a/packages/dd-trace/test/sampling_rule.spec.js
+++ b/packages/dd-trace/test/sampling_rule.spec.js
@@ -4,6 +4,7 @@ require('./setup/tap')
 
 const { expect } = require('chai')
 const id = require('../src/id')
+const SpanContext = require('../src/opentracing/span_context')
 
 function createDummySpans () {
   const operations = [
@@ -352,7 +353,7 @@ describe('sampling rule', () => {
         maxPerSecond: 1
       })
 
-      expect(rule.sample()).to.equal(true)
+      expect(rule.sample(new SpanContext({ traceId: id() }))).to.equal(true)
     })
 
     it('should not sample on non-allowed sample rate', () => {
@@ -363,7 +364,7 @@ describe('sampling rule', () => {
         maxPerSecond: 1
       })
 
-      expect(rule.sample()).to.equal(false)
+      expect(rule.sample(new SpanContext({ traceId: id('6148299799767393280', 10) }))).to.equal(false)
     })
   })
 
@@ -399,8 +400,10 @@ describe('sampling rule', () => {
         maxPerSecond: 1
       })
 
-      expect(rule.sample()).to.equal(true)
-      expect(rule.sample()).to.equal(false)
+      const spanContext = new SpanContext({ traceId: id('2986627970102095326', 10) })
+
+      expect(rule.sample(spanContext)).to.equal(true)
+      expect(rule.sample(spanContext)).to.equal(false)
     })
 
     it('should allow unlimited rate limits', () => {
@@ -411,7 +414,7 @@ describe('sampling rule', () => {
       })
 
       for (let i = 0; i < 1e3; i++) {
-        expect(rule.sample()).to.equal(true)
+        expect(rule.sample(new SpanContext({ traceId: id() }))).to.equal(true)
       }
     })
 
@@ -424,10 +427,10 @@ describe('sampling rule', () => {
       })
 
       const clock = sinon.useFakeTimers(new Date())
-      expect(rule.sample()).to.equal(true)
-      expect(rule.sample()).to.equal(false)
+      expect(rule.sample(new SpanContext({ traceId: id() }))).to.equal(true)
+      expect(rule.sample(new SpanContext({ traceId: id() }))).to.equal(false)
       clock.tick(1000)
-      expect(rule.sample()).to.equal(true)
+      expect(rule.sample(new SpanContext({ traceId: id() }))).to.equal(true)
     })
   })
 })


Reviews:
user: login: BridgeAR
body: This is really great work!
As far as I can tell, this does implement the spec as defined. LGTM with my comment being addressed.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T20:59:19Z
user: login: rochdev
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-21T12:21:33Z
user: login: genesor
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T14:43:52Z
user: login: rochdev
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T18:31:41Z
user: login: genesor
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T09:45:53Z
user: login: genesor
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T12:27:33Z
user: login: rochdev
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-22T12:39:18Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-26T14:42:39Z
user: login: BridgeAR
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-28T22:31:37Z

Review comments:
diff_hunk: @@ -26,6 +26,14 @@ class Identifier {
       : toNumberString(this._buffer, radix)
   }
 
+  toBigInt () {
+    let result = BigInt(0)
+    for (let i = 0; i < this._buffer.length; i++) {
+      result = (result << BigInt(8)) | BigInt(this._buffer[i])
+    }
path: packages/dd-trace/src/id.js
user: login: BridgeAR
body: It is possible to have a single step less:

```suggestion
    let result = this._buffer[0]
    for (let i = 1; i < this._buffer.length; i++) {
      result = (result << BigInt(8)) | BigInt(this._buffer[i])
    }
```

We might also be able to simplify this by using Node.js methods such as https://nodejs.org/api/buffer.html#bufreadbiguint64beoffset. We could generally simplify that implementation.
created_at: 2025-05-16T15:04:47Z
author_association: MEMBER
diff_hunk: @@ -43,6 +43,12 @@ class DatadogSpanContext {
     }
   }
 
+  // toTraceIdBigInt returns the traceId as a BigInt
+  // in case of a 128 bit traceId, it returns the lower 64 bits
+  toTraceIdBigInt () {
path: packages/dd-trace/src/opentracing/span_context.js
user: login: rochdev
body: This adds a public API. It should either be documented if we expect users to use it, or we should use the ID directly if we prefer to keep this private.
created_at: 2025-05-21T12:15:23Z
author_association: MEMBER
diff_hunk: @@ -16,10 +32,21 @@ class Sampler {
   }
 
   /**
-   * @returns {boolean}
+   * Determines whether a trace/span should be sampled based on the configured sampling rate.
+   *
+   * @param {SpanContext} context - The span context to evaluate.
+   * @returns {boolean} `true` if the trace/span should be sampled, otherwise `false`.
    */
-  isSampled () {
-    return this._rate === 1 || Math.random() < this._rate
+  isSampled (context) {
path: packages/dd-trace/src/sampler.js
user: login: rochdev
body: This could accept a span as well as a context to avoid having to call `.context()` everywhere.
created_at: 2025-05-21T12:15:57Z
author_association: MEMBER
diff_hunk: @@ -26,6 +26,14 @@ class Identifier {
       : toNumberString(this._buffer, radix)
   }
 
+  toBigInt () {
+    let result = BigInt(0)
+    for (let i = 0; i < this._buffer.length; i++) {
+      result = (result << BigInt(8)) | BigInt(this._buffer[i])
+    }
path: packages/dd-trace/src/id.js
user: login: rochdev
body: It should even be possible at this point to just always use a BigInt internally instead of a byte array which will remove the need for a conversion and could potentially be faster than the current approach.
created_at: 2025-05-21T12:17:45Z
author_association: MEMBER
diff_hunk: @@ -16,10 +32,21 @@ class Sampler {
   }
 
   /**
-   * @returns {boolean}
+   * Determines whether a trace/span should be sampled based on the configured sampling rate.
+   *
+   * @param {SpanContext} context - The span context to evaluate.
+   * @returns {boolean} `true` if the trace/span should be sampled, otherwise `false`.
    */
-  isSampled () {
-    return this._rate === 1 || Math.random() < this._rate
+  isSampled (context) {
path: packages/dd-trace/src/sampler.js
user: login: genesor
body: > This could accept a span as well as a context to avoid having to call `.context()` everywhere.

Indeed @rochdev this would be better on the caller side. 

Should we accept both Span and SpanContext or just Span ? 
created_at: 2025-05-21T14:43:51Z
author_association: MEMBER
diff_hunk: @@ -16,10 +32,21 @@ class Sampler {
   }
 
   /**
-   * @returns {boolean}
+   * Determines whether a trace/span should be sampled based on the configured sampling rate.
+   *
+   * @param {SpanContext} context - The span context to evaluate.
+   * @returns {boolean} `true` if the trace/span should be sampled, otherwise `false`.
    */
-  isSampled () {
-    return this._rate === 1 || Math.random() < this._rate
+  isSampled (context) {
path: packages/dd-trace/src/sampler.js
user: login: rochdev
body: I'd say to accept both so that the caller can pass what they have.
created_at: 2025-05-21T18:31:41Z
author_association: MEMBER
diff_hunk: @@ -43,6 +43,12 @@ class DatadogSpanContext {
     }
   }
 
+  // toTraceIdBigInt returns the traceId as a BigInt
+  // in case of a 128 bit traceId, it returns the lower 64 bits
+  toTraceIdBigInt () {
path: packages/dd-trace/src/opentracing/span_context.js
user: login: genesor
body: I removed this function and the code is now using the ID directly
created_at: 2025-05-22T09:45:53Z
author_association: MEMBER
diff_hunk: @@ -26,6 +26,14 @@ class Identifier {
       : toNumberString(this._buffer, radix)
   }
 
+  toBigInt () {
+    let result = BigInt(0)
+    for (let i = 0; i < this._buffer.length; i++) {
+      result = (result << BigInt(8)) | BigInt(this._buffer[i])
+    }
path: packages/dd-trace/src/id.js
user: login: genesor
body: @BridgeAR @rochdev I changed the code to `Buffer.readBigUInt64BE` instead of manually shifting bits. I should be safer. 

About the BigInt instead of byte array I'd agree (especially for clarity purpose) but I'm afraid of the impact that would change the scope of this PR. 
created_at: 2025-05-22T12:27:33Z
author_association: MEMBER
diff_hunk: @@ -16,10 +32,23 @@ class Sampler {
   }
 
   /**
-   * @returns {boolean}
+   * Determines whether a trace/span should be sampled based on the configured sampling rate.
+   *
+   * @param {Span|SpanContext} span - The span or span context to evaluate.
+   * @returns {boolean} `true` if the trace/span should be sampled, otherwise `false`.
    */
-  isSampled () {
-    return this._rate === 1 || Math.random() < this._rate
+  isSampled (span) {
+    if (this._rate === 1) {
+      return true
+    }
+
+    if (this._rate === 0) {
+      return false
+    }
+
+    span = typeof span.context === 'function' ? span.context() : span
path: packages/dd-trace/src/sampler.js
user: login: BridgeAR
body: This makes me wonder if we can limit to always be either. That way this check would not have to run in all cases and we limit the API to a specific input type, which I also believe would be good.
created_at: 2025-05-26T14:42:38Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-13T09:31:37Z
body: 
# Overall package size

Self size: 9.48 MB
Deduped: 103.5 MB
No deduping: 104.02 MB

<details><summary>Dependency sizes</summary>
| name | version | self size | total size |
|------|---------|-----------|------------|
| @datadog/libdatadog | 0.5.1 | 29.73 MB | 29.73 MB |
| @datadog/native-appsec | 8.5.2 | 19.33 MB | 19.34 MB |
| @datadog/pprof | 5.8.0 | 12.55 MB | 12.92 MB |
| @datadog/native-iast-taint-tracking | 4.0.0 | 11.72 MB | 11.73 MB |
| @opentelemetry/core | 1.30.1 | 908.66 kB | 7.16 MB |
| protobufjs | 7.4.0 | 2.77 MB | 5.42 MB |
| @datadog/wasm-js-rewriter | 4.0.1 | 2.85 MB | 3.58 MB |
| @datadog/native-metrics | 3.1.1 | 1.02 MB | 1.43 MB |
| @opentelemetry/api | 1.8.0 | 1.21 MB | 1.21 MB |
| import-in-the-middle | 1.13.1 | 117.64 kB | 839.26 kB |
| source-map | 0.7.4 | 226 kB | 226 kB |
| opentracing | 0.14.7 | 194.81 kB | 194.81 kB |
| lru-cache | 7.18.3 | 133.92 kB | 133.92 kB |
| pprof-format | 2.1.0 | 111.69 kB | 111.69 kB |
| @datadog/sketches-js | 2.1.1 | 109.9 kB | 109.9 kB |
| lodash.sortby | 4.7.0 | 75.76 kB | 75.76 kB |
| ignore | 5.3.2 | 53.63 kB | 53.63 kB |
| istanbul-lib-coverage | 3.2.0 | 29.34 kB | 29.34 kB |
| rfdc | 1.4.1 | 27.15 kB | 27.15 kB |
| @isaacs/ttlcache | 1.4.1 | 25.2 kB | 25.2 kB |
| dc-polyfill | 0.1.9 | 25.11 kB | 25.11 kB |
| tlhunter-sorted-set | 0.1.0 | 24.94 kB | 24.94 kB |
| shell-quote | 1.8.2 | 23.54 kB | 23.54 kB |
| limiter | 1.1.5 | 23.17 kB | 23.17 kB |
| retry | 0.13.1 | 18.85 kB | 18.85 kB |
| semifies | 1.0.0 | 15.84 kB | 15.84 kB |
| jest-docblock | 29.7.0 | 8.99 kB | 12.76 kB |
| crypto-randomuuid | 1.0.0 | 11.18 kB | 11.18 kB |
| ttl-set | 1.0.0 | 4.61 kB | 9.69 kB |
| mutexify | 1.4.0 | 5.71 kB | 8.74 kB |
| path-to-regexp | 0.1.12 | 6.6 kB | 6.6 kB |
| koalas | 1.0.2 | 6.47 kB | 6.47 kB |
| module-details-from-path | 1.0.3 | 4.47 kB | 4.47 kB |


</details>



<sub>🤖 This report was automatically generated by [heaviest-objects-in-the-universe](https://github.com/qard/heaviest-objects-in-the-universe/)<sub>
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-13T09:31:38Z
body: ## [Codecov](https://app.codecov.io/gh/DataDog/dd-trace-js/pull/5712?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DataDog) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 79.39%. Comparing base [(`676146a`)](https://app.codecov.io/gh/DataDog/dd-trace-js/commit/676146afe4485438a4661a8eafc1127b5cc3d0f4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DataDog) to head [(`1f16c62`)](https://app.codecov.io/gh/DataDog/dd-trace-js/commit/1f16c62d114561e73918fab5a5d46b91b09690aa?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DataDog).
> Report is 5 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #5712      +/-   ##
==========================================
- Coverage   79.40%   79.39%   -0.01%     
==========================================
  Files         520      522       +2     
  Lines       23788    23826      +38     
==========================================
+ Hits        18888    18917      +29     
- Misses       4900     4909       +9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/DataDog/dd-trace-js/pull/5712?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DataDog).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DataDog).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: datadog-datadog-prod-us1[bot]
created_at: 2025-05-13T09:32:26Z
body: # Datadog Summary

<b>✅&nbsp;Code Quality</b>&nbsp;&nbsp;&nbsp;&nbsp;<b>✅&nbsp;Code Security</b>&nbsp;&nbsp;&nbsp;&nbsp;<b>✅&nbsp;Dependencies</b>




## Test Optimization Report

Branch report: [`ben.db/APMAPI-1265-knuth-sampling`](https://app.datadoghq.com/ci/test-branch/github.com%2Fdatadog%2Fdd-trace-js/dd-trace-js-integration-tests/ben.db%2FAPMAPI-1265-knuth-sampling?env=none&index=citest&_user_click_origin_=github)
Commit report: [`5bb542f`](https://app.datadoghq.com/ci/test-commit/github.com%2Fdatadog%2Fdd-trace-js/dd-trace-js-integration-tests/ben.db%2FAPMAPI-1265-knuth-sampling/66e25c89751ad439f8cec6eabc6661def2f84355?env=none&index=citest&_user_click_origin_=github)
Test service: `dd-trace-js-integration-tests`

✅ 0 Failed, 933 Passed, 0 Skipped, 12m 2.2s Total Time





---
Was this helpful? Give us [feedback](https://dd.enterprise.slack.com/archives/C01FA7FASUR)!

author_association: NONE
user: login: pr-commenter[bot]
created_at: 2025-05-13T09:48:54Z
body: ## Benchmarks

Benchmark execution time: 2025-05-28 18:44:07

Comparing candidate commit 1f16c62d114561e73918fab5a5d46b91b09690aa in PR branch `ben.db/APMAPI-1265-knuth-sampling` with baseline commit 676146afe4485438a4661a8eafc1127b5cc3d0f4 in branch `master`.

Found 0 performance improvements and 0 performance regressions! Performance is the same for 1266 metrics, 57 unstable metrics.


author_association: NONE
user: login: datadog-datadog-prod-us1[bot]
created_at: 2025-05-15T09:23:22Z
body: 
## Datadog Report

Branch report: [`ben.db/APMAPI-1265-knuth-sampling`](https://app.datadoghq.com/ci/test-branch/github.com%2Fdatadog%2Fdd-trace-js/dd-trace-js-integration-tests/ben.db%2FAPMAPI-1265-knuth-sampling?env=none&index=citest&_user_click_origin_=github)
Commit report: [`855a323`](https://app.datadoghq.com/ci/test-commit/github.com%2Fdatadog%2Fdd-trace-js/dd-trace-js-integration-tests/ben.db%2FAPMAPI-1265-knuth-sampling/855a32362c5adc2e0ad2dd65aeef73fef7e91fb8?env=none&index=citest&_user_click_origin_=github)
Test service: `dd-trace-js-integration-tests`

:white_check_mark: 0 Failed, 1156 Passed, 0 Skipped, 20m 26.35s Total Time




author_association: NONE
user: login: BridgeAR
created_at: 2025-05-28T22:33:04Z
body: Great work, @genesor!
author_association: MEMBER