Skip to content

Commit 6bc1364

Browse files
authored
Merge pull request #3372 from DataDog/hector.morilloprieto/RUM-15139
RUM-15139: Use rebased sessionReplaySampleRate for deterministic Session Replay sampling
2 parents 5ecd159 + 294bfe6 commit 6bc1364

14 files changed

Lines changed: 464 additions & 357 deletions

File tree

dd-sdk-android-internal/api/apiSurface

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ sealed class com.datadog.android.internal.profiling.ProfilerStopEvent
164164
constructor(TTIDRumContext? = null)
165165
data class com.datadog.android.internal.profiling.TTIDRumContext
166166
constructor(String, String, String, String?, String?, String?)
167+
object com.datadog.android.internal.sampling.DeterministicSampling
168+
fun combinedSampleRate(Float, Float): Float
169+
object com.datadog.android.internal.sampling.SessionSamplingIdProvider
170+
fun provideId(String): ULong
167171
interface com.datadog.android.internal.system.BuildSdkVersionProvider
168172
val version: Int
169173
val isAtLeastN: Boolean

dd-sdk-android-internal/api/dd-sdk-android-internal.api

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,16 @@ public final class com/datadog/android/internal/profiling/TTIDRumContext {
310310
public fun toString ()Ljava/lang/String;
311311
}
312312

313+
public final class com/datadog/android/internal/sampling/DeterministicSampling {
314+
public static final field INSTANCE Lcom/datadog/android/internal/sampling/DeterministicSampling;
315+
public final fun combinedSampleRate (FF)F
316+
}
317+
318+
public final class com/datadog/android/internal/sampling/SessionSamplingIdProvider {
319+
public static final field INSTANCE Lcom/datadog/android/internal/sampling/SessionSamplingIdProvider;
320+
public final fun provideId-I7RO_PI (Ljava/lang/String;)J
321+
}
322+
313323
public abstract interface class com/datadog/android/internal/system/BuildSdkVersionProvider {
314324
public static final field Companion Lcom/datadog/android/internal/system/BuildSdkVersionProvider$Companion;
315325
public abstract fun getVersion ()I
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.internal.sampling
8+
9+
/**
10+
* Utilities for deterministic sampling calculations.
11+
*/
12+
object DeterministicSampling {
13+
14+
private const val SAMPLE_ALL_RATE: Float = 100f
15+
16+
/**
17+
* Returns the effective sampling rate when a child feature's [featureSampleRate] is applied
18+
* on top of a parent session's [rumSessionSampleRate].
19+
*
20+
* The combined rate represents the probability that both the session AND the child feature
21+
* are sampled in, preserving the deterministic guarantee: any session that passes the combined
22+
* threshold is guaranteed to have also passed the session-level threshold.
23+
*
24+
* @param rumSessionSampleRate The RUM session sampling rate (0–100).
25+
* @param featureSampleRate The child feature sampling rate (0–100).
26+
* @return The effective combined rate, clamped to [0, 100].
27+
*/
28+
fun combinedSampleRate(rumSessionSampleRate: Float, featureSampleRate: Float): Float =
29+
(rumSessionSampleRate * featureSampleRate / SAMPLE_ALL_RATE)
30+
.coerceIn(0f, SAMPLE_ALL_RATE)
31+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.internal.sampling
8+
9+
private const val HEX_RADIX = 16
10+
11+
/**
12+
* Extracts a numeric identifier from a RUM session ID for use as input to a [DeterministicSampler].
13+
*/
14+
object SessionSamplingIdProvider {
15+
16+
/**
17+
* Returns the last hyphen-delimited segment of [sessionId] parsed as an unsigned hexadecimal
18+
* long, or `0` if the input is malformed or the segment is not valid hex.
19+
*
20+
* For a UUID `aaaaaaaa-bbbb-cccc-dddd-1234567890ab` this returns `0x1234567890ab`.
21+
*/
22+
fun provideId(sessionId: String): ULong {
23+
return sessionId.split('-')
24+
.lastOrNull()
25+
?.toULongOrNull(HEX_RADIX)
26+
?: 0uL
27+
}
28+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.internal.sampling
8+
9+
import com.datadog.android.internal.forge.Configurator
10+
import fr.xgouchet.elmyr.annotation.FloatForgery
11+
import fr.xgouchet.elmyr.junit5.ForgeConfiguration
12+
import fr.xgouchet.elmyr.junit5.ForgeExtension
13+
import org.assertj.core.api.Assertions.assertThat
14+
import org.junit.jupiter.api.Test
15+
import org.junit.jupiter.api.extension.ExtendWith
16+
17+
@ExtendWith(ForgeExtension::class)
18+
@ForgeConfiguration(Configurator::class)
19+
internal class DeterministicSamplingTest {
20+
21+
@Test
22+
fun `M return combined rate W combinedSampleRate() {valid rates}`() {
23+
assertThat(DeterministicSampling.combinedSampleRate(50f, 20f)).isEqualTo(10f)
24+
}
25+
26+
@Test
27+
fun `M return 0 W combinedSampleRate() {featureRate is 0}`(
28+
@FloatForgery(min = 0f, max = 100f) sessionRate: Float
29+
) {
30+
assertThat(DeterministicSampling.combinedSampleRate(sessionRate, 0f)).isEqualTo(0f)
31+
}
32+
33+
@Test
34+
fun `M return 0 W combinedSampleRate() {sessionRate is 0}`(
35+
@FloatForgery(min = 0f, max = 100f) featureRate: Float
36+
) {
37+
assertThat(DeterministicSampling.combinedSampleRate(0f, featureRate)).isEqualTo(0f)
38+
}
39+
40+
@Test
41+
fun `M return featureRate W combinedSampleRate() {sessionRate is 100}`() {
42+
assertThat(DeterministicSampling.combinedSampleRate(100f, 75f)).isEqualTo(75f)
43+
}
44+
45+
@Test
46+
fun `M return sessionRate W combinedSampleRate() {featureRate is 100}`() {
47+
assertThat(DeterministicSampling.combinedSampleRate(75f, 100f)).isEqualTo(75f)
48+
}
49+
50+
@Test
51+
fun `M return 100 W combinedSampleRate() {both rates are 100}`() {
52+
assertThat(DeterministicSampling.combinedSampleRate(100f, 100f)).isEqualTo(100f)
53+
}
54+
55+
@Test
56+
fun `M return product divided by 100 W combinedSampleRate() {random valid rates}`(
57+
@FloatForgery(min = 0f, max = 100f) sessionRate: Float,
58+
@FloatForgery(min = 0f, max = 100f) featureRate: Float
59+
) {
60+
val expected = sessionRate * featureRate / 100f
61+
assertThat(DeterministicSampling.combinedSampleRate(sessionRate, featureRate)).isEqualTo(expected)
62+
}
63+
64+
@Test
65+
fun `M clamp to 100 W combinedSampleRate() {rates exceed valid range}`() {
66+
assertThat(DeterministicSampling.combinedSampleRate(200f, 100f)).isEqualTo(100f)
67+
}
68+
69+
@Test
70+
fun `M clamp to 0 W combinedSampleRate() {negative rate}`() {
71+
assertThat(DeterministicSampling.combinedSampleRate(-50f, 50f)).isEqualTo(0f)
72+
}
73+
}

features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/sampling/SessionSamplingIdProviderTest.kt renamed to dd-sdk-android-internal/src/test/java/com/datadog/android/internal/sampling/SessionSamplingIdProviderTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Copyright 2016-Present Datadog, Inc.
55
*/
66

7-
package com.datadog.android.rum.internal.sampling
7+
package com.datadog.android.internal.sampling
88

99
import org.assertj.core.api.Assertions.assertThat
1010
import org.junit.jupiter.api.Test

detekt_custom_safe_calls.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,7 @@ datadog:
11351135
- "kotlin.Double.toInt()"
11361136
- "kotlin.Double.toLong()"
11371137
- "kotlin.Double.toULong()"
1138+
- "kotlin.Float.coerceIn(kotlin.Float, kotlin.Float)"
11381139
- "kotlin.Float.fromBits(kotlin.Int)"
11391140
- "kotlin.Float.percent()"
11401141
- "kotlin.Float.roundToInt()"

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ import com.datadog.android.api.feature.FeatureSdkCore
1717
import com.datadog.android.core.InternalSdkCore
1818
import com.datadog.android.core.sampling.DeterministicSampler
1919
import com.datadog.android.core.sampling.RateBasedSampler
20+
import com.datadog.android.internal.sampling.SessionSamplingIdProvider
2021
import com.datadog.android.rum.internal.RumAnonymousIdentifierManager
2122
import com.datadog.android.rum.internal.RumFeature
2223
import com.datadog.android.rum.internal.domain.scope.RumVitalAppLaunchEventHelper
2324
import com.datadog.android.rum.internal.metric.SessionEndedMetricDispatcher
2425
import com.datadog.android.rum.internal.monitor.DatadogRumMonitor
25-
import com.datadog.android.rum.internal.sampling.SessionSamplingIdProvider
2626
import com.datadog.android.rum.internal.startup.RumAppStartupTelemetryReporter
2727
import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager
2828
import com.datadog.android.telemetry.internal.TelemetryEventHandler

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ internal data class RumContext(
5050

5151
companion object {
5252
val NULL_UUID = UUID(0, 0).toString()
53+
const val SAMPLE_ALL_RATE: Float = 100f
5354

5455
// be careful when changing values below, they may be indirectly referenced (as string
5556
// literal) from other modules

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

Lines changed: 14 additions & 7 deletions
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 val sessionSampleRate: Float = sessionSampler.getSampleRate() ?: RumContext.SAMPLE_ALL_RATE
6869
private var startReason: StartReason = StartReason.USER_APP_LAUNCH
6970
internal var isActive: Boolean = true
7071
private val sessionStartNs = AtomicLong(sdkCore.timeProvider.getDeviceElapsedTimeNanos())
@@ -88,7 +89,7 @@ internal class RumSessionScope(
8889
memoryVitalMonitor = memoryVitalMonitor,
8990
frameRateVitalMonitor = frameRateVitalMonitor,
9091
applicationDisplayed = applicationDisplayed,
91-
sampleRate = sessionSampler.getSampleRate() ?: 100f,
92+
sampleRate = sessionSampleRate,
9293
initialResourceIdentifier = networkSettledResourceIdentifier,
9394
slowFramesListener = slowFramesListener,
9495
lastInteractionIdentifier = lastInteractionIdentifier,
@@ -275,7 +276,7 @@ internal class RumSessionScope(
275276
renewSession(event.eventTime, StartReason.MAX_DURATION)
276277
}
277278

278-
updateSessionStateForSessionReplay(sessionState, sessionId)
279+
updateSessionStateForSessionReplay(sessionId)
279280
}
280281

281282
private fun renewSession(time: Time, reason: StartReason) {
@@ -298,13 +299,16 @@ internal class RumSessionScope(
298299
sessionListener?.onSessionStarted(sessionId, !keepSession)
299300
}
300301

301-
private fun updateSessionStateForSessionReplay(state: State, sessionId: String) {
302-
val keepSession = (state == State.TRACKED)
302+
private fun updateSessionStateForSessionReplay(sessionId: String) {
303303
sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.sendEvent(
304304
mapOf(
305305
SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RUM_SESSION_RENEWED_BUS_MESSAGE,
306-
RUM_KEEP_SESSION_BUS_MESSAGE_KEY to keepSession,
307-
RUM_SESSION_ID_BUS_MESSAGE_KEY to sessionId
306+
RUM_SESSION_ID_BUS_MESSAGE_KEY to sessionId,
307+
RUM_SESSION_SAMPLE_RATE_BUS_MESSAGE_KEY to sessionSampleRate,
308+
// Kept for backward compatibility with older Session Replay modules that
309+
// don't yet consume sessionSampleRate.
310+
// RUM-15962: Remove with SDK v4 release (RUM-13454).
311+
RUM_KEEP_SESSION_BUS_MESSAGE_KEY to (sessionState == State.TRACKED)
308312
)
309313
)
310314
}
@@ -315,8 +319,11 @@ internal class RumSessionScope(
315319

316320
internal const val SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY = "type"
317321
internal const val RUM_SESSION_RENEWED_BUS_MESSAGE = "rum_session_renewed"
318-
internal const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession"
319322
internal const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId"
323+
324+
// RUM-15962: Remove with SDK v4 release (RUM-13454).
325+
internal const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession"
326+
internal const val RUM_SESSION_SAMPLE_RATE_BUS_MESSAGE_KEY = "sessionSampleRate"
320327
internal val DEFAULT_SESSION_INACTIVITY_NS = TimeUnit.MINUTES.toNanos(15)
321328
internal val DEFAULT_SESSION_MAX_DURATION_NS = TimeUnit.HOURS.toNanos(4)
322329
}

0 commit comments

Comments
 (0)