Skip to content

Commit 5320e34

Browse files
committed
RUM-15138: Rebase trace sample rate against RUM session sample rate for correlated cross-product sampling
DeterministicTraceSampler now applies a cross-product rebasing formula when a span carries a RUM session ID tag: rebasedRate = sessionSampleRate × traceSampleRate / 100. Previously, the raw traceSampleRate was used as the sampling threshold regardless of whether the session ID was the hash key, causing the effective trace rate among RUM-tracked sessions to be incorrect. The session sample rate is propagated via RumContext (new sessionSampleRate field) into the SDK feature context, then written onto spans as a tag by RumContextPropagator alongside the existing RUM tags. DeterministicTraceSampler reads the tag at sampling time and applies the rebasing; spans without a session ID tag continue to use the raw trace rate unchanged. RumSessionScope snapshots sessionSampleRate in renewSession() at the moment the sampling decision is made, ensuring the rate is immutable for the lifetime of that session.
1 parent 7ff6870 commit 5320e34

14 files changed

Lines changed: 317 additions & 21 deletions

File tree

detekt_custom_safe_calls.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,7 @@ datadog:
11261126
- "kotlin.Double.toInt()"
11271127
- "kotlin.Double.toLong()"
11281128
- "kotlin.Double.toULong()"
1129+
- "kotlin.Float.coerceAtMost(kotlin.Float)"
11291130
- "kotlin.Float.fromBits(kotlin.Int)"
11301131
- "kotlin.Float.percent()"
11311132
- "kotlin.Float.roundToInt()"

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ internal data class RumContext(
2525
val syntheticsResultId: String? = null,
2626
val viewTimestamp: Long = 0L,
2727
val viewTimestampOffset: Long = 0L,
28-
val hasReplay: Boolean = false
28+
val hasReplay: Boolean = false,
29+
val sessionSampleRate: Float = FULL_SESSION_SAMPLE_RATE
2930
) {
3031

3132
fun toMap(): Map<String, Any?> {
@@ -44,7 +45,8 @@ internal data class RumContext(
4445
SYNTHETICS_RESULT_ID to syntheticsResultId,
4546
VIEW_TIMESTAMP to viewTimestamp,
4647
HAS_REPLAY to hasReplay,
47-
VIEW_TIMESTAMP_OFFSET to viewTimestampOffset
48+
VIEW_TIMESTAMP_OFFSET to viewTimestampOffset,
49+
SESSION_SAMPLE_RATE to sessionSampleRate
4850
)
4951
}
5052

@@ -68,6 +70,8 @@ internal data class RumContext(
6870
const val HAS_REPLAY = "view_has_replay"
6971
const val VIEW_TIMESTAMP = "view_timestamp"
7072
const val VIEW_TIMESTAMP_OFFSET = "view_timestamp_offset"
73+
const val SESSION_SAMPLE_RATE = "session_sample_rate"
74+
const val FULL_SESSION_SAMPLE_RATE: Float = 100f
7175

7276
fun fromFeatureContext(featureContext: Map<String, Any?>): RumContext {
7377
val applicationId = featureContext[APPLICATION_ID] as? String
@@ -89,6 +93,8 @@ internal data class RumContext(
8993
val hasReplay = featureContext[HAS_REPLAY] as? Boolean ?: false
9094
val viewTimestamp = featureContext[VIEW_TIMESTAMP] as? Long ?: 0L
9195
val viewTimestampOffset = featureContext[VIEW_TIMESTAMP_OFFSET] as? Long ?: 0L
96+
val sessionSampleRate = (featureContext[SESSION_SAMPLE_RATE] as? Number)
97+
?.toFloat() ?: FULL_SESSION_SAMPLE_RATE
9298

9399
return RumContext(
94100
applicationId = applicationId ?: NULL_UUID,
@@ -105,7 +111,8 @@ internal data class RumContext(
105111
syntheticsResultId = syntheticsResultId,
106112
viewTimestamp = viewTimestamp,
107113
viewTimestampOffset = viewTimestampOffset,
108-
hasReplay = hasReplay
114+
hasReplay = hasReplay,
115+
sessionSampleRate = sessionSampleRate
109116
)
110117
}
111118
}

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ internal class RumSessionScope(
6565

6666
internal var sessionId = RumContext.NULL_UUID
6767
internal var sessionState: State = State.NOT_TRACKED
68+
internal var sessionSampleRate: Float = sessionSampler.getSampleRate() ?: RumContext.FULL_SESSION_SAMPLE_RATE
6869
private var startReason: StartReason = StartReason.USER_APP_LAUNCH
6970
internal var isActive: Boolean = true
7071
private val sessionStartNs = AtomicLong(sdkCore.timeProvider.getDeviceElapsedTimeNanos())
@@ -211,7 +212,8 @@ internal class RumSessionScope(
211212
sessionId = sessionId,
212213
sessionState = sessionState,
213214
sessionStartReason = startReason,
214-
isSessionActive = isActive
215+
isSessionActive = isActive,
216+
sessionSampleRate = sessionSampleRate
215217
)
216218
}
217219

@@ -280,6 +282,7 @@ internal class RumSessionScope(
280282

281283
private fun renewSession(time: Time, reason: StartReason) {
282284
val newSessionId = UUID.randomUUID().toString()
285+
sessionSampleRate = sessionSampler.getSampleRate() ?: RumContext.FULL_SESSION_SAMPLE_RATE
283286
val keepSession = sessionSampler.sample(newSessionId)
284287
startReason = reason
285288
sessionState = if (keepSession) State.TRACKED else State.NOT_TRACKED

features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/RumContextTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import fr.xgouchet.elmyr.junit5.ForgeConfiguration
1212
import fr.xgouchet.elmyr.junit5.ForgeExtension
1313
import org.assertj.core.api.Assertions.assertThat
1414
import org.junit.jupiter.api.RepeatedTest
15+
import org.junit.jupiter.api.Test
1516
import org.junit.jupiter.api.extension.ExtendWith
1617

1718
@ExtendWith(ForgeExtension::class)
@@ -28,4 +29,18 @@ internal class RumContextTest {
2829
// Then
2930
assertThat(anotherRumContext).isEqualTo(fakeRumContext)
3031
}
32+
33+
@Test
34+
fun `M parse session sample rate W fromFeatureContext() {value is Double}`() {
35+
// Given
36+
val featureContext = mapOf<String, Any?>(
37+
RumContext.SESSION_SAMPLE_RATE to 42.5
38+
)
39+
40+
// When
41+
val rumContext = RumContext.fromFeatureContext(featureContext)
42+
43+
// Then
44+
assertThat(rumContext.sessionSampleRate).isEqualTo(42.5f)
45+
}
3146
}

features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,16 @@ internal class RumSessionScopeTest {
266266
assertThat(childScope?.sampleRate).isCloseTo(fakeSampleRate, offset(0.001f))
267267
}
268268

269+
@Test
270+
fun `M use full session sample rate W init() { getSampleRate returns null }`() {
271+
// Given
272+
whenever(mockSessionSampler.getSampleRate()).thenReturn(null)
273+
initializeTestedScope(withMockChildScope = false)
274+
275+
// Then
276+
assertThat(testedScope.sessionSampleRate).isEqualTo(RumContext.FULL_SESSION_SAMPLE_RATE)
277+
}
278+
269279
@Test
270280
fun `M delegate events to child scope W handleViewEvent() {TRACKED}`(
271281
forge: Forge
@@ -478,6 +488,43 @@ internal class RumSessionScopeTest {
478488
assertThat(context.viewId).isEqualTo(fakeParentContext.viewId)
479489
}
480490

491+
@Test
492+
fun `M keep session sample rate stable W getRumContext() {sampler rate changes mid-session}`(
493+
forge: Forge
494+
) {
495+
// Given
496+
whenever(mockSessionSampler.getSampleRate()).thenReturn(100f, 20f)
497+
whenever(mockSessionSampler.sample(any())).thenReturn(true)
498+
initializeTestedScope()
499+
500+
// When
501+
testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter)
502+
val firstContext = testedScope.getRumContext()
503+
whenever(mockSessionSampler.getSampleRate()).thenReturn(100f)
504+
val secondContext = testedScope.getRumContext()
505+
506+
// Then
507+
assertThat(firstContext.sessionSampleRate).isEqualTo(20f)
508+
assertThat(secondContext.sessionSampleRate).isEqualTo(20f)
509+
}
510+
511+
@Test
512+
fun `M use full session sample rate W getRumContext() {getSampleRate returns null on renewSession}`(
513+
forge: Forge
514+
) {
515+
// Given
516+
whenever(mockSessionSampler.getSampleRate()).thenReturn(null)
517+
whenever(mockSessionSampler.sample(any())).thenReturn(true)
518+
initializeTestedScope()
519+
520+
// When
521+
testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter)
522+
val context = testedScope.getRumContext()
523+
524+
// Then
525+
assertThat(context.sessionSampleRate).isEqualTo(RumContext.FULL_SESSION_SAMPLE_RATE)
526+
}
527+
481528
@Test
482529
fun `M set TRACKED W renewSession() {sampler returns true}`(forge: Forge) {
483530
// Given

features/dd-sdk-android-trace/api/apiSurface

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ open class com.datadog.android.trace.DeterministicTraceSampler : com.datadog.and
2222
constructor(() -> Float)
2323
constructor(Float)
2424
constructor(Double)
25+
override fun sample(com.datadog.android.trace.api.span.DatadogSpan): Boolean
2526
annotation com.datadog.android.trace.ExperimentalTraceApi
2627
object com.datadog.android.trace.GlobalDatadogTracer
2728
fun registerIfAbsent(com.datadog.android.trace.api.tracer.DatadogTracer): Boolean

features/dd-sdk-android-trace/api/dd-sdk-android-trace.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public class com/datadog/android/trace/DeterministicTraceSampler : com/datadog/a
3232
public fun <init> (D)V
3333
public fun <init> (F)V
3434
public fun <init> (Lkotlin/jvm/functions/Function0;)V
35+
public fun sample (Lcom/datadog/android/trace/api/span/DatadogSpan;)Z
36+
public synthetic fun sample (Ljava/lang/Object;)Z
3537
}
3638

3739
public abstract interface annotation class com/datadog/android/trace/ExperimentalTraceApi : java/lang/annotation/Annotation {

features/dd-sdk-android-trace/src/main/kotlin/com/datadog/android/trace/DeterministicTraceSampler.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@ package com.datadog.android.trace
88

99
import androidx.annotation.FloatRange
1010
import com.datadog.android.core.sampling.DeterministicSampler
11+
import com.datadog.android.log.LogAttributes
1112
import com.datadog.android.trace.api.span.DatadogSpan
13+
import com.datadog.android.trace.internal.RumContextPropagator
1214
import com.datadog.android.trace.internal.net.SpanSamplingIdProvider
1315

1416
/**
1517
* A [com.datadog.android.core.sampling.DeterministicSampler] using the TraceID of a Span to compute the sampling decision.
1618
*
19+
* When a span is linked to an active RUM session (i.e. the span carries a `rum.session.id` tag),
20+
* the sampling threshold is automatically rebased against the RUM session sample rate so that
21+
* the combined effective sampling probability reflects the configured trace sample rate applied
22+
* only to the sessions already tracked by RUM:
23+
*
24+
* ```
25+
* rebasedTraceSampleRate = sessionSampleRate * traceSampleRate / 100
26+
* ```
27+
*
1728
* @param sampleRateProvider Provider for the sample rate value which will be called each time
1829
* the sampling decision needs to be made. All the values should be in the range [0;100].
1930
*/
@@ -41,4 +52,33 @@ open class DeterministicTraceSampler(
4152
constructor(
4253
@FloatRange(from = 0.0, to = 100.0) sampleRate: Double
4354
) : this(sampleRate.toFloat())
55+
56+
/** @inheritDoc */
57+
override fun sample(item: DatadogSpan): Boolean {
58+
val sampleRate = rebasedSampleRate(item)
59+
return when {
60+
sampleRate >= DeterministicSampler.SAMPLE_ALL_RATE -> true
61+
sampleRate <= 0f -> false
62+
else -> {
63+
val hash = SpanSamplingIdProvider.provideId(item) * DeterministicSampler.SAMPLER_HASHER
64+
val threshold = (
65+
DeterministicSampler.MAX_ID.toDouble() * sampleRate / DeterministicSampler.SAMPLE_ALL_RATE
66+
).toULong()
67+
hash < threshold
68+
}
69+
}
70+
}
71+
72+
private fun rebasedSampleRate(item: DatadogSpan): Float {
73+
val traceSampleRate = getSampleRate()
74+
val sessionId = item.context().tags[LogAttributes.RUM_SESSION_ID] as? String
75+
val sessionSampleRate = item.context().tags[RumContextPropagator.SESSION_SAMPLE_RATE_KEY] as? Number
76+
77+
return if (sessionId != null && sessionSampleRate != null) {
78+
(traceSampleRate * sessionSampleRate.toFloat() / DeterministicSampler.SAMPLE_ALL_RATE)
79+
.coerceAtMost(DeterministicSampler.SAMPLE_ALL_RATE)
80+
} else {
81+
traceSampleRate
82+
}
83+
}
4484
}

features/dd-sdk-android-trace/src/main/kotlin/com/datadog/android/trace/internal/RumContextPropagator.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class RumContextPropagator(private val sdkCoreProvider: () -> FeatureSdkCore?) {
6262
instance.setTag(LogAttributes.RUM_SESSION_ID, rumContext["session_id"])
6363
instance.setTag(LogAttributes.RUM_VIEW_ID, rumContext["view_id"])
6464
instance.setTag(LogAttributes.RUM_ACTION_ID, rumContext["action_id"])
65+
instance.setTag(SESSION_SAMPLE_RATE_KEY, rumContext[SESSION_SAMPLE_RATE_KEY])
6566
instance.setTag(HttpCodec.RUM_KEY_USER_ID, datadogContext.userInfo.id)
6667
instance.setTag(HttpCodec.RUM_KEY_ACCOUNT_ID, datadogContext.accountInfo?.id)
6768
}
@@ -109,6 +110,7 @@ class RumContextPropagator(private val sdkCoreProvider: () -> FeatureSdkCore?) {
109110

110111
companion object {
111112
internal const val DATADOG_INITIAL_CONTEXT: String = "_dd.datadog_initial_context"
113+
internal const val SESSION_SAMPLE_RATE_KEY: String = "session_sample_rate"
112114

113115
internal const val INITIAL_DATADOG_CONTEXT_NOT_AVAILABLE_ERROR = "Initial span creation Datadog context" +
114116
" is not available at the write time."

features/dd-sdk-android-trace/src/test/kotlin/com/datadog/android/trace/internal/RumContextPropagatorTest.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.datadog.android.trace.internal.RumContextPropagator.Companion.injectR
2424
import com.datadog.android.trace.utils.RumContextTestsUtils.RUM_CONTEXT_ACTION_ID
2525
import com.datadog.android.trace.utils.RumContextTestsUtils.RUM_CONTEXT_APPLICATION_ID
2626
import com.datadog.android.trace.utils.RumContextTestsUtils.RUM_CONTEXT_SESSION_ID
27+
import com.datadog.android.trace.utils.RumContextTestsUtils.RUM_CONTEXT_SESSION_SAMPLE_RATE
2728
import com.datadog.android.trace.utils.RumContextTestsUtils.RUM_CONTEXT_VIEW_ID
2829
import com.datadog.android.trace.utils.RumContextTestsUtils.aDatadogContextWithRumContext
2930
import com.datadog.android.trace.utils.RumContextTestsUtils.aRumContext
@@ -293,6 +294,9 @@ class RumContextPropagatorTest {
293294
verify(span).setTag(LogAttributes.RUM_SESSION_ID, fakeRumContext[RUM_CONTEXT_SESSION_ID])
294295
verify(span).setTag(LogAttributes.RUM_VIEW_ID, fakeRumContext[RUM_CONTEXT_VIEW_ID])
295296
verify(span).setTag(LogAttributes.RUM_ACTION_ID, fakeRumContext[RUM_CONTEXT_ACTION_ID])
297+
verify(
298+
span
299+
).setTag(RumContextPropagator.SESSION_SAMPLE_RATE_KEY, fakeRumContext[RUM_CONTEXT_SESSION_SAMPLE_RATE])
296300
verify(span).setTag(HttpCodec.RUM_KEY_ACCOUNT_ID, fakeDatadogContext.accountInfo?.id as? Any)
297301
verify(span).setTag(HttpCodec.RUM_KEY_USER_ID, fakeDatadogContext.userInfo.id as? Any)
298302
verify(span).setTag(DATADOG_INITIAL_CONTEXT, null as Any?)
@@ -317,6 +321,9 @@ class RumContextPropagatorTest {
317321
verify(span).setTag(LogAttributes.RUM_SESSION_ID, fakeRumContext[RUM_CONTEXT_SESSION_ID])
318322
verify(span).setTag(LogAttributes.RUM_VIEW_ID, fakeRumContext[RUM_CONTEXT_VIEW_ID])
319323
verify(span).setTag(LogAttributes.RUM_ACTION_ID, fakeRumContext[RUM_CONTEXT_ACTION_ID])
324+
verify(
325+
span
326+
).setTag(RumContextPropagator.SESSION_SAMPLE_RATE_KEY, fakeRumContext[RUM_CONTEXT_SESSION_SAMPLE_RATE])
320327
verify(span).setTag(HttpCodec.RUM_KEY_ACCOUNT_ID, fakeDatadogContext.accountInfo?.id as? Any)
321328
verify(span).setTag(HttpCodec.RUM_KEY_USER_ID, fakeDatadogContext.userInfo.id as? Any)
322329
verify(span).setTag(DATADOG_INITIAL_CONTEXT, null as Any?)
@@ -339,6 +346,7 @@ class RumContextPropagatorTest {
339346
verify(span).setTag(LogAttributes.RUM_SESSION_ID, null as Any?)
340347
verify(span).setTag(LogAttributes.RUM_VIEW_ID, null as Any?)
341348
verify(span).setTag(LogAttributes.RUM_ACTION_ID, null as Any?)
349+
verify(span).setTag(RumContextPropagator.SESSION_SAMPLE_RATE_KEY, null as Any?)
342350
verify(span).setTag(HttpCodec.RUM_KEY_ACCOUNT_ID, null as Any?)
343351
verify(span).setTag(HttpCodec.RUM_KEY_USER_ID, null as Any?)
344352
verify(span).setTag(DATADOG_INITIAL_CONTEXT, null as Any?)
@@ -362,6 +370,7 @@ class RumContextPropagatorTest {
362370
verify(span).setTag(LogAttributes.RUM_SESSION_ID, null as Any?)
363371
verify(span).setTag(LogAttributes.RUM_VIEW_ID, null as Any?)
364372
verify(span).setTag(LogAttributes.RUM_ACTION_ID, null as Any?)
373+
verify(span).setTag(RumContextPropagator.SESSION_SAMPLE_RATE_KEY, null as Any?)
365374
verify(span).setTag(HttpCodec.RUM_KEY_ACCOUNT_ID, null as Any?)
366375
verify(span).setTag(HttpCodec.RUM_KEY_USER_ID, null as Any?)
367376
verify(span).setTag(DATADOG_INITIAL_CONTEXT, null as Any?)

0 commit comments

Comments
 (0)