From 518c07a32e8d5ac708476b0498b06e04931a5182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt?= Date: Wed, 11 Dec 2024 16:27:24 +0100 Subject: [PATCH] =?UTF-8?q?=20=E2=9A=97=EF=B8=8F=20=E2=9C=A8=20[RUM-6868]?= =?UTF-8?q?=20implement=20consistent=20probabilistic=20trace=20sampling=20?= =?UTF-8?q?=20(#3186)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚚 [RUM-6868] move crypto in a separate module * 🚚 [RUM-6868] move identifier logic in a separate module * 🚚 [RUM-6868] simplify identifier implementation This commit reduces the scope of identifiers to make alternative identifier implementations easier. * ✨ [RUM-6868] make trace identifiers 64 bits To comply with other tracers identifiers and be able to properly implement probabilistic sampling, let's make trace identifiers 64 bits. This PR differentiate Span and Trace identifiers to make sure we use each at the right place. * ✅ [RUM-6868] consider identifiers byte ordering little endian This is not strictly needed from a functionality point of view, but helps implementing tests that work with both the current UintArray-based identifier and the future BigInt-based implementation. The implementation is also simpler. Rational: In our tests, we are mocking `getRandomValues` to edit the random bytes of the identifier, then we make sure the identifier gets formated as a string correctly. Currently, we set the last byte to 0xff, and since we consider this byte to be the least significant byte (big endian), naturally the output is ff. But in the next commit we'll introduce an alternative implementation of identifiers based on BigInt generated using a BigUInt64Array. In this setup, bigints are usually encoded as little endian. So if we edit the last byte to 0xff, we actually edit the most significant byte, and the output would be ff00000000000. To keep tests simple, let's use the same endianness for both implementation, so editing the same byte results in the same trace id. * ✨ [RUM-6868] implement trace ids via bigints * ✨ [RUM-6868] implement consistent trace sampling * 🚩 [RUM-6868] put consistent sampling behind an experimental flag * 👌 add comment about ie11 support --------- Co-authored-by: Thomas Lebeau --- .../core/src/tools/experimentalFeatures.ts | 1 + packages/rum-core/src/browser/crypto.ts | 4 + .../src/domain/requestCollection.spec.ts | 7 +- .../rum-core/src/domain/requestCollection.ts | 7 +- .../resource/resourceCollection.spec.ts | 14 +-- .../src/domain/resource/resourceCollection.ts | 8 +- .../src/domain/tracing/identifier.spec.ts | 90 +++++++++++++++++++ .../rum-core/src/domain/tracing/identifier.ts | 86 ++++++++++++++++++ .../src/domain/tracing/sampler.spec.ts | 62 +++++++++++++ .../rum-core/src/domain/tracing/sampler.ts | 44 +++++++++ .../src/domain/tracing/tracer.spec.ts | 52 ++++------- .../rum-core/src/domain/tracing/tracer.ts | 85 ++++-------------- tsconfig.base.json | 2 +- 13 files changed, 339 insertions(+), 123 deletions(-) create mode 100644 packages/rum-core/src/browser/crypto.ts create mode 100644 packages/rum-core/src/domain/tracing/identifier.spec.ts create mode 100644 packages/rum-core/src/domain/tracing/identifier.ts create mode 100644 packages/rum-core/src/domain/tracing/sampler.spec.ts create mode 100644 packages/rum-core/src/domain/tracing/sampler.ts diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 66a1e8eac5..2f7980d8bc 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -19,6 +19,7 @@ export enum ExperimentalFeature { LONG_ANIMATION_FRAME = 'long_animation_frame', ANONYMOUS_USER_TRACKING = 'anonymous_user_tracking', ACTION_NAME_MASKING = 'action_name_masking', + CONSISTENT_TRACE_SAMPLING = 'consistent_trace_sampling', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/rum-core/src/browser/crypto.ts b/packages/rum-core/src/browser/crypto.ts new file mode 100644 index 0000000000..c18d5e3ea5 --- /dev/null +++ b/packages/rum-core/src/browser/crypto.ts @@ -0,0 +1,4 @@ +export function getCrypto() { + // TODO: remove msCrypto when IE11 support is dropped + return window.crypto || (window as any).msCrypto +} diff --git a/packages/rum-core/src/domain/requestCollection.spec.ts b/packages/rum-core/src/domain/requestCollection.spec.ts index 28e78c7f7f..2d296de3eb 100644 --- a/packages/rum-core/src/domain/requestCollection.spec.ts +++ b/packages/rum-core/src/domain/requestCollection.spec.ts @@ -7,7 +7,8 @@ import { LifeCycle, LifeCycleEventType } from './lifeCycle' import type { RequestCompleteEvent, RequestStartEvent } from './requestCollection' import { trackFetch, trackXhr } from './requestCollection' import type { Tracer } from './tracing/tracer' -import { clearTracingIfNeeded, createTraceIdentifier } from './tracing/tracer' +import { clearTracingIfNeeded } from './tracing/tracer' +import { createSpanIdentifier, createTraceIdentifier } from './tracing/identifier' const DEFAULT_PAYLOAD = {} as Payload @@ -34,7 +35,7 @@ describe('collect fetch', () => { clearTracingIfNeeded, traceFetch: (context) => { context.traceId = createTraceIdentifier() - context.spanId = createTraceIdentifier() + context.spanId = createSpanIdentifier() }, } ;({ stop: stopFetchTracking } = trackFetch(lifeCycle, tracerStub as Tracer)) @@ -197,7 +198,7 @@ describe('collect xhr', () => { clearTracingIfNeeded, traceXhr: (context) => { context.traceId = createTraceIdentifier() - context.spanId = createTraceIdentifier() + context.spanId = createSpanIdentifier() }, } ;({ stop: stopXhrTracking } = trackXhr(lifeCycle, configuration, tracerStub as Tracer)) diff --git a/packages/rum-core/src/domain/requestCollection.ts b/packages/rum-core/src/domain/requestCollection.ts index 4375b8e423..a2772b8050 100644 --- a/packages/rum-core/src/domain/requestCollection.ts +++ b/packages/rum-core/src/domain/requestCollection.ts @@ -20,12 +20,13 @@ import type { RumConfiguration } from './configuration' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' import { isAllowedRequestUrl } from './resource/resourceUtils' -import type { TraceIdentifier, Tracer } from './tracing/tracer' +import type { Tracer } from './tracing/tracer' import { startTracer } from './tracing/tracer' +import type { SpanIdentifier, TraceIdentifier } from './tracing/identifier' export interface CustomContext { requestIndex: number - spanId?: TraceIdentifier + spanId?: SpanIdentifier traceId?: TraceIdentifier traceSampled?: boolean } @@ -47,7 +48,7 @@ export interface RequestCompleteEvent { responseType?: string startClocks: ClocksState duration: Duration - spanId?: TraceIdentifier + spanId?: SpanIdentifier traceId?: TraceIdentifier traceSampled?: boolean xhr?: XMLHttpRequest diff --git a/packages/rum-core/src/domain/resource/resourceCollection.spec.ts b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts index fd3f1e06b7..7396382e56 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.spec.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts @@ -14,11 +14,11 @@ import { RumEventType } from '../../rawRumEvent.types' import type { RawRumEventCollectedData } from '../lifeCycle' import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import type { RequestCompleteEvent } from '../requestCollection' -import { createTraceIdentifier } from '../tracing/tracer' import type { RumConfiguration } from '../configuration' import { validateAndBuildRumConfiguration } from '../configuration' import type { RumPerformanceEntry } from '../../browser/performanceObservable' import { RumPerformanceEntryType } from '../../browser/performanceObservable' +import { createSpanIdentifier, createTraceIdentifier } from '../tracing/identifier' import { startResourceCollection } from './resourceCollection' const HANDLING_STACK_REGEX = /^Error: \n\s+at @/ @@ -186,7 +186,7 @@ describe('resourceCollection', () => { createCompletedRequest({ type: RequestType.XHR, traceId: createTraceIdentifier(), - spanId: createTraceIdentifier(), + spanId: createSpanIdentifier(), traceSampled: true, }) ) @@ -316,7 +316,7 @@ describe('resourceCollection', () => { LifeCycleEventType.REQUEST_COMPLETED, createCompletedRequest({ traceSampled: true, - spanId: createTraceIdentifier(), + spanId: createSpanIdentifier(), traceId: createTraceIdentifier(), }) ) @@ -331,7 +331,7 @@ describe('resourceCollection', () => { LifeCycleEventType.REQUEST_COMPLETED, createCompletedRequest({ traceSampled: false, - spanId: createTraceIdentifier(), + spanId: createSpanIdentifier(), traceId: createTraceIdentifier(), }) ) @@ -352,7 +352,7 @@ describe('resourceCollection', () => { LifeCycleEventType.REQUEST_COMPLETED, createCompletedRequest({ traceSampled: true, - spanId: createTraceIdentifier(), + spanId: createSpanIdentifier(), traceId: createTraceIdentifier(), }) ) @@ -371,7 +371,7 @@ describe('resourceCollection', () => { LifeCycleEventType.REQUEST_COMPLETED, createCompletedRequest({ traceSampled: true, - spanId: createTraceIdentifier(), + spanId: createSpanIdentifier(), traceId: createTraceIdentifier(), }) ) @@ -391,7 +391,7 @@ describe('resourceCollection', () => { LifeCycleEventType.REQUEST_COMPLETED, createCompletedRequest({ traceSampled: true, - spanId: createTraceIdentifier(), + spanId: createSpanIdentifier(), traceId: createTraceIdentifier(), }) ) diff --git a/packages/rum-core/src/domain/resource/resourceCollection.ts b/packages/rum-core/src/domain/resource/resourceCollection.ts index 4035865c3a..bdc521593e 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.ts @@ -24,7 +24,7 @@ import type { RawRumEventCollectedData, LifeCycle } from '../lifeCycle' import type { RequestCompleteEvent } from '../requestCollection' import type { PageStateHistory } from '../contexts/pageStateHistory' import { PageState } from '../contexts/pageStateHistory' -import { createTraceIdentifier } from '../tracing/tracer' +import { createSpanIdentifier } from '../tracing/identifier' import { matchRequestResourceEntry } from './matchRequestResourceEntry' import { computeResourceEntryDetails, @@ -199,8 +199,8 @@ function computeRequestTracingInfo(request: RequestCompleteEvent, configuration: } return { _dd: { - span_id: request.spanId!.toDecimalString(), - trace_id: request.traceId!.toDecimalString(), + span_id: request.spanId!.toString(), + trace_id: request.traceId!.toString(), rule_psr: getRulePsr(configuration), }, } @@ -214,7 +214,7 @@ function computeResourceEntryTracingInfo(entry: RumPerformanceResourceTiming, co return { _dd: { trace_id: entry.traceId, - span_id: createTraceIdentifier().toDecimalString(), + span_id: createSpanIdentifier().toString(), rule_psr: getRulePsr(configuration), }, } diff --git a/packages/rum-core/src/domain/tracing/identifier.spec.ts b/packages/rum-core/src/domain/tracing/identifier.spec.ts new file mode 100644 index 0000000000..f95b635bbf --- /dev/null +++ b/packages/rum-core/src/domain/tracing/identifier.spec.ts @@ -0,0 +1,90 @@ +import { ExperimentalFeature } from '@datadog/browser-core' +import { mockExperimentalFeatures } from '../../../../core/test' +import { getCrypto } from '../../browser/crypto' +import { createSpanIdentifier, createTraceIdentifier, toPaddedHexadecimalString } from './identifier' + +describe('identifier', () => { + describe('TraceIdentifier', () => { + it('generates a random id', () => { + const identifier = createTraceIdentifier() + expect(identifier.toString()).toMatch(/^\d+$/) + }) + + it('formats using base 16', () => { + mockRandomValues((buffer) => (buffer[0] = 0xff)) + const identifier = createTraceIdentifier() + expect(identifier.toString(16)).toEqual('ff') + }) + + it('should generate a max value of 64 bits', () => { + mockRandomValues((buffer) => fill(buffer, 0xff)) + const identifier = createTraceIdentifier() + expect(identifier.toString(16)).toEqual('ffffffffffffffff') + }) + }) + + describe('SpanIdentifier', () => { + it('generates a max value of 63 bits', () => { + mockRandomValues((buffer) => fill(buffer, 0xff)) + const identifier = createSpanIdentifier() + expect(identifier.toString(16)).toEqual('7fffffffffffffff') + }) + }) + + // Run the same tests again with consistent trace sampling enabled, which uses the BigInt + // implementation + describe('with CONSISTENT_TRACE_SAMPLING enabled', () => { + beforeEach(() => { + mockExperimentalFeatures([ExperimentalFeature.CONSISTENT_TRACE_SAMPLING]) + }) + + describe('TraceIdentifier', () => { + it('generates a random id', () => { + const identifier = createTraceIdentifier() + expect(identifier.toString()).toMatch(/^\d+$/) + }) + + it('formats using base 16', () => { + mockRandomValues((buffer) => (buffer[0] = 0xff)) + const identifier = createTraceIdentifier() + expect(identifier.toString(16)).toEqual('ff') + }) + + it('should generate a max value of 64 bits', () => { + mockRandomValues((buffer) => fill(buffer, 0xff)) + const identifier = createTraceIdentifier() + expect(identifier.toString(16)).toEqual('ffffffffffffffff') + }) + }) + + describe('SpanIdentifier', () => { + it('generates a max value of 63 bits', () => { + mockRandomValues((buffer) => fill(buffer, 0xff)) + const identifier = createSpanIdentifier() + expect(identifier.toString(16)).toEqual('7fffffffffffffff') + }) + }) + }) +}) + +describe('toPaddedHexadecimalString', () => { + it('should pad the string to 16 characters', () => { + mockRandomValues((buffer) => (buffer[0] = 0x01)) + const identifier = createTraceIdentifier() + expect(toPaddedHexadecimalString(identifier)).toEqual('0000000000000001') + }) +}) + +function mockRandomValues(cb: (buffer: Uint8Array) => void) { + spyOn(getCrypto(), 'getRandomValues').and.callFake((bufferView) => { + cb(new Uint8Array(bufferView!.buffer)) + return bufferView + }) +} + +// TODO: replace with `buffer.fill(value)` when we drop support for IE11 +function fill(buffer: Uint8Array, value: number) { + for (let i = 0; i < buffer.length; i++) { + buffer[i] = value + } +} diff --git a/packages/rum-core/src/domain/tracing/identifier.ts b/packages/rum-core/src/domain/tracing/identifier.ts new file mode 100644 index 0000000000..e2084d95d0 --- /dev/null +++ b/packages/rum-core/src/domain/tracing/identifier.ts @@ -0,0 +1,86 @@ +import { ExperimentalFeature, isExperimentalFeatureEnabled } from '@datadog/browser-core' +import { getCrypto } from '../../browser/crypto' + +interface BaseIdentifier { + toString(radix?: number): string +} + +export interface TraceIdentifier extends BaseIdentifier { + // We use a brand to distinguish between TraceIdentifier and SpanIdentifier, else TypeScript + // considers them as the same type + __brand: 'traceIdentifier' +} + +export interface SpanIdentifier extends BaseIdentifier { + __brand: 'spanIdentifier' +} + +export function createTraceIdentifier() { + return createIdentifier(64) as TraceIdentifier +} + +export function createSpanIdentifier() { + return createIdentifier(63) as SpanIdentifier +} + +let createIdentifierImplementationCache: ((bits: 63 | 64) => BaseIdentifier) | undefined + +function createIdentifier(bits: 63 | 64): BaseIdentifier { + if (!createIdentifierImplementationCache) { + createIdentifierImplementationCache = + isExperimentalFeatureEnabled(ExperimentalFeature.CONSISTENT_TRACE_SAMPLING) && areBigIntIdentifiersSupported() + ? createIdentifierUsingBigInt + : createIdentifierUsingUint32Array + } + return createIdentifierImplementationCache(bits) +} + +export function areBigIntIdentifiersSupported() { + try { + crypto.getRandomValues(new BigUint64Array(1)) + return true + } catch { + return false + } +} + +function createIdentifierUsingBigInt(bits: 63 | 64): BaseIdentifier { + let id = crypto.getRandomValues(new BigUint64Array(1))[0] + if (bits === 63) { + // eslint-disable-next-line no-bitwise + id >>= BigInt('1') + } + return id +} + +// TODO: remove this when all browser we support have BigInt support +function createIdentifierUsingUint32Array(bits: 63 | 64): BaseIdentifier { + const buffer = getCrypto().getRandomValues(new Uint32Array(2)) + if (bits === 63) { + // eslint-disable-next-line no-bitwise + buffer[buffer.length - 1] >>>= 1 // force 63-bit + } + + return { + toString(radix = 10) { + let high = buffer[1] + let low = buffer[0] + let str = '' + + do { + const mod = (high % radix) * 4294967296 + low + high = Math.floor(high / radix) + low = Math.floor(mod / radix) + str = (mod % radix).toString(radix) + str + } while (high || low) + + return str + }, + } +} + +export function toPaddedHexadecimalString(id: BaseIdentifier) { + const traceId = id.toString(16) + // TODO: replace with String.prototype.padStart when we drop IE11 support + return Array(17 - traceId.length).join('0') + traceId +} diff --git a/packages/rum-core/src/domain/tracing/sampler.spec.ts b/packages/rum-core/src/domain/tracing/sampler.spec.ts new file mode 100644 index 0000000000..43e7b99010 --- /dev/null +++ b/packages/rum-core/src/domain/tracing/sampler.spec.ts @@ -0,0 +1,62 @@ +import type { TraceIdentifier } from './identifier' +import { isTraceSampled } from './sampler' + +describe('isTraceSampled', () => { + describe('with bigint support', () => { + beforeEach(() => { + if (!window.BigInt) { + pending('BigInt is not supported') + } + }) + + it('returns true when sampleRate is 100', () => { + expect(isTraceSampled(BigInt('1234') as unknown as TraceIdentifier, 100)).toBeTrue() + }) + + it('returns false when sampleRate is 0', () => { + expect(isTraceSampled(BigInt('1234') as unknown as TraceIdentifier, 0)).toBeFalse() + }) + + it('sampling should be based on the trace id', () => { + // Generated using the dd-trace-go implementation with the following program: https://go.dev/play/p/CUrDJtze8E_e + const inputs: Array<[bigint, number, boolean]> = [ + [BigInt('5577006791947779410'), 94.0509, true], + [BigInt('15352856648520921629'), 43.7714, true], + [BigInt('3916589616287113937'), 68.6823, true], + [BigInt('894385949183117216'), 30.0912, true], + [BigInt('12156940908066221323'), 46.889, true], + + [BigInt('9828766684487745566'), 15.6519, false], + [BigInt('4751997750760398084'), 81.364, false], + [BigInt('11199607447739267382'), 38.0657, false], + [BigInt('6263450610539110790'), 21.8553, false], + [BigInt('1874068156324778273'), 36.0871, false], + ] + + for (const [identifier, sampleRate, expected] of inputs) { + expect(isTraceSampled(identifier as unknown as TraceIdentifier, sampleRate)) + .withContext(`identifier=${identifier}, sampleRate=${sampleRate}`) + .toBe(expected) + } + }) + }) + + describe('without bigint support', () => { + it('returns true when sampleRate is 100', () => { + expect(isTraceSampled({} as TraceIdentifier, 100)).toBeTrue() + }) + + it('returns false when sampleRate is 0', () => { + expect(isTraceSampled({} as TraceIdentifier, 0)).toBeFalse() + }) + + it('sampling should be random', () => { + spyOn(Math, 'random').and.returnValues(0.2, 0.8, 0.2, 0.8, 0.2) + expect(isTraceSampled({} as TraceIdentifier, 50)).toBeTrue() + expect(isTraceSampled({} as TraceIdentifier, 50)).toBeFalse() + expect(isTraceSampled({} as TraceIdentifier, 50)).toBeTrue() + expect(isTraceSampled({} as TraceIdentifier, 50)).toBeFalse() + expect(isTraceSampled({} as TraceIdentifier, 50)).toBeTrue() + }) + }) +}) diff --git a/packages/rum-core/src/domain/tracing/sampler.ts b/packages/rum-core/src/domain/tracing/sampler.ts new file mode 100644 index 0000000000..433f996699 --- /dev/null +++ b/packages/rum-core/src/domain/tracing/sampler.ts @@ -0,0 +1,44 @@ +import { performDraw } from '@datadog/browser-core' +import type { TraceIdentifier } from './identifier' + +export function isTraceSampled(identifier: TraceIdentifier, sampleRate: number) { + // Shortcuts for common cases. This is not strictly necessary, but it makes the code faster for + // customers willing to ingest all traces. + if (sampleRate === 100) { + return true + } + + if (sampleRate === 0) { + return false + } + + // For simplicity, we don't use consistent sampling for browser that don't support BigInt + // TODO: remove this when all browser we support have BigInt support + if (typeof identifier !== 'bigint') { + return performDraw(sampleRate) + } + + // Offer consistent sampling for the same trace id across different environments. The rule is: + // + // (identifier * knuthFactor) % 2^64 < sampleRate * 2^64 + // + // Because JavaScript numbers are 64-bit floats, we can't represent 64-bit integers, and the + // modulo would be incorrect. Thus, we are using BigInts here. + // + // Implementation in other languages: + // * Go https://github.com/DataDog/dd-trace-go/blob/ec6fbb1f2d517b7b8e69961052adf7136f3af773/ddtrace/tracer/sampler.go#L86-L91 + // * Python https://github.com/DataDog/dd-trace-py/blob/0cee2f066fb6e79aa15947c1514c0f406dea47c5/ddtrace/sampling_rule.py#L197 + // * Ruby https://github.com/DataDog/dd-trace-rb/blob/1a6e255cdcb7e7e22235ea5955f90f6dfa91045d/lib/datadog/tracing/sampling/rate_sampler.rb#L42 + // * C++ https://github.com/DataDog/dd-trace-cpp/blob/159629edc438ae45f2bb318eb7bd51abd05e94b5/src/datadog/trace_sampler.cpp#L58 + // * Java https://github.com/DataDog/dd-trace-java/blob/896dd6b380533216e0bdee59614606c8272d313e/dd-trace-core/src/main/java/datadog/trace/common/sampling/DeterministicSampler.java#L48 + // + // Note: All implementations have slight variations. Some of them use '<=' instead of '<', and + // use `sampleRate * 2^64 - 1` instead of `sampleRate * 2^64`. The following implementation + // should adhere to the spec and is a bit simpler than using a 2^64-1 limit as there are less + // BigInt arithmetic to write. In practice this does not matter, as we are using floating point + // numbers in the end, and Number(2n**64n-1n) === Number(2n**64n). + const knuthFactor = BigInt('1111111111111111111') + const twoPow64 = BigInt('0x10000000000000000') // 2n ** 64n + const hash = (identifier * knuthFactor) % twoPow64 + return Number(hash) <= (sampleRate / 100) * Number(twoPow64) +} diff --git a/packages/rum-core/src/domain/tracing/tracer.spec.ts b/packages/rum-core/src/domain/tracing/tracer.spec.ts index eff44929d8..00d004ac10 100644 --- a/packages/rum-core/src/domain/tracing/tracer.spec.ts +++ b/packages/rum-core/src/domain/tracing/tracer.spec.ts @@ -4,7 +4,9 @@ import { createRumSessionManagerMock } from '../../../test' import type { RumFetchResolveContext, RumFetchStartContext, RumXhrStartContext } from '../requestCollection' import type { RumConfiguration, RumInitConfiguration } from '../configuration' import { validateAndBuildRumConfiguration } from '../configuration' -import { startTracer, createTraceIdentifier, type TraceIdentifier, getCrypto } from './tracer' +import { startTracer } from './tracer' +import type { SpanIdentifier, TraceIdentifier } from './identifier' +import { createSpanIdentifier, createTraceIdentifier } from './identifier' describe('tracer', () => { let configuration: RumConfiguration @@ -75,8 +77,7 @@ describe('tracer', () => { }) it("should trace request with priority '1' when sampled", () => { - spyOn(Math, 'random').and.callFake(() => 0) - const tracer = startTracer({ ...configuration, traceSampleRate: 50 }, sessionManager) + const tracer = startTracer({ ...configuration, traceSampleRate: 100 }, sessionManager) const context = { ...ALLOWED_DOMAIN_CONTEXT } tracer.traceXhr(context, xhr as unknown as XMLHttpRequest) @@ -87,8 +88,7 @@ describe('tracer', () => { }) it("should trace request with priority '0' when not sampled", () => { - spyOn(Math, 'random').and.callFake(() => 1) - const tracer = startTracer({ ...configuration, traceSampleRate: 50 }, sessionManager) + const tracer = startTracer({ ...configuration, traceSampleRate: 0 }, sessionManager) const context = { ...ALLOWED_DOMAIN_CONTEXT } tracer.traceXhr(context, xhr as unknown as XMLHttpRequest) @@ -99,11 +99,9 @@ describe('tracer', () => { }) it("should trace request with sampled set to '0' in OTel headers when not sampled", () => { - spyOn(Math, 'random').and.callFake(() => 1) - const configurationWithAllOtelHeaders = validateAndBuildRumConfiguration({ ...INIT_CONFIGURATION, - traceSampleRate: 50, + traceSampleRate: 0, allowedTracingUrls: [{ match: window.location.origin, propagatorTypes: ['b3', 'tracecontext', 'b3multi'] }], })! @@ -457,8 +455,7 @@ describe('tracer', () => { it("should trace request with priority '1' when sampled", () => { const context: Partial = { ...ALLOWED_DOMAIN_CONTEXT } - spyOn(Math, 'random').and.callFake(() => 0) - const tracer = startTracer({ ...configuration, traceSampleRate: 50 }, sessionManager) + const tracer = startTracer({ ...configuration, traceSampleRate: 100 }, sessionManager) tracer.traceFetch(context) expect(context.traceSampled).toBe(true) @@ -470,8 +467,7 @@ describe('tracer', () => { it("should trace request with priority '0' when not sampled", () => { const context: Partial = { ...ALLOWED_DOMAIN_CONTEXT } - spyOn(Math, 'random').and.callFake(() => 1) - const tracer = startTracer({ ...configuration, traceSampleRate: 50 }, sessionManager) + const tracer = startTracer({ ...configuration, traceSampleRate: 0 }, sessionManager) tracer.traceFetch(context) expect(context.traceSampled).toBe(false) @@ -622,9 +618,9 @@ describe('tracer', () => { const context: RumFetchResolveContext = { status: 0, - spanId: createTraceIdentifier(), + spanId: createSpanIdentifier(), traceId: createTraceIdentifier(), - } as any + } satisfies Partial as any tracer.clearTracingIfNeeded(context) expect(context.traceId).toBeUndefined() @@ -636,9 +632,9 @@ describe('tracer', () => { const context: RumFetchResolveContext = { status: 200, - spanId: createTraceIdentifier(), + spanId: createSpanIdentifier(), traceId: createTraceIdentifier(), - } as any + } satisfies Partial as any tracer.clearTracingIfNeeded(context) expect(context.traceId).toBeDefined() @@ -647,22 +643,6 @@ describe('tracer', () => { }) }) -describe('TraceIdentifier', () => { - it('should generate id', () => { - const identifier = createTraceIdentifier() - - expect(identifier.toDecimalString()).toMatch(/^\d+$/) - }) - - it('should pad the string to 16 characters', () => { - spyOn(getCrypto() as any, 'getRandomValues').and.callFake((buffer: Uint8Array) => { - buffer[buffer.length - 1] = 0x01 - }) - const identifier = createTraceIdentifier() - expect(identifier.toPaddedHexadecimalString()).toEqual('0000000000000001') - }) -}) - function toPlainObject(headers: Headers) { const result: { [key: string]: string } = {} headers.forEach((value, key) => { @@ -671,16 +651,16 @@ function toPlainObject(headers: Headers) { return result } -function tracingHeadersFor(traceId: TraceIdentifier, spanId: TraceIdentifier, samplingPriority: '1' | '0') { +function tracingHeadersFor(traceId: TraceIdentifier, spanId: SpanIdentifier, samplingPriority: '1' | '0') { return { 'x-datadog-origin': 'rum', - 'x-datadog-parent-id': spanId.toDecimalString(), + 'x-datadog-parent-id': spanId.toString(), 'x-datadog-sampling-priority': samplingPriority, - 'x-datadog-trace-id': traceId.toDecimalString(), + 'x-datadog-trace-id': traceId.toString(), } } -function tracingHeadersAsArrayFor(traceId: TraceIdentifier, spanId: TraceIdentifier, samplingPriority: '1' | '0') { +function tracingHeadersAsArrayFor(traceId: TraceIdentifier, spanId: SpanIdentifier, samplingPriority: '1' | '0') { return objectEntries(tracingHeadersFor(traceId, spanId, samplingPriority)) } diff --git a/packages/rum-core/src/domain/tracing/tracer.ts b/packages/rum-core/src/domain/tracing/tracer.ts index 6d377ea019..2b7c70ce64 100644 --- a/packages/rum-core/src/domain/tracing/tracer.ts +++ b/packages/rum-core/src/domain/tracing/tracer.ts @@ -1,7 +1,6 @@ import { objectEntries, shallowClone, - performDraw, isNumber, assign, find, @@ -18,7 +17,11 @@ import type { RumXhrStartContext, } from '../requestCollection' import type { RumSessionManager } from '../rumSessionManager' +import { getCrypto } from '../../browser/crypto' import type { PropagatorType, TracingOption } from './tracer.types' +import type { SpanIdentifier, TraceIdentifier } from './identifier' +import { createSpanIdentifier, createTraceIdentifier, toPaddedHexadecimalString } from './identifier' +import { isTraceSampled } from './sampler' export interface Tracer { traceFetch: (context: Partial) => void @@ -118,14 +121,16 @@ function injectHeadersIfTracingAllowed( if (!tracingOption) { return } - context.traceSampled = !isNumber(configuration.traceSampleRate) || performDraw(configuration.traceSampleRate) + const traceId = createTraceIdentifier() + context.traceSampled = + !isNumber(configuration.traceSampleRate) || isTraceSampled(traceId, configuration.traceSampleRate) if (!context.traceSampled && configuration.traceContextInjection !== TraceContextInjection.ALL) { return } - context.traceId = createTraceIdentifier() - context.spanId = createTraceIdentifier() + context.traceId = traceId + context.spanId = createSpanIdentifier() inject(makeTracingHeaders(context.traceId, context.spanId, context.traceSampled, tracingOption.propagatorTypes)) } @@ -134,17 +139,13 @@ export function isTracingSupported() { return getCrypto() !== undefined } -export function getCrypto() { - return window.crypto || (window as any).msCrypto -} - /** * When trace is not sampled, set priority to '0' instead of not adding the tracing headers * to prepare the implementation for sampling delegation. */ function makeTracingHeaders( traceId: TraceIdentifier, - spanId: TraceIdentifier, + spanId: SpanIdentifier, traceSampled: boolean, propagatorTypes: PropagatorType[] ): TracingHeaders { @@ -155,16 +156,16 @@ function makeTracingHeaders( case 'datadog': { assign(tracingHeaders, { 'x-datadog-origin': 'rum', - 'x-datadog-parent-id': spanId.toDecimalString(), + 'x-datadog-parent-id': spanId.toString(), 'x-datadog-sampling-priority': traceSampled ? '1' : '0', - 'x-datadog-trace-id': traceId.toDecimalString(), + 'x-datadog-trace-id': traceId.toString(), }) break } // https://www.w3.org/TR/trace-context/ case 'tracecontext': { assign(tracingHeaders, { - traceparent: `00-0000000000000000${traceId.toPaddedHexadecimalString()}-${spanId.toPaddedHexadecimalString()}-0${ + traceparent: `00-0000000000000000${toPaddedHexadecimalString(traceId)}-${toPaddedHexadecimalString(spanId)}-0${ traceSampled ? '1' : '0' }`, }) @@ -173,16 +174,14 @@ function makeTracingHeaders( // https://github.com/openzipkin/b3-propagation case 'b3': { assign(tracingHeaders, { - b3: `${traceId.toPaddedHexadecimalString()}-${spanId.toPaddedHexadecimalString()}-${ - traceSampled ? '1' : '0' - }`, + b3: `${toPaddedHexadecimalString(traceId)}-${toPaddedHexadecimalString(spanId)}-${traceSampled ? '1' : '0'}`, }) break } case 'b3multi': { assign(tracingHeaders, { - 'X-B3-TraceId': traceId.toPaddedHexadecimalString(), - 'X-B3-SpanId': spanId.toPaddedHexadecimalString(), + 'X-B3-TraceId': toPaddedHexadecimalString(traceId), + 'X-B3-SpanId': toPaddedHexadecimalString(spanId), 'X-B3-Sampled': traceSampled ? '1' : '0', }) break @@ -191,55 +190,3 @@ function makeTracingHeaders( }) return tracingHeaders } - -/* eslint-disable no-bitwise */ -export interface TraceIdentifier { - toDecimalString: () => string - toPaddedHexadecimalString: () => string -} - -export function createTraceIdentifier(): TraceIdentifier { - const buffer: Uint8Array = new Uint8Array(8) - getCrypto().getRandomValues(buffer) - buffer[0] = buffer[0] & 0x7f // force 63-bit - - function readInt32(offset: number) { - return buffer[offset] * 16777216 + (buffer[offset + 1] << 16) + (buffer[offset + 2] << 8) + buffer[offset + 3] - } - - function toString(radix: number) { - let high = readInt32(0) - let low = readInt32(4) - let str = '' - - do { - const mod = (high % radix) * 4294967296 + low - high = Math.floor(high / radix) - low = Math.floor(mod / radix) - str = (mod % radix).toString(radix) + str - } while (high || low) - - return str - } - - /** - * Format used everywhere except the trace intake - */ - function toDecimalString() { - return toString(10) - } - - /** - * Format used by OTel headers - */ - function toPaddedHexadecimalString() { - const traceId = toString(16) - return Array(17 - traceId.length).join('0') + traceId - } - - return { - toDecimalString, - toPaddedHexadecimalString, - } -} -/* eslint-enable no-bitwise */ diff --git a/tsconfig.base.json b/tsconfig.base.json index c393720bce..1cac1e8bc3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -12,7 +12,7 @@ "jsx": "react", "types": [], - "lib": ["ES2016", "DOM"], + "lib": ["ES2020", "DOM"], "paths": { "@datadog/browser-core": ["./packages/core/src"],