From 758845bf79935bd601a6aeffd342966f04c6afec Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 06:03:15 -0700 Subject: [PATCH 01/19] feat(rum): let sampling rates be set remotely instead of only at init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both sampling rates were fixed when the app called `RUM.enable()`, so changing either one meant shipping a new release and waiting for users to update. That is days or weeks at exactly the moments the knob is worth having: an incident, a launch, a bill that jumped overnight. With `setRemoteConfigurationEnabled(true)` the SDK takes the session and session replay sample rates from the application's settings instead, polling `/api/v2/rum/config` for them. Left off — the default — nothing is requested and the SDK behaves exactly as before. The rates are read where a session's fate is decided, in `renewSession`, so a change never disturbs a session already under way: it applies from the next one. The server can also ask for immediate activation, in which case the session is restarted as soon as rates that actually change this client arrive, so a new one starts under them. Restarting rather than flipping the running session in place keeps every session a complete record of itself. The replay rate travels to Session Replay on the message RUM already sends it when a session is renewed, so one request drives both decisions and there is no second store to keep in step. Failure is always "keep collecting with what you have": nothing here can delay initialisation, an error or timeout leaves the stored rates untouched, and a rate the server does not send stays with the value passed at init — a rate is never invented, least of all a zero, which would switch off collection nobody asked to switch off. Events keep reporting the rate their session was really drawn at rather than the one the app was built with, so the configured sample rate on an event stays true. --- features/dd-sdk-android-rum/api/apiSurface | 1 + .../api/dd-sdk-android-rum.api | 1 + .../kotlin/com/datadog/android/rum/Rum.kt | 1 + .../datadog/android/rum/RumConfiguration.kt | 21 ++ .../android/rum/internal/RumFeature.kt | 64 +++++- .../domain/scope/RumApplicationScope.kt | 6 +- .../internal/domain/scope/RumSessionScope.kt | 23 +- .../domain/scope/RumViewManagerScope.kt | 4 +- .../rum/internal/monitor/DatadogRumMonitor.kt | 8 +- .../remoteconfig/RemoteSamplingController.kt | 186 +++++++++++++++ .../remoteconfig/RemoteSamplingStore.kt | 106 +++++++++ .../domain/scope/RumSessionScopeTest.kt | 54 +++++ .../RemoteSamplingControllerTest.kt | 213 ++++++++++++++++++ .../internal/SessionReplayFeature.kt | 20 +- 14 files changed, 700 insertions(+), 8 deletions(-) create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index f8f94b5168..d3b66c5a89 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -62,6 +62,7 @@ data class com.datadog.android.rum.RumConfiguration class Builder constructor(String) fun setSessionSampleRate(Float): Builder + fun setRemoteConfigurationEnabled(Boolean): Builder fun collectAccessibility(Boolean): Builder fun setTelemetrySampleRate(Float): Builder fun trackUserInteractions(Array = emptyArray(), com.datadog.android.rum.tracking.InteractionPredicate = NoOpInteractionPredicate()): Builder diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index 00238026f7..5981e2665c 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -107,6 +107,7 @@ public final class com/datadog/android/rum/RumConfiguration$Builder { public final fun setInitialResourceIdentifier (Lcom/datadog/android/rum/metric/networksettled/InitialResourceIdentifier;)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setLastInteractionIdentifier (Lcom/datadog/android/rum/metric/interactiontonextview/LastInteractionIdentifier;)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setLongTaskEventMapper (Lcom/datadog/android/event/EventMapper;)Lcom/datadog/android/rum/RumConfiguration$Builder; + public final fun setRemoteConfigurationEnabled (Z)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setResourceEventMapper (Lcom/datadog/android/event/EventMapper;)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setSessionListener (Lcom/datadog/android/rum/RumSessionListener;)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setSessionSampleRate (F)Lcom/datadog/android/rum/RumConfiguration$Builder; diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt index 0537f21fa6..618434e834 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt @@ -132,6 +132,7 @@ object Rum { sdkCore = sdkCore, sessionEndedMetricDispatcher = sessionEndedMetricDispatcher, sampleRate = rumFeature.sampleRate, + remoteSampling = rumFeature.remoteSamplingStore, writer = rumFeature.dataWriter, handler = Handler(Looper.getMainLooper()), telemetryEventHandler = TelemetryEventHandler( diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt index b491dba615..f6e7601d16 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt @@ -63,6 +63,27 @@ data class RumConfiguration internal constructor( return this } + /** + * Take the sampling rates from the application's settings in the Flashcat console instead + * of only from the values set here, so they can be changed without shipping a new release + * of this app. + * + * A change applies to sessions started after it arrives; a session already under way keeps + * the decision it was created with, unless the console asks for immediate activation, in + * which case the running session ends and a new one starts under the new rates. The values + * set here stay in use until the first settings arrive, and whenever they cannot be + * reached. + * + * Disabled by default: left off, the SDK makes no extra request and behaves exactly as it + * did before this existed. + * + * @param enabled whether the console may set the sampling rates. + */ + fun setRemoteConfigurationEnabled(enabled: Boolean): Builder { + rumConfig = rumConfig.copy(remoteConfigurationEnabled = enabled) + return this + } + /** * Whether to collect accessibility attributes - this is disabled by default. * diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 23f68aedbc..dc8ddd0385 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -74,6 +74,8 @@ import com.datadog.android.rum.internal.metric.slowframes.DefaultUISlownessMetri import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.monitor.DatadogRumMonitor +import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingController +import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore import com.datadog.android.rum.internal.net.RumRequestFactory import com.datadog.android.rum.internal.startup.RumAppStartupDetector import com.datadog.android.rum.internal.startup.RumFirstDrawTimeReporter @@ -166,6 +168,14 @@ internal class RumFeature( private var anrDetectorExecutorService: ExecutorService? = null internal var anrDetectorRunnable: ANRDetectorRunnable? = null internal lateinit var appContext: Context + + /** + * FLASHCAT FORK - the sampling rates the console last sent, and the job that keeps them fresh. + * Both stay null when the app did not opt in, which is what makes remote configuration cost + * nothing — no storage, no request, no behaviour change — for everyone who has not asked for it. + */ + internal var remoteSamplingStore: RemoteSamplingStore? = null + private var remoteSamplingController: RemoteSamplingController? = null internal var initialResourceIdentifier: InitialResourceIdentifier = NoOpInitialResourceIdentifier() internal var lastInteractionIdentifier: LastInteractionIdentifier? = NoOpLastInteractionIdentifier() internal var slowFramesListener: SlowFramesListener? = null @@ -268,6 +278,8 @@ internal class RumFeature( initializeANRDetector() } + startRemoteSampling(appContext) + registerTrackingStrategies(appContext) sessionListener = configuration.sessionListener @@ -334,6 +346,10 @@ internal class RumFeature( override fun onStop() { sdkCore.removeEventReceiver(name) + remoteSamplingController?.stop() + remoteSamplingController = null + remoteSamplingStore = null + rumContextUpdateReceivers.forEach { sdkCore.removeContextUpdateReceiver(it) } @@ -752,6 +768,46 @@ internal class RumFeature( ) } + /** + * FLASHCAT FORK - begins keeping the console's sampling rates fresh. + * + * Everything about it is best-effort: if the SDK context is not readable yet, or storage is + * unavailable, the app simply keeps sampling at the rates it was initialised with. Nothing here + * may delay initialisation or interrupt collection. + */ + private fun startRemoteSampling(appContext: Context) { + if (!configuration.remoteConfigurationEnabled) return + + val context = (sdkCore as? InternalSdkCore)?.getDatadogContext() ?: return + val intakeUrl = configuration.customEndpointUrl ?: (context.site.intakeEndpoint + RUM_INTAKE_PATH) + + val store = RemoteSamplingStore( + appContext = appContext, + storeKey = RemoteSamplingStore.buildStoreKey(context), + internalLogger = sdkCore.internalLogger + ) + remoteSamplingStore = store + + remoteSamplingController = RemoteSamplingController( + sdkCore = sdkCore, + configUrl = RemoteSamplingController.buildConfigUrl( + intakeUrl = intakeUrl, + clientToken = context.clientToken, + env = context.env, + appVersion = context.version + ), + store = store, + initialSessionSampleRate = sampleRate, + callFactory = sdkCore.createOkHttpCallFactory(), + executor = sdkCore.createScheduledExecutorService("rum-remote-sampling"), + // Looked up when it fires rather than captured now: the monitor is registered after + // features are initialised, and by the time a response comes back it is there. + restartSession = { + (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor)?.resetSession() + } + ).also { it.start() } + } + // endregion internal data class Configuration( @@ -786,7 +842,9 @@ internal class RumFeature( val rumSessionTypeOverride: RumSessionType?, val collectAccessibility: Boolean, val disableJankStats: Boolean, - val insightsCollector: InsightsCollector + val insightsCollector: InsightsCollector, + // FLASHCAT FORK - opt in to taking the sampling rates from the console. + val remoteConfigurationEnabled: Boolean = false ) internal companion object { @@ -867,6 +925,10 @@ internal class RumFeature( "Slow frames monitoring enabled." internal const val SLOW_FRAMES_MONITORING_DISABLED_MESSAGE = "Slow frames monitoring disabled." + // FLASHCAT FORK - where the RUM intake lives under a site host; the configuration endpoint + // sits beside it, which is also how the private-deployment nginx template is laid out. + internal const val RUM_INTAKE_PATH = "/api/v2/rum" + internal const val RUM_FEATURE_NOT_YET_INITIALIZED = "RUM feature is not initialized yet, you need to register it with a" + " SDK instance by calling SdkCore#registerFeature method." diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt index 00fa888c30..cf870a8cac 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt @@ -27,6 +27,7 @@ import com.datadog.android.rum.internal.domain.display.DisplayInfo import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener +import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.metric.interactiontonextview.LastInteractionIdentifier @@ -54,7 +55,9 @@ internal class RumApplicationScope( private val batteryInfoProvider: InfoProvider, private val displayInfoProvider: InfoProvider, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + // FLASHCAT FORK - the console's sampling rates, or null when the app did not opt in. + private val remoteSampling: RemoteSamplingStore? = null ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -67,6 +70,7 @@ internal class RumApplicationScope( sdkCore = sdkCore, sessionEndedMetricDispatcher = sessionEndedMetricDispatcher, sampleRate = sampleRate, + remoteSampling = remoteSampling, backgroundTrackingEnabled = backgroundTrackingEnabled, trackFrustrations = trackFrustrations, viewChangedListener = this, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index f308397e7e..2e46673ee9 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -27,6 +27,7 @@ import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollect import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager +import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore import com.datadog.android.rum.internal.utils.percent import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.metric.interactiontonextview.LastInteractionIdentifier @@ -61,9 +62,17 @@ internal class RumSessionScope( private val sessionMaxDurationNanos: Long = DEFAULT_SESSION_MAX_DURATION_NS, rumSessionTypeOverride: RumSessionType?, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + // FLASHCAT FORK - rates the console can change without the app shipping a new release. Null + // when the app did not opt in, which is what keeps this whole path inert by default. + private val remoteSampling: RemoteSamplingStore? = null ) : RumScope { + // FLASHCAT FORK - the rate the current session was actually drawn at. It is what events report + // as their configured sample rate, so it has to be the effective one rather than whatever the + // app passed to init. + internal var effectiveSampleRate: Float = sampleRate + internal var sessionId = RumContext.NULL_UUID internal var sessionState: State = State.NOT_TRACKED private var startReason: StartReason = StartReason.USER_APP_LAUNCH @@ -282,7 +291,12 @@ internal class RumSessionScope( } private fun renewSession(time: Time, reason: StartReason) { - val keepSession = random.nextFloat() < sampleRate.percent() + // FLASHCAT FORK - read the console's rate here, at the one moment a session's fate is + // decided. A session already running is never redrawn, so a rate arriving mid-session + // cannot start or stop collecting for someone in the middle of using the app. + effectiveSampleRate = remoteSampling?.sessionSampleRate() ?: sampleRate + childScope?.sampleRate = effectiveSampleRate + val keepSession = random.nextFloat() < effectiveSampleRate.percent() startReason = reason sessionState = if (keepSession) State.TRACKED else State.NOT_TRACKED sessionId = UUID.randomUUID().toString() @@ -306,6 +320,10 @@ internal class RumSessionScope( mapOf( SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RUM_SESSION_RENEWED_BUS_MESSAGE, RUM_KEEP_SESSION_BUS_MESSAGE_KEY to keepSession, + // FLASHCAT FORK - Session Replay draws its own sample when it sees this message, + // and the console's replay rate is fetched on this side. Passing it along is what + // lets one fetch drive both decisions without a second store. + RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to remoteSampling?.sessionReplaySampleRate(), RUM_SESSION_ID_BUS_MESSAGE_KEY to sessionId ) ) @@ -318,6 +336,7 @@ internal class RumSessionScope( internal const val SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY = "type" internal const val RUM_SESSION_RENEWED_BUS_MESSAGE = "rum_session_renewed" internal const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession" + internal const val RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY = "replaySampleRate" internal const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" internal val DEFAULT_SESSION_INACTIVITY_NS = TimeUnit.MINUTES.toNanos(15) internal val DEFAULT_SESSION_MAX_DURATION_NS = TimeUnit.HOURS.toNanos(4) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt index 179b1c885b..5981df1b5b 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt @@ -52,7 +52,9 @@ internal class RumViewManagerScope( private val memoryVitalMonitor: VitalMonitor, private val frameRateVitalMonitor: VitalMonitor, internal var applicationDisplayed: Boolean, - internal val sampleRate: Float, + // FLASHCAT FORK - var rather than val: the session scope sets this to the rate it actually + // drew with, which the console can change between sessions. + internal var sampleRate: Float, internal val initialResourceIdentifier: InitialResourceIdentifier, private val slowFramesListener: SlowFramesListener?, lastInteractionIdentifier: LastInteractionIdentifier?, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index 6deccb25b7..b3379a6ed4 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -58,6 +58,7 @@ import com.datadog.android.rum.internal.domain.scope.RumSessionScope import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener +import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo @@ -99,7 +100,9 @@ internal class DatadogRumMonitor( batteryInfoProvider: InfoProvider, displayInfoProvider: InfoProvider, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + // FLASHCAT FORK - the console's sampling rates, or null when the app did not opt in. + remoteSampling: RemoteSamplingStore? = null ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -122,7 +125,8 @@ internal class DatadogRumMonitor( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + remoteSampling = remoteSampling ) internal val keepAliveRunnable = Runnable { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt new file mode 100644 index 0000000000..de0c1861ee --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt @@ -0,0 +1,186 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import androidx.annotation.WorkerThread +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore +import okhttp3.Call +import okhttp3.Request +import org.json.JSONObject +import java.io.IOException +import java.net.URLEncoder +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +/** + * Keeps the stored sampling rates in step with what the console says. + * + * Nothing here can hold up the SDK or interrupt collection: the first fetch is scheduled like any + * other, and a request that fails, times out or comes back unreadable leaves the stored rates + * exactly as they were. Wiping them on a bad minute would swing a whole fleet back to the rates it + * was built with, which is the opposite of what someone who turned a knob deliberately wants. + */ +internal class RemoteSamplingController( + private val sdkCore: FeatureSdkCore, + private val configUrl: String, + private val store: RemoteSamplingStore, + private val initialSessionSampleRate: Float, + private val callFactory: Call.Factory, + private val executor: ScheduledExecutorService, + private val restartSession: () -> Unit +) { + + fun start() { + schedule(0L) + } + + fun stop() { + executor.shutdownNow() + } + + private fun schedule(delaySeconds: Long) { + try { + executor.schedule({ fetchOnce() }, delaySeconds, TimeUnit.SECONDS) + } catch (e: RejectedExecutionException) { + // The SDK is shutting down. Nothing to keep fresh. + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "Remote sampling refresh not scheduled: executor is shutting down." }, + e + ) + } + } + + @WorkerThread + private fun fetchOnce() { + // Armed before the request goes out, so a request that never comes back still leads to + // another attempt instead of leaving the app on whatever it last knew, forever. + var nextDelaySeconds = DEFAULT_TTL_SECONDS + + try { + val request = Request.Builder().url(configUrl).get().build() + callFactory.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val payload = response.body?.string() + if (payload != null) { + nextDelaySeconds = apply(payload) + } + } + } + } catch (e: IOException) { + logFetchFailure(e) + } catch (e: IllegalStateException) { + logFetchFailure(e) + } + + schedule(nextDelaySeconds) + } + + /** + * Stores what the response carried and, when the console asked for it, restarts the session so + * the new rates take hold now instead of at the visitor's next one. + * + * The session is only restarted when the rates this client will draw with really changed. + * Without that check, a console resending an unchanged configuration would cut every session in + * two on every poll. + */ + internal fun apply(payload: String): Long { + val json = JSONObject(payload) + val ttl = json.optLong(FIELD_TTL, DEFAULT_TTL_SECONDS) + val enabled = json.optBoolean(FIELD_ENABLED, false) + val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) + + val before = RemoteSamplingRates(store.sessionSampleRate(), store.sessionReplaySampleRate()) + val after = if (enabled) readRates(json.optJSONObject(FIELD_RUM)) else EMPTY_RATES + store.store(after) + + if (activation == ACTIVATION_IMMEDIATE && changesThisClient(before, after)) { + restartSession() + } + + return if (ttl > 0) ttl else DEFAULT_TTL_SECONDS + } + + private fun readRates(rum: JSONObject?): RemoteSamplingRates { + if (rum == null) return EMPTY_RATES + return RemoteSamplingRates( + sessionSampleRate = readRate(rum, FIELD_SESSION_SAMPLE_RATE), + sessionReplaySampleRate = readRate(rum, FIELD_SESSION_REPLAY_SAMPLE_RATE) + ) + } + + /** + * A rate the response did not send stays absent, so the value passed to init keeps applying. + * An out-of-range number is treated the same way rather than clamped: a rate we cannot trust is + * not a rate to sample a customer's traffic with. + */ + private fun readRate(rum: JSONObject, field: String): Float? { + if (!rum.has(field)) return null + val rate = rum.optDouble(field, Double.NaN) + return if (rate.isNaN() || rate < 0.0 || rate > MAX_RATE) null else rate.toFloat() + } + + private fun changesThisClient(before: RemoteSamplingRates, after: RemoteSamplingRates): Boolean { + val sessionBefore = before.sessionSampleRate ?: initialSessionSampleRate + val sessionAfter = after.sessionSampleRate ?: initialSessionSampleRate + + // The replay rate is configured on the Session Replay feature rather than here, so there is + // no init value to fall back to on this side. Comparing what was stored is exact for every + // change after the first, and at worst restarts one session the first time the console sets + // a replay rate that happens to equal the one the app was built with. + return sessionBefore != sessionAfter || + before.sessionReplaySampleRate != after.sessionReplaySampleRate + } + + private fun logFetchFailure(e: Throwable) { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { FETCH_FAILED_MESSAGE }, + e + ) + } + + companion object { + internal const val DEFAULT_TTL_SECONDS = 300L + internal const val ACTIVATION_NEXT_SESSION = "next_session" + internal const val ACTIVATION_IMMEDIATE = "immediate" + + private const val MAX_RATE = 100.0 + private const val FIELD_TTL = "ttl" + private const val FIELD_ENABLED = "enabled" + private const val FIELD_ACTIVATION = "activation" + private const val FIELD_RUM = "rum" + private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" + private const val FIELD_SESSION_REPLAY_SAMPLE_RATE = "sessionReplaySampleRate" + + private val EMPTY_RATES = RemoteSamplingRates(null, null) + + internal const val FETCH_FAILED_MESSAGE = + "Unable to refresh the remote sampling rates; keeping the ones already in use." + + /** + * Where to ask. A custom endpoint means the app was pointed at the customer's own host for + * the RUM intake, and the configuration lives beside it there — which is exactly the layout + * the private-deployment nginx template serves. + */ + fun buildConfigUrl(intakeUrl: String, clientToken: String, env: String, appVersion: String): String { + val parameters = buildString { + append("?client_token=").append(encode(clientToken)) + append("&sdk=android") + if (env.isNotEmpty()) append("&env=").append(encode(env)) + if (appVersion.isNotEmpty()) append("&app_version=").append(encode(appVersion)) + } + return intakeUrl.trimEnd('/') + "/config" + parameters + } + + private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") + } +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt new file mode 100644 index 0000000000..447c925305 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt @@ -0,0 +1,106 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import android.content.Context +import android.content.SharedPreferences +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext + +/** + * Holds the sampling rates the console last sent for this application. + * + * They live on disk rather than in memory so a rate fetched during one launch already applies to + * the first session of the next one, instead of every cold start beginning on the rates the app was + * built with and only correcting itself once a request comes back. + * + * A rate the console did not send is absent here, never zero: the caller falls back to the value + * passed to the SDK at init. Inventing a zero would silently stop collection nobody asked to stop. + */ +internal class RemoteSamplingStore( + appContext: Context, + private val storeKey: String, + private val internalLogger: InternalLogger +) { + + private val preferences: SharedPreferences? = try { + appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + } catch (e: SecurityException) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { STORAGE_UNAVAILABLE_MESSAGE }, + e + ) + null + } + + fun sessionSampleRate(): Float? = read(sessionKey()) + + fun sessionReplaySampleRate(): Float? = read(replayKey()) + + /** + * Replaces what is stored with what the response carried. Rates the response omitted are + * removed rather than left behind, so switching a knob off in the console really does hand that + * knob back to the value the app was initialised with. + */ + fun store(rates: RemoteSamplingRates) { + val editor = preferences?.edit() ?: return + write(editor, sessionKey(), rates.sessionSampleRate) + write(editor, replayKey(), rates.sessionReplaySampleRate) + editor.apply() + } + + private fun read(key: String): Float? { + val stored = preferences?.getFloat(key, ABSENT) ?: ABSENT + return if (stored == ABSENT) null else stored + } + + private fun write(editor: SharedPreferences.Editor, key: String, rate: Float?) { + if (rate == null) { + editor.remove(key) + } else { + editor.putFloat(key, rate) + } + } + + private fun sessionKey() = "$storeKey.sessionSampleRate" + + private fun replayKey() = "$storeKey.sessionReplaySampleRate" + + companion object { + private const val PREFERENCES_NAME = "flashcat-rum-remote-sampling" + + // SharedPreferences has no "absent" for a primitive read, and every legitimate rate is + // within 0..100, so a negative sentinel can never collide with a stored value. + private const val ABSENT = -1f + + internal const val STORAGE_UNAVAILABLE_MESSAGE = + "Unable to open the remote sampling store; sampling will use the rates passed to init." + + /** + * Identifies whose rates these are. It covers everything that can change the answer — which + * application, in which environment, at which version — so an app that ships a new version + * does not read the previous one's rates. + * + * It deliberately leaves out the SDK version: including it would discard the stored rates on + * every SDK upgrade and put the first session after an upgrade back on the init values. + */ + fun buildStoreKey(context: DatadogContext): String = + "${context.service}|${context.env}|${context.version}" + } +} + +/** + * The rates carried by one configuration response. Null means the console did not set that knob. + */ +internal data class RemoteSamplingRates( + val sessionSampleRate: Float?, + val sessionReplaySampleRate: Float? +) { + fun isEmpty(): Boolean = sessionSampleRate == null && sessionReplaySampleRate == null +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index e45020987e..e7a1f26f66 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -1199,6 +1199,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1208,6 +1211,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1244,6 +1250,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1253,6 +1262,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1283,6 +1295,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1291,6 +1306,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1320,6 +1338,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1329,6 +1350,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1360,6 +1384,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1369,6 +1396,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1378,6 +1408,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1409,6 +1442,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1417,6 +1453,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1448,6 +1487,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1456,6 +1498,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1487,6 +1532,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1495,6 +1543,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1503,6 +1554,9 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, + // No remote sampling configured here, so Session Replay is told to keep using the + // rate the app was built with. + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt new file mode 100644 index 0000000000..583964df34 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt @@ -0,0 +1,213 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import com.datadog.android.api.feature.FeatureSdkCore +import okhttp3.Call +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.ScheduledExecutorService + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class RemoteSamplingControllerTest { + + private lateinit var store: RemoteSamplingStore + private var restarts = 0 + private lateinit var testedController: RemoteSamplingController + + @BeforeEach + fun setUp() { + store = mock() + // Stubbed explicitly rather than left to the mock's default: what "nothing stored" means is + // the whole point of several of these tests, and a default that is not null would quietly + // turn them into tests of something else. + whenever(store.sessionSampleRate()).thenReturn(null) + whenever(store.sessionReplaySampleRate()).thenReturn(null) + restarts = 0 + testedController = RemoteSamplingController( + sdkCore = mock(), + configUrl = "https://example.com/api/v2/rum/config", + store = store, + initialSessionSampleRate = INIT_SESSION_RATE, + callFactory = mock(), + executor = mock(), + restartSession = { restarts++ } + ) + } + + // region storing + + @Test + fun `M store the rates the response carries W apply()`() { + testedController.apply(body(rum = """"sessionSampleRate":42,"sessionReplaySampleRate":7""")) + + verify(store).store(RemoteSamplingRates(42f, 7f)) + } + + @Test + fun `M store a zero rate W apply() { zero is a setting, not a missing value }`() { + testedController.apply(body(rum = """"sessionSampleRate":0""")) + + verify(store).store(RemoteSamplingRates(0f, null)) + } + + @Test + fun `M leave a rate absent W apply() { response omits it }`() { + // An absent rate must fall back to what the app passed to init. Writing a zero in its place + // would silently stop collection nobody asked to stop. + testedController.apply(body(rum = """"sessionSampleRate":42""")) + + verify(store).store(RemoteSamplingRates(42f, null)) + } + + @Test + fun `M ignore a rate outside 0-100 W apply()`() { + testedController.apply(body(rum = """"sessionSampleRate":420""")) + + verify(store).store(RemoteSamplingRates(null, null)) + } + + @Test + fun `M forget the rates W apply() { remote configuration switched off }`() { + testedController.apply(body(enabled = false, rum = """"sessionSampleRate":42""")) + + verify(store).store(RemoteSamplingRates(null, null)) + } + + // endregion + + // region activation + + @Test + fun `M leave the running session alone W apply() { activation is next_session }`() { + whenever(store.sessionSampleRate()).thenReturn(10f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":100""")) + + assertThat(restarts).isZero() + } + + @Test + fun `M restart the session W apply() { activation is immediate and the rate changed }`() { + whenever(store.sessionSampleRate()).thenReturn(10f) + + testedController.apply(body(activation = "immediate", rum = """"sessionSampleRate":100""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M leave the running session alone W apply() { immediate but nothing changed }`() { + // A console resending an unchanged configuration on every poll must not cut every session + // in two. + whenever(store.sessionSampleRate()).thenReturn(100f) + + testedController.apply(body(activation = "immediate", rum = """"sessionSampleRate":100""")) + + assertThat(restarts).isZero() + } + + @Test + fun `M leave the running session alone W apply() { immediate rate equals the init rate }`() { + whenever(store.sessionSampleRate()).thenReturn(null) + + testedController.apply(body(activation = "immediate", rum = """"sessionSampleRate":$INIT_SESSION_RATE""")) + + assertThat(restarts).isZero() + } + + @Test + fun `M restart the session W apply() { immediate and only the replay rate changed }`() { + whenever(store.sessionSampleRate()).thenReturn(null) + whenever(store.sessionReplaySampleRate()).thenReturn(10f) + + testedController.apply( + body(activation = "immediate", rum = """"sessionSampleRate":$INIT_SESSION_RATE,"sessionReplaySampleRate":90""") + ) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { immediate and the kill switch takes the rates away }`() { + whenever(store.sessionSampleRate()).thenReturn(100f) + + testedController.apply(body(activation = "immediate", enabled = false)) + + assertThat(restarts).isOne() + } + + // endregion + + // region ttl + + @Test + fun `M follow the server ttl W apply()`() { + assertThat(testedController.apply(body(ttl = 42))).isEqualTo(42L) + } + + @Test + fun `M fall back to the default ttl W apply() { server sent none }`() { + assertThat(testedController.apply(body(ttl = 0))).isEqualTo(RemoteSamplingController.DEFAULT_TTL_SECONDS) + } + + // endregion + + // region url + + @Test + fun `M put the configuration beside the intake W buildConfigUrl()`() { + val url = RemoteSamplingController.buildConfigUrl( + intakeUrl = "https://rum.example.com/api/v2/rum", + clientToken = "token", + env = "staging", + appVersion = "1.2.3" + ) + + assertThat(url).startsWith("https://rum.example.com/api/v2/rum/config?") + assertThat(url).contains("client_token=token") + assertThat(url).contains("sdk=android") + assertThat(url).contains("env=staging") + assertThat(url).contains("app_version=1.2.3") + } + + @Test + fun `M leave out what the app did not set W buildConfigUrl()`() { + val url = RemoteSamplingController.buildConfigUrl( + intakeUrl = "https://rum.example.com/api/v2/rum", + clientToken = "token", + env = "", + appVersion = "" + ) + + assertThat(url).doesNotContain("env=") + assertThat(url).doesNotContain("app_version=") + } + + // endregion + + private fun body( + ttl: Int = 300, + enabled: Boolean = true, + activation: String = "next_session", + rum: String = "" + ): String = """{"version":3,"ttl":$ttl,"enabled":$enabled,"activation":"$activation","rum":{$rum}}""" + + companion object { + private const val INIT_SESSION_RATE = 20f + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt index 16ba0e0e35..7d1133e9b5 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -113,6 +113,13 @@ internal class SessionReplayFeature( private val isRecording = AtomicBoolean(false) // is the current session sampled in + // FLASHCAT FORK - the replay rate the console last sent, or null when it set none. + @Volatile + internal var remoteReplaySampleRate: Float? = null + + // Consulted only when a remote rate exists, so an injected sampler keeps its behaviour. + private val remoteAwareSampler: Sampler = RateBasedSampler { remoteReplaySampleRate ?: 0f } + private val isSessionSampledIn = AtomicBoolean(false) internal var sessionReplayRecorder: Recorder = NoOpRecorder() @@ -259,6 +266,10 @@ internal class SessionReplayFeature( private fun parseSessionMetadata(sessionMetadata: Map<*, *>): SessionData? { val keepSession = sessionMetadata[RUM_KEEP_SESSION_BUS_MESSAGE_KEY] as? Boolean val sessionId = sessionMetadata[RUM_SESSION_ID_BUS_MESSAGE_KEY] as? String + // FLASHCAT FORK - absent, or null, means the console set no replay rate and the one the app + // was configured with keeps applying. It is read before sampling so the session about to be + // drawn uses it. + remoteReplaySampleRate = sessionMetadata[RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY] as? Float if (keepSession == null || sessionId == null) { logEventMissingMandatoryFieldsError() @@ -274,7 +285,13 @@ internal class SessionReplayFeature( private fun applySampling(alreadySeenSession: Boolean) { if (!alreadySeenSession) { - isSessionSampledIn.set(rateBasedSampler.sample(Unit)) + // FLASHCAT FORK - the console can set the replay rate without the app shipping a new + // release. RUM fetches it and passes it along with the session it just renewed, so one + // request drives both the session and the replay decision. With nothing set remotely + // this is exactly the sampler the app was configured with. + val remoteRate = remoteReplaySampleRate + val sampler = if (remoteRate == null) rateBasedSampler else remoteAwareSampler + isSessionSampledIn.set(sampler.sample(Unit)) } } @@ -431,6 +448,7 @@ internal class SessionReplayFeature( const val SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY = "type" const val RUM_SESSION_RENEWED_BUS_MESSAGE = "rum_session_renewed" const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession" + const val RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY = "replaySampleRate" const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" internal const val SESSION_REPLAY_SAMPLE_RATE_KEY = "session_replay_sample_rate" internal const val SESSION_REPLAY_TEXT_AND_INPUT_PRIVACY_KEY = "session_replay_text_and_input_privacy" From 488e9f12e30cd5a7ac6ac639eb248ef4bf79c36d Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 08:56:13 -0700 Subject: [PATCH 02/19] feat(rum): ask for the sampling settings again when the app returns An app spends most of its life in the background, where the poll timer cannot be trusted: the system may not run it for hours. Someone could reopen the app and carry on under settings that were changed while it was away. Returning to the foreground is now its own reason to ask, subject to the same ttl, so switching between apps does not turn into a request each time. Rotations and activity-to-activity navigation keep the started count above zero, so neither is mistaken for a return. Deliberately not a method the app has to call: the apps that would never get fresh settings are exactly the ones that never read far enough to find such a method. The ttl the server asked for is now remembered when the response is read rather than around the request, so a fetch that fails keeps it instead of falling back to ours. --- .../android/rum/internal/RumFeature.kt | 18 +++++- .../remoteconfig/ProcessForegroundCallback.kt | 49 ++++++++++++++++ .../remoteconfig/RemoteSamplingController.kt | 29 +++++++++- .../ProcessForegroundCallbackTest.kt | 58 +++++++++++++++++++ .../RemoteSamplingControllerTest.kt | 44 +++++++++++++- 5 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index dc8ddd0385..33b7e0af34 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -74,6 +74,7 @@ import com.datadog.android.rum.internal.metric.slowframes.DefaultUISlownessMetri import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.monitor.DatadogRumMonitor +import com.datadog.android.rum.internal.remoteconfig.ProcessForegroundCallback import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingController import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore import com.datadog.android.rum.internal.net.RumRequestFactory @@ -176,6 +177,7 @@ internal class RumFeature( */ internal var remoteSamplingStore: RemoteSamplingStore? = null private var remoteSamplingController: RemoteSamplingController? = null + private var remoteSamplingForegroundCallback: ProcessForegroundCallback? = null internal var initialResourceIdentifier: InitialResourceIdentifier = NoOpInitialResourceIdentifier() internal var lastInteractionIdentifier: LastInteractionIdentifier? = NoOpLastInteractionIdentifier() internal var slowFramesListener: SlowFramesListener? = null @@ -346,6 +348,8 @@ internal class RumFeature( override fun onStop() { sdkCore.removeEventReceiver(name) + remoteSamplingForegroundCallback?.let { (appContext as? Application)?.unregisterActivityLifecycleCallbacks(it) } + remoteSamplingForegroundCallback = null remoteSamplingController?.stop() remoteSamplingController = null remoteSamplingStore = null @@ -805,7 +809,19 @@ internal class RumFeature( restartSession = { (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor)?.resetSession() } - ).also { it.start() } + ).also { controller -> + controller.start() + + // The poll timer alone is not enough on a phone: an app in the background may not have + // it run for hours. Asking again on the way back to the foreground is what makes the + // console's change land soon after someone reopens the app, and it costs the app no + // code of its own. + (appContext as? Application)?.let { application -> + val callback = ProcessForegroundCallback { controller.refreshIfStale() } + application.registerActivityLifecycleCallbacks(callback) + remoteSamplingForegroundCallback = callback + } + } } // endregion diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt new file mode 100644 index 0000000000..bd218744e3 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt @@ -0,0 +1,49 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import androidx.annotation.MainThread +import java.util.concurrent.atomic.AtomicInteger + +/** + * Calls back when the process comes to the foreground, having had no started activity before. + * + * An app spends most of its life in the background, where a poll timer is unreliable: the system + * may not run it for hours. Asking again on the way back in is what stops someone reopening the app + * and carrying on under settings that were changed while it was away — without the app having to + * call anything itself. + * + * Rotations and activity-to-activity navigation keep the counter above zero, so neither is mistaken + * for a return to the foreground. + */ +internal class ProcessForegroundCallback( + private val onForeground: () -> Unit +) : Application.ActivityLifecycleCallbacks { + + private val startedActivities = AtomicInteger(0) + + @MainThread + override fun onActivityStarted(activity: Activity) { + if (startedActivities.incrementAndGet() == 1) { + onForeground() + } + } + + @MainThread + override fun onActivityStopped(activity: Activity) { + startedActivities.decrementAndGet() + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt index de0c1861ee..f153d0e66d 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt @@ -6,6 +6,7 @@ package com.datadog.android.rum.internal.remoteconfig +import android.os.SystemClock import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore @@ -33,13 +34,32 @@ internal class RemoteSamplingController( private val initialSessionSampleRate: Float, private val callFactory: Call.Factory, private val executor: ScheduledExecutorService, - private val restartSession: () -> Unit + private val restartSession: () -> Unit, + private val elapsedTimeMs: () -> Long = SystemClock::elapsedRealtime ) { + @Volatile + private var lastFetchAtMs: Long = 0 + + @Volatile + private var currentTtlSeconds: Long = DEFAULT_TTL_SECONDS + fun start() { schedule(0L) } + /** + * Asks again if what we hold has outlived its ttl. Called when the app returns to the + * foreground, where the poll timer cannot be trusted: the system may not have run it for hours. + * + * The staleness check is what keeps this from turning every app switch into a request. + */ + fun refreshIfStale() { + if (elapsedTimeMs() - lastFetchAtMs >= currentTtlSeconds * MILLIS_PER_SECOND) { + schedule(0L) + } + } + fun stop() { executor.shutdownNow() } @@ -63,6 +83,7 @@ internal class RemoteSamplingController( // Armed before the request goes out, so a request that never comes back still leads to // another attempt instead of leaving the app on whatever it last knew, forever. var nextDelaySeconds = DEFAULT_TTL_SECONDS + lastFetchAtMs = elapsedTimeMs() try { val request = Request.Builder().url(configUrl).get().build() @@ -105,7 +126,10 @@ internal class RemoteSamplingController( restartSession() } - return if (ttl > 0) ttl else DEFAULT_TTL_SECONDS + // Remembered here rather than around the request, so a fetch that fails keeps the ttl the + // server last asked for instead of falling back to ours. + currentTtlSeconds = if (ttl > 0) ttl else DEFAULT_TTL_SECONDS + return currentTtlSeconds } private fun readRates(rum: JSONObject?): RemoteSamplingRates { @@ -154,6 +178,7 @@ internal class RemoteSamplingController( internal const val ACTIVATION_IMMEDIATE = "immediate" private const val MAX_RATE = 100.0 + private const val MILLIS_PER_SECOND = 1_000L private const val FIELD_TTL = "ttl" private const val FIELD_ENABLED = "enabled" private const val FIELD_ACTIVATION = "activation" diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt new file mode 100644 index 0000000000..e245dad904 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt @@ -0,0 +1,58 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import android.app.Activity +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.mock + +@ExtendWith(MockitoExtension::class) +internal class ProcessForegroundCallbackTest { + + private var foregroundCount = 0 + private lateinit var testedCallback: ProcessForegroundCallback + + @BeforeEach + fun setUp() { + foregroundCount = 0 + testedCallback = ProcessForegroundCallback { foregroundCount++ } + } + + @Test + fun `M report the foreground W the first activity starts`() { + testedCallback.onActivityStarted(mock()) + + assertThat(foregroundCount).isOne() + } + + @Test + fun `M report nothing W navigating between activities`() { + // The next activity starts before the previous one stops, so the process never left the + // foreground and there is nothing to refresh. + val first = mock() + val second = mock() + testedCallback.onActivityStarted(first) + testedCallback.onActivityStarted(second) + testedCallback.onActivityStopped(first) + + assertThat(foregroundCount).isOne() + } + + @Test + fun `M report the foreground again W the app comes back after leaving`() { + val activity = mock() + testedCallback.onActivityStarted(activity) + testedCallback.onActivityStopped(activity) + testedCallback.onActivityStarted(activity) + + assertThat(foregroundCount).isEqualTo(2) + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt index 583964df34..3884909b5a 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt @@ -14,19 +14,25 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.reset import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit @ExtendWith(MockitoExtension::class) @MockitoSettings(strictness = Strictness.LENIENT) internal class RemoteSamplingControllerTest { private lateinit var store: RemoteSamplingStore + private lateinit var executor: ScheduledExecutorService private var restarts = 0 + private var elapsedMs = 0L private lateinit var testedController: RemoteSamplingController @BeforeEach @@ -38,14 +44,17 @@ internal class RemoteSamplingControllerTest { whenever(store.sessionSampleRate()).thenReturn(null) whenever(store.sessionReplaySampleRate()).thenReturn(null) restarts = 0 + elapsedMs = 0L + executor = mock() testedController = RemoteSamplingController( sdkCore = mock(), configUrl = "https://example.com/api/v2/rum/config", store = store, initialSessionSampleRate = INIT_SESSION_RATE, callFactory = mock(), - executor = mock(), - restartSession = { restarts++ } + executor = executor, + restartSession = { restarts++ }, + elapsedTimeMs = { elapsedMs } ) } @@ -167,6 +176,37 @@ internal class RemoteSamplingControllerTest { // endregion + // region coming back to the foreground + + @Test + fun `M ask again W refreshIfStale() { what we hold outlived its ttl }`() { + // An app in the background may not have had its poll timer run for hours, so returning to + // the foreground is its own reason to ask. + testedController.start() + testedController.apply(body(ttl = 60)) + reset(executor) + + elapsedMs = 61_000L + testedController.refreshIfStale() + + verify(executor).schedule(any(), eq(0L), eq(TimeUnit.SECONDS)) + } + + @Test + fun `M ask nothing W refreshIfStale() { what we hold is still fresh }`() { + // Switching apps back and forth must not turn into a request each time. + testedController.start() + testedController.apply(body(ttl = 300)) + reset(executor) + + elapsedMs = 10_000L + testedController.refreshIfStale() + + verify(executor, never()).schedule(any(), any(), any()) + } + + // endregion + // region url @Test From 915f0ef949d264127fe3716fc163b6f9c2be22f1 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 09:05:30 -0700 Subject: [PATCH 03/19] feat(rum): report which settings version the app is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console had no honest way to tell whether a saved change had reached anyone. Events cannot answer it: an event only exists for a session that was kept, so at a low sample rate they describe the sampled few, and the size of that blind spot is set by the very rate being changed. The version each response carried is now stored alongside the rates and sent back on the next request — the one request every client makes, whether or not its session was kept. It is kept even when the response carried no rates, which is what 'remote configuration is off, use your own settings' looks like, so the console can still see the app is up to date with the change that turned them off. --- .../remoteconfig/RemoteSamplingController.kt | 14 +++++++++-- .../remoteconfig/RemoteSamplingStore.kt | 25 ++++++++++++++++++- .../RemoteSamplingControllerTest.kt | 19 ++++++++++---- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt index f153d0e66d..db96b59205 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt @@ -86,7 +86,11 @@ internal class RemoteSamplingController( lastFetchAtMs = elapsedTimeMs() try { - val request = Request.Builder().url(configUrl).get().build() + // Telling the server which version this app is running is what lets the console answer + // "has my change reached everyone yet". It goes on the request every client makes, + // whether or not its session was kept. + val url = store.appliedVersion()?.let { "$configUrl&applied_version=$it" } ?: configUrl + val request = Request.Builder().url(url).get().build() callFactory.newCall(request).execute().use { response -> if (response.isSuccessful) { val payload = response.body?.string() @@ -119,7 +123,12 @@ internal class RemoteSamplingController( val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) val before = RemoteSamplingRates(store.sessionSampleRate(), store.sessionReplaySampleRate()) - val after = if (enabled) readRates(json.optJSONObject(FIELD_RUM)) else EMPTY_RATES + val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } + val after = if (enabled) { + readRates(json.optJSONObject(FIELD_RUM)).copy(version = version) + } else { + EMPTY_RATES.copy(version = version) + } store.store(after) if (activation == ACTIVATION_IMMEDIATE && changesThisClient(before, after)) { @@ -179,6 +188,7 @@ internal class RemoteSamplingController( private const val MAX_RATE = 100.0 private const val MILLIS_PER_SECOND = 1_000L + private const val FIELD_VERSION = "version" private const val FIELD_TTL = "ttl" private const val FIELD_ENABLED = "enabled" private const val FIELD_ACTIVATION = "activation" diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt index 447c925305..7f54683129 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt @@ -43,6 +43,17 @@ internal class RemoteSamplingStore( fun sessionReplaySampleRate(): Float? = read(replayKey()) + /** + * Which version of the settings the stored rates came from, or null before the first answer. + * Reported back on the next request so the console can say how far a change has reached — a + * question the events cannot answer, because a session that was not kept sends none, and the + * miss rate is set by the very rate being changed. + */ + fun appliedVersion(): Int? { + val stored = preferences?.getInt(versionKey(), ABSENT_VERSION) ?: ABSENT_VERSION + return if (stored == ABSENT_VERSION) null else stored + } + /** * Replaces what is stored with what the response carried. Rates the response omitted are * removed rather than left behind, so switching a knob off in the console really does hand that @@ -52,6 +63,14 @@ internal class RemoteSamplingStore( val editor = preferences?.edit() ?: return write(editor, sessionKey(), rates.sessionSampleRate) write(editor, replayKey(), rates.sessionReplaySampleRate) + // Kept even when there are no rates — that is what "remote configuration is off, use your + // own settings" looks like — so the console can still see this client is up to date with + // the change that turned them off. + if (rates.version == null) { + editor.remove(versionKey()) + } else { + editor.putInt(versionKey(), rates.version) + } editor.apply() } @@ -72,12 +91,15 @@ internal class RemoteSamplingStore( private fun replayKey() = "$storeKey.sessionReplaySampleRate" + private fun versionKey() = "$storeKey.version" + companion object { private const val PREFERENCES_NAME = "flashcat-rum-remote-sampling" // SharedPreferences has no "absent" for a primitive read, and every legitimate rate is // within 0..100, so a negative sentinel can never collide with a stored value. private const val ABSENT = -1f + private const val ABSENT_VERSION = -1 internal const val STORAGE_UNAVAILABLE_MESSAGE = "Unable to open the remote sampling store; sampling will use the rates passed to init." @@ -100,7 +122,8 @@ internal class RemoteSamplingStore( */ internal data class RemoteSamplingRates( val sessionSampleRate: Float?, - val sessionReplaySampleRate: Float? + val sessionReplaySampleRate: Float?, + val version: Int? = null ) { fun isEmpty(): Boolean = sessionSampleRate == null && sessionReplaySampleRate == null } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt index 3884909b5a..e8d94e2f8e 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt @@ -64,14 +64,14 @@ internal class RemoteSamplingControllerTest { fun `M store the rates the response carries W apply()`() { testedController.apply(body(rum = """"sessionSampleRate":42,"sessionReplaySampleRate":7""")) - verify(store).store(RemoteSamplingRates(42f, 7f)) + verify(store).store(RemoteSamplingRates(42f, 7f, 3)) } @Test fun `M store a zero rate W apply() { zero is a setting, not a missing value }`() { testedController.apply(body(rum = """"sessionSampleRate":0""")) - verify(store).store(RemoteSamplingRates(0f, null)) + verify(store).store(RemoteSamplingRates(0f, null, 3)) } @Test @@ -80,21 +80,21 @@ internal class RemoteSamplingControllerTest { // would silently stop collection nobody asked to stop. testedController.apply(body(rum = """"sessionSampleRate":42""")) - verify(store).store(RemoteSamplingRates(42f, null)) + verify(store).store(RemoteSamplingRates(42f, null, 3)) } @Test fun `M ignore a rate outside 0-100 W apply()`() { testedController.apply(body(rum = """"sessionSampleRate":420""")) - verify(store).store(RemoteSamplingRates(null, null)) + verify(store).store(RemoteSamplingRates(null, null, 3)) } @Test fun `M forget the rates W apply() { remote configuration switched off }`() { testedController.apply(body(enabled = false, rum = """"sessionSampleRate":42""")) - verify(store).store(RemoteSamplingRates(null, null)) + verify(store).store(RemoteSamplingRates(null, null, 3)) } // endregion @@ -176,6 +176,15 @@ internal class RemoteSamplingControllerTest { // endregion + @Test + fun `M keep the version W apply() { remote configuration switched off }`() { + // The rates are gone, but the console still needs to see this client is up to date with + // the change that turned them off. + testedController.apply(body(enabled = false)) + + verify(store).store(RemoteSamplingRates(null, null, 3)) + } + // region coming back to the foreground @Test From c65c532e151eb18bf0316bf8881855a241f7275a Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 19:38:03 -0700 Subject: [PATCH 04/19] fix(rum): only refresh on foreground when the server allows it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking again when the app returned to the foreground was unconditional. The poll spreads requests across the ttl; returning to the foreground does the opposite, bunching them at the moment everyone opens the app — the same shape as a release herd, and the ttl throttle bounds the rate rather than the shape. It now happens only when the configuration says so, which is off by default. --- .../remoteconfig/RemoteSamplingController.kt | 29 ++++++++++++++++--- .../RemoteSamplingControllerTest.kt | 25 +++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt index db96b59205..e62da7231c 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt @@ -44,18 +44,28 @@ internal class RemoteSamplingController( @Volatile private var currentTtlSeconds: Long = DEFAULT_TTL_SECONDS + @Volatile + private var refreshOnForeground: Boolean = false + fun start() { schedule(0L) } /** - * Asks again if what we hold has outlived its ttl. Called when the app returns to the - * foreground, where the poll timer cannot be trusted: the system may not have run it for hours. + * Asks again when the app returns to the foreground, where the poll timer cannot be trusted: + * the system may not have run it for hours. + * + * Off unless an operator turned it on for this application. The poll spreads requests across + * the ttl; returning to the foreground does the opposite, bunching them at the moment everyone + * opens the app — the same shape as a release herd, arriving when the endpoint can least + * absorb it. Worth it for an application whose owner needs a change to land within minutes, + * not worth it for everyone else, so it is theirs to choose rather than ours to assume. * - * The staleness check is what keeps this from turning every app switch into a request. + * The staleness check is the second guard: it keeps switching between apps from turning into a + * request each time. */ fun refreshIfStale() { - if (elapsedTimeMs() - lastFetchAtMs >= currentTtlSeconds * MILLIS_PER_SECOND) { + if (shouldRefreshOnForeground(refreshOnForeground, elapsedTimeMs() - lastFetchAtMs, currentTtlSeconds)) { schedule(0L) } } @@ -121,6 +131,7 @@ internal class RemoteSamplingController( val ttl = json.optLong(FIELD_TTL, DEFAULT_TTL_SECONDS) val enabled = json.optBoolean(FIELD_ENABLED, false) val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) + refreshOnForeground = json.optBoolean(FIELD_REFRESH_ON_FOREGROUND, false) val before = RemoteSamplingRates(store.sessionSampleRate(), store.sessionReplaySampleRate()) val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } @@ -189,6 +200,7 @@ internal class RemoteSamplingController( private const val MAX_RATE = 100.0 private const val MILLIS_PER_SECOND = 1_000L private const val FIELD_VERSION = "version" + private const val FIELD_REFRESH_ON_FOREGROUND = "refresh_on_foreground" private const val FIELD_TTL = "ttl" private const val FIELD_ENABLED = "enabled" private const val FIELD_ACTIVATION = "activation" @@ -217,5 +229,14 @@ internal class RemoteSamplingController( } private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") + + /** + * Whether returning to the foreground is a reason to ask again. + * + * Both halves guard different things: the permission keeps the request pattern off unless + * someone chose it, and the age keeps app switching from becoming a request each time. + */ + internal fun shouldRefreshOnForeground(allowed: Boolean, ageMs: Long, ttlSeconds: Long): Boolean = + allowed && ageMs >= ttlSeconds * MILLIS_PER_SECOND } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt index e8d94e2f8e..266a75c45d 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt @@ -188,11 +188,11 @@ internal class RemoteSamplingControllerTest { // region coming back to the foreground @Test - fun `M ask again W refreshIfStale() { what we hold outlived its ttl }`() { + fun `M ask again W refreshIfStale() { allowed and what we hold outlived its ttl }`() { // An app in the background may not have had its poll timer run for hours, so returning to // the foreground is its own reason to ask. testedController.start() - testedController.apply(body(ttl = 60)) + testedController.apply(body(ttl = 60, refreshOnForeground = true)) reset(executor) elapsedMs = 61_000L @@ -201,11 +201,25 @@ internal class RemoteSamplingControllerTest { verify(executor).schedule(any(), eq(0L), eq(TimeUnit.SECONDS)) } + @Test + fun `M ask nothing W refreshIfStale() { not allowed }`() { + // Off by default: returning to the foreground bunches requests at the moment everyone + // opens the app, which is the shape the endpoint copes with worst. + testedController.start() + testedController.apply(body(ttl = 60)) + reset(executor) + + elapsedMs = 61_000L + testedController.refreshIfStale() + + verify(executor, never()).schedule(any(), any(), any()) + } + @Test fun `M ask nothing W refreshIfStale() { what we hold is still fresh }`() { // Switching apps back and forth must not turn into a request each time. testedController.start() - testedController.apply(body(ttl = 300)) + testedController.apply(body(ttl = 300, refreshOnForeground = true)) reset(executor) elapsedMs = 10_000L @@ -253,8 +267,11 @@ internal class RemoteSamplingControllerTest { ttl: Int = 300, enabled: Boolean = true, activation: String = "next_session", + refreshOnForeground: Boolean = false, rum: String = "" - ): String = """{"version":3,"ttl":$ttl,"enabled":$enabled,"activation":"$activation","rum":{$rum}}""" + ): String = + """{"version":3,"ttl":$ttl,"enabled":$enabled,"activation":"$activation",""" + + """"refresh_on_foreground":$refreshOnForeground,"rum":{$rum}}""" companion object { private const val INIT_SESSION_RATE = 20f From e63ad9dffe0afac353911ca90b854b832118c177 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 18 Aug 2026 21:23:57 -0700 Subject: [PATCH 05/19] feat(rum): let the host application force a session to be collected setForcedSession() on RumMonitor is the escape hatch for "collect this user now": the application knows who needs debugging (its own allow-list, a support flow), the SDK only provides the switch. The session restarts so the forced draw applies from a clean session - RUM cannot flip the replay decision of one already under way - and the renewal message tells Session Replay to skip its own draw, so a forced session always comes out with replay. Calling again while the forced session runs is a no-op, and the forced state lasts for the process lifetime, so the application decides on each app start whether to call again. --- features/dd-sdk-android-rum/api/apiSurface | 1 + .../api/dd-sdk-android-rum.api | 1 + .../com/datadog/android/rum/RumMonitor.kt | 9 ++ .../rum/internal/domain/scope/RumRawEvent.kt | 4 + .../internal/domain/scope/RumSessionScope.kt | 24 ++++- .../rum/internal/monitor/DatadogRumMonitor.kt | 6 ++ .../domain/scope/RumSessionScopeTest.kt | 92 +++++++++++++++++++ .../internal/SessionReplayFeature.kt | 8 +- 8 files changed, 143 insertions(+), 2 deletions(-) diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index d3b66c5a89..06320e8036 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -117,6 +117,7 @@ interface com.datadog.android.rum.RumMonitor fun getAttributes(): Map fun clearAttributes() fun stopSession() + fun setForcedSession() fun addViewLoadingTime(Boolean) fun addViewAttributes(Map) fun removeViewAttributes(Collection) diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index 5981e2665c..f7497435fa 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -166,6 +166,7 @@ public abstract interface class com/datadog/android/rum/RumMonitor { public abstract fun removeViewAttributes (Ljava/util/Collection;)V public abstract fun reportAppFullyDisplayed ()V public abstract fun setDebug (Z)V + public abstract fun setForcedSession ()V public abstract fun startAction (Lcom/datadog/android/rum/RumActionType;Ljava/lang/String;Ljava/util/Map;)V public abstract fun startFeatureOperation (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V public abstract fun startResource (Ljava/lang/String;Lcom/datadog/android/rum/RumResourceMethod;Ljava/lang/String;Ljava/util/Map;)V diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt index dc98c911a7..1aca67c53e 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt @@ -302,6 +302,15 @@ interface RumMonitor { */ fun stopSession() + /** + * Forces the session to be collected, with Session Replay, regardless of the configured sample + * rates. Call it when your own code decides a user needs debugging (an allow-list, a support + * flow). The current session is restarted so collection starts from a clean session; calling + * again while the forced session is running does nothing. The forced state lasts for the + * process lifetime, so decide on each app start whether to call again. + */ + fun setForcedSession() + /** * Adds view loading time to the active view based on the time elapsed since the view was started. * The view loading time is automatically calculated as the difference between the current time diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt index c12b827716..7db72bdeb2 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt @@ -170,6 +170,10 @@ internal sealed class RumRawEvent { override val eventTime: Time = Time() ) : RumRawEvent() + internal data class SetForcedSession( + override val eventTime: Time = Time() + ) : RumRawEvent() + internal data class KeepAlive( override val eventTime: Time = Time() ) : RumRawEvent() diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index 2e46673ee9..d2111b773d 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -75,6 +75,11 @@ internal class RumSessionScope( internal var sessionId = RumContext.NULL_UUID internal var sessionState: State = State.NOT_TRACKED + + // FLASHCAT FORK - set through `setForcedSession()`, read at draw time. Once set it stays set + // for the process lifetime, so every session renewed after the call is collected with replay; + // the host application decides on each app start whether to call again. + internal var forcedSession = false private var startReason: StartReason = StartReason.USER_APP_LAUNCH internal var isActive: Boolean = true private val sessionStartNs = AtomicLong(sdkCore.timeProvider.getDeviceElapsedTimeNanos()) @@ -158,6 +163,19 @@ internal class RumSessionScope( ): RumScope? { if (event is RumRawEvent.ResetSession) { renewSession(event.eventTime, StartReason.EXPLICIT_STOP) + } else if (event is RumRawEvent.SetForcedSession) { + // FLASHCAT FORK - the escape hatch for "collect this user NOW": the application knows + // who needs debugging, the SDK only provides the switch. The session restarts so the + // forced draw applies from a clean session — RUM cannot flip the replay decision of a + // session already under way. Calling again while the forced session runs is a no-op, + // so a host calling on every screen does not shred sessions. + if (!(forcedSession && sessionState == State.TRACKED)) { + forcedSession = true + renewSession(event.eventTime, StartReason.EXPLICIT_STOP) + // Forcing is a deliberate act of the host application; without this the renewal + // is immediately re-expired when no user interaction happened yet. + lastUserInteractionNs.set(sdkCore.timeProvider.getDeviceElapsedTimeNanos()) + } } else if (event is RumRawEvent.StopSession) { stopSession() } @@ -296,7 +314,7 @@ internal class RumSessionScope( // cannot start or stop collecting for someone in the middle of using the app. effectiveSampleRate = remoteSampling?.sessionSampleRate() ?: sampleRate childScope?.sampleRate = effectiveSampleRate - val keepSession = random.nextFloat() < effectiveSampleRate.percent() + val keepSession = forcedSession || random.nextFloat() < effectiveSampleRate.percent() startReason = reason sessionState = if (keepSession) State.TRACKED else State.NOT_TRACKED sessionId = UUID.randomUUID().toString() @@ -324,6 +342,9 @@ internal class RumSessionScope( // and the console's replay rate is fetched on this side. Passing it along is what // lets one fetch drive both decisions without a second store. RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to remoteSampling?.sessionReplaySampleRate(), + // FLASHCAT FORK - a forced session must come out with replay, so Session Replay + // skips its own draw when this is set. + RUM_SESSION_FORCED_BUS_MESSAGE_KEY to forcedSession, RUM_SESSION_ID_BUS_MESSAGE_KEY to sessionId ) ) @@ -337,6 +358,7 @@ internal class RumSessionScope( internal const val RUM_SESSION_RENEWED_BUS_MESSAGE = "rum_session_renewed" internal const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession" internal const val RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY = "replaySampleRate" + internal const val RUM_SESSION_FORCED_BUS_MESSAGE_KEY = "sessionForced" internal const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" internal val DEFAULT_SESSION_INACTIVITY_NS = TimeUnit.MINUTES.toNanos(15) internal val DEFAULT_SESSION_MAX_DURATION_NS = TimeUnit.HOURS.toNanos(4) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index b3379a6ed4..67b4dcf561 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -449,6 +449,12 @@ internal class DatadogRumMonitor( ) } + override fun setForcedSession() { + handleEvent( + RumRawEvent.SetForcedSession() + ) + } + @ExperimentalRumApi override fun reportAppFullyDisplayed() { handleEvent( diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index e7a1f26f66..1b48ca49ae 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -63,6 +63,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -963,6 +964,79 @@ internal class RumSessionScopeTest { // endregion + // region Forced Session + + @Test + fun `M start a tracked session W handleEvent(SetForcedSession) { zero sample rate }`() { + // Given + initializeTestedScope(0f) + + // When + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + val context = testedScope.getRumContext() + + // Then + assertThat(context.sessionId).isNotEqualTo(RumContext.NULL_UUID) + assertThat(context.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + assertThat(context.sessionStartReason).isEqualTo(RumSessionScope.StartReason.EXPLICIT_STOP) + } + + @Test + fun `M keep the running forced session W handleEvent(SetForcedSession) { called again }`() { + // Given + initializeTestedScope(0f) + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + val forcedSessionId = testedScope.getRumContext().sessionId + + // When + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.getRumContext().sessionId).isEqualTo(forcedSessionId) + } + + @Test + fun `M keep drawing tracked sessions W handleEvent(SetForcedSession) { later renewal }`() { + // Given + initializeTestedScope(0f) + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + val forcedSessionId = testedScope.getRumContext().sessionId + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + val context = testedScope.getRumContext() + + // Then + assertThat(context.sessionId).isNotEqualTo(forcedSessionId) + assertThat(context.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + } + + @Test + fun `M tell Session Replay the session is forced W handleEvent(SetForcedSession)`() { + // Given + initializeTestedScope(0f, withMockChildScope = false) + + // When + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + val argumentCaptor = argumentCaptor() + verify(mockSessionReplayFeatureScope, atLeastOnce()).sendEvent(argumentCaptor.capture()) + assertThat(argumentCaptor.lastValue).isEqualTo( + mapOf( + RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, + RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true, + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to + testedScope.getRumContext().sessionId + ) + ) + } + + // endregion + // region Active View @Test @@ -1202,6 +1276,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1214,6 +1289,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1253,6 +1329,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1265,6 +1342,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1298,6 +1376,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1309,6 +1388,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1341,6 +1421,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1353,6 +1434,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1387,6 +1469,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1399,6 +1482,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1411,6 +1495,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1445,6 +1530,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1456,6 +1542,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1490,6 +1577,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1501,6 +1589,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1535,6 +1624,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1546,6 +1636,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1557,6 +1648,7 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt index 7d1133e9b5..586bbdc41e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -117,6 +117,10 @@ internal class SessionReplayFeature( @Volatile internal var remoteReplaySampleRate: Float? = null + // FLASHCAT FORK - true when RUM renewed this session under a forced draw; replay then skips + // its own draw, because a forced session must come out with replay. + internal var sessionForced: Boolean = false + // Consulted only when a remote rate exists, so an injected sampler keeps its behaviour. private val remoteAwareSampler: Sampler = RateBasedSampler { remoteReplaySampleRate ?: 0f } @@ -270,6 +274,7 @@ internal class SessionReplayFeature( // was configured with keeps applying. It is read before sampling so the session about to be // drawn uses it. remoteReplaySampleRate = sessionMetadata[RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY] as? Float + sessionForced = sessionMetadata[RUM_SESSION_FORCED_BUS_MESSAGE_KEY] as? Boolean ?: false if (keepSession == null || sessionId == null) { logEventMissingMandatoryFieldsError() @@ -291,7 +296,7 @@ internal class SessionReplayFeature( // this is exactly the sampler the app was configured with. val remoteRate = remoteReplaySampleRate val sampler = if (remoteRate == null) rateBasedSampler else remoteAwareSampler - isSessionSampledIn.set(sampler.sample(Unit)) + isSessionSampledIn.set(sessionForced || sampler.sample(Unit)) } } @@ -449,6 +454,7 @@ internal class SessionReplayFeature( const val RUM_SESSION_RENEWED_BUS_MESSAGE = "rum_session_renewed" const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession" const val RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY = "replaySampleRate" + const val RUM_SESSION_FORCED_BUS_MESSAGE_KEY = "sessionForced" const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" internal const val SESSION_REPLAY_SAMPLE_RATE_KEY = "session_replay_sample_rate" internal const val SESSION_REPLAY_TEXT_AND_INPUT_PRIVACY_KEY = "session_replay_text_and_input_privacy" From 307c427ccc86960330d6c7234d8212d224857b2a Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 19 Aug 2026 03:01:52 -0700 Subject: [PATCH 06/19] feat(rum): deliver the console's custom values to the host application The console can publish a small bag of application-defined JSON values alongside the sampling settings; the SDK persists it with them and hands it to the host application verbatim through RumMonitor.getRemoteConfig(), as the raw JSON object string, never interpreting it. What a value means is entirely up to the application's own code - a debug allow-list to pair with setForcedSession(), a feature toggle. The bag is persisted like the rates, so what one launch fetched answers immediately on the next; when the kill switch turns remote configuration off, the bag goes with it. A custom-only change never restarts a session - immediate activation keeps comparing rates alone. --- features/dd-sdk-android-rum/api/apiSurface | 1 + .../api/dd-sdk-android-rum.api | 1 + .../com/datadog/android/rum/RumMonitor.kt | 10 +++++++ .../rum/internal/monitor/DatadogRumMonitor.kt | 6 +++- .../remoteconfig/RemoteSamplingController.kt | 8 ++++- .../remoteconfig/RemoteSamplingStore.kt | 17 ++++++++++- .../RemoteSamplingControllerTest.kt | 29 +++++++++++++++++-- 7 files changed, 67 insertions(+), 5 deletions(-) diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index 06320e8036..c5725b7126 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -118,6 +118,7 @@ interface com.datadog.android.rum.RumMonitor fun clearAttributes() fun stopSession() fun setForcedSession() + fun getRemoteConfig(): String? fun addViewLoadingTime(Boolean) fun addViewAttributes(Map) fun removeViewAttributes(Collection) diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index f7497435fa..8bf45b7b04 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -162,6 +162,7 @@ public abstract interface class com/datadog/android/rum/RumMonitor { public abstract fun getAttributes ()Ljava/util/Map; public abstract fun getCurrentSessionId (Lkotlin/jvm/functions/Function1;)V public abstract fun getDebug ()Z + public abstract fun getRemoteConfig ()Ljava/lang/String; public abstract fun removeAttribute (Ljava/lang/String;)V public abstract fun removeViewAttributes (Ljava/util/Collection;)V public abstract fun reportAppFullyDisplayed ()V diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt index 1aca67c53e..148c7e711f 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt @@ -311,6 +311,16 @@ interface RumMonitor { */ fun setForcedSession() + /** + * Returns the custom values published for this application in the console, as the raw JSON + * object string, or null when nothing is published or remote configuration is off. The SDK + * delivers them verbatim and never interprets them - what a value means is entirely up to your + * own code (a debug allow-list to pair with [setForcedSession], a feature toggle). Values are + * cached locally, so what a previous launch fetched answers immediately on the next. The + * content is readable by anyone holding the public client token - it is public information. + */ + fun getRemoteConfig(): String? + /** * Adds view loading time to the active view based on the time elapsed since the view was started. * The view loading time is automatically calculated as the difference between the current time diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index 67b4dcf561..9e38133d52 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -102,7 +102,7 @@ internal class DatadogRumMonitor( private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, insightsCollector: InsightsCollector, // FLASHCAT FORK - the console's sampling rates, or null when the app did not opt in. - remoteSampling: RemoteSamplingStore? = null + private val remoteSampling: RemoteSamplingStore? = null ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -455,6 +455,10 @@ internal class DatadogRumMonitor( ) } + override fun getRemoteConfig(): String? { + return remoteSampling?.custom() + } + @ExperimentalRumApi override fun reportAppFullyDisplayed() { handleEvent( diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt index e62da7231c..a94cc31a3e 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt @@ -136,7 +136,12 @@ internal class RemoteSamplingController( val before = RemoteSamplingRates(store.sessionSampleRate(), store.sessionReplaySampleRate()) val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } val after = if (enabled) { - readRates(json.optJSONObject(FIELD_RUM)).copy(version = version) + readRates(json.optJSONObject(FIELD_RUM)).copy( + version = version, + // Stored as the raw string: the platform's job is delivery, the meaning belongs to + // the host application. + custom = json.optJSONObject(FIELD_CUSTOM)?.toString() + ) } else { EMPTY_RATES.copy(version = version) } @@ -204,6 +209,7 @@ internal class RemoteSamplingController( private const val FIELD_TTL = "ttl" private const val FIELD_ENABLED = "enabled" private const val FIELD_ACTIVATION = "activation" + private const val FIELD_CUSTOM = "custom" private const val FIELD_RUM = "rum" private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" private const val FIELD_SESSION_REPLAY_SAMPLE_RATE = "sessionReplaySampleRate" diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt index 7f54683129..caafd2ecef 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt @@ -41,6 +41,12 @@ internal class RemoteSamplingStore( fun sessionSampleRate(): Float? = read(sessionKey()) + /** + * The application-defined bag the console last published, as the raw JSON object string, or + * null when none is published. The platform never interprets it — see [RumMonitor.getRemoteConfig]. + */ + fun custom(): String? = preferences?.getString(customKey(), null) + fun sessionReplaySampleRate(): Float? = read(replayKey()) /** @@ -71,6 +77,11 @@ internal class RemoteSamplingStore( } else { editor.putInt(versionKey(), rates.version) } + if (rates.custom == null) { + editor.remove(customKey()) + } else { + editor.putString(customKey(), rates.custom) + } editor.apply() } @@ -93,6 +104,8 @@ internal class RemoteSamplingStore( private fun versionKey() = "$storeKey.version" + private fun customKey() = "$storeKey.custom" + companion object { private const val PREFERENCES_NAME = "flashcat-rum-remote-sampling" @@ -123,7 +136,9 @@ internal class RemoteSamplingStore( internal data class RemoteSamplingRates( val sessionSampleRate: Float?, val sessionReplaySampleRate: Float?, - val version: Int? = null + val version: Int? = null, + /** Raw JSON object string of the console's custom pass-through values, delivered verbatim. */ + val custom: String? = null ) { fun isEmpty(): Boolean = sessionSampleRate == null && sessionReplaySampleRate == null } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt index 266a75c45d..f9e42e83f9 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.rum.internal.remoteconfig import com.datadog.android.api.feature.FeatureSdkCore import okhttp3.Call +import org.json.JSONObject import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -15,6 +16,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -261,6 +263,27 @@ internal class RemoteSamplingControllerTest { assertThat(url).doesNotContain("app_version=") } + @Test + fun `M store the custom bag verbatim W apply()`() { + testedController.apply(body(custom = """{"viplist":["u-1","u-2"],"debug":true}""")) + + argumentCaptor { + verify(store).store(capture()) + assertThat(JSONObject(firstValue.custom!!).getBoolean("debug")).isTrue() + assertThat(JSONObject(firstValue.custom!!).getJSONArray("viplist").length()).isEqualTo(2) + } + } + + @Test + fun `M drop the custom bag W apply() { remote configuration switched off }`() { + testedController.apply(body(enabled = false, custom = """{"debug":true}""")) + + argumentCaptor { + verify(store).store(capture()) + assertThat(firstValue.custom).isNull() + } + } + // endregion private fun body( @@ -268,10 +291,12 @@ internal class RemoteSamplingControllerTest { enabled: Boolean = true, activation: String = "next_session", refreshOnForeground: Boolean = false, - rum: String = "" + rum: String = "", + custom: String? = null ): String = """{"version":3,"ttl":$ttl,"enabled":$enabled,"activation":"$activation",""" + - """"refresh_on_foreground":$refreshOnForeground,"rum":{$rum}}""" + """"refresh_on_foreground":$refreshOnForeground,"rum":{$rum}""" + + (if (custom == null) "" else ""","custom":$custom""") + "}" companion object { private const val INIT_SESSION_RATE = 20f From dc1ad6be3b92004ec1f82e1a3d08d5d94d48f34a Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 00:59:53 -0700 Subject: [PATCH 07/19] refactor(rum): rename the remote sampling channel to remote configuration The channel no longer carries only sampling rates - the console's contract grew a trace sample rate and a replay privacy level - so everything internal that called it "sampling" takes the broader name: the controller, the store and its preferences file, and every symbol wired through the feature, the monitor and the scopes. The public init option is untouched (setRemoteConfigurationEnabled already said it), as is the endpoint and every behaviour; this commit only moves names. --- .../kotlin/com/datadog/android/rum/Rum.kt | 2 +- .../android/rum/internal/RumFeature.kt | 38 +++++++++---------- .../domain/scope/RumApplicationScope.kt | 6 +-- .../internal/domain/scope/RumSessionScope.kt | 8 ++-- .../rum/internal/monitor/DatadogRumMonitor.kt | 8 ++-- ...ontroller.kt => RemoteConfigController.kt} | 20 +++++----- ...eSamplingStore.kt => RemoteConfigStore.kt} | 12 +++--- ...rTest.kt => RemoteConfigControllerTest.kt} | 30 +++++++-------- 8 files changed, 62 insertions(+), 62 deletions(-) rename features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/{RemoteSamplingController.kt => RemoteConfigController.kt} (93%) rename features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/{RemoteSamplingStore.kt => RemoteConfigStore.kt} (94%) rename features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/{RemoteSamplingControllerTest.kt => RemoteConfigControllerTest.kt} (91%) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt index 618434e834..9c84e23a10 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt @@ -132,7 +132,7 @@ object Rum { sdkCore = sdkCore, sessionEndedMetricDispatcher = sessionEndedMetricDispatcher, sampleRate = rumFeature.sampleRate, - remoteSampling = rumFeature.remoteSamplingStore, + remoteConfig = rumFeature.remoteConfigStore, writer = rumFeature.dataWriter, handler = Handler(Looper.getMainLooper()), telemetryEventHandler = TelemetryEventHandler( diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 33b7e0af34..d0cd675867 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -75,8 +75,8 @@ import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.monitor.DatadogRumMonitor import com.datadog.android.rum.internal.remoteconfig.ProcessForegroundCallback -import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingController -import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore +import com.datadog.android.rum.internal.remoteconfig.RemoteConfigController +import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore import com.datadog.android.rum.internal.net.RumRequestFactory import com.datadog.android.rum.internal.startup.RumAppStartupDetector import com.datadog.android.rum.internal.startup.RumFirstDrawTimeReporter @@ -175,9 +175,9 @@ internal class RumFeature( * Both stay null when the app did not opt in, which is what makes remote configuration cost * nothing — no storage, no request, no behaviour change — for everyone who has not asked for it. */ - internal var remoteSamplingStore: RemoteSamplingStore? = null - private var remoteSamplingController: RemoteSamplingController? = null - private var remoteSamplingForegroundCallback: ProcessForegroundCallback? = null + internal var remoteConfigStore: RemoteConfigStore? = null + private var remoteConfigController: RemoteConfigController? = null + private var remoteConfigForegroundCallback: ProcessForegroundCallback? = null internal var initialResourceIdentifier: InitialResourceIdentifier = NoOpInitialResourceIdentifier() internal var lastInteractionIdentifier: LastInteractionIdentifier? = NoOpLastInteractionIdentifier() internal var slowFramesListener: SlowFramesListener? = null @@ -280,7 +280,7 @@ internal class RumFeature( initializeANRDetector() } - startRemoteSampling(appContext) + startRemoteConfiguration(appContext) registerTrackingStrategies(appContext) @@ -348,11 +348,11 @@ internal class RumFeature( override fun onStop() { sdkCore.removeEventReceiver(name) - remoteSamplingForegroundCallback?.let { (appContext as? Application)?.unregisterActivityLifecycleCallbacks(it) } - remoteSamplingForegroundCallback = null - remoteSamplingController?.stop() - remoteSamplingController = null - remoteSamplingStore = null + remoteConfigForegroundCallback?.let { (appContext as? Application)?.unregisterActivityLifecycleCallbacks(it) } + remoteConfigForegroundCallback = null + remoteConfigController?.stop() + remoteConfigController = null + remoteConfigStore = null rumContextUpdateReceivers.forEach { sdkCore.removeContextUpdateReceiver(it) @@ -779,22 +779,22 @@ internal class RumFeature( * unavailable, the app simply keeps sampling at the rates it was initialised with. Nothing here * may delay initialisation or interrupt collection. */ - private fun startRemoteSampling(appContext: Context) { + private fun startRemoteConfiguration(appContext: Context) { if (!configuration.remoteConfigurationEnabled) return val context = (sdkCore as? InternalSdkCore)?.getDatadogContext() ?: return val intakeUrl = configuration.customEndpointUrl ?: (context.site.intakeEndpoint + RUM_INTAKE_PATH) - val store = RemoteSamplingStore( + val store = RemoteConfigStore( appContext = appContext, - storeKey = RemoteSamplingStore.buildStoreKey(context), + storeKey = RemoteConfigStore.buildStoreKey(context), internalLogger = sdkCore.internalLogger ) - remoteSamplingStore = store + remoteConfigStore = store - remoteSamplingController = RemoteSamplingController( + remoteConfigController = RemoteConfigController( sdkCore = sdkCore, - configUrl = RemoteSamplingController.buildConfigUrl( + configUrl = RemoteConfigController.buildConfigUrl( intakeUrl = intakeUrl, clientToken = context.clientToken, env = context.env, @@ -803,7 +803,7 @@ internal class RumFeature( store = store, initialSessionSampleRate = sampleRate, callFactory = sdkCore.createOkHttpCallFactory(), - executor = sdkCore.createScheduledExecutorService("rum-remote-sampling"), + executor = sdkCore.createScheduledExecutorService("rum-remote-config"), // Looked up when it fires rather than captured now: the monitor is registered after // features are initialised, and by the time a response comes back it is there. restartSession = { @@ -819,7 +819,7 @@ internal class RumFeature( (appContext as? Application)?.let { application -> val callback = ProcessForegroundCallback { controller.refreshIfStale() } application.registerActivityLifecycleCallbacks(callback) - remoteSamplingForegroundCallback = callback + remoteConfigForegroundCallback = callback } } } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt index cf870a8cac..81b97fc437 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt @@ -27,7 +27,7 @@ import com.datadog.android.rum.internal.domain.display.DisplayInfo import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener -import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore +import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.metric.interactiontonextview.LastInteractionIdentifier @@ -57,7 +57,7 @@ internal class RumApplicationScope( private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, private val insightsCollector: InsightsCollector, // FLASHCAT FORK - the console's sampling rates, or null when the app did not opt in. - private val remoteSampling: RemoteSamplingStore? = null + private val remoteConfig: RemoteConfigStore? = null ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -70,7 +70,7 @@ internal class RumApplicationScope( sdkCore = sdkCore, sessionEndedMetricDispatcher = sessionEndedMetricDispatcher, sampleRate = sampleRate, - remoteSampling = remoteSampling, + remoteConfig = remoteConfig, backgroundTrackingEnabled = backgroundTrackingEnabled, trackFrustrations = trackFrustrations, viewChangedListener = this, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index d2111b773d..03c7926008 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -27,7 +27,7 @@ import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollect import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager -import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore +import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore import com.datadog.android.rum.internal.utils.percent import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.metric.interactiontonextview.LastInteractionIdentifier @@ -65,7 +65,7 @@ internal class RumSessionScope( insightsCollector: InsightsCollector, // FLASHCAT FORK - rates the console can change without the app shipping a new release. Null // when the app did not opt in, which is what keeps this whole path inert by default. - private val remoteSampling: RemoteSamplingStore? = null + private val remoteConfig: RemoteConfigStore? = null ) : RumScope { // FLASHCAT FORK - the rate the current session was actually drawn at. It is what events report @@ -312,7 +312,7 @@ internal class RumSessionScope( // FLASHCAT FORK - read the console's rate here, at the one moment a session's fate is // decided. A session already running is never redrawn, so a rate arriving mid-session // cannot start or stop collecting for someone in the middle of using the app. - effectiveSampleRate = remoteSampling?.sessionSampleRate() ?: sampleRate + effectiveSampleRate = remoteConfig?.sessionSampleRate() ?: sampleRate childScope?.sampleRate = effectiveSampleRate val keepSession = forcedSession || random.nextFloat() < effectiveSampleRate.percent() startReason = reason @@ -341,7 +341,7 @@ internal class RumSessionScope( // FLASHCAT FORK - Session Replay draws its own sample when it sees this message, // and the console's replay rate is fetched on this side. Passing it along is what // lets one fetch drive both decisions without a second store. - RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to remoteSampling?.sessionReplaySampleRate(), + RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to remoteConfig?.sessionReplaySampleRate(), // FLASHCAT FORK - a forced session must come out with replay, so Session Replay // skips its own draw when this is set. RUM_SESSION_FORCED_BUS_MESSAGE_KEY to forcedSession, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index 9e38133d52..f49e32febf 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -58,7 +58,7 @@ import com.datadog.android.rum.internal.domain.scope.RumSessionScope import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener -import com.datadog.android.rum.internal.remoteconfig.RemoteSamplingStore +import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo @@ -102,7 +102,7 @@ internal class DatadogRumMonitor( private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, insightsCollector: InsightsCollector, // FLASHCAT FORK - the console's sampling rates, or null when the app did not opt in. - private val remoteSampling: RemoteSamplingStore? = null + private val remoteConfig: RemoteConfigStore? = null ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -126,7 +126,7 @@ internal class DatadogRumMonitor( displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, insightsCollector = insightsCollector, - remoteSampling = remoteSampling + remoteConfig = remoteConfig ) internal val keepAliveRunnable = Runnable { @@ -456,7 +456,7 @@ internal class DatadogRumMonitor( } override fun getRemoteConfig(): String? { - return remoteSampling?.custom() + return remoteConfig?.custom() } @ExperimentalRumApi diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt similarity index 93% rename from features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt rename to features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index a94cc31a3e..89cd3b8a42 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -20,17 +20,17 @@ import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit /** - * Keeps the stored sampling rates in step with what the console says. + * Keeps the stored remote configuration in step with what the console says. * * Nothing here can hold up the SDK or interrupt collection: the first fetch is scheduled like any * other, and a request that fails, times out or comes back unreadable leaves the stored rates * exactly as they were. Wiping them on a bad minute would swing a whole fleet back to the rates it * was built with, which is the opposite of what someone who turned a knob deliberately wants. */ -internal class RemoteSamplingController( +internal class RemoteConfigController( private val sdkCore: FeatureSdkCore, private val configUrl: String, - private val store: RemoteSamplingStore, + private val store: RemoteConfigStore, private val initialSessionSampleRate: Float, private val callFactory: Call.Factory, private val executor: ScheduledExecutorService, @@ -82,7 +82,7 @@ internal class RemoteSamplingController( sdkCore.internalLogger.log( InternalLogger.Level.DEBUG, InternalLogger.Target.MAINTAINER, - { "Remote sampling refresh not scheduled: executor is shutting down." }, + { "Remote configuration refresh not scheduled: executor is shutting down." }, e ) } @@ -133,7 +133,7 @@ internal class RemoteSamplingController( val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) refreshOnForeground = json.optBoolean(FIELD_REFRESH_ON_FOREGROUND, false) - val before = RemoteSamplingRates(store.sessionSampleRate(), store.sessionReplaySampleRate()) + val before = RemoteConfigValues(store.sessionSampleRate(), store.sessionReplaySampleRate()) val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } val after = if (enabled) { readRates(json.optJSONObject(FIELD_RUM)).copy( @@ -157,9 +157,9 @@ internal class RemoteSamplingController( return currentTtlSeconds } - private fun readRates(rum: JSONObject?): RemoteSamplingRates { + private fun readRates(rum: JSONObject?): RemoteConfigValues { if (rum == null) return EMPTY_RATES - return RemoteSamplingRates( + return RemoteConfigValues( sessionSampleRate = readRate(rum, FIELD_SESSION_SAMPLE_RATE), sessionReplaySampleRate = readRate(rum, FIELD_SESSION_REPLAY_SAMPLE_RATE) ) @@ -176,7 +176,7 @@ internal class RemoteSamplingController( return if (rate.isNaN() || rate < 0.0 || rate > MAX_RATE) null else rate.toFloat() } - private fun changesThisClient(before: RemoteSamplingRates, after: RemoteSamplingRates): Boolean { + private fun changesThisClient(before: RemoteConfigValues, after: RemoteConfigValues): Boolean { val sessionBefore = before.sessionSampleRate ?: initialSessionSampleRate val sessionAfter = after.sessionSampleRate ?: initialSessionSampleRate @@ -214,10 +214,10 @@ internal class RemoteSamplingController( private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" private const val FIELD_SESSION_REPLAY_SAMPLE_RATE = "sessionReplaySampleRate" - private val EMPTY_RATES = RemoteSamplingRates(null, null) + private val EMPTY_RATES = RemoteConfigValues(null, null) internal const val FETCH_FAILED_MESSAGE = - "Unable to refresh the remote sampling rates; keeping the ones already in use." + "Unable to refresh the remote configuration; keeping the values already in use." /** * Where to ask. A custom endpoint means the app was pointed at the customer's own host for diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt similarity index 94% rename from features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt rename to features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt index caafd2ecef..b59d20724a 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -12,7 +12,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext /** - * Holds the sampling rates the console last sent for this application. + * Holds the remote configuration the console last sent for this application. * * They live on disk rather than in memory so a rate fetched during one launch already applies to * the first session of the next one, instead of every cold start beginning on the rates the app was @@ -21,7 +21,7 @@ import com.datadog.android.api.context.DatadogContext * A rate the console did not send is absent here, never zero: the caller falls back to the value * passed to the SDK at init. Inventing a zero would silently stop collection nobody asked to stop. */ -internal class RemoteSamplingStore( +internal class RemoteConfigStore( appContext: Context, private val storeKey: String, private val internalLogger: InternalLogger @@ -65,7 +65,7 @@ internal class RemoteSamplingStore( * removed rather than left behind, so switching a knob off in the console really does hand that * knob back to the value the app was initialised with. */ - fun store(rates: RemoteSamplingRates) { + fun store(rates: RemoteConfigValues) { val editor = preferences?.edit() ?: return write(editor, sessionKey(), rates.sessionSampleRate) write(editor, replayKey(), rates.sessionReplaySampleRate) @@ -107,7 +107,7 @@ internal class RemoteSamplingStore( private fun customKey() = "$storeKey.custom" companion object { - private const val PREFERENCES_NAME = "flashcat-rum-remote-sampling" + private const val PREFERENCES_NAME = "flashcat-rum-remote-config" // SharedPreferences has no "absent" for a primitive read, and every legitimate rate is // within 0..100, so a negative sentinel can never collide with a stored value. @@ -115,7 +115,7 @@ internal class RemoteSamplingStore( private const val ABSENT_VERSION = -1 internal const val STORAGE_UNAVAILABLE_MESSAGE = - "Unable to open the remote sampling store; sampling will use the rates passed to init." + "Unable to open the remote configuration store; the values passed to init will apply." /** * Identifies whose rates these are. It covers everything that can change the answer — which @@ -133,7 +133,7 @@ internal class RemoteSamplingStore( /** * The rates carried by one configuration response. Null means the console did not set that knob. */ -internal data class RemoteSamplingRates( +internal data class RemoteConfigValues( val sessionSampleRate: Float?, val sessionReplaySampleRate: Float?, val version: Int? = null, diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt similarity index 91% rename from features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt rename to features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index f9e42e83f9..5c60e24b65 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteSamplingControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -29,13 +29,13 @@ import java.util.concurrent.TimeUnit @ExtendWith(MockitoExtension::class) @MockitoSettings(strictness = Strictness.LENIENT) -internal class RemoteSamplingControllerTest { +internal class RemoteConfigControllerTest { - private lateinit var store: RemoteSamplingStore + private lateinit var store: RemoteConfigStore private lateinit var executor: ScheduledExecutorService private var restarts = 0 private var elapsedMs = 0L - private lateinit var testedController: RemoteSamplingController + private lateinit var testedController: RemoteConfigController @BeforeEach fun setUp() { @@ -48,7 +48,7 @@ internal class RemoteSamplingControllerTest { restarts = 0 elapsedMs = 0L executor = mock() - testedController = RemoteSamplingController( + testedController = RemoteConfigController( sdkCore = mock(), configUrl = "https://example.com/api/v2/rum/config", store = store, @@ -66,14 +66,14 @@ internal class RemoteSamplingControllerTest { fun `M store the rates the response carries W apply()`() { testedController.apply(body(rum = """"sessionSampleRate":42,"sessionReplaySampleRate":7""")) - verify(store).store(RemoteSamplingRates(42f, 7f, 3)) + verify(store).store(RemoteConfigValues(42f, 7f, 3)) } @Test fun `M store a zero rate W apply() { zero is a setting, not a missing value }`() { testedController.apply(body(rum = """"sessionSampleRate":0""")) - verify(store).store(RemoteSamplingRates(0f, null, 3)) + verify(store).store(RemoteConfigValues(0f, null, 3)) } @Test @@ -82,21 +82,21 @@ internal class RemoteSamplingControllerTest { // would silently stop collection nobody asked to stop. testedController.apply(body(rum = """"sessionSampleRate":42""")) - verify(store).store(RemoteSamplingRates(42f, null, 3)) + verify(store).store(RemoteConfigValues(42f, null, 3)) } @Test fun `M ignore a rate outside 0-100 W apply()`() { testedController.apply(body(rum = """"sessionSampleRate":420""")) - verify(store).store(RemoteSamplingRates(null, null, 3)) + verify(store).store(RemoteConfigValues(null, null, 3)) } @Test fun `M forget the rates W apply() { remote configuration switched off }`() { testedController.apply(body(enabled = false, rum = """"sessionSampleRate":42""")) - verify(store).store(RemoteSamplingRates(null, null, 3)) + verify(store).store(RemoteConfigValues(null, null, 3)) } // endregion @@ -173,7 +173,7 @@ internal class RemoteSamplingControllerTest { @Test fun `M fall back to the default ttl W apply() { server sent none }`() { - assertThat(testedController.apply(body(ttl = 0))).isEqualTo(RemoteSamplingController.DEFAULT_TTL_SECONDS) + assertThat(testedController.apply(body(ttl = 0))).isEqualTo(RemoteConfigController.DEFAULT_TTL_SECONDS) } // endregion @@ -184,7 +184,7 @@ internal class RemoteSamplingControllerTest { // the change that turned them off. testedController.apply(body(enabled = false)) - verify(store).store(RemoteSamplingRates(null, null, 3)) + verify(store).store(RemoteConfigValues(null, null, 3)) } // region coming back to the foreground @@ -236,7 +236,7 @@ internal class RemoteSamplingControllerTest { @Test fun `M put the configuration beside the intake W buildConfigUrl()`() { - val url = RemoteSamplingController.buildConfigUrl( + val url = RemoteConfigController.buildConfigUrl( intakeUrl = "https://rum.example.com/api/v2/rum", clientToken = "token", env = "staging", @@ -252,7 +252,7 @@ internal class RemoteSamplingControllerTest { @Test fun `M leave out what the app did not set W buildConfigUrl()`() { - val url = RemoteSamplingController.buildConfigUrl( + val url = RemoteConfigController.buildConfigUrl( intakeUrl = "https://rum.example.com/api/v2/rum", clientToken = "token", env = "", @@ -267,7 +267,7 @@ internal class RemoteSamplingControllerTest { fun `M store the custom bag verbatim W apply()`() { testedController.apply(body(custom = """{"viplist":["u-1","u-2"],"debug":true}""")) - argumentCaptor { + argumentCaptor { verify(store).store(capture()) assertThat(JSONObject(firstValue.custom!!).getBoolean("debug")).isTrue() assertThat(JSONObject(firstValue.custom!!).getJSONArray("viplist").length()).isEqualTo(2) @@ -278,7 +278,7 @@ internal class RemoteSamplingControllerTest { fun `M drop the custom bag W apply() { remote configuration switched off }`() { testedController.apply(body(enabled = false, custom = """{"debug":true}""")) - argumentCaptor { + argumentCaptor { verify(store).store(capture()) assertThat(firstValue.custom).isNull() } From 1eee2d52783103448a1e9745ee84d2f238d54703 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 01:16:15 -0700 Subject: [PATCH 08/19] feat(rum): fetch remote configuration per session instead of polling The ttl poll timer is gone. A fetch now happens at start-up and after every session draw - the one rhythm a change can matter on, since the draw for the running session has already happened and the response lands in storage for the next one, which is exactly the next-session semantics the console promises. The server's ttl field stays, but only bounds staleness for the (server-gated) foreground refresh; a polling mode may come back later and the protocol field is reserved for it. A failed fetch is retried quickly (5s), then patiently (60s), then not at all until the next natural trigger: two extra requests per outage per client, so a fleet can never turn an endpoint incident into a storm. Each delay is spread by +/-20% so recovering clients do not all return at the same moment. A new trigger cancels a waiting retry and re-arms the backoff, and a failure never clears the stored values. Wiring note: sessions created by startNewSession (after the first) now also receive the remote configuration store, which the earlier wiring had only given to the very first session scope. --- .../kotlin/com/datadog/android/rum/Rum.kt | 3 + .../android/rum/internal/RumFeature.kt | 2 +- .../domain/scope/RumApplicationScope.kt | 10 +- .../internal/domain/scope/RumSessionScope.kt | 9 +- .../rum/internal/monitor/DatadogRumMonitor.kt | 8 +- .../remoteconfig/RemoteConfigController.kt | 157 ++++++++---- .../RemoteConfigControllerTest.kt | 224 +++++++++++++++--- 7 files changed, 335 insertions(+), 78 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt index 9c84e23a10..768fd74b04 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt @@ -133,6 +133,9 @@ object Rum { sessionEndedMetricDispatcher = sessionEndedMetricDispatcher, sampleRate = rumFeature.sampleRate, remoteConfig = rumFeature.remoteConfigStore, + // FLASHCAT FORK - looked up when it fires rather than captured now: a session start + // simply asks again, and there is nothing to ask with when the app did not opt in. + onSessionDrawn = { rumFeature.remoteConfigController?.onSessionStarted() }, writer = rumFeature.dataWriter, handler = Handler(Looper.getMainLooper()), telemetryEventHandler = TelemetryEventHandler( diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index d0cd675867..4996567c7d 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -176,7 +176,7 @@ internal class RumFeature( * nothing — no storage, no request, no behaviour change — for everyone who has not asked for it. */ internal var remoteConfigStore: RemoteConfigStore? = null - private var remoteConfigController: RemoteConfigController? = null + internal var remoteConfigController: RemoteConfigController? = null private var remoteConfigForegroundCallback: ProcessForegroundCallback? = null internal var initialResourceIdentifier: InitialResourceIdentifier = NoOpInitialResourceIdentifier() internal var lastInteractionIdentifier: LastInteractionIdentifier? = NoOpLastInteractionIdentifier() diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt index 81b97fc437..73b9dad5e7 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt @@ -57,7 +57,10 @@ internal class RumApplicationScope( private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, private val insightsCollector: InsightsCollector, // FLASHCAT FORK - the console's sampling rates, or null when the app did not opt in. - private val remoteConfig: RemoteConfigStore? = null + private val remoteConfig: RemoteConfigStore? = null, + // FLASHCAT FORK - fired after each session draw, so the stored configuration is re-fetched on + // the only rhythm that can matter. No-op when the app did not opt in. + private val onSessionDrawn: () -> Unit = {} ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -71,6 +74,7 @@ internal class RumApplicationScope( sessionEndedMetricDispatcher = sessionEndedMetricDispatcher, sampleRate = sampleRate, remoteConfig = remoteConfig, + onSessionDrawn = onSessionDrawn, backgroundTrackingEnabled = backgroundTrackingEnabled, trackFrustrations = trackFrustrations, viewChangedListener = this, @@ -210,7 +214,9 @@ internal class RumApplicationScope( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + remoteConfig = remoteConfig, + onSessionDrawn = onSessionDrawn ) childScopes.add(newSession) if (event !is RumRawEvent.StartView) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index 03c7926008..1f2e9177fa 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -65,7 +65,10 @@ internal class RumSessionScope( insightsCollector: InsightsCollector, // FLASHCAT FORK - rates the console can change without the app shipping a new release. Null // when the app did not opt in, which is what keeps this whole path inert by default. - private val remoteConfig: RemoteConfigStore? = null + private val remoteConfig: RemoteConfigStore? = null, + // FLASHCAT FORK - fired after each draw, so the stored configuration is re-fetched on the only + // rhythm that can matter: a changed value can only apply to the next session anyway. + private val onSessionDrawn: () -> Unit = {} ) : RumScope { // FLASHCAT FORK - the rate the current session was actually drawn at. It is what events report @@ -330,6 +333,10 @@ internal class RumSessionScope( ) } sessionListener?.onSessionStarted(sessionId, !keepSession) + // FLASHCAT FORK - the draw is done, so now is the moment to ask again: the response lands + // in storage for the NEXT session's draw, which is exactly the next-session semantics the + // console promises. Nothing here waits for the request. + onSessionDrawn() } private fun updateSessionStateForSessionReplay(state: State, sessionId: String) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index f49e32febf..2c083b6f36 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -102,7 +102,10 @@ internal class DatadogRumMonitor( private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, insightsCollector: InsightsCollector, // FLASHCAT FORK - the console's sampling rates, or null when the app did not opt in. - private val remoteConfig: RemoteConfigStore? = null + private val remoteConfig: RemoteConfigStore? = null, + // FLASHCAT FORK - fired after each session draw, so the stored configuration is re-fetched on + // the only rhythm that can matter. No-op when the app did not opt in. + private val onSessionDrawn: () -> Unit = {} ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -126,7 +129,8 @@ internal class DatadogRumMonitor( displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, insightsCollector = insightsCollector, - remoteConfig = remoteConfig + remoteConfig = remoteConfig, + onSessionDrawn = onSessionDrawn ) internal val keepAliveRunnable = Runnable { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index 89cd3b8a42..e7b00ebdd9 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -17,15 +17,24 @@ import java.io.IOException import java.net.URLEncoder import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.random.Random /** * Keeps the stored remote configuration in step with what the console says. * - * Nothing here can hold up the SDK or interrupt collection: the first fetch is scheduled like any - * other, and a request that fails, times out or comes back unreadable leaves the stored rates - * exactly as they were. Wiping them on a bad minute would swing a whole fleet back to the rates it - * was built with, which is the opposite of what someone who turned a knob deliberately wants. + * Fetching follows the rhythm of the sessions that read it: once at start-up and once whenever a + * new session begins — a change can only matter at the next draw, so asking more often than + * sessions are drawn would be requests for nothing. There is no timer between sessions; the + * server's `ttl` field is accepted and only bounds how stale the stored values may be when the + * console allows a foreground refresh, reserved for a future polling mode. + * + * Nothing here can hold up the SDK or interrupt collection: a trigger never blocks on the request, + * and a request that fails, times out or comes back unreadable leaves the stored values exactly + * as they were. Wiping them on a bad minute would swing a whole fleet back to the values it was + * built with, which is the opposite of what someone who turned a knob deliberately wants. */ internal class RemoteConfigController( private val sdkCore: FeatureSdkCore, @@ -35,7 +44,8 @@ internal class RemoteConfigController( private val callFactory: Call.Factory, private val executor: ScheduledExecutorService, private val restartSession: () -> Unit, - private val elapsedTimeMs: () -> Long = SystemClock::elapsedRealtime + private val elapsedTimeMs: () -> Long = SystemClock::elapsedRealtime, + private val jitter: () -> Double = { Random.nextDouble() } ) { @Volatile @@ -47,26 +57,36 @@ internal class RemoteConfigController( @Volatile private var refreshOnForeground: Boolean = false - fun start() { - schedule(0L) - } + private val inFlight = AtomicBoolean(false) + private var failedAttempts = 0 + private var pendingRetry: ScheduledFuture<*>? = null + + fun start() = triggerFetch() /** - * Asks again when the app returns to the foreground, where the poll timer cannot be trusted: - * the system may not have run it for hours. + * A new session is the one moment a changed configuration can matter: its draw has just + * happened with whatever was stored, and the response to this request lands in storage for + * the next draw. It never waits for the request — a session is never delayed by the network. + */ + fun onSessionStarted() = triggerFetch() + + /** + * Asks again when the app returns to the foreground, where timers cannot be trusted: + * the system may not have run them for hours. * - * Off unless an operator turned it on for this application. The poll spreads requests across - * the ttl; returning to the foreground does the opposite, bunching them at the moment everyone - * opens the app — the same shape as a release herd, arriving when the endpoint can least - * absorb it. Worth it for an application whose owner needs a change to land within minutes, - * not worth it for everyone else, so it is theirs to choose rather than ours to assume. + * Off unless an operator turned it on for this application. Session starts spread requests + * across the day; returning to the foreground does the opposite, bunching them at the moment + * everyone opens the app — the same shape as a release herd, arriving when the endpoint can + * least absorb it. Worth it for an application whose owner needs a change to land within + * minutes, not worth it for everyone else, so it is theirs to choose rather than ours to + * assume. * * The staleness check is the second guard: it keeps switching between apps from turning into a * request each time. */ fun refreshIfStale() { if (shouldRefreshOnForeground(refreshOnForeground, elapsedTimeMs() - lastFetchAtMs, currentTtlSeconds)) { - schedule(0L) + triggerFetch() } } @@ -74,28 +94,33 @@ internal class RemoteConfigController( executor.shutdownNow() } - private fun schedule(delaySeconds: Long) { + /** + * Runs a fetch now, dropping any retry still waiting: a natural trigger re-arms the whole + * backoff, so a session starting in the middle of an outage does not wait out the patient + * retry before asking again. + */ + private fun triggerFetch() { + synchronized(this) { + pendingRetry?.cancel(false) + failedAttempts = 0 + } + if (!inFlight.compareAndSet(false, true)) return try { - executor.schedule({ fetchOnce() }, delaySeconds, TimeUnit.SECONDS) + executor.execute { fetchOnce() } } catch (e: RejectedExecutionException) { // The SDK is shutting down. Nothing to keep fresh. - sdkCore.internalLogger.log( - InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, - { "Remote configuration refresh not scheduled: executor is shutting down." }, - e - ) + inFlight.set(false) + logScheduleRejected(e) } } @WorkerThread private fun fetchOnce() { - // Armed before the request goes out, so a request that never comes back still leads to - // another attempt instead of leaving the app on whatever it last knew, forever. - var nextDelaySeconds = DEFAULT_TTL_SECONDS + // Stamped before the request goes out, so a request that never comes back still counts as + // an attempt for the staleness gate instead of leaving the app on whatever it last knew. lastFetchAtMs = elapsedTimeMs() - try { + val succeeded = try { // Telling the server which version this app is running is what lets the console answer // "has my change reached everyone yet". It goes on the request every client makes, // whether or not its session was kept. @@ -103,30 +128,55 @@ internal class RemoteConfigController( val request = Request.Builder().url(url).get().build() callFactory.newCall(request).execute().use { response -> if (response.isSuccessful) { - val payload = response.body?.string() - if (payload != null) { - nextDelaySeconds = apply(payload) - } + response.body?.string()?.let { apply(it) } != null + } else { + false } } } catch (e: IOException) { logFetchFailure(e) + false } catch (e: IllegalStateException) { logFetchFailure(e) + false } - schedule(nextDelaySeconds) + inFlight.set(false) + if (!succeeded) scheduleRetry() + } + + /** + * A failed fetch is retried quickly, then patiently, then not at all until the next natural + * trigger (a new session, or the next app start). The budget is deliberately tiny — two extra + * requests per outage per client, so a fleet can never turn an endpoint incident into a storm. + */ + private fun scheduleRetry() { + synchronized(this) { + if (failedAttempts >= RETRY_DELAYS_SECONDS.size) return + val delaySeconds = jittered(RETRY_DELAYS_SECONDS[failedAttempts], jitter()) + failedAttempts++ + try { + pendingRetry = executor.schedule( + { if (inFlight.compareAndSet(false, true)) fetchOnce() }, + delaySeconds, + TimeUnit.SECONDS + ) + } catch (e: RejectedExecutionException) { + // The SDK is shutting down. Nothing to keep fresh. + logScheduleRejected(e) + } + } } /** * Stores what the response carried and, when the console asked for it, restarts the session so - * the new rates take hold now instead of at the visitor's next one. + * the new values take hold now instead of at the visitor's next one. * - * The session is only restarted when the rates this client will draw with really changed. + * The session is only restarted when the values this client will draw with really changed. * Without that check, a console resending an unchanged configuration would cut every session in - * two on every poll. + * two on every fetch. */ - internal fun apply(payload: String): Long { + internal fun apply(payload: String) { val json = JSONObject(payload) val ttl = json.optLong(FIELD_TTL, DEFAULT_TTL_SECONDS) val enabled = json.optBoolean(FIELD_ENABLED, false) @@ -136,14 +186,14 @@ internal class RemoteConfigController( val before = RemoteConfigValues(store.sessionSampleRate(), store.sessionReplaySampleRate()) val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } val after = if (enabled) { - readRates(json.optJSONObject(FIELD_RUM)).copy( + readValues(json.optJSONObject(FIELD_RUM)).copy( version = version, // Stored as the raw string: the platform's job is delivery, the meaning belongs to // the host application. custom = json.optJSONObject(FIELD_CUSTOM)?.toString() ) } else { - EMPTY_RATES.copy(version = version) + EMPTY_VALUES.copy(version = version) } store.store(after) @@ -154,11 +204,10 @@ internal class RemoteConfigController( // Remembered here rather than around the request, so a fetch that fails keeps the ttl the // server last asked for instead of falling back to ours. currentTtlSeconds = if (ttl > 0) ttl else DEFAULT_TTL_SECONDS - return currentTtlSeconds } - private fun readRates(rum: JSONObject?): RemoteConfigValues { - if (rum == null) return EMPTY_RATES + private fun readValues(rum: JSONObject?): RemoteConfigValues { + if (rum == null) return EMPTY_VALUES return RemoteConfigValues( sessionSampleRate = readRate(rum, FIELD_SESSION_SAMPLE_RATE), sessionReplaySampleRate = readRate(rum, FIELD_SESSION_REPLAY_SAMPLE_RATE) @@ -166,7 +215,7 @@ internal class RemoteConfigController( } /** - * A rate the response did not send stays absent, so the value passed to init keeps applying. + * A value the response did not send stays absent, so the value passed to init keeps applying. * An out-of-range number is treated the same way rather than clamped: a rate we cannot trust is * not a rate to sample a customer's traffic with. */ @@ -197,13 +246,25 @@ internal class RemoteConfigController( ) } + private fun logScheduleRejected(e: RejectedExecutionException) { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "Remote configuration refresh not scheduled: executor is shutting down." }, + e + ) + } + companion object { internal const val DEFAULT_TTL_SECONDS = 300L internal const val ACTIVATION_NEXT_SESSION = "next_session" internal const val ACTIVATION_IMMEDIATE = "immediate" + internal val RETRY_DELAYS_SECONDS = longArrayOf(5L, 60L) + private const val MAX_RATE = 100.0 private const val MILLIS_PER_SECOND = 1_000L + private const val JITTER_FRACTION = 0.2 private const val FIELD_VERSION = "version" private const val FIELD_REFRESH_ON_FOREGROUND = "refresh_on_foreground" private const val FIELD_TTL = "ttl" @@ -214,7 +275,7 @@ internal class RemoteConfigController( private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" private const val FIELD_SESSION_REPLAY_SAMPLE_RATE = "sessionReplaySampleRate" - private val EMPTY_RATES = RemoteConfigValues(null, null) + private val EMPTY_VALUES = RemoteConfigValues(null, null) internal const val FETCH_FAILED_MESSAGE = "Unable to refresh the remote configuration; keeping the values already in use." @@ -244,5 +305,13 @@ internal class RemoteConfigController( */ internal fun shouldRefreshOnForeground(allowed: Boolean, ageMs: Long, ttlSeconds: Long): Boolean = allowed && ageMs >= ttlSeconds * MILLIS_PER_SECOND + + /** + * Spreads a delay by ±20%. An endpoint incident aligns every failed client's retry clock to + * the same moment; without this, recovery would be greeted by the whole fleet at once, + * exactly when the endpoint is weakest. + */ + internal fun jittered(delaySeconds: Long, randomFraction: Double): Long = + (delaySeconds * (1 - JITTER_FRACTION + 2 * JITTER_FRACTION * randomFraction)).toLong() } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index 5c60e24b65..bca60143d4 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -8,6 +8,11 @@ package com.datadog.android.rum.internal.remoteconfig import com.datadog.android.api.feature.FeatureSdkCore import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody import org.json.JSONObject import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach @@ -20,11 +25,13 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.reset +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import java.io.IOException import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit @ExtendWith(MockitoExtension::class) @@ -33,6 +40,8 @@ internal class RemoteConfigControllerTest { private lateinit var store: RemoteConfigStore private lateinit var executor: ScheduledExecutorService + private lateinit var callFactory: Call.Factory + private lateinit var call: Call private var restarts = 0 private var elapsedMs = 0L private lateinit var testedController: RemoteConfigController @@ -48,15 +57,21 @@ internal class RemoteConfigControllerTest { restarts = 0 elapsedMs = 0L executor = mock() + callFactory = mock() + call = mock() + whenever(callFactory.newCall(any())).thenReturn(call) + val sdkCore = mock() + whenever(sdkCore.internalLogger).thenReturn(mock()) testedController = RemoteConfigController( - sdkCore = mock(), + sdkCore = sdkCore, configUrl = "https://example.com/api/v2/rum/config", store = store, initialSessionSampleRate = INIT_SESSION_RATE, - callFactory = mock(), + callFactory = callFactory, executor = executor, restartSession = { restarts++ }, - elapsedTimeMs = { elapsedMs } + elapsedTimeMs = { elapsedMs }, + jitter = { 0.5 } ) } @@ -123,8 +138,7 @@ internal class RemoteConfigControllerTest { @Test fun `M leave the running session alone W apply() { immediate but nothing changed }`() { - // A console resending an unchanged configuration on every poll must not cut every session - // in two. + // A console resending an unchanged configuration must not cut every session in two. whenever(store.sessionSampleRate()).thenReturn(100f) testedController.apply(body(activation = "immediate", rum = """"sessionSampleRate":100""")) @@ -164,70 +178,189 @@ internal class RemoteConfigControllerTest { // endregion - // region ttl + @Test + fun `M keep the version W apply() { remote configuration switched off }`() { + // The rates are gone, but the console still needs to see this client is up to date with + // the change that turned them off. + testedController.apply(body(enabled = false)) + + verify(store).store(RemoteConfigValues(null, null, 3)) + } + + // region fetching @Test - fun `M follow the server ttl W apply()`() { - assertThat(testedController.apply(body(ttl = 42))).isEqualTo(42L) + fun `M fetch right away W start()`() { + testedController.start() + + verify(executor).execute(any()) } @Test - fun `M fall back to the default ttl W apply() { server sent none }`() { - assertThat(testedController.apply(body(ttl = 0))).isEqualTo(RemoteConfigController.DEFAULT_TTL_SECONDS) + fun `M never run two fetches at once W start() { previous one still running }`() { + testedController.start() + testedController.onSessionStarted() + + // The captured runnable never ran, so the first fetch is still in flight and the second + // trigger must not pile another request on top of it. + verify(executor).execute(any()) } - // endregion + @Test + fun `M store what the server answered W fetch succeeds`() { + whenever(call.execute()).thenReturn(response(200, body(rum = """"sessionSampleRate":42"""))) + + runPendingFetch() + + verify(store).store(RemoteConfigValues(42f, null, 3)) + } @Test - fun `M keep the version W apply() { remote configuration switched off }`() { - // The rates are gone, but the console still needs to see this client is up to date with - // the change that turned them off. - testedController.apply(body(enabled = false)) + fun `M tell the server which version is applied W fetch`() { + whenever(store.appliedVersion()).thenReturn(7) + whenever(call.execute()).thenReturn(response(200, body())) - verify(store).store(RemoteConfigValues(null, null, 3)) + runPendingFetch() + + argumentCaptor { + verify(callFactory).newCall(capture()) + assertThat(firstValue.url.toString()).contains("applied_version=7") + } + } + + @Test + fun `M not retry W fetch succeeds`() { + whenever(call.execute()).thenReturn(response(200, body())) + + runPendingFetch() + + verify(executor, never()).schedule(any(), any(), any()) + } + + @Test + fun `M keep the stored values W fetch fails`() { + whenever(call.execute()).thenThrow(IOException("no route to host")) + + runPendingFetch() + + verify(store, never()).store(any()) } + @Test + fun `M retry quickly W fetch fails`() { + whenever(call.execute()).thenThrow(IOException("no route to host")) + + runPendingFetch() + + // No jitter at 0.5: the first retry is exactly the quick one. + verify(executor).schedule(any(), eq(5L), eq(TimeUnit.SECONDS)) + } + + @Test + fun `M retry patiently W the quick retry also fails`() { + whenever(call.execute()).thenThrow(IOException("no route to host")) + + runPendingFetch() + whenever(executor.schedule(any(), any(), any())).thenReturn(mock>()) + runPendingRetry() + + verify(executor).schedule(any(), eq(60L), eq(TimeUnit.SECONDS)) + } + + @Test + fun `M stop retrying until the next trigger W the patient retry also fails`() { + whenever(call.execute()).thenThrow(IOException("no route to host")) + + runPendingFetch() + whenever(executor.schedule(any(), any(), any())).thenReturn(mock>()) + runPendingRetry() + runPendingRetry() + + // Two retries were scheduled (5s and 60s) and no third one ever is. + verify(executor, times(2)).schedule(any(), any(), any()) + } + + @Test + fun `M re-arm the backoff W onSessionStarted() { a retry was still waiting }`() { + whenever(call.execute()).thenThrow(IOException("no route to host")) + val pendingRetry = mock>() + whenever(executor.schedule(any(), any(), any())).thenReturn(pendingRetry) + + runPendingFetch() + testedController.onSessionStarted() + + verify(pendingRetry).cancel(false) + // The trigger runs its own fetch right away instead of waiting out the retry. + verify(executor, times(2)).execute(any()) + } + + @Test + fun `M spread the retry by plus-minus 20 percent W jittered()`() { + assertThat(RemoteConfigController.jittered(5L, 0.0)).isEqualTo(4L) + assertThat(RemoteConfigController.jittered(5L, 1.0)).isEqualTo(6L) + assertThat(RemoteConfigController.jittered(60L, 0.0)).isEqualTo(48L) + assertThat(RemoteConfigController.jittered(60L, 1.0)).isEqualTo(72L) + } + + // endregion + // region coming back to the foreground @Test fun `M ask again W refreshIfStale() { allowed and what we hold outlived its ttl }`() { - // An app in the background may not have had its poll timer run for hours, so returning to - // the foreground is its own reason to ask. - testedController.start() testedController.apply(body(ttl = 60, refreshOnForeground = true)) - reset(executor) elapsedMs = 61_000L testedController.refreshIfStale() - verify(executor).schedule(any(), eq(0L), eq(TimeUnit.SECONDS)) + verify(executor).execute(any()) } @Test fun `M ask nothing W refreshIfStale() { not allowed }`() { // Off by default: returning to the foreground bunches requests at the moment everyone // opens the app, which is the shape the endpoint copes with worst. - testedController.start() testedController.apply(body(ttl = 60)) - reset(executor) elapsedMs = 61_000L testedController.refreshIfStale() - verify(executor, never()).schedule(any(), any(), any()) + verify(executor, never()).execute(any()) } @Test fun `M ask nothing W refreshIfStale() { what we hold is still fresh }`() { // Switching apps back and forth must not turn into a request each time. - testedController.start() testedController.apply(body(ttl = 300, refreshOnForeground = true)) - reset(executor) elapsedMs = 10_000L testedController.refreshIfStale() - verify(executor, never()).schedule(any(), any(), any()) + verify(executor, never()).execute(any()) + } + + @Test + fun `M follow the server ttl for staleness W refreshIfStale()`() { + testedController.apply(body(ttl = 42, refreshOnForeground = true)) + + elapsedMs = 41_000L + testedController.refreshIfStale() + elapsedMs = 43_000L + testedController.refreshIfStale() + + verify(executor).execute(any()) + } + + @Test + fun `M fall back to the default ttl for staleness W refreshIfStale() { server sent none }`() { + testedController.apply(body(ttl = 0, refreshOnForeground = true)) + + elapsedMs = RemoteConfigController.DEFAULT_TTL_SECONDS * 1_000L - 1 + testedController.refreshIfStale() + elapsedMs = RemoteConfigController.DEFAULT_TTL_SECONDS * 1_000L + 1 + testedController.refreshIfStale() + + verify(executor).execute(any()) } // endregion @@ -286,6 +419,39 @@ internal class RemoteConfigControllerTest { // endregion + // region test helpers + + /** + * Runs the runnable the controller handed to the executor: the fetch it would do on a worker + * thread in a running app. + */ + private fun runPendingFetch() { + testedController.start() + argumentCaptor { + verify(executor).execute(capture()) + firstValue.run() + } + } + + /** + * Runs the runnable the controller scheduled as a retry after a failed fetch. + */ + private fun runPendingRetry() { + argumentCaptor { + verify(executor, org.mockito.kotlin.atLeastOnce()).schedule(capture(), any(), any()) + lastValue.run() + } + } + + private fun response(code: Int, payload: String): Response = + Response.Builder() + .request(Request.Builder().url("https://example.com/api/v2/rum/config").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("OK") + .body(payload.toResponseBody("application/json".toMediaType())) + .build() + private fun body( ttl: Int = 300, enabled: Boolean = true, @@ -298,6 +464,8 @@ internal class RemoteConfigControllerTest { """"refresh_on_foreground":$refreshOnForeground,"rum":{$rum}""" + (if (custom == null) "" else ""","custom":$custom""") + "}" + // endregion + companion object { private const val INIT_SESSION_RATE = 20f } From 5582bd49b88c928b65a812d9c2e889aa4acab0e6 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 01:27:00 -0700 Subject: [PATCH 09/19] feat(rum): revalidate the stored configuration instead of refetching it The server answers a conditional request with 304 when nothing changed, so the SDK now stores the ETag beside the configuration it validated and echoes it back as If-None-Match. The validator belongs to that stored answer specifically - the body varies per caller context - so it lives in the same store and is kept even by the kill switch, whose answer is what the next revalidation stands on. A 304 counts as a success: nothing to apply, no retry owed, and the staleness bookkeeping moves on. The store key now covers everything that can change the answer: the storage format version (a prefix, bumped on format change rather than on SDK upgrade), the endpoint host, the RUM application id, the service, the environment and the app version. It deliberately still leaves out the SDK version, which would throw the cache away on every upgrade. The SDK version goes on the request instead, as sdk_version, for the server's future targeting. Store persistence gains its first unit tests (round-trip across instances, omitted knobs forgotten, kill switch keeps the version, storage unavailable falls back to init) via an in-memory SharedPreferences. --- .../android/rum/internal/RumFeature.kt | 17 +- .../remoteconfig/RemoteConfigController.kt | 48 +++- .../remoteconfig/RemoteConfigStore.kt | 74 ++++-- .../RemoteConfigControllerTest.kt | 73 +++++- .../remoteconfig/RemoteConfigStoreTest.kt | 227 ++++++++++++++++++ 5 files changed, 404 insertions(+), 35 deletions(-) create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 4996567c7d..38b94b7516 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -773,10 +773,10 @@ internal class RumFeature( } /** - * FLASHCAT FORK - begins keeping the console's sampling rates fresh. + * FLASHCAT FORK - begins keeping the console's configuration fresh. * * Everything about it is best-effort: if the SDK context is not readable yet, or storage is - * unavailable, the app simply keeps sampling at the rates it was initialised with. Nothing here + * unavailable, the app simply keeps the values it was initialised with. Nothing here * may delay initialisation or interrupt collection. */ private fun startRemoteConfiguration(appContext: Context) { @@ -787,7 +787,11 @@ internal class RumFeature( val store = RemoteConfigStore( appContext = appContext, - storeKey = RemoteConfigStore.buildStoreKey(context), + storeKey = RemoteConfigStore.buildStoreKey( + context = context, + intakeUrl = intakeUrl, + applicationId = applicationId + ), internalLogger = sdkCore.internalLogger ) remoteConfigStore = store @@ -798,7 +802,8 @@ internal class RumFeature( intakeUrl = intakeUrl, clientToken = context.clientToken, env = context.env, - appVersion = context.version + appVersion = context.version, + sdkVersion = context.sdkVersion ), store = store, initialSessionSampleRate = sampleRate, @@ -812,8 +817,8 @@ internal class RumFeature( ).also { controller -> controller.start() - // The poll timer alone is not enough on a phone: an app in the background may not have - // it run for hours. Asking again on the way back to the foreground is what makes the + // An app in the background may not run another session for hours. Asking again on the + // way back to the foreground — when the console allows it — is what makes the // console's change land soon after someone reopens the app, and it costs the app no // code of its own. (appContext as? Application)?.let { application -> diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index e7b00ebdd9..d8310c5b94 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -125,12 +125,25 @@ internal class RemoteConfigController( // "has my change reached everyone yet". It goes on the request every client makes, // whether or not its session was kept. val url = store.appliedVersion()?.let { "$configUrl&applied_version=$it" } ?: configUrl - val request = Request.Builder().url(url).get().build() - callFactory.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body?.string()?.let { apply(it) } != null - } else { - false + val requestBuilder = Request.Builder().url(url).get() + // The answer varies per caller, so the validator only means something paired with the + // configuration it validated: it is stored beside it and echoed back exactly as sent. + store.etag()?.let { requestBuilder.header(HEADER_IF_NONE_MATCH, it) } + callFactory.newCall(requestBuilder.build()).execute().use { response -> + when { + // Unchanged: what is stored is still the answer, so there is nothing to apply — + // but the ask itself succeeded, and no retry is owed. + response.code == HTTP_NOT_MODIFIED -> true + response.isSuccessful -> { + val payload = response.body?.string() + if (payload == null) { + false + } else { + apply(payload, response.header(HEADER_ETAG)) + true + } + } + else -> false } } } catch (e: IOException) { @@ -176,7 +189,7 @@ internal class RemoteConfigController( * Without that check, a console resending an unchanged configuration would cut every session in * two on every fetch. */ - internal fun apply(payload: String) { + internal fun apply(payload: String, etag: String? = null) { val json = JSONObject(payload) val ttl = json.optLong(FIELD_TTL, DEFAULT_TTL_SECONDS) val enabled = json.optBoolean(FIELD_ENABLED, false) @@ -190,10 +203,11 @@ internal class RemoteConfigController( version = version, // Stored as the raw string: the platform's job is delivery, the meaning belongs to // the host application. - custom = json.optJSONObject(FIELD_CUSTOM)?.toString() + custom = json.optJSONObject(FIELD_CUSTOM)?.toString(), + etag = etag ) } else { - EMPTY_VALUES.copy(version = version) + EMPTY_VALUES.copy(version = version, etag = etag) } store.store(after) @@ -277,6 +291,10 @@ internal class RemoteConfigController( private val EMPTY_VALUES = RemoteConfigValues(null, null) + private const val HTTP_NOT_MODIFIED = 304 + private const val HEADER_ETAG = "ETag" + private const val HEADER_IF_NONE_MATCH = "If-None-Match" + internal const val FETCH_FAILED_MESSAGE = "Unable to refresh the remote configuration; keeping the values already in use." @@ -284,13 +302,23 @@ internal class RemoteConfigController( * Where to ask. A custom endpoint means the app was pointed at the customer's own host for * the RUM intake, and the configuration lives beside it there — which is exactly the layout * the private-deployment nginx template serves. + * + * The SDK version rides along purely as information: it keys nothing on this side (see the + * store key), and the server may one day target a configuration at a range of them. */ - fun buildConfigUrl(intakeUrl: String, clientToken: String, env: String, appVersion: String): String { + fun buildConfigUrl( + intakeUrl: String, + clientToken: String, + env: String, + appVersion: String, + sdkVersion: String + ): String { val parameters = buildString { append("?client_token=").append(encode(clientToken)) append("&sdk=android") if (env.isNotEmpty()) append("&env=").append(encode(env)) if (appVersion.isNotEmpty()) append("&app_version=").append(encode(appVersion)) + if (sdkVersion.isNotEmpty()) append("&sdk_version=").append(encode(sdkVersion)) } return intakeUrl.trimEnd('/') + "/config" + parameters } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt index b59d20724a..7d405e159c 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -49,6 +49,13 @@ internal class RemoteConfigStore( fun sessionReplaySampleRate(): Float? = read(replayKey()) + /** + * The validator the server sent with the stored configuration, echoed back as If-None-Match so + * an unchanged answer costs a 304 instead of a body. It belongs to this stored configuration + * specifically: the answer varies per caller, so it cannot be shared or guessed. + */ + fun etag(): String? = preferences?.getString(etagKey(), null) + /** * Which version of the settings the stored rates came from, or null before the first answer. * Reported back on the next request so the console can say how far a change has reached — a @@ -65,22 +72,27 @@ internal class RemoteConfigStore( * removed rather than left behind, so switching a knob off in the console really does hand that * knob back to the value the app was initialised with. */ - fun store(rates: RemoteConfigValues) { + fun store(values: RemoteConfigValues) { val editor = preferences?.edit() ?: return - write(editor, sessionKey(), rates.sessionSampleRate) - write(editor, replayKey(), rates.sessionReplaySampleRate) + write(editor, sessionKey(), values.sessionSampleRate) + write(editor, replayKey(), values.sessionReplaySampleRate) // Kept even when there are no rates — that is what "remote configuration is off, use your // own settings" looks like — so the console can still see this client is up to date with // the change that turned them off. - if (rates.version == null) { + if (values.version == null) { editor.remove(versionKey()) } else { - editor.putInt(versionKey(), rates.version) + editor.putInt(versionKey(), values.version) } - if (rates.custom == null) { + if (values.custom == null) { editor.remove(customKey()) } else { - editor.putString(customKey(), rates.custom) + editor.putString(customKey(), values.custom) + } + if (values.etag == null) { + editor.remove(etagKey()) + } else { + editor.putString(etagKey(), values.etag) } editor.apply() } @@ -106,9 +118,19 @@ internal class RemoteConfigStore( private fun customKey() = "$storeKey.custom" + private fun etagKey() = "$storeKey.etag" + companion object { private const val PREFERENCES_NAME = "flashcat-rum-remote-config" + /** + * The `1` is the storage format version, not the SDK version: it changes only when the + * shape of what we store changes, so an SDK upgrade keeps the cache (losing it would put + * the first session after every upgrade back on the init values), while a format change + * orphans the old entry instead of asking new code to parse it. + */ + internal const val STORE_KEY_PREFIX = "_fc_rc_1_" + // SharedPreferences has no "absent" for a primitive read, and every legitimate rate is // within 0..100, so a negative sentinel can never collide with a stored value. private const val ABSENT = -1f @@ -118,27 +140,47 @@ internal class RemoteConfigStore( "Unable to open the remote configuration store; the values passed to init will apply." /** - * Identifies whose rates these are. It covers everything that can change the answer — which - * application, in which environment, at which version — so an app that ships a new version - * does not read the previous one's rates. + * Identifies whose configuration this is. It covers everything that can change the answer — + * which endpoint the app asks, which application, in which environment, at which app + * version — so an app that ships a new version, or two applications sharing a device, never + * read each other's values. * - * It deliberately leaves out the SDK version: including it would discard the stored rates on - * every SDK upgrade and put the first session after an upgrade back on the init values. + * It deliberately leaves out the SDK version: including it would discard the stored values + * on every SDK upgrade and put the first session after an upgrade back on the init values. + * The storage format version lives in [STORE_KEY_PREFIX] instead, so only a real format + * change orphans the cache. */ - fun buildStoreKey(context: DatadogContext): String = - "${context.service}|${context.env}|${context.version}" + fun buildStoreKey(context: DatadogContext, intakeUrl: String, applicationId: String): String { + val host = try { + @Suppress("UnsafeThirdPartyFunctionCall") // caught right below + java.net.URI(intakeUrl).host ?: intakeUrl + } catch (e: IllegalArgumentException) { + intakeUrl + } catch (e: java.net.URISyntaxException) { + intakeUrl + } + return STORE_KEY_PREFIX + listOf( + host, + applicationId, + context.service, + context.env, + context.version + ).joinToString("|") + } } } /** - * The rates carried by one configuration response. Null means the console did not set that knob. + * The values carried by one configuration response. Null means the console did not set that knob. */ internal data class RemoteConfigValues( val sessionSampleRate: Float?, val sessionReplaySampleRate: Float?, val version: Int? = null, /** Raw JSON object string of the console's custom pass-through values, delivered verbatim. */ - val custom: String? = null + val custom: String? = null, + /** The validator to echo back as If-None-Match on the next request, quoted as the server sent it. */ + val etag: String? = null ) { fun isEmpty(): Boolean = sessionSampleRate == null && sessionReplaySampleRate == null } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index bca60143d4..bcfc916ce7 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -228,6 +228,68 @@ internal class RemoteConfigControllerTest { } } + @Test + fun `M offer the stored validator W fetch { one was stored }`() { + whenever(store.etag()).thenReturn("\"abc123\"") + whenever(call.execute()).thenReturn(response(200, body())) + + runPendingFetch() + + argumentCaptor { + verify(callFactory).newCall(capture()) + assertThat(firstValue.header("If-None-Match")).isEqualTo("\"abc123\"") + } + } + + @Test + fun `M offer no validator W fetch { none was stored }`() { + whenever(store.etag()).thenReturn(null) + whenever(call.execute()).thenReturn(response(200, body())) + + runPendingFetch() + + argumentCaptor { + verify(callFactory).newCall(capture()) + assertThat(firstValue.header("If-None-Match")).isNull() + } + } + + @Test + fun `M keep the stored values and call it a success W fetch answers not modified`() { + whenever(call.execute()).thenReturn(response(304, "")) + + runPendingFetch() + + verify(store, never()).store(any()) + verify(executor, never()).schedule(any(), any(), any()) + } + + @Test + fun `M store the validator the answer came with W fetch succeeds`() { + whenever(call.execute()).thenReturn(response(200, body(), etag = "\"v42\"")) + + runPendingFetch() + + argumentCaptor { + verify(store).store(capture()) + assertThat(firstValue.etag).isEqualTo("\"v42\"") + } + } + + @Test + fun `M keep the validator W apply() { remote configuration switched off }`() { + // The values are gone, but the validator belongs to the answer that turned them off and is + // what the next If-None-Match is built from. + testedController.apply(body(enabled = false), etag = "\"v43\"") + + argumentCaptor { + verify(store).store(capture()) + assertThat(firstValue.sessionSampleRate).isNull() + assertThat(firstValue.version).isEqualTo(3) + assertThat(firstValue.etag).isEqualTo("\"v43\"") + } + } + @Test fun `M not retry W fetch succeeds`() { whenever(call.execute()).thenReturn(response(200, body())) @@ -373,7 +435,8 @@ internal class RemoteConfigControllerTest { intakeUrl = "https://rum.example.com/api/v2/rum", clientToken = "token", env = "staging", - appVersion = "1.2.3" + appVersion = "1.2.3", + sdkVersion = "2.26.0" ) assertThat(url).startsWith("https://rum.example.com/api/v2/rum/config?") @@ -381,6 +444,7 @@ internal class RemoteConfigControllerTest { assertThat(url).contains("sdk=android") assertThat(url).contains("env=staging") assertThat(url).contains("app_version=1.2.3") + assertThat(url).contains("sdk_version=2.26.0") } @Test @@ -389,11 +453,13 @@ internal class RemoteConfigControllerTest { intakeUrl = "https://rum.example.com/api/v2/rum", clientToken = "token", env = "", - appVersion = "" + appVersion = "", + sdkVersion = "" ) assertThat(url).doesNotContain("env=") assertThat(url).doesNotContain("app_version=") + assertThat(url).doesNotContain("sdk_version=") } @Test @@ -443,12 +509,13 @@ internal class RemoteConfigControllerTest { } } - private fun response(code: Int, payload: String): Response = + private fun response(code: Int, payload: String, etag: String? = null): Response = Response.Builder() .request(Request.Builder().url("https://example.com/api/v2/rum/config").build()) .protocol(Protocol.HTTP_1_1) .code(code) .message("OK") + .apply { if (etag != null) header("ETag", etag) } .body(payload.toResponseBody("application/json".toMediaType())) .build() diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt new file mode 100644 index 0000000000..d68a653a7f --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt @@ -0,0 +1,227 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import android.content.Context +import android.content.SharedPreferences +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class RemoteConfigStoreTest { + + private lateinit var preferences: InMemorySharedPreferences + private lateinit var appContext: Context + + @BeforeEach + fun setUp() { + preferences = InMemorySharedPreferences() + appContext = mock() + whenever(appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)) + .thenReturn(preferences) + } + + // region store key + + @Test + fun `M cover everything that changes the answer W buildStoreKey()`() { + val key = RemoteConfigStore.buildStoreKey( + context = datadogContext(), + intakeUrl = "https://rum.example.com/api/v2/rum", + applicationId = "app-1" + ) + + assertThat(key).startsWith(RemoteConfigStore.STORE_KEY_PREFIX) + assertThat(key).contains("rum.example.com") + assertThat(key).contains("app-1") + assertThat(key).contains(SERVICE) + assertThat(key).contains(ENV) + assertThat(key).contains(APP_VERSION) + } + + @Test + fun `M leave the sdk version out of the key W buildStoreKey()`() { + // Including it would discard the stored values on every SDK upgrade and put the first + // session after an upgrade back on the init values. + val key = RemoteConfigStore.buildStoreKey( + context = datadogContext(), + intakeUrl = "https://rum.example.com/api/v2/rum", + applicationId = "app-1" + ) + + assertThat(key).doesNotContain(SDK_VERSION) + } + + @Test + fun `M key by the endpoint host W buildStoreKey() { two intakes, two answers }`() { + val context = datadogContext() + + val first = RemoteConfigStore.buildStoreKey(context, "https://rum-a.example.com/api/v2/rum", "app-1") + val second = RemoteConfigStore.buildStoreKey(context, "https://rum-b.example.com/api/v2/rum", "app-1") + + assertThat(first).isNotEqualTo(second) + } + + // endregion + + // region persistence + + @Test + fun `M read back on the next launch what a response stored W store()`() { + testedStore().store( + RemoteConfigValues( + sessionSampleRate = 42f, + sessionReplaySampleRate = 7f, + version = 3, + custom = """{"viplist":["u-1"]}""", + etag = "\"v3\"" + ) + ) + + // A fresh instance over the same preferences is what the next process start looks like. + val nextLaunch = testedStore() + assertThat(nextLaunch.sessionSampleRate()).isEqualTo(42f) + assertThat(nextLaunch.sessionReplaySampleRate()).isEqualTo(7f) + assertThat(nextLaunch.appliedVersion()).isEqualTo(3) + assertThat(nextLaunch.custom()).isEqualTo("""{"viplist":["u-1"]}""") + assertThat(nextLaunch.etag()).isEqualTo("\"v3\"") + } + + @Test + fun `M answer absent before the first response W read`() { + val store = testedStore() + + assertThat(store.sessionSampleRate()).isNull() + assertThat(store.sessionReplaySampleRate()).isNull() + assertThat(store.appliedVersion()).isNull() + assertThat(store.custom()).isNull() + assertThat(store.etag()).isNull() + } + + @Test + fun `M forget the knobs a response omitted W store()`() { + // A knob nobody configured must go back to the init value, not linger at the last one. + val store = testedStore() + store.store(RemoteConfigValues(42f, 7f, 3, custom = """{"debug":true}""", etag = "\"v3\"")) + + store.store(RemoteConfigValues(null, null, 4)) + + assertThat(store.sessionSampleRate()).isNull() + assertThat(store.sessionReplaySampleRate()).isNull() + assertThat(store.custom()).isNull() + assertThat(store.appliedVersion()).isEqualTo(4) + } + + @Test + fun `M keep the version W store() { remote configuration switched off }`() { + val store = testedStore() + store.store(RemoteConfigValues(42f, 7f, 3)) + + store.store(RemoteConfigValues(null, null, 4)) + + assertThat(store.appliedVersion()).isEqualTo(4) + } + + @Test + fun `M fall back to the init values W storage is unavailable`() { + whenever(appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)) + .thenThrow(SecurityException("no storage for you")) + val store = RemoteConfigStore(appContext, "key", mock()) + + store.store(RemoteConfigValues(42f, 7f, 3)) + + assertThat(store.sessionSampleRate()).isNull() + assertThat(store.sessionReplaySampleRate()).isNull() + assertThat(store.appliedVersion()).isNull() + } + + // endregion + + private fun testedStore(): RemoteConfigStore = + RemoteConfigStore(appContext, "test-key", mock()) + + private fun datadogContext(): DatadogContext { + val context = mock() + whenever(context.service).thenReturn(SERVICE) + whenever(context.env).thenReturn(ENV) + whenever(context.version).thenReturn(APP_VERSION) + whenever(context.sdkVersion).thenReturn(SDK_VERSION) + return context + } + + /** + * Just enough of [SharedPreferences] to persist across store instances, which is the whole + * point of these tests. + */ + private class InMemorySharedPreferences : SharedPreferences { + + private val values = HashMap() + + override fun getAll(): Map = values + + override fun getString(key: String?, defValue: String?): String? = + values[key] as? String ?: defValue + + @Suppress("OverridingDeprecatedMember") + override fun getStringSet(key: String?, defValues: Set?): Set? = defValues + + override fun getInt(key: String?, defValue: Int): Int = + values[key] as? Int ?: defValue + + override fun getLong(key: String?, defValue: Long): Long = + values[key] as? Long ?: defValue + + override fun getFloat(key: String?, defValue: Float): Float = + values[key] as? Float ?: defValue + + override fun getBoolean(key: String?, defValue: Boolean): Boolean = + values[key] as? Boolean ?: defValue + + override fun contains(key: String?): Boolean = values.containsKey(key) + + override fun edit(): SharedPreferences.Editor = InMemoryEditor() + + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener? + ) = Unit + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener? + ) = Unit + + inner class InMemoryEditor : SharedPreferences.Editor { + override fun putString(key: String?, value: String?) = apply { values[key!!] = value } + override fun putStringSet(key: String?, value: Set?) = apply { values[key!!] = value } + override fun putInt(key: String?, value: Int) = apply { values[key!!] = value } + override fun putLong(key: String?, value: Long) = apply { values[key!!] = value } + override fun putFloat(key: String?, value: Float) = apply { values[key!!] = value } + override fun putBoolean(key: String?, value: Boolean) = apply { values[key!!] = value } + override fun remove(key: String?) = apply { values.remove(key) } + override fun clear() = apply { values.clear() } + override fun commit(): Boolean = true + override fun apply() = Unit + } + } + + companion object { + private const val PREFERENCES_NAME = "flashcat-rum-remote-config" + private const val SERVICE = "shop-android" + private const val ENV = "staging" + private const val APP_VERSION = "1.2.3" + private const val SDK_VERSION = "9.9.9" + } +} From b620775f1bec58510f56a0a2876119abb29a53be Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 01:56:13 -0700 Subject: [PATCH 10/19] feat(rum): report the configuration a session was drawn under on its events Events used to carry the init sampling rate even when the console's settings decided the draw, skewing server-side extrapolation. Each session draw now records a DrawnConfiguration - the rates it actually used (the console's where it set them, the init values where it did not) and the remote settings version they came from - married to the session id and kept in storage next to the settings cache, so a stale record is inert rather than wrong. View events report the drawn rates in _dd.configuration, now also populating the existing session_replay_sample_rate field, and carry rc_version naming the settings version so an audit can recover the exact configuration from the version history (0 when none was ever fetched; the field is a FlashCat addition to the view schema - our intake reads it, others ignore it). The drawn replay rate falls back to what Session Replay publishes about its own configuration, since the console-side rate lives on the RUM feature. Sessions drawn without remote configuration report nothing new - for them the init values are the drawn values. Also covers the previously untested remote read at session renewal and the replay rate riding the session-renewed bus message. --- .../src/main/json/rum/view-schema.json | 6 + .../internal/domain/scope/RumSessionScope.kt | 38 ++++- .../domain/scope/RumViewManagerScope.kt | 7 + .../rum/internal/domain/scope/RumViewScope.kt | 17 ++- .../remoteconfig/DrawnConfiguration.kt | 73 ++++++++++ .../remoteconfig/RemoteConfigStore.kt | 14 ++ .../domain/scope/RumSessionScopeTest.kt | 136 +++++++++++++++++- .../internal/domain/scope/RumViewScopeTest.kt | 57 ++++++++ .../remoteconfig/RemoteConfigStoreTest.kt | 45 ++++++ 9 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt diff --git a/features/dd-sdk-android-rum/src/main/json/rum/view-schema.json b/features/dd-sdk-android-rum/src/main/json/rum/view-schema.json index 887a1b7ec2..50bc152bb9 100644 --- a/features/dd-sdk-android-rum/src/main/json/rum/view-schema.json +++ b/features/dd-sdk-android-rum/src/main/json/rum/view-schema.json @@ -523,6 +523,12 @@ "type": "boolean", "description": "Whether session replay recording configured to start manually", "readOnly": true + }, + "rc_version": { + "type": "integer", + "description": "FlashCat fork - version of the remote configuration the session was drawn under; 0 when none was ever fetched", + "minimum": 0, + "readOnly": true } } }, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index 1f2e9177fa..f16fcddeb3 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -27,6 +27,7 @@ import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollect import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager +import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore import com.datadog.android.rum.internal.utils.percent import com.datadog.android.rum.internal.vitals.VitalMonitor @@ -76,6 +77,13 @@ internal class RumSessionScope( // app passed to init. internal var effectiveSampleRate: Float = sampleRate + // FLASHCAT FORK - the configuration the current session was drawn under, so its events can + // report the rates and the settings version that actually decided them. Null when the app did + // not opt in: events then keep reporting the init values, which in that case are the values + // the draw used anyway. + internal var drawnConfiguration: DrawnConfiguration? = null + private set + internal var sessionId = RumContext.NULL_UUID internal var sessionState: State = State.NOT_TRACKED @@ -116,7 +124,7 @@ internal class RumSessionScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector + insightsCollector = insightsCollector ) internal val activeView: RumViewScope? @@ -321,6 +329,20 @@ internal class RumSessionScope( startReason = reason sessionState = if (keepSession) State.TRACKED else State.NOT_TRACKED sessionId = UUID.randomUUID().toString() + // FLASHCAT FORK - remember what this session was drawn under, married to its id: the + // events of this session report these values for as long as it lives, and the record left + // in storage is inert the moment another id is drawn. + drawnConfiguration = remoteConfig?.let { config -> + DrawnConfiguration( + sessionId = sessionId, + version = config.appliedVersion() ?: 0, + sessionSampleRate = effectiveSampleRate, + sessionReplaySampleRate = config.sessionReplaySampleRate() + ?: initialSessionReplaySampleRate() + ) + } + drawnConfiguration?.let { remoteConfig?.storeDrawRecord(it) } + childScope?.drawnConfiguration = drawnConfiguration sessionStartNs.set(time.nanoTime) rumSessionScopeStartupManager = rumSessionScopeStartupManagerFactory() childScope?.renewViewScopes(time) @@ -339,6 +361,16 @@ internal class RumSessionScope( onSessionDrawn() } + // FLASHCAT FORK - the replay rate the app was built with, read from what Session Replay + // published about itself: the drawn rate is the console's where it set one and this one where + // it did not. Null when Session Replay is not there to say, and the field is then not reported. + private fun initialSessionReplaySampleRate(): Float? = + ( + sdkCore.getFeatureContext( + Feature.SESSION_REPLAY_FEATURE_NAME + )[SESSION_REPLAY_SAMPLE_RATE_CONTEXT_KEY] as? Number + )?.toFloat() + private fun updateSessionStateForSessionReplay(state: State, sessionId: String) { val keepSession = (state == State.TRACKED) sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.sendEvent( @@ -367,6 +399,10 @@ internal class RumSessionScope( internal const val RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY = "replaySampleRate" internal const val RUM_SESSION_FORCED_BUS_MESSAGE_KEY = "sessionForced" internal const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" + + // FLASHCAT FORK - the key under which Session Replay publishes the rate the app configured + // it with; duplicated here because internal constants do not cross module boundaries. + internal const val SESSION_REPLAY_SAMPLE_RATE_CONTEXT_KEY = "session_replay_sample_rate" internal val DEFAULT_SESSION_INACTIVITY_NS = TimeUnit.MINUTES.toNanos(15) internal val DEFAULT_SESSION_MAX_DURATION_NS = TimeUnit.HOURS.toNanos(4) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt index 5981df1b5b..4e787c688c 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt @@ -31,6 +31,7 @@ import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.ViewEndedMetricDispatcher import com.datadog.android.rum.internal.metric.interactiontonextview.InteractionToNextViewMetricResolver import com.datadog.android.rum.internal.metric.networksettled.NetworkSettledMetricResolver +import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.vitals.NoOpVitalMonitor import com.datadog.android.rum.internal.vitals.VitalMonitor @@ -55,6 +56,9 @@ internal class RumViewManagerScope( // FLASHCAT FORK - var rather than val: the session scope sets this to the rate it actually // drew with, which the console can change between sessions. internal var sampleRate: Float, + // FLASHCAT FORK - the configuration the session was drawn under, handed to each view scope so + // its events report the draw rather than the init values. Null when the app did not opt in. + internal var drawnConfiguration: DrawnConfiguration? = null, internal val initialResourceIdentifier: InitialResourceIdentifier, private val slowFramesListener: SlowFramesListener?, lastInteractionIdentifier: LastInteractionIdentifier?, @@ -283,6 +287,7 @@ internal class RumViewManagerScope( frameRateVitalMonitor = frameRateVitalMonitor, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledResourceIdentifier = initialResourceIdentifier, slowFramesListener = slowFramesListener, @@ -366,6 +371,7 @@ internal class RumViewManagerScope( type = viewType, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledMetricResolver = networkSettledMetricResolver, viewEndedMetricDispatcher = viewEndedMetricDispatcher, @@ -409,6 +415,7 @@ internal class RumViewManagerScope( type = viewType, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledMetricResolver = networkSettledMetricResolver, viewEndedMetricDispatcher = viewEndedMetricDispatcher, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt index 68ea62ba1e..3ab7dcce25 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt @@ -41,6 +41,7 @@ import com.datadog.android.rum.internal.metric.interactiontonextview.Interaction import com.datadog.android.rum.internal.metric.interactiontonextview.InternalInteractionContext import com.datadog.android.rum.internal.metric.networksettled.InternalResourceContext import com.datadog.android.rum.internal.metric.networksettled.NetworkSettledMetricResolver +import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.monitor.StorageEvent import com.datadog.android.rum.internal.toError @@ -81,6 +82,9 @@ internal open class RumViewScope( internal val type: RumViewType = RumViewType.FOREGROUND, private val trackFrustrations: Boolean, internal val sampleRate: Float, + // FLASHCAT FORK - the configuration the session was drawn under, reported on this view's + // events instead of the init values. Null when the app did not opt in to remote configuration. + internal val drawnConfiguration: DrawnConfiguration? = null, private val interactionToNextViewMetricResolver: InteractionToNextViewMetricResolver, private val networkSettledMetricResolver: NetworkSettledMetricResolver, private val slowFramesListener: SlowFramesListener?, @@ -1346,7 +1350,16 @@ internal open class RumViewScope( sessionPrecondition = rumContext.sessionStartReason.toViewSessionPrecondition() ), replayStats = replayStats, - configuration = ViewEvent.Configuration(sessionSampleRate = sampleRate) + // FLASHCAT FORK - the rates this session was actually drawn under (the + // console's where it set them) and the settings version they came from, so + // server-side extrapolation and audits line up with the draw. rc_version is a + // FlashCat addition on top of the shared schema; our intake reads it, others + // ignore it. + configuration = ViewEvent.Configuration( + sessionSampleRate = sampleRate, + sessionReplaySampleRate = drawnConfiguration?.sessionReplaySampleRate, + rcVersion = drawnConfiguration?.version?.toLong() + ) ), connectivity = datadogContext.networkInfo.toViewConnectivity(), service = datadogContext.service, @@ -1647,6 +1660,7 @@ internal open class RumViewScope( frameRateVitalMonitor: VitalMonitor, trackFrustrations: Boolean, sampleRate: Float, + drawnConfiguration: DrawnConfiguration? = null, interactionToNextViewMetricResolver: InteractionToNextViewMetricResolver, networkSettledResourceIdentifier: InitialResourceIdentifier, slowFramesListener: SlowFramesListener?, @@ -1683,6 +1697,7 @@ internal open class RumViewScope( type = viewType, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledMetricResolver = networkSettledMetricResolver, viewEndedMetricDispatcher = viewEndedMetricDispatcher, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt new file mode 100644 index 0000000000..aadacd1ae5 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt @@ -0,0 +1,73 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import org.json.JSONException +import org.json.JSONObject + +/** + * FLASHCAT FORK - the configuration a session was drawn under: the rates actually used at the draw + * (the console's where it set them, the init values where it did not) and the remote settings + * version they came from. Events carry these instead of the init values, so server-side + * extrapolation and audits line up with the draw that kept the session — a session is never + * re-judged, so the metadata must be from its creation, not from whatever has arrived since. + */ +internal data class DrawnConfiguration( + /** The session this record belongs to; a record naming another session is stale and inert. */ + val sessionId: String, + /** The remote settings version the draw read, or 0 when none was ever fetched. */ + val version: Int, + val sessionSampleRate: Float, + /** Null when the draw could not know it (Session Replay not publishing); then not reported. */ + val sessionReplaySampleRate: Float? +) { + + fun toJsonString(): String = JSONObject() + .put(FIELD_SESSION_ID, sessionId) + .put(FIELD_VERSION, version) + .put(FIELD_SESSION_SAMPLE_RATE, sessionSampleRate.toDouble()) + .apply { + if (sessionReplaySampleRate != null) { + put(FIELD_SESSION_REPLAY_SAMPLE_RATE, sessionReplaySampleRate.toDouble()) + } + } + .toString() + + companion object { + private const val FIELD_SESSION_ID = "id" + private const val FIELD_VERSION = "version" + private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" + private const val FIELD_SESSION_REPLAY_SAMPLE_RATE = "sessionReplaySampleRate" + + /** + * Parses a stored record, tolerating what older versions did not write: a field missing + * from an old record reads as if the console never set that knob, so an SDK upgrade + * changes nothing for a session already drawn. + */ + fun fromJsonString(json: String): DrawnConfiguration? = try { + val obj = JSONObject(json) + val sessionId = obj.optString(FIELD_SESSION_ID).takeIf { it.isNotEmpty() } + if (sessionId == null || !obj.has(FIELD_SESSION_SAMPLE_RATE)) { + null + } else { + DrawnConfiguration( + sessionId = sessionId, + version = obj.optInt(FIELD_VERSION, 0), + sessionSampleRate = obj.getDouble(FIELD_SESSION_SAMPLE_RATE).toFloat(), + sessionReplaySampleRate = if (obj.has(FIELD_SESSION_REPLAY_SAMPLE_RATE)) { + obj.getDouble(FIELD_SESSION_REPLAY_SAMPLE_RATE).toFloat() + } else { + null + } + ) + } + } catch (e: JSONException) { + // Storage holding something we did not write is no record at all. + null + } + } +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt index 7d405e159c..4544ef3d21 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -56,6 +56,18 @@ internal class RemoteConfigStore( */ fun etag(): String? = preferences?.getString(etagKey(), null) + /** + * Which configuration the given session was drawn under, kept next to the values it was drawn + * from. The session id inside is the validity check: a record from a previous, expired session + * simply never matches again. + */ + fun storeDrawRecord(record: DrawnConfiguration) { + preferences?.edit()?.putString(drawRecordKey(), record.toJsonString())?.apply() + } + + fun readDrawRecord(): DrawnConfiguration? = + preferences?.getString(drawRecordKey(), null)?.let { DrawnConfiguration.fromJsonString(it) } + /** * Which version of the settings the stored rates came from, or null before the first answer. * Reported back on the next request so the console can say how far a change has reached — a @@ -120,6 +132,8 @@ internal class RemoteConfigStore( private fun etagKey() = "$storeKey.etag" + private fun drawRecordKey() = "$storeKey.draw" + companion object { private const val PREFERENCES_NAME = "flashcat-rum-remote-config" diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index 1b48ca49ae..ea1b2d167f 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -31,6 +31,8 @@ import com.datadog.android.rum.internal.domain.display.DisplayInfo import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener +import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration +import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore import com.datadog.android.rum.internal.startup.RumAppStartupTelemetryReporter import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.startup.RumStartupScenario @@ -1037,6 +1039,132 @@ internal class RumSessionScopeTest { // endregion + // region Remote Configuration + + @Test + fun `M draw the session with the console's rates W handleEvent { remote configuration stored }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.sessionSampleRate()) doReturn 42f + whenever(remoteConfig.sessionReplaySampleRate()) doReturn 9f + whenever(remoteConfig.appliedVersion()) doReturn 7 + initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig) + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + val context = testedScope.getRumContext() + + // Then + assertThat(testedScope.effectiveSampleRate).isEqualTo(42f) + assertThat(testedScope.drawnConfiguration).isEqualTo( + DrawnConfiguration( + sessionId = context.sessionId, + version = 7, + sessionSampleRate = 42f, + sessionReplaySampleRate = 9f + ) + ) + } + + @Test + fun `M fall back to the init values W handleEvent { console set nothing }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.sessionSampleRate()) doReturn null + whenever(remoteConfig.sessionReplaySampleRate()) doReturn null + whenever(remoteConfig.appliedVersion()) doReturn null + whenever(mockSdkCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn + mapOf(RumSessionScope.SESSION_REPLAY_SAMPLE_RATE_CONTEXT_KEY to 30L) + initializeTestedScope(sampleRate = 80f, remoteConfig = remoteConfig) + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then - the draw used the init values, and version 0 says no configuration was ever fetched + assertThat(testedScope.effectiveSampleRate).isEqualTo(80f) + assertThat(testedScope.drawnConfiguration?.version).isZero() + assertThat(testedScope.drawnConfiguration?.sessionSampleRate).isEqualTo(80f) + assertThat(testedScope.drawnConfiguration?.sessionReplaySampleRate).isEqualTo(30f) + } + + @Test + fun `M remember the draw for the session's events W handleEvent { remote configuration on }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.sessionSampleRate()) doReturn 42f + initializeTestedScope(remoteConfig = remoteConfig) + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then - the record is married to the session it drew, and the view scopes report from it + val record = testedScope.drawnConfiguration + assertThat(record?.sessionId).isEqualTo(testedScope.getRumContext().sessionId) + verify(remoteConfig).storeDrawRecord(record!!) + verify(mockChildScope).drawnConfiguration = record + } + + @Test + fun `M ask the console again W handleEvent { a session was just drawn }`() { + // Given + var fetches = 0 + initializeTestedScope(onSessionDrawn = { fetches++ }) + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(fetches).isOne() + } + + @Test + fun `M record no draw W handleEvent { the app did not opt in }`() { + // Given + initializeTestedScope() + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then - events keep reporting the init values, which are the values the draw used anyway + assertThat(testedScope.drawnConfiguration).isNull() + } + + @Test + fun `M tell Session Replay the console's replay rate W handleEvent { remote rate stored }`( + @Forgery key: RumScopeKey + ) { + // Given + val remoteConfig = mock() + whenever(remoteConfig.sessionSampleRate()) doReturn 100f + whenever(remoteConfig.sessionReplaySampleRate()) doReturn 9f + initializeTestedScope(withMockChildScope = false, remoteConfig = remoteConfig) + + // When + testedScope.handleEvent( + RumRawEvent.StartView(key, emptyMap()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + val argumentCaptor = argumentCaptor() + verify(mockSessionReplayFeatureScope, atLeastOnce()).sendEvent(argumentCaptor.capture()) + assertThat(argumentCaptor.lastValue).isEqualTo( + mapOf( + RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, + RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to 9f, + RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to + testedScope.getRumContext().sessionId + ) + ) + } + + // endregion + // region Active View @Test @@ -1827,7 +1955,9 @@ internal class RumSessionScopeTest { private fun initializeTestedScope( sampleRate: Float = 100f, withMockChildScope: Boolean = true, - backgroundTrackingEnabled: Boolean? = null + backgroundTrackingEnabled: Boolean? = null, + remoteConfig: RemoteConfigStore? = null, + onSessionDrawn: () -> Unit = {} ) { testedScope = RumSessionScope( parentScope = mockParentScope, @@ -1853,7 +1983,9 @@ internal class RumSessionScopeTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = { mockRumSessionScopeStartupManager }, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + remoteConfig = remoteConfig, + onSessionDrawn = onSessionDrawn ) if (withMockChildScope) { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 92a34301af..88f4c0be4d 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -57,6 +57,7 @@ import com.datadog.android.rum.internal.metric.interactiontonextview.InternalInt import com.datadog.android.rum.internal.metric.networksettled.InternalResourceContext import com.datadog.android.rum.internal.metric.networksettled.NetworkSettledMetricResolver import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener +import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.monitor.StorageEvent import com.datadog.android.rum.internal.toAction @@ -648,6 +649,60 @@ internal class RumViewScopeTest { assertThat(result).isNull() } + @Test + fun `M report the draw the session was created under W handleEvent(StartView) { remote config on }`( + @Forgery key: RumScopeKey + ) { + // Given + val drawnConfiguration = DrawnConfiguration( + sessionId = fakeParentContext.sessionId, + version = 7, + sessionSampleRate = fakeSampleRate, + sessionReplaySampleRate = 9f + ) + testedScope = newRumViewScope(trackFrustrations = true, drawnConfiguration = drawnConfiguration) + mockSessionReplayContext(testedScope) + + // When + val result = testedScope.handleEvent( + RumRawEvent.StartView(key, emptyMap()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then - the rates the session was drawn with, and rc_version naming the settings version + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.dd.configuration?.sessionSampleRate).isEqualTo(fakeSampleRate) + assertThat(lastValue.dd.configuration?.sessionReplaySampleRate).isEqualTo(9f) + assertThat(lastValue.dd.configuration?.rcVersion).isEqualTo(7L) + } + assertThat(result).isNull() + } + + @Test + fun `M report no draw W handleEvent(StartView) { the app did not opt in }`( + @Forgery key: RumScopeKey + ) { + // When + val result = testedScope.handleEvent( + RumRawEvent.StartView(key, emptyMap()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then - nothing new: the init values are the values the draw used anyway + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.dd.configuration?.sessionSampleRate).isEqualTo(fakeSampleRate) + assertThat(lastValue.dd.configuration?.sessionReplaySampleRate).isNull() + assertThat(lastValue.dd.configuration?.rcVersion).isNull() + } + assertThat(result).isNull() + } + @Test fun `M send event once W handleEvent(StartView) twice on active view`( @Forgery key: RumScopeKey, @@ -9157,6 +9212,7 @@ internal class RumViewScopeTest { type: RumViewType = fakeViewType, trackFrustrations: Boolean = fakeTrackFrustrations, sampleRate: Float = fakeSampleRate, + drawnConfiguration: DrawnConfiguration? = null, interactionNextViewMetricResolver: InteractionToNextViewMetricResolver = mockInteractionToNextViewMetricResolver, networkSettledMetricResolver: NetworkSettledMetricResolver = mockNetworkSettledMetricResolver, @@ -9178,6 +9234,7 @@ internal class RumViewScopeTest { type = type, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionNextViewMetricResolver, networkSettledMetricResolver = networkSettledMetricResolver, slowFramesListener = slowFramesMetricListener, diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt index d68a653a7f..2db4618bc2 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt @@ -151,6 +151,51 @@ internal class RemoteConfigStoreTest { // endregion + // region draw record + + @Test + fun `M read back the draw a session was recorded under W storeDrawRecord()`() { + val store = testedStore() + val record = DrawnConfiguration( + sessionId = "session-1", + version = 7, + sessionSampleRate = 42f, + sessionReplaySampleRate = 9f + ) + + store.storeDrawRecord(record) + + assertThat(testedStore().readDrawRecord()).isEqualTo(record) + } + + @Test + fun `M tolerate a record an older version wrote W readDrawRecord() { fields missing }`() { + // A field that did not exist when the record was written reads as if the console never + // set that knob: an SDK upgrade changes nothing for a session already drawn. + preferences.edit().putString( + "test-key.draw", + """{"id":"session-1","version":7,"sessionSampleRate":42.0}""" + ).apply() + + assertThat(testedStore().readDrawRecord()).isEqualTo( + DrawnConfiguration( + sessionId = "session-1", + version = 7, + sessionSampleRate = 42f, + sessionReplaySampleRate = null + ) + ) + } + + @Test + fun `M answer no record W readDrawRecord() { storage holds something we did not write }`() { + preferences.edit().putString("test-key.draw", "not json").apply() + + assertThat(testedStore().readDrawRecord()).isNull() + } + + // endregion + private fun testedStore(): RemoteConfigStore = RemoteConfigStore(appContext, "test-key", mock()) From 6a04de3c6bd6c3c10df3239e84db8eb8ba61ee97 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 02:24:34 -0700 Subject: [PATCH 11/19] chore(rum): record rcVersion in the API surface The field landed with the event change; the generated surface files did not go with it, so the api-surface check would have failed on the next run for a change that was already made. Only the RUM surface. The session-replay-noop surface is also stale in the tree, but it was stale before this branch and its drift is upstream Session Replay API, not ours to carry in here. --- features/dd-sdk-android-rum/api/apiSurface | 2 +- features/dd-sdk-android-rum/api/dd-sdk-android-rum.api | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index c5725b7126..3b2fe37e74 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -1841,7 +1841,7 @@ data class com.datadog.android.rum.model.ViewEvent fun fromJson(kotlin.String): DdSession fun fromJsonObject(com.google.gson.JsonObject): DdSession data class Configuration - constructor(kotlin.Number, kotlin.Number? = null, kotlin.Number? = null, kotlin.Boolean? = null) + constructor(kotlin.Number, kotlin.Number? = null, kotlin.Number? = null, kotlin.Boolean? = null, kotlin.Long? = null) fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): Configuration diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index 8bf45b7b04..bd0a5b1650 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -4945,18 +4945,20 @@ public final class com/datadog/android/rum/model/ViewEvent$Companion { public final class com/datadog/android/rum/model/ViewEvent$Configuration { public static final field Companion Lcom/datadog/android/rum/model/ViewEvent$Configuration$Companion; - public fun (Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Boolean;)V - public synthetic fun (Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Boolean;Ljava/lang/Long;)V + public synthetic fun (Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Boolean;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/Number; public final fun component2 ()Ljava/lang/Number; public final fun component3 ()Ljava/lang/Number; public final fun component4 ()Ljava/lang/Boolean; - public final fun copy (Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Boolean;)Lcom/datadog/android/rum/model/ViewEvent$Configuration; - public static synthetic fun copy$default (Lcom/datadog/android/rum/model/ViewEvent$Configuration;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/rum/model/ViewEvent$Configuration; + public final fun component5 ()Ljava/lang/Long; + public final fun copy (Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Boolean;Ljava/lang/Long;)Lcom/datadog/android/rum/model/ViewEvent$Configuration; + public static synthetic fun copy$default (Lcom/datadog/android/rum/model/ViewEvent$Configuration;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Number;Ljava/lang/Boolean;Ljava/lang/Long;ILjava/lang/Object;)Lcom/datadog/android/rum/model/ViewEvent$Configuration; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/rum/model/ViewEvent$Configuration; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/rum/model/ViewEvent$Configuration; public final fun getProfilingSampleRate ()Ljava/lang/Number; + public final fun getRcVersion ()Ljava/lang/Long; public final fun getSessionReplaySampleRate ()Ljava/lang/Number; public final fun getSessionSampleRate ()Ljava/lang/Number; public final fun getStartSessionReplayRecordingManually ()Ljava/lang/Boolean; From f21498cd702186c6ee4bcddf6d1463fe97af649e Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 03:03:21 -0700 Subject: [PATCH 12/19] refactor(rum): drop remote delivery of the Session Replay sample rate Session Replay is not supported on native yet, so a replay rate the console could set had nothing to act on here. Only the session sample rate is delivered; the replay rate stays where the app configures it. It is removed from the stored values, from the draw record events carry, and from the bus message RUM sends Session Replay on renewal, so Session Replay draws with exactly the sampler the app was built with. The forced-session flag on that message is unaffected. --- .../internal/domain/scope/RumSessionScope.kt | 22 +----------- .../rum/internal/domain/scope/RumViewScope.kt | 1 - .../remoteconfig/DrawnConfiguration.kt | 23 +++--------- .../remoteconfig/RemoteConfigController.kt | 22 ++++-------- .../remoteconfig/RemoteConfigStore.kt | 8 +---- .../domain/scope/RumSessionScopeTest.kt | 29 +-------------- .../internal/domain/scope/RumViewScopeTest.kt | 5 +-- .../RemoteConfigControllerTest.kt | 35 ++++++------------- .../remoteconfig/RemoteConfigStoreTest.kt | 29 ++++++--------- .../internal/SessionReplayFeature.kt | 22 ++---------- 10 files changed, 39 insertions(+), 157 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index f16fcddeb3..e23299171a 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -336,9 +336,7 @@ internal class RumSessionScope( DrawnConfiguration( sessionId = sessionId, version = config.appliedVersion() ?: 0, - sessionSampleRate = effectiveSampleRate, - sessionReplaySampleRate = config.sessionReplaySampleRate() - ?: initialSessionReplaySampleRate() + sessionSampleRate = effectiveSampleRate ) } drawnConfiguration?.let { remoteConfig?.storeDrawRecord(it) } @@ -361,26 +359,12 @@ internal class RumSessionScope( onSessionDrawn() } - // FLASHCAT FORK - the replay rate the app was built with, read from what Session Replay - // published about itself: the drawn rate is the console's where it set one and this one where - // it did not. Null when Session Replay is not there to say, and the field is then not reported. - private fun initialSessionReplaySampleRate(): Float? = - ( - sdkCore.getFeatureContext( - Feature.SESSION_REPLAY_FEATURE_NAME - )[SESSION_REPLAY_SAMPLE_RATE_CONTEXT_KEY] as? Number - )?.toFloat() - private fun updateSessionStateForSessionReplay(state: State, sessionId: String) { val keepSession = (state == State.TRACKED) sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.sendEvent( mapOf( SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RUM_SESSION_RENEWED_BUS_MESSAGE, RUM_KEEP_SESSION_BUS_MESSAGE_KEY to keepSession, - // FLASHCAT FORK - Session Replay draws its own sample when it sees this message, - // and the console's replay rate is fetched on this side. Passing it along is what - // lets one fetch drive both decisions without a second store. - RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to remoteConfig?.sessionReplaySampleRate(), // FLASHCAT FORK - a forced session must come out with replay, so Session Replay // skips its own draw when this is set. RUM_SESSION_FORCED_BUS_MESSAGE_KEY to forcedSession, @@ -396,13 +380,9 @@ internal class RumSessionScope( internal const val SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY = "type" internal const val RUM_SESSION_RENEWED_BUS_MESSAGE = "rum_session_renewed" internal const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession" - internal const val RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY = "replaySampleRate" internal const val RUM_SESSION_FORCED_BUS_MESSAGE_KEY = "sessionForced" internal const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" - // FLASHCAT FORK - the key under which Session Replay publishes the rate the app configured - // it with; duplicated here because internal constants do not cross module boundaries. - internal const val SESSION_REPLAY_SAMPLE_RATE_CONTEXT_KEY = "session_replay_sample_rate" internal val DEFAULT_SESSION_INACTIVITY_NS = TimeUnit.MINUTES.toNanos(15) internal val DEFAULT_SESSION_MAX_DURATION_NS = TimeUnit.HOURS.toNanos(4) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt index 3ab7dcce25..53930e9a85 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt @@ -1357,7 +1357,6 @@ internal open class RumViewScope( // ignore it. configuration = ViewEvent.Configuration( sessionSampleRate = sampleRate, - sessionReplaySampleRate = drawnConfiguration?.sessionReplaySampleRate, rcVersion = drawnConfiguration?.version?.toLong() ) ), diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt index aadacd1ae5..17ffc2c12b 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt @@ -10,9 +10,9 @@ import org.json.JSONException import org.json.JSONObject /** - * FLASHCAT FORK - the configuration a session was drawn under: the rates actually used at the draw - * (the console's where it set them, the init values where it did not) and the remote settings - * version they came from. Events carry these instead of the init values, so server-side + * FLASHCAT FORK - the configuration a session was drawn under: the rate actually used at the draw + * (the console's where it set one, the init value where it did not) and the remote settings + * version it came from. Events carry these instead of the init values, so server-side * extrapolation and audits line up with the draw that kept the session — a session is never * re-judged, so the metadata must be from its creation, not from whatever has arrived since. */ @@ -21,27 +21,19 @@ internal data class DrawnConfiguration( val sessionId: String, /** The remote settings version the draw read, or 0 when none was ever fetched. */ val version: Int, - val sessionSampleRate: Float, - /** Null when the draw could not know it (Session Replay not publishing); then not reported. */ - val sessionReplaySampleRate: Float? + val sessionSampleRate: Float ) { fun toJsonString(): String = JSONObject() .put(FIELD_SESSION_ID, sessionId) .put(FIELD_VERSION, version) .put(FIELD_SESSION_SAMPLE_RATE, sessionSampleRate.toDouble()) - .apply { - if (sessionReplaySampleRate != null) { - put(FIELD_SESSION_REPLAY_SAMPLE_RATE, sessionReplaySampleRate.toDouble()) - } - } .toString() companion object { private const val FIELD_SESSION_ID = "id" private const val FIELD_VERSION = "version" private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" - private const val FIELD_SESSION_REPLAY_SAMPLE_RATE = "sessionReplaySampleRate" /** * Parses a stored record, tolerating what older versions did not write: a field missing @@ -57,12 +49,7 @@ internal data class DrawnConfiguration( DrawnConfiguration( sessionId = sessionId, version = obj.optInt(FIELD_VERSION, 0), - sessionSampleRate = obj.getDouble(FIELD_SESSION_SAMPLE_RATE).toFloat(), - sessionReplaySampleRate = if (obj.has(FIELD_SESSION_REPLAY_SAMPLE_RATE)) { - obj.getDouble(FIELD_SESSION_REPLAY_SAMPLE_RATE).toFloat() - } else { - null - } + sessionSampleRate = obj.getDouble(FIELD_SESSION_SAMPLE_RATE).toFloat() ) } } catch (e: JSONException) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index d8310c5b94..220cf103c9 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -196,7 +196,7 @@ internal class RemoteConfigController( val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) refreshOnForeground = json.optBoolean(FIELD_REFRESH_ON_FOREGROUND, false) - val before = RemoteConfigValues(store.sessionSampleRate(), store.sessionReplaySampleRate()) + val before = RemoteConfigValues(store.sessionSampleRate()) val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } val after = if (enabled) { readValues(json.optJSONObject(FIELD_RUM)).copy( @@ -223,8 +223,7 @@ internal class RemoteConfigController( private fun readValues(rum: JSONObject?): RemoteConfigValues { if (rum == null) return EMPTY_VALUES return RemoteConfigValues( - sessionSampleRate = readRate(rum, FIELD_SESSION_SAMPLE_RATE), - sessionReplaySampleRate = readRate(rum, FIELD_SESSION_REPLAY_SAMPLE_RATE) + sessionSampleRate = readRate(rum, FIELD_SESSION_SAMPLE_RATE) ) } @@ -239,17 +238,9 @@ internal class RemoteConfigController( return if (rate.isNaN() || rate < 0.0 || rate > MAX_RATE) null else rate.toFloat() } - private fun changesThisClient(before: RemoteConfigValues, after: RemoteConfigValues): Boolean { - val sessionBefore = before.sessionSampleRate ?: initialSessionSampleRate - val sessionAfter = after.sessionSampleRate ?: initialSessionSampleRate - - // The replay rate is configured on the Session Replay feature rather than here, so there is - // no init value to fall back to on this side. Comparing what was stored is exact for every - // change after the first, and at worst restarts one session the first time the console sets - // a replay rate that happens to equal the one the app was built with. - return sessionBefore != sessionAfter || - before.sessionReplaySampleRate != after.sessionReplaySampleRate - } + private fun changesThisClient(before: RemoteConfigValues, after: RemoteConfigValues): Boolean = + (before.sessionSampleRate ?: initialSessionSampleRate) != + (after.sessionSampleRate ?: initialSessionSampleRate) private fun logFetchFailure(e: Throwable) { sdkCore.internalLogger.log( @@ -287,9 +278,8 @@ internal class RemoteConfigController( private const val FIELD_CUSTOM = "custom" private const val FIELD_RUM = "rum" private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" - private const val FIELD_SESSION_REPLAY_SAMPLE_RATE = "sessionReplaySampleRate" - private val EMPTY_VALUES = RemoteConfigValues(null, null) + private val EMPTY_VALUES = RemoteConfigValues(null) private const val HTTP_NOT_MODIFIED = 304 private const val HEADER_ETAG = "ETag" diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt index 4544ef3d21..692f4a8c2f 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -47,8 +47,6 @@ internal class RemoteConfigStore( */ fun custom(): String? = preferences?.getString(customKey(), null) - fun sessionReplaySampleRate(): Float? = read(replayKey()) - /** * The validator the server sent with the stored configuration, echoed back as If-None-Match so * an unchanged answer costs a 304 instead of a body. It belongs to this stored configuration @@ -87,7 +85,6 @@ internal class RemoteConfigStore( fun store(values: RemoteConfigValues) { val editor = preferences?.edit() ?: return write(editor, sessionKey(), values.sessionSampleRate) - write(editor, replayKey(), values.sessionReplaySampleRate) // Kept even when there are no rates — that is what "remote configuration is off, use your // own settings" looks like — so the console can still see this client is up to date with // the change that turned them off. @@ -124,8 +121,6 @@ internal class RemoteConfigStore( private fun sessionKey() = "$storeKey.sessionSampleRate" - private fun replayKey() = "$storeKey.sessionReplaySampleRate" - private fun versionKey() = "$storeKey.version" private fun customKey() = "$storeKey.custom" @@ -189,12 +184,11 @@ internal class RemoteConfigStore( */ internal data class RemoteConfigValues( val sessionSampleRate: Float?, - val sessionReplaySampleRate: Float?, val version: Int? = null, /** Raw JSON object string of the console's custom pass-through values, delivered verbatim. */ val custom: String? = null, /** The validator to echo back as If-None-Match on the next request, quoted as the server sent it. */ val etag: String? = null ) { - fun isEmpty(): Boolean = sessionSampleRate == null && sessionReplaySampleRate == null + fun isEmpty(): Boolean = sessionSampleRate == null } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index ea1b2d167f..4c0ce77aa6 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -1029,7 +1029,6 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId @@ -1046,7 +1045,6 @@ internal class RumSessionScopeTest { // Given val remoteConfig = mock() whenever(remoteConfig.sessionSampleRate()) doReturn 42f - whenever(remoteConfig.sessionReplaySampleRate()) doReturn 9f whenever(remoteConfig.appliedVersion()) doReturn 7 initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig) @@ -1060,8 +1058,7 @@ internal class RumSessionScopeTest { DrawnConfiguration( sessionId = context.sessionId, version = 7, - sessionSampleRate = 42f, - sessionReplaySampleRate = 9f + sessionSampleRate = 42f ) ) } @@ -1071,10 +1068,7 @@ internal class RumSessionScopeTest { // Given val remoteConfig = mock() whenever(remoteConfig.sessionSampleRate()) doReturn null - whenever(remoteConfig.sessionReplaySampleRate()) doReturn null whenever(remoteConfig.appliedVersion()) doReturn null - whenever(mockSdkCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn - mapOf(RumSessionScope.SESSION_REPLAY_SAMPLE_RATE_CONTEXT_KEY to 30L) initializeTestedScope(sampleRate = 80f, remoteConfig = remoteConfig) // When @@ -1084,7 +1078,6 @@ internal class RumSessionScopeTest { assertThat(testedScope.effectiveSampleRate).isEqualTo(80f) assertThat(testedScope.drawnConfiguration?.version).isZero() assertThat(testedScope.drawnConfiguration?.sessionSampleRate).isEqualTo(80f) - assertThat(testedScope.drawnConfiguration?.sessionReplaySampleRate).isEqualTo(30f) } @Test @@ -1136,7 +1129,6 @@ internal class RumSessionScopeTest { // Given val remoteConfig = mock() whenever(remoteConfig.sessionSampleRate()) doReturn 100f - whenever(remoteConfig.sessionReplaySampleRate()) doReturn 9f initializeTestedScope(withMockChildScope = false, remoteConfig = remoteConfig) // When @@ -1155,7 +1147,6 @@ internal class RumSessionScopeTest { RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to RumSessionScope.RUM_SESSION_RENEWED_BUS_MESSAGE, RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to 9f, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId @@ -1403,7 +1394,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId @@ -1416,7 +1406,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId @@ -1456,7 +1445,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId @@ -1469,7 +1457,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId @@ -1503,7 +1490,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1515,7 +1501,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1548,7 +1533,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId @@ -1561,7 +1545,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId @@ -1596,7 +1579,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId @@ -1609,7 +1591,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId @@ -1622,7 +1603,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId @@ -1657,7 +1637,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1669,7 +1648,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId @@ -1704,7 +1682,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1716,7 +1693,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1751,7 +1727,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1763,7 +1738,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1775,7 +1749,6 @@ internal class RumSessionScopeTest { RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to false, // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. - RumSessionScope.RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY to null, RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 88f4c0be4d..5fd4f244f9 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -657,8 +657,7 @@ internal class RumViewScopeTest { val drawnConfiguration = DrawnConfiguration( sessionId = fakeParentContext.sessionId, version = 7, - sessionSampleRate = fakeSampleRate, - sessionReplaySampleRate = 9f + sessionSampleRate = fakeSampleRate ) testedScope = newRumViewScope(trackFrustrations = true, drawnConfiguration = drawnConfiguration) mockSessionReplayContext(testedScope) @@ -675,7 +674,6 @@ internal class RumViewScopeTest { argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) assertThat(lastValue.dd.configuration?.sessionSampleRate).isEqualTo(fakeSampleRate) - assertThat(lastValue.dd.configuration?.sessionReplaySampleRate).isEqualTo(9f) assertThat(lastValue.dd.configuration?.rcVersion).isEqualTo(7L) } assertThat(result).isNull() @@ -697,7 +695,6 @@ internal class RumViewScopeTest { argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) assertThat(lastValue.dd.configuration?.sessionSampleRate).isEqualTo(fakeSampleRate) - assertThat(lastValue.dd.configuration?.sessionReplaySampleRate).isNull() assertThat(lastValue.dd.configuration?.rcVersion).isNull() } assertThat(result).isNull() diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index bcfc916ce7..dd20cc3d24 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -53,7 +53,6 @@ internal class RemoteConfigControllerTest { // the whole point of several of these tests, and a default that is not null would quietly // turn them into tests of something else. whenever(store.sessionSampleRate()).thenReturn(null) - whenever(store.sessionReplaySampleRate()).thenReturn(null) restarts = 0 elapsedMs = 0L executor = mock() @@ -78,40 +77,40 @@ internal class RemoteConfigControllerTest { // region storing @Test - fun `M store the rates the response carries W apply()`() { - testedController.apply(body(rum = """"sessionSampleRate":42,"sessionReplaySampleRate":7""")) + fun `M store the rate the response carries W apply()`() { + testedController.apply(body(rum = """"sessionSampleRate":42""")) - verify(store).store(RemoteConfigValues(42f, 7f, 3)) + verify(store).store(RemoteConfigValues(42f, 3)) } @Test fun `M store a zero rate W apply() { zero is a setting, not a missing value }`() { testedController.apply(body(rum = """"sessionSampleRate":0""")) - verify(store).store(RemoteConfigValues(0f, null, 3)) + verify(store).store(RemoteConfigValues(0f, 3)) } @Test - fun `M leave a rate absent W apply() { response omits it }`() { + fun `M leave the rate absent W apply() { response omits it }`() { // An absent rate must fall back to what the app passed to init. Writing a zero in its place // would silently stop collection nobody asked to stop. - testedController.apply(body(rum = """"sessionSampleRate":42""")) + testedController.apply(body(rum = "")) - verify(store).store(RemoteConfigValues(42f, null, 3)) + verify(store).store(RemoteConfigValues(null, 3)) } @Test fun `M ignore a rate outside 0-100 W apply()`() { testedController.apply(body(rum = """"sessionSampleRate":420""")) - verify(store).store(RemoteConfigValues(null, null, 3)) + verify(store).store(RemoteConfigValues(null, 3)) } @Test fun `M forget the rates W apply() { remote configuration switched off }`() { testedController.apply(body(enabled = false, rum = """"sessionSampleRate":42""")) - verify(store).store(RemoteConfigValues(null, null, 3)) + verify(store).store(RemoteConfigValues(null, 3)) } // endregion @@ -155,18 +154,6 @@ internal class RemoteConfigControllerTest { assertThat(restarts).isZero() } - @Test - fun `M restart the session W apply() { immediate and only the replay rate changed }`() { - whenever(store.sessionSampleRate()).thenReturn(null) - whenever(store.sessionReplaySampleRate()).thenReturn(10f) - - testedController.apply( - body(activation = "immediate", rum = """"sessionSampleRate":$INIT_SESSION_RATE,"sessionReplaySampleRate":90""") - ) - - assertThat(restarts).isOne() - } - @Test fun `M restart the session W apply() { immediate and the kill switch takes the rates away }`() { whenever(store.sessionSampleRate()).thenReturn(100f) @@ -184,7 +171,7 @@ internal class RemoteConfigControllerTest { // the change that turned them off. testedController.apply(body(enabled = false)) - verify(store).store(RemoteConfigValues(null, null, 3)) + verify(store).store(RemoteConfigValues(null, 3)) } // region fetching @@ -212,7 +199,7 @@ internal class RemoteConfigControllerTest { runPendingFetch() - verify(store).store(RemoteConfigValues(42f, null, 3)) + verify(store).store(RemoteConfigValues(42f, 3)) } @Test diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt index 2db4618bc2..f5775ec8cc 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt @@ -85,7 +85,6 @@ internal class RemoteConfigStoreTest { testedStore().store( RemoteConfigValues( sessionSampleRate = 42f, - sessionReplaySampleRate = 7f, version = 3, custom = """{"viplist":["u-1"]}""", etag = "\"v3\"" @@ -95,7 +94,6 @@ internal class RemoteConfigStoreTest { // A fresh instance over the same preferences is what the next process start looks like. val nextLaunch = testedStore() assertThat(nextLaunch.sessionSampleRate()).isEqualTo(42f) - assertThat(nextLaunch.sessionReplaySampleRate()).isEqualTo(7f) assertThat(nextLaunch.appliedVersion()).isEqualTo(3) assertThat(nextLaunch.custom()).isEqualTo("""{"viplist":["u-1"]}""") assertThat(nextLaunch.etag()).isEqualTo("\"v3\"") @@ -106,7 +104,6 @@ internal class RemoteConfigStoreTest { val store = testedStore() assertThat(store.sessionSampleRate()).isNull() - assertThat(store.sessionReplaySampleRate()).isNull() assertThat(store.appliedVersion()).isNull() assertThat(store.custom()).isNull() assertThat(store.etag()).isNull() @@ -116,12 +113,11 @@ internal class RemoteConfigStoreTest { fun `M forget the knobs a response omitted W store()`() { // A knob nobody configured must go back to the init value, not linger at the last one. val store = testedStore() - store.store(RemoteConfigValues(42f, 7f, 3, custom = """{"debug":true}""", etag = "\"v3\"")) + store.store(RemoteConfigValues(42f, 3, custom = """{"debug":true}""", etag = "\"v3\"")) - store.store(RemoteConfigValues(null, null, 4)) + store.store(RemoteConfigValues(null, 4)) assertThat(store.sessionSampleRate()).isNull() - assertThat(store.sessionReplaySampleRate()).isNull() assertThat(store.custom()).isNull() assertThat(store.appliedVersion()).isEqualTo(4) } @@ -129,9 +125,9 @@ internal class RemoteConfigStoreTest { @Test fun `M keep the version W store() { remote configuration switched off }`() { val store = testedStore() - store.store(RemoteConfigValues(42f, 7f, 3)) + store.store(RemoteConfigValues(42f, 3)) - store.store(RemoteConfigValues(null, null, 4)) + store.store(RemoteConfigValues(null, 4)) assertThat(store.appliedVersion()).isEqualTo(4) } @@ -142,10 +138,9 @@ internal class RemoteConfigStoreTest { .thenThrow(SecurityException("no storage for you")) val store = RemoteConfigStore(appContext, "key", mock()) - store.store(RemoteConfigValues(42f, 7f, 3)) + store.store(RemoteConfigValues(42f, 3)) assertThat(store.sessionSampleRate()).isNull() - assertThat(store.sessionReplaySampleRate()).isNull() assertThat(store.appliedVersion()).isNull() } @@ -159,8 +154,7 @@ internal class RemoteConfigStoreTest { val record = DrawnConfiguration( sessionId = "session-1", version = 7, - sessionSampleRate = 42f, - sessionReplaySampleRate = 9f + sessionSampleRate = 42f ) store.storeDrawRecord(record) @@ -170,19 +164,18 @@ internal class RemoteConfigStoreTest { @Test fun `M tolerate a record an older version wrote W readDrawRecord() { fields missing }`() { - // A field that did not exist when the record was written reads as if the console never - // set that knob: an SDK upgrade changes nothing for a session already drawn. + // A record written before the version field existed reads as version 0 — "no configuration + // was ever fetched" — so an SDK upgrade changes nothing for a session already drawn. preferences.edit().putString( "test-key.draw", - """{"id":"session-1","version":7,"sessionSampleRate":42.0}""" + """{"id":"session-1","sessionSampleRate":42.0}""" ).apply() assertThat(testedStore().readDrawRecord()).isEqualTo( DrawnConfiguration( sessionId = "session-1", - version = 7, - sessionSampleRate = 42f, - sessionReplaySampleRate = null + version = 0, + sessionSampleRate = 42f ) ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt index 586bbdc41e..b534808c5a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -112,18 +112,11 @@ internal class SessionReplayFeature( // are we recording at the moment private val isRecording = AtomicBoolean(false) - // is the current session sampled in - // FLASHCAT FORK - the replay rate the console last sent, or null when it set none. - @Volatile - internal var remoteReplaySampleRate: Float? = null - // FLASHCAT FORK - true when RUM renewed this session under a forced draw; replay then skips // its own draw, because a forced session must come out with replay. internal var sessionForced: Boolean = false - // Consulted only when a remote rate exists, so an injected sampler keeps its behaviour. - private val remoteAwareSampler: Sampler = RateBasedSampler { remoteReplaySampleRate ?: 0f } - + // is the current session sampled in private val isSessionSampledIn = AtomicBoolean(false) internal var sessionReplayRecorder: Recorder = NoOpRecorder() @@ -270,10 +263,6 @@ internal class SessionReplayFeature( private fun parseSessionMetadata(sessionMetadata: Map<*, *>): SessionData? { val keepSession = sessionMetadata[RUM_KEEP_SESSION_BUS_MESSAGE_KEY] as? Boolean val sessionId = sessionMetadata[RUM_SESSION_ID_BUS_MESSAGE_KEY] as? String - // FLASHCAT FORK - absent, or null, means the console set no replay rate and the one the app - // was configured with keeps applying. It is read before sampling so the session about to be - // drawn uses it. - remoteReplaySampleRate = sessionMetadata[RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY] as? Float sessionForced = sessionMetadata[RUM_SESSION_FORCED_BUS_MESSAGE_KEY] as? Boolean ?: false if (keepSession == null || sessionId == null) { @@ -290,13 +279,7 @@ internal class SessionReplayFeature( private fun applySampling(alreadySeenSession: Boolean) { if (!alreadySeenSession) { - // FLASHCAT FORK - the console can set the replay rate without the app shipping a new - // release. RUM fetches it and passes it along with the session it just renewed, so one - // request drives both the session and the replay decision. With nothing set remotely - // this is exactly the sampler the app was configured with. - val remoteRate = remoteReplaySampleRate - val sampler = if (remoteRate == null) rateBasedSampler else remoteAwareSampler - isSessionSampledIn.set(sessionForced || sampler.sample(Unit)) + isSessionSampledIn.set(sessionForced || rateBasedSampler.sample(Unit)) } } @@ -453,7 +436,6 @@ internal class SessionReplayFeature( const val SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY = "type" const val RUM_SESSION_RENEWED_BUS_MESSAGE = "rum_session_renewed" const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession" - const val RUM_REPLAY_SAMPLE_RATE_BUS_MESSAGE_KEY = "replaySampleRate" const val RUM_SESSION_FORCED_BUS_MESSAGE_KEY = "sessionForced" const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" internal const val SESSION_REPLAY_SAMPLE_RATE_KEY = "session_replay_sample_rate" From 3f6e63083acbfca68b4c0e64d48cd1f21088055b Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 05:10:25 -0700 Subject: [PATCH 13/19] refactor(rum): return the console's custom values decoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web and iOS hand the application a dictionary; returning the raw JSON string here made the same console value cost an extra parser on Android alone. Nested objects and arrays come back as Map and List, and a body that cannot be read answers as nothing published — no rate or decision depends on this bag. Storage keeps the raw JSON, which has no reason to understand it. --- features/dd-sdk-android-rum/api/apiSurface | 2 +- .../api/dd-sdk-android-rum.api | 2 +- .../com/datadog/android/rum/RumMonitor.kt | 15 +++--- .../rum/internal/monitor/DatadogRumMonitor.kt | 5 +- .../rum/internal/remoteconfig/CustomValues.kt | 44 +++++++++++++++ .../internal/remoteconfig/CustomValuesTest.kt | 54 +++++++++++++++++++ 6 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/CustomValues.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/CustomValuesTest.kt diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index 3b2fe37e74..60f7732d40 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -118,7 +118,7 @@ interface com.datadog.android.rum.RumMonitor fun clearAttributes() fun stopSession() fun setForcedSession() - fun getRemoteConfig(): String? + fun getRemoteConfig(): Map? fun addViewLoadingTime(Boolean) fun addViewAttributes(Map) fun removeViewAttributes(Collection) diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index bd0a5b1650..892ffc4536 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -162,7 +162,7 @@ public abstract interface class com/datadog/android/rum/RumMonitor { public abstract fun getAttributes ()Ljava/util/Map; public abstract fun getCurrentSessionId (Lkotlin/jvm/functions/Function1;)V public abstract fun getDebug ()Z - public abstract fun getRemoteConfig ()Ljava/lang/String; + public abstract fun getRemoteConfig ()Ljava/util/Map; public abstract fun removeAttribute (Ljava/lang/String;)V public abstract fun removeViewAttributes (Ljava/util/Collection;)V public abstract fun reportAppFullyDisplayed ()V diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt index 148c7e711f..b45050710a 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt @@ -312,14 +312,15 @@ interface RumMonitor { fun setForcedSession() /** - * Returns the custom values published for this application in the console, as the raw JSON - * object string, or null when nothing is published or remote configuration is off. The SDK - * delivers them verbatim and never interprets them - what a value means is entirely up to your - * own code (a debug allow-list to pair with [setForcedSession], a feature toggle). Values are - * cached locally, so what a previous launch fetched answers immediately on the next. The - * content is readable by anyone holding the public client token - it is public information. + * Returns the custom values published for this application in the console, or null when + * nothing is published or remote configuration is off. The SDK delivers them verbatim and + * never interprets them - what a value means is entirely up to your own code (a debug + * allow-list to pair with [setForcedSession], a feature toggle). Nested objects and arrays + * come back as [Map] and [List]. Values are cached locally, so what a previous launch fetched + * answers immediately on the next. The content is readable by anyone holding the public client + * token - it is public information. */ - fun getRemoteConfig(): String? + fun getRemoteConfig(): Map? /** * Adds view loading time to the active view based on the time elapsed since the view was started. diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index 2c083b6f36..cf8f7b3744 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -59,6 +59,7 @@ import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollect import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore +import com.datadog.android.rum.internal.remoteconfig.decodeCustomValues import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo @@ -459,8 +460,8 @@ internal class DatadogRumMonitor( ) } - override fun getRemoteConfig(): String? { - return remoteConfig?.custom() + override fun getRemoteConfig(): Map? { + return decodeCustomValues(remoteConfig?.custom()) } @ExperimentalRumApi diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/CustomValues.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/CustomValues.kt new file mode 100644 index 0000000000..dd42f9f1a3 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/CustomValues.kt @@ -0,0 +1,44 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * FLASHCAT FORK - decodes the console's custom bag into plain Kotlin values. + * + * Stored as the raw JSON the console sent, because storage has no reason to understand it, but + * handed to the host application decoded: every other platform hands back a dictionary, and + * leaving one of them to parse a string would make the same console value cost more on Android + * than anywhere else. + * + * A body we cannot parse reads as nothing published rather than as an error: the bag is + * application-defined, and no rate or decision depends on it. + */ +internal fun decodeCustomValues(json: String?): Map? { + if (json == null) return null + return try { + JSONObject(json).asMap() + } catch (e: JSONException) { + null + } +} + +private fun JSONObject.asMap(): Map = + keys().asSequence().associateWith { unwrap(get(it)) } + +private fun JSONArray.asList(): List = + (0 until length()).map { unwrap(get(it)) } + +private fun unwrap(value: Any?): Any? = when (value) { + JSONObject.NULL -> null + is JSONObject -> value.asMap() + is JSONArray -> value.asList() + else -> value +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/CustomValuesTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/CustomValuesTest.kt new file mode 100644 index 0000000000..2afb9b8a68 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/CustomValuesTest.kt @@ -0,0 +1,54 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.remoteconfig + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class CustomValuesTest { + + @Test + fun `M decode plain values W decodeCustomValues()`() { + val values = decodeCustomValues("""{"flag":true,"limit":5,"name":"beta"}""") + + assertThat(values).isEqualTo(mapOf("flag" to true, "limit" to 5, "name" to "beta")) + } + + @Test + fun `M decode nested objects and arrays W decodeCustomValues()`() { + // The host application reads these directly, so a nested shape must arrive as Map and List + // rather than as something it has to parse a second time. + val values = decodeCustomValues("""{"viplist":["u-1","u-2"],"limits":{"rum":10}}""") + + assertThat(values).isEqualTo( + mapOf( + "viplist" to listOf("u-1", "u-2"), + "limits" to mapOf("rum" to 10) + ) + ) + } + + @Test + fun `M decode a JSON null as null W decodeCustomValues()`() { + val values = decodeCustomValues("""{"cleared":null}""") + + assertThat(values).containsEntry("cleared", null) + } + + @Test + fun `M answer nothing published W decodeCustomValues() { nothing stored }`() { + assertThat(decodeCustomValues(null)).isNull() + } + + @Test + fun `M answer nothing published W decodeCustomValues() { body is not an object }`() { + // No rate or decision depends on this bag, so an unreadable body is nothing published + // rather than an error the application has to handle. + assertThat(decodeCustomValues("not json")).isNull() + assertThat(decodeCustomValues("""["an","array"]""")).isNull() + } +} From a7296039d7c91cd660e6cffbb1a4fb8ad493e66e Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 19:00:25 -0700 Subject: [PATCH 14/19] refactor(rum): drop what the narrowed scope left behind `RemoteConfigValues.isEmpty()` had no caller anywhere: it meant something while several knobs were delivered, and says nothing now that only the session sample rate is. `readRate` took the field name from its single call site, and the retry schedule was visible outside the file that is its only reader. --- .../internal/remoteconfig/RemoteConfigController.kt | 10 +++++----- .../rum/internal/remoteconfig/RemoteConfigStore.kt | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index 220cf103c9..5bdd44e311 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -223,7 +223,7 @@ internal class RemoteConfigController( private fun readValues(rum: JSONObject?): RemoteConfigValues { if (rum == null) return EMPTY_VALUES return RemoteConfigValues( - sessionSampleRate = readRate(rum, FIELD_SESSION_SAMPLE_RATE) + sessionSampleRate = readRate(rum) ) } @@ -232,9 +232,9 @@ internal class RemoteConfigController( * An out-of-range number is treated the same way rather than clamped: a rate we cannot trust is * not a rate to sample a customer's traffic with. */ - private fun readRate(rum: JSONObject, field: String): Float? { - if (!rum.has(field)) return null - val rate = rum.optDouble(field, Double.NaN) + private fun readRate(rum: JSONObject): Float? { + if (!rum.has(FIELD_SESSION_SAMPLE_RATE)) return null + val rate = rum.optDouble(FIELD_SESSION_SAMPLE_RATE, Double.NaN) return if (rate.isNaN() || rate < 0.0 || rate > MAX_RATE) null else rate.toFloat() } @@ -265,7 +265,7 @@ internal class RemoteConfigController( internal const val ACTIVATION_NEXT_SESSION = "next_session" internal const val ACTIVATION_IMMEDIATE = "immediate" - internal val RETRY_DELAYS_SECONDS = longArrayOf(5L, 60L) + private val RETRY_DELAYS_SECONDS = longArrayOf(5L, 60L) private const val MAX_RATE = 100.0 private const val MILLIS_PER_SECOND = 1_000L diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt index 692f4a8c2f..67754785c9 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -189,6 +189,4 @@ internal data class RemoteConfigValues( val custom: String? = null, /** The validator to echo back as If-None-Match on the next request, quoted as the server sent it. */ val etag: String? = null -) { - fun isEmpty(): Boolean = sessionSampleRate == null -} +) From beba6f7fddeeda5134d591b5e7ad2cf7783c90fb Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 20:36:21 -0700 Subject: [PATCH 15/19] fix(rum): refuse configuration responses this SDK cannot vouch for Reading a configuration response had two ways to go wrong, and neither was handled. A body that is not JSON threw out of the fetch. The parse ran inside a try that only catches IOException and IllegalStateException, so a JSONException escaped the whole method: the in-flight flag was never cleared, every later trigger returned early, and remote configuration stopped for the lifetime of the process with no log and no retry. A captive portal answering 200 with a login page is enough to cause it, and nothing checks the content type. A body written to a newer contract was read field by field and applied. The server states the shape it wrote in `schema_version`; a reader that guesses instead of checking is exactly what that field exists to prevent, and only code already on the device can refuse - a check added in a later SDK would be ignored by the very clients it needs to protect. `apply()` now reports one of three outcomes instead of throwing: APPLIED the body was read and its values are stored UNREADABLE not a configuration at all - ask again UNSUPPORTED_SCHEMA a contract this SDK does not read - refused whole Only UNREADABLE is retried. A schema we do not know is an answer, not a failure: asking again would fetch the same refusal, so a server-side schema bump cannot turn a fleet into a retry storm. Nothing from a refused body reaches storage, not even the fields that happened to parse, and the values already in use keep applying either way. --- .../remoteconfig/RemoteConfigController.kt | 85 ++++++++++++++++++- .../RemoteConfigControllerTest.kt | 83 +++++++++++++++++- 2 files changed, 161 insertions(+), 7 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index 5bdd44e311..09c6265f4b 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import okhttp3.Call import okhttp3.Request +import org.json.JSONException import org.json.JSONObject import java.io.IOException import java.net.URLEncoder @@ -139,8 +140,11 @@ internal class RemoteConfigController( if (payload == null) { false } else { - apply(payload, response.header(HEADER_ETAG)) - true + // An unreadable body is the only outcome worth asking again for. A + // body we understood — even one we must refuse because its schema is + // newer than this SDK — is an answered question, and repeating it + // would just be the same refusal twice. + apply(payload, response.header(HEADER_ETAG)) != Outcome.UNREADABLE } } else -> false @@ -181,6 +185,29 @@ internal class RemoteConfigController( } } + /** + * What reading one response body came to. Only [UNREADABLE] is worth asking again for: the + * other two are answers, whether or not this SDK can act on them. + */ + internal enum class Outcome { + /** The body was read and its values are now stored. */ + APPLIED, + + /** + * The body was not a configuration at all — not JSON, or truncated. A captive portal + * answering 200 with a login page looks exactly like this, so it is treated as a request + * that did not arrive rather than as a configuration saying nothing. + */ + UNREADABLE, + + /** + * The body is a configuration written to a contract this SDK does not know. Refused whole: + * a payload shaped for a newer reader can be misread field by field while every individual + * field still parses, and half-understood sampling settings are worse than none. + */ + UNSUPPORTED_SCHEMA + } + /** * Stores what the response carried and, when the console asked for it, restarts the session so * the new values take hold now instead of at the visitor's next one. @@ -189,8 +216,24 @@ internal class RemoteConfigController( * Without that check, a console resending an unchanged configuration would cut every session in * two on every fetch. */ - internal fun apply(payload: String, etag: String? = null) { - val json = JSONObject(payload) + internal fun apply(payload: String, etag: String? = null): Outcome { + val json = try { + @Suppress("UnsafeThirdPartyFunctionCall") // caught right here + JSONObject(payload) + } catch (e: JSONException) { + logUnreadableBody(e) + return Outcome.UNREADABLE + } + + // Checked before anything is read out of the body. The server states the shape it wrote, + // and a reader that guesses instead of checking is exactly what this field exists to + // prevent — which is why it has to be honoured by the first SDK that ships, not by a + // later one: only code already on the device can refuse. + if (json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT) != SUPPORTED_SCHEMA_VERSION) { + logUnsupportedSchema(json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT)) + return Outcome.UNSUPPORTED_SCHEMA + } + val ttl = json.optLong(FIELD_TTL, DEFAULT_TTL_SECONDS) val enabled = json.optBoolean(FIELD_ENABLED, false) val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) @@ -218,6 +261,7 @@ internal class RemoteConfigController( // Remembered here rather than around the request, so a fetch that fails keeps the ttl the // server last asked for instead of falling back to ours. currentTtlSeconds = if (ttl > 0) ttl else DEFAULT_TTL_SECONDS + return Outcome.APPLIED } private fun readValues(rum: JSONObject?): RemoteConfigValues { @@ -242,6 +286,23 @@ internal class RemoteConfigController( (before.sessionSampleRate ?: initialSessionSampleRate) != (after.sessionSampleRate ?: initialSessionSampleRate) + private fun logUnreadableBody(e: JSONException) { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { UNREADABLE_BODY_MESSAGE }, + e + ) + } + + private fun logUnsupportedSchema(received: Int) { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { UNSUPPORTED_SCHEMA_MESSAGE.format(received, SUPPORTED_SCHEMA_VERSION) } + ) + } + private fun logFetchFailure(e: Throwable) { sdkCore.internalLogger.log( InternalLogger.Level.DEBUG, @@ -270,6 +331,7 @@ internal class RemoteConfigController( private const val MAX_RATE = 100.0 private const val MILLIS_PER_SECOND = 1_000L private const val JITTER_FRACTION = 0.2 + private const val FIELD_SCHEMA_VERSION = "schema_version" private const val FIELD_VERSION = "version" private const val FIELD_REFRESH_ON_FOREGROUND = "refresh_on_foreground" private const val FIELD_TTL = "ttl" @@ -285,6 +347,21 @@ internal class RemoteConfigController( private const val HEADER_ETAG = "ETag" private const val HEADER_IF_NONE_MATCH = "If-None-Match" + /** + * The contract this SDK reads. It is not the SDK version and not the settings version: + * it names the SHAPE of the body, and the server bumps it only when a body would be + * misread by a reader written against the previous shape. + */ + internal const val SUPPORTED_SCHEMA_VERSION = 1 + private const val SCHEMA_VERSION_ABSENT = 0 + + internal const val UNREADABLE_BODY_MESSAGE = + "The remote configuration response was not readable; keeping the values already in use." + + internal const val UNSUPPORTED_SCHEMA_MESSAGE = + "Ignoring a remote configuration written to schema version %d; this SDK reads version" + + " %d. Update the SDK to take the console's settings again." + internal const val FETCH_FAILED_MESSAGE = "Unable to refresh the remote configuration; keeping the values already in use." diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index dd20cc3d24..6c4153bdd8 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -13,8 +13,8 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody -import org.json.JSONObject import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -472,6 +472,81 @@ internal class RemoteConfigControllerTest { // endregion + // region contract guards + + @Test + fun `M keep the stored values and ask again W apply() { body is not a configuration }`() { + val outcome = testedController.apply("captive portal") + + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + verify(store, never()).store(any()) + assertThat(restarts).isEqualTo(0) + } + + @Test + fun `M not wedge the controller W fetch() { body is not a configuration }`() { + whenever(call.execute()).thenReturn(response(200, "captive portal")) + runPendingFetch() + + // The whole point: an unreadable body must leave the controller able to ask again. If the + // parse escaped, inFlight would still be set and this second trigger would be dropped. + testedController.onSessionStarted() + + verify(executor, times(2)).execute(any()) + } + + @Test + fun `M ask again W fetch() { body is not a configuration }`() { + whenever(call.execute()).thenReturn(response(200, "not json at all")) + + runPendingFetch() + + verify(executor).schedule(any(), any(), any()) + } + + @Test + fun `M refuse the whole configuration W apply() { schema this SDK does not read }`() { + val outcome = testedController.apply( + body(rum = """"sessionSampleRate":42""", schemaVersion = 99) + ) + + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNSUPPORTED_SCHEMA) + // Nothing of a body we cannot vouch for reaches storage, not even the fields that happened + // to parse. + verify(store, never()).store(any()) + } + + @Test + fun `M refuse the whole configuration W apply() { no schema at all }`() { + val outcome = testedController.apply( + body(rum = """"sessionSampleRate":42""", schemaVersion = null) + ) + + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNSUPPORTED_SCHEMA) + verify(store, never()).store(any()) + } + + @Test + fun `M not ask again W fetch() { schema this SDK does not read }`() { + whenever(call.execute()).thenReturn(response(200, body(schemaVersion = 99))) + + runPendingFetch() + + // Retrying would fetch the same refusal. The server answered; this SDK simply cannot use + // the answer until it is updated. + verify(executor, never()).schedule(any(), any(), any()) + } + + @Test + fun `M apply the configuration W apply() { schema this SDK reads }`() { + val outcome = testedController.apply(body(rum = """"sessionSampleRate":42""")) + + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.APPLIED) + verify(store).store(any()) + } + + // endregion + // region test helpers /** @@ -512,9 +587,11 @@ internal class RemoteConfigControllerTest { activation: String = "next_session", refreshOnForeground: Boolean = false, rum: String = "", - custom: String? = null + custom: String? = null, + schemaVersion: Int? = RemoteConfigController.SUPPORTED_SCHEMA_VERSION ): String = - """{"version":3,"ttl":$ttl,"enabled":$enabled,"activation":"$activation",""" + + "{" + (if (schemaVersion == null) "" else """"schema_version":$schemaVersion,""") + + """"version":3,"ttl":$ttl,"enabled":$enabled,"activation":"$activation",""" + """"refresh_on_foreground":$refreshOnForeground,"rum":{$rum}""" + (if (custom == null) "" else ""","custom":$custom""") + "}" From b3afd610f256d0e9123c4b421e39c56c95c576ad Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 20:36:36 -0700 Subject: [PATCH 16/19] feat(rum): let the application have the last word on the session draw `setBeforeSampling` is consulted synchronously every time a new session is about to be drawn, with the rate that would apply and the console's custom values. Return a rate to override it, or null to leave it alone. It runs after the console's rate on purpose: an allow-list is only useful if it can keep collecting a visitor the console's rate would drop. Anything unusable - a null, a rate outside 0..100, a throw - leaves the incoming rate alone. A mistake in the host application must never take a customer's collection down with it. Two behaviours are corrected to match the iOS and HarmonyOS SDKs, so one console setting means one thing everywhere: - setForcedSession() no longer restarts a session that is already being collected. RUM cannot retro-collect what a running session already dropped, so cutting it in two gained nothing; only an uncollected session is now replaced. - rc_version is omitted rather than sent as 0 before the first configuration arrives, which is the shape the other platforms send. Imports touched by the remote-configuration work are also sorted to the layout .editorconfig declares. --- features/dd-sdk-android-rum/api/apiSurface | 5 + .../api/dd-sdk-android-rum.api | 18 ++++ .../com/datadog/android/rum/BeforeSampling.kt | 42 ++++++++ .../kotlin/com/datadog/android/rum/Rum.kt | 1 + .../datadog/android/rum/RumConfiguration.kt | 18 ++++ .../android/rum/internal/RumFeature.kt | 8 +- .../domain/scope/RumApplicationScope.kt | 9 +- .../internal/domain/scope/RumSessionScope.kt | 60 ++++++++++-- .../domain/scope/RumViewManagerScope.kt | 2 +- .../rum/internal/domain/scope/RumViewScope.kt | 6 +- .../rum/internal/monitor/DatadogRumMonitor.kt | 8 +- .../domain/scope/RumSessionScopeTest.kt | 97 ++++++++++++++++++- .../internal/domain/scope/RumViewScopeTest.kt | 2 +- 13 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/BeforeSampling.kt diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index 60f7732d40..a7c9047051 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -1,3 +1,7 @@ +data class com.datadog.android.rum.BeforeSamplingContext + constructor(Float, Map?) +interface com.datadog.android.rum.BeforeSamplingCallback + fun sampleRate(BeforeSamplingContext): Float? fun T.useMonitored(com.datadog.android.api.SdkCore = Datadog.getInstance(), (T) -> R): R annotation com.datadog.android.rum.ExperimentalRumApi object com.datadog.android.rum.GlobalRumMonitor @@ -63,6 +67,7 @@ data class com.datadog.android.rum.RumConfiguration constructor(String) fun setSessionSampleRate(Float): Builder fun setRemoteConfigurationEnabled(Boolean): Builder + fun setBeforeSampling(BeforeSamplingCallback): Builder fun collectAccessibility(Boolean): Builder fun setTelemetrySampleRate(Float): Builder fun trackUserInteractions(Array = emptyArray(), com.datadog.android.rum.tracking.InteractionPredicate = NoOpInteractionPredicate()): Builder diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index 892ffc4536..1cf1e491e4 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -1,3 +1,20 @@ +public abstract interface class com/datadog/android/rum/BeforeSamplingCallback { + public abstract fun sampleRate (Lcom/datadog/android/rum/BeforeSamplingContext;)Ljava/lang/Float; +} + +public final class com/datadog/android/rum/BeforeSamplingContext { + public fun (FLjava/util/Map;)V + public final fun component1 ()F + public final fun component2 ()Ljava/util/Map; + public final fun copy (FLjava/util/Map;)Lcom/datadog/android/rum/BeforeSamplingContext; + public static synthetic fun copy$default (Lcom/datadog/android/rum/BeforeSamplingContext;FLjava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/rum/BeforeSamplingContext; + public fun equals (Ljava/lang/Object;)Z + public final fun getCustom ()Ljava/util/Map; + public final fun getSessionSampleRate ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/datadog/android/rum/CloseableExtKt { public static final fun useMonitored (Ljava/io/Closeable;Lcom/datadog/android/api/SdkCore;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public static synthetic fun useMonitored$default (Ljava/io/Closeable;Lcom/datadog/android/api/SdkCore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; @@ -103,6 +120,7 @@ public final class com/datadog/android/rum/RumConfiguration$Builder { public final fun collectAccessibility (Z)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun disableUserInteractionTracking ()Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setActionEventMapper (Lcom/datadog/android/event/EventMapper;)Lcom/datadog/android/rum/RumConfiguration$Builder; + public final fun setBeforeSampling (Lcom/datadog/android/rum/BeforeSamplingCallback;)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setErrorEventMapper (Lcom/datadog/android/event/EventMapper;)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setInitialResourceIdentifier (Lcom/datadog/android/rum/metric/networksettled/InitialResourceIdentifier;)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setLastInteractionIdentifier (Lcom/datadog/android/rum/metric/interactiontonextview/LastInteractionIdentifier;)Lcom/datadog/android/rum/RumConfiguration$Builder; diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/BeforeSampling.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/BeforeSampling.kt new file mode 100644 index 0000000000..474d0c187f --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/BeforeSampling.kt @@ -0,0 +1,42 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum + +/** + * What the SDK is about to draw a new session with, handed to [BeforeSamplingCallback]: the rate + * that would apply (the console's where it published one, the value passed to init where it did + * not) and the console's custom values, decoded. + * + * @param sessionSampleRate the rate, between 0 and 100, that would decide this session. + * @param custom the console's custom values, or null when remote configuration is off or nothing + * is published. Same content as [RumMonitor.getRemoteConfig]. + */ +data class BeforeSamplingContext( + val sessionSampleRate: Float, + val custom: Map? +) + +/** + * The application's last word on session sampling, called synchronously each time a new session is + * about to be drawn. + * + * Return a rate to override the one the SDK was going to use — 100 always collects, 0 never does — + * or null to leave it alone. The typical use is an allow-list: keep every session of the handful of + * users you are debugging while the fleet stays at a low rate. + * + * It runs inside session creation, so it must be fast and must not block. A throw, or a rate + * outside 0..100, is ignored and the incoming rate applies: a mistake here must never take a + * customer's collection down with it. A session already under way is never re-decided. + */ +fun interface BeforeSamplingCallback { + + /** + * @param context the rate that would apply and the console's custom values. + * @return the rate to draw this session with, or null to keep [BeforeSamplingContext.sessionSampleRate]. + */ + fun sampleRate(context: BeforeSamplingContext): Float? +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt index 768fd74b04..53ecee42b2 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt @@ -136,6 +136,7 @@ object Rum { // FLASHCAT FORK - looked up when it fires rather than captured now: a session start // simply asks again, and there is nothing to ask with when the app did not opt in. onSessionDrawn = { rumFeature.remoteConfigController?.onSessionStarted() }, + beforeSampling = rumFeature.configuration.beforeSampling, writer = rumFeature.dataWriter, handler = Handler(Looper.getMainLooper()), telemetryEventHandler = TelemetryEventHandler( diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt index f6e7601d16..43e711dcdf 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt @@ -84,6 +84,24 @@ data class RumConfiguration internal constructor( return this } + /** + * Have the last word on session sampling. + * + * The callback runs synchronously each time a new session is about to be drawn, with the + * rate that would apply and the console's custom values; return a rate to override it, or + * null to leave it alone. The typical use is an allow-list: keep every session of the + * handful of users you are debugging while the fleet stays at a low rate. + * + * It is the last step of the draw, after the console's rate, precisely so an allow-list can + * keep collecting a visitor the console's rate would drop. + * + * @param callback the hook to consult at every draw. + */ + fun setBeforeSampling(callback: BeforeSamplingCallback): Builder { + rumConfig = rumConfig.copy(beforeSampling = callback) + return this + } + /** * Whether to collect accessibility attributes - this is disabled by default. * diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 38b94b7516..8c521ce0b7 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -36,6 +36,7 @@ import com.datadog.android.event.NoOpEventMapper import com.datadog.android.internal.flags.RumFlagEvaluationMessage import com.datadog.android.internal.system.BuildSdkVersionProvider import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.rum.BeforeSamplingCallback import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumSessionListener @@ -74,10 +75,10 @@ import com.datadog.android.rum.internal.metric.slowframes.DefaultUISlownessMetri import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.monitor.DatadogRumMonitor +import com.datadog.android.rum.internal.net.RumRequestFactory import com.datadog.android.rum.internal.remoteconfig.ProcessForegroundCallback import com.datadog.android.rum.internal.remoteconfig.RemoteConfigController import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore -import com.datadog.android.rum.internal.net.RumRequestFactory import com.datadog.android.rum.internal.startup.RumAppStartupDetector import com.datadog.android.rum.internal.startup.RumFirstDrawTimeReporter import com.datadog.android.rum.internal.startup.RumStartupScenario @@ -865,7 +866,10 @@ internal class RumFeature( val disableJankStats: Boolean, val insightsCollector: InsightsCollector, // FLASHCAT FORK - opt in to taking the sampling rates from the console. - val remoteConfigurationEnabled: Boolean = false + val remoteConfigurationEnabled: Boolean = false, + // FLASHCAT FORK - the host application's last word on the session draw, consulted after + // the console's rate. Null unless the app set one. + val beforeSampling: BeforeSamplingCallback? = null ) internal companion object { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt index 73b9dad5e7..3d4a4b84b6 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt @@ -14,6 +14,7 @@ import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.rum.BeforeSamplingCallback import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumSessionListener @@ -60,7 +61,9 @@ internal class RumApplicationScope( private val remoteConfig: RemoteConfigStore? = null, // FLASHCAT FORK - fired after each session draw, so the stored configuration is re-fetched on // the only rhythm that can matter. No-op when the app did not opt in. - private val onSessionDrawn: () -> Unit = {} + private val onSessionDrawn: () -> Unit = {}, + // FLASHCAT FORK - the host application's last word on the draw. Null unless the app set one. + private val beforeSampling: BeforeSamplingCallback? = null ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -75,6 +78,7 @@ internal class RumApplicationScope( sampleRate = sampleRate, remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, + beforeSampling = beforeSampling, backgroundTrackingEnabled = backgroundTrackingEnabled, trackFrustrations = trackFrustrations, viewChangedListener = this, @@ -216,7 +220,8 @@ internal class RumApplicationScope( rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, insightsCollector = insightsCollector, remoteConfig = remoteConfig, - onSessionDrawn = onSessionDrawn + onSessionDrawn = onSessionDrawn, + beforeSampling = beforeSampling ) childScopes.add(newSession) if (event !is RumRawEvent.StartView) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index e23299171a..e522c92a6d 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -7,6 +7,7 @@ package com.datadog.android.rum.internal.domain.scope import androidx.annotation.WorkerThread +import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.feature.Feature @@ -15,6 +16,8 @@ import com.datadog.android.api.storage.NoOpDataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.internal.profiling.ProfilerStopEvent +import com.datadog.android.rum.BeforeSamplingCallback +import com.datadog.android.rum.BeforeSamplingContext import com.datadog.android.rum.RumSessionListener import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.domain.InfoProvider @@ -26,9 +29,10 @@ import com.datadog.android.rum.internal.domain.display.DisplayInfo import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener -import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore +import com.datadog.android.rum.internal.remoteconfig.decodeCustomValues +import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.utils.percent import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.metric.interactiontonextview.LastInteractionIdentifier @@ -69,7 +73,10 @@ internal class RumSessionScope( private val remoteConfig: RemoteConfigStore? = null, // FLASHCAT FORK - fired after each draw, so the stored configuration is re-fetched on the only // rhythm that can matter: a changed value can only apply to the next session anyway. - private val onSessionDrawn: () -> Unit = {} + private val onSessionDrawn: () -> Unit = {}, + // FLASHCAT FORK - the host application's last word on the draw, consulted after the console's + // rate. Null unless the app set one. + private val beforeSampling: BeforeSamplingCallback? = null ) : RumScope { // FLASHCAT FORK - the rate the current session was actually drawn at. It is what events report @@ -176,12 +183,13 @@ internal class RumSessionScope( renewSession(event.eventTime, StartReason.EXPLICIT_STOP) } else if (event is RumRawEvent.SetForcedSession) { // FLASHCAT FORK - the escape hatch for "collect this user NOW": the application knows - // who needs debugging, the SDK only provides the switch. The session restarts so the - // forced draw applies from a clean session — RUM cannot flip the replay decision of a - // session already under way. Calling again while the forced session runs is a no-op, - // so a host calling on every screen does not shred sessions. - if (!(forcedSession && sessionState == State.TRACKED)) { - forcedSession = true + // who needs debugging, the SDK only provides the switch. From here on every draw keeps + // the session, for the lifetime of the process. + forcedSession = true + // A session already being collected keeps running: RUM cannot retro-collect what a + // running session already dropped, so cutting it in two would gain nothing. One that + // was NOT collected restarts now, so a collected one takes its place. + if (sessionState != State.TRACKED) { renewSession(event.eventTime, StartReason.EXPLICIT_STOP) // Forcing is a deliberate act of the host application; without this the renewal // is immediately re-expired when no user interaction happened yet. @@ -323,7 +331,10 @@ internal class RumSessionScope( // FLASHCAT FORK - read the console's rate here, at the one moment a session's fate is // decided. A session already running is never redrawn, so a rate arriving mid-session // cannot start or stop collecting for someone in the middle of using the app. - effectiveSampleRate = remoteConfig?.sessionSampleRate() ?: sampleRate + // Order matters: the console's rate first, then the app's own hook. The hook is the last + // word precisely so an allow-list can keep collecting a visitor the console's rate would + // drop. + effectiveSampleRate = askBeforeSampling(remoteConfig?.sessionSampleRate() ?: sampleRate) childScope?.sampleRate = effectiveSampleRate val keepSession = forcedSession || random.nextFloat() < effectiveSampleRate.percent() startReason = reason @@ -359,6 +370,32 @@ internal class RumSessionScope( onSessionDrawn() } + /** + * FLASHCAT FORK - asks the host application's hook for the rate to draw with. Anything + * unusable — a throw, a null, a rate outside 0..100 — leaves the incoming rate alone: a mistake + * in the host application must never take a customer's collection down with it. + */ + private fun askBeforeSampling(rate: Float): Float { + val hook = beforeSampling ?: return rate + val override = try { + val custom = decodeCustomValues(remoteConfig?.custom()) + hook.sampleRate(BeforeSamplingContext(sessionSampleRate = rate, custom = custom)) + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + sdkCore.internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { BEFORE_SAMPLING_THREW_MESSAGE.format(rate) }, + e + ) + null + } + return if (override == null || override.isNaN() || override < 0f || override > MAX_SAMPLE_RATE) { + rate + } else { + override + } + } + private fun updateSessionStateForSessionReplay(state: State, sessionId: String) { val keepSession = (state == State.TRACKED) sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.sendEvent( @@ -383,6 +420,11 @@ internal class RumSessionScope( internal const val RUM_SESSION_FORCED_BUS_MESSAGE_KEY = "sessionForced" internal const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" + private const val MAX_SAMPLE_RATE = 100f + + internal const val BEFORE_SAMPLING_THREW_MESSAGE = + "The beforeSampling callback failed; drawing this session at %s instead." + internal val DEFAULT_SESSION_INACTIVITY_NS = TimeUnit.MINUTES.toNanos(15) internal val DEFAULT_SESSION_MAX_DURATION_NS = TimeUnit.HOURS.toNanos(4) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt index 4e787c688c..77e24fc3f9 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt @@ -31,8 +31,8 @@ import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.ViewEndedMetricDispatcher import com.datadog.android.rum.internal.metric.interactiontonextview.InteractionToNextViewMetricResolver import com.datadog.android.rum.internal.metric.networksettled.NetworkSettledMetricResolver -import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener +import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.vitals.NoOpVitalMonitor import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.metric.interactiontonextview.LastInteractionIdentifier diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt index 53930e9a85..cf5135bf2a 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt @@ -41,9 +41,9 @@ import com.datadog.android.rum.internal.metric.interactiontonextview.Interaction import com.datadog.android.rum.internal.metric.interactiontonextview.InternalInteractionContext import com.datadog.android.rum.internal.metric.networksettled.InternalResourceContext import com.datadog.android.rum.internal.metric.networksettled.NetworkSettledMetricResolver -import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.monitor.StorageEvent +import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.toError import com.datadog.android.rum.internal.toLongTask import com.datadog.android.rum.internal.toView @@ -1357,7 +1357,9 @@ internal open class RumViewScope( // ignore it. configuration = ViewEvent.Configuration( sessionSampleRate = sampleRate, - rcVersion = drawnConfiguration?.version?.toLong() + // Omitted rather than sent as 0 before the first configuration arrives — + // the same shape iOS and HarmonyOS send, so one wire form means one thing. + rcVersion = drawnConfiguration?.version?.takeIf { it > 0 }?.toLong() ) ), connectivity = datadogContext.networkInfo.toViewConnectivity(), diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index cf8f7b3744..4f31497531 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -26,6 +26,7 @@ import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.telemetry.InternalTelemetryEvent.ApiUsage.AddOperationStepVital.ActionType import com.datadog.android.internal.thread.NamedCallable +import com.datadog.android.rum.BeforeSamplingCallback import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.ExperimentalRumApi import com.datadog.android.rum.RumActionType @@ -106,7 +107,9 @@ internal class DatadogRumMonitor( private val remoteConfig: RemoteConfigStore? = null, // FLASHCAT FORK - fired after each session draw, so the stored configuration is re-fetched on // the only rhythm that can matter. No-op when the app did not opt in. - private val onSessionDrawn: () -> Unit = {} + private val onSessionDrawn: () -> Unit = {}, + // FLASHCAT FORK - the host application's last word on the draw. Null unless the app set one. + private val beforeSampling: BeforeSamplingCallback? = null ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -131,7 +134,8 @@ internal class DatadogRumMonitor( rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, insightsCollector = insightsCollector, remoteConfig = remoteConfig, - onSessionDrawn = onSessionDrawn + onSessionDrawn = onSessionDrawn, + beforeSampling = beforeSampling ) internal val keepAliveRunnable = Runnable { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index 4c0ce77aa6..e4cb6b9e23 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -20,6 +20,8 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.internal.profiling.ProfilerStopEvent import com.datadog.android.internal.tests.stub.StubTimeProvider +import com.datadog.android.rum.BeforeSamplingCallback +import com.datadog.android.rum.BeforeSamplingContext import com.datadog.android.rum.RumSessionListener import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.domain.InfoProvider @@ -983,6 +985,27 @@ internal class RumSessionScopeTest { assertThat(context.sessionStartReason).isEqualTo(RumSessionScope.StartReason.EXPLICIT_STOP) } + @Test + fun `M keep the running session W handleEvent(SetForcedSession) { already collected }`( + @Forgery key: RumScopeKey + ) { + // Given a live session the draw already kept. It has to be started by an interaction: + // a session renewed with no interaction behind it expires on the very next event. + initializeTestedScope(100f, withMockChildScope = false) + testedScope.handleEvent(RumRawEvent.StartView(key, emptyMap()), fakeDatadogContext, mockEventWriteScope, mockWriter) + val collectedSessionId = testedScope.getRumContext().sessionId + assertThat(testedScope.getRumContext().sessionState).isEqualTo(RumSessionScope.State.TRACKED) + + // When + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + // RUM cannot retro-collect what a running session already dropped, so cutting a session + // that is already collected in two would gain nothing. Same behaviour as iOS and HarmonyOS. + assertThat(testedScope.getRumContext().sessionId).isEqualTo(collectedSessionId) + assertThat(testedScope.forcedSession).isTrue() + } + @Test fun `M keep the running forced session W handleEvent(SetForcedSession) { called again }`() { // Given @@ -1925,12 +1948,81 @@ internal class RumSessionScopeTest { ) } + // region beforeSampling + + @Test + fun `M draw with the hook's rate W handleEvent { beforeSampling overrides }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.sessionSampleRate()) doReturn 1f + initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig, beforeSampling = { 100f }) + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.effectiveSampleRate).isEqualTo(100f) + } + + @Test + fun `M see the console's rate W handleEvent { beforeSampling reads its context }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.sessionSampleRate()) doReturn 42f + whenever(remoteConfig.custom()) doReturn """{"vip":["a"]}""" + var seen: BeforeSamplingContext? = null + initializeTestedScope( + sampleRate = 100f, + remoteConfig = remoteConfig, + beforeSampling = { seen = it; null } + ) + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + // The hook is consulted AFTER the console, so what it sees is the rate that would apply. + assertThat(seen?.sessionSampleRate).isEqualTo(42f) + assertThat(seen?.custom).isEqualTo(mapOf("vip" to listOf("a"))) + } + + @Test + fun `M keep the incoming rate W handleEvent { beforeSampling returns nothing }`() { + initializeTestedScope(sampleRate = 30f, beforeSampling = { null }) + + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + assertThat(testedScope.effectiveSampleRate).isEqualTo(30f) + } + + @Test + fun `M keep the incoming rate W handleEvent { beforeSampling returns an impossible rate }`() { + initializeTestedScope(sampleRate = 30f, beforeSampling = { 150f }) + + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + assertThat(testedScope.effectiveSampleRate).isEqualTo(30f) + } + + @Test + fun `M keep collecting W handleEvent { beforeSampling throws }`() { + // A mistake in the host application must never take a customer's collection down with it. + initializeTestedScope(sampleRate = 30f, beforeSampling = { throw IllegalStateException("boom") }) + + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + assertThat(testedScope.effectiveSampleRate).isEqualTo(30f) + } + + // endregion + private fun initializeTestedScope( sampleRate: Float = 100f, withMockChildScope: Boolean = true, backgroundTrackingEnabled: Boolean? = null, remoteConfig: RemoteConfigStore? = null, - onSessionDrawn: () -> Unit = {} + onSessionDrawn: () -> Unit = {}, + beforeSampling: BeforeSamplingCallback? = null ) { testedScope = RumSessionScope( parentScope = mockParentScope, @@ -1958,7 +2050,8 @@ internal class RumSessionScopeTest { rumSessionScopeStartupManagerFactory = { mockRumSessionScopeStartupManager }, insightsCollector = mockInsightsCollector, remoteConfig = remoteConfig, - onSessionDrawn = onSessionDrawn + onSessionDrawn = onSessionDrawn, + beforeSampling = beforeSampling ) if (withMockChildScope) { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 5fd4f244f9..03d859b70f 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -57,9 +57,9 @@ import com.datadog.android.rum.internal.metric.interactiontonextview.InternalInt import com.datadog.android.rum.internal.metric.networksettled.InternalResourceContext import com.datadog.android.rum.internal.metric.networksettled.NetworkSettledMetricResolver import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener -import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.monitor.StorageEvent +import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.toAction import com.datadog.android.rum.internal.toError import com.datadog.android.rum.internal.toLongTask From a0a744e35f58fb9bc6006bf2db251023b67edba4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 06:59:06 -0700 Subject: [PATCH 17/19] refactor(rum): drop the draw record nothing reads The configuration a session was drawn under is held in memory and travels to the view scopes that report it, which is all it is for. It was also written to shared preferences on every session renewal, and nothing ever read it back: a session does not survive the process here, so there is nothing for a stored record to be restored into. What is left is a disk write per renewal and a JSON codec kept alive to serve it. The record itself, and everything that reports from it, is unchanged. --- .../internal/domain/scope/RumSessionScope.kt | 1 - .../remoteconfig/DrawnConfiguration.kt | 42 +----------------- .../remoteconfig/RemoteConfigStore.kt | 13 ------ .../domain/scope/RumSessionScopeTest.kt | 1 - .../remoteconfig/RemoteConfigStoreTest.kt | 43 ------------------- 5 files changed, 2 insertions(+), 98 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index e522c92a6d..6058a9c3bc 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -350,7 +350,6 @@ internal class RumSessionScope( sessionSampleRate = effectiveSampleRate ) } - drawnConfiguration?.let { remoteConfig?.storeDrawRecord(it) } childScope?.drawnConfiguration = drawnConfiguration sessionStartNs.set(time.nanoTime) rumSessionScopeStartupManager = rumSessionScopeStartupManagerFactory() diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt index 17ffc2c12b..8e21eac620 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt @@ -6,9 +6,6 @@ package com.datadog.android.rum.internal.remoteconfig -import org.json.JSONException -import org.json.JSONObject - /** * FLASHCAT FORK - the configuration a session was drawn under: the rate actually used at the draw * (the console's where it set one, the init value where it did not) and the remote settings @@ -17,44 +14,9 @@ import org.json.JSONObject * re-judged, so the metadata must be from its creation, not from whatever has arrived since. */ internal data class DrawnConfiguration( - /** The session this record belongs to; a record naming another session is stale and inert. */ + /** The session this record belongs to, so a record can never be read against another one. */ val sessionId: String, /** The remote settings version the draw read, or 0 when none was ever fetched. */ val version: Int, val sessionSampleRate: Float -) { - - fun toJsonString(): String = JSONObject() - .put(FIELD_SESSION_ID, sessionId) - .put(FIELD_VERSION, version) - .put(FIELD_SESSION_SAMPLE_RATE, sessionSampleRate.toDouble()) - .toString() - - companion object { - private const val FIELD_SESSION_ID = "id" - private const val FIELD_VERSION = "version" - private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" - - /** - * Parses a stored record, tolerating what older versions did not write: a field missing - * from an old record reads as if the console never set that knob, so an SDK upgrade - * changes nothing for a session already drawn. - */ - fun fromJsonString(json: String): DrawnConfiguration? = try { - val obj = JSONObject(json) - val sessionId = obj.optString(FIELD_SESSION_ID).takeIf { it.isNotEmpty() } - if (sessionId == null || !obj.has(FIELD_SESSION_SAMPLE_RATE)) { - null - } else { - DrawnConfiguration( - sessionId = sessionId, - version = obj.optInt(FIELD_VERSION, 0), - sessionSampleRate = obj.getDouble(FIELD_SESSION_SAMPLE_RATE).toFloat() - ) - } - } catch (e: JSONException) { - // Storage holding something we did not write is no record at all. - null - } - } -} +) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt index 67754785c9..62175c6260 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -54,18 +54,6 @@ internal class RemoteConfigStore( */ fun etag(): String? = preferences?.getString(etagKey(), null) - /** - * Which configuration the given session was drawn under, kept next to the values it was drawn - * from. The session id inside is the validity check: a record from a previous, expired session - * simply never matches again. - */ - fun storeDrawRecord(record: DrawnConfiguration) { - preferences?.edit()?.putString(drawRecordKey(), record.toJsonString())?.apply() - } - - fun readDrawRecord(): DrawnConfiguration? = - preferences?.getString(drawRecordKey(), null)?.let { DrawnConfiguration.fromJsonString(it) } - /** * Which version of the settings the stored rates came from, or null before the first answer. * Reported back on the next request so the console can say how far a change has reached — a @@ -127,7 +115,6 @@ internal class RemoteConfigStore( private fun etagKey() = "$storeKey.etag" - private fun drawRecordKey() = "$storeKey.draw" companion object { private const val PREFERENCES_NAME = "flashcat-rum-remote-config" diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index e4cb6b9e23..b7238e4ef8 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -1116,7 +1116,6 @@ internal class RumSessionScopeTest { // Then - the record is married to the session it drew, and the view scopes report from it val record = testedScope.drawnConfiguration assertThat(record?.sessionId).isEqualTo(testedScope.getRumContext().sessionId) - verify(remoteConfig).storeDrawRecord(record!!) verify(mockChildScope).drawnConfiguration = record } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt index f5775ec8cc..7e8303ce1b 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt @@ -146,49 +146,6 @@ internal class RemoteConfigStoreTest { // endregion - // region draw record - - @Test - fun `M read back the draw a session was recorded under W storeDrawRecord()`() { - val store = testedStore() - val record = DrawnConfiguration( - sessionId = "session-1", - version = 7, - sessionSampleRate = 42f - ) - - store.storeDrawRecord(record) - - assertThat(testedStore().readDrawRecord()).isEqualTo(record) - } - - @Test - fun `M tolerate a record an older version wrote W readDrawRecord() { fields missing }`() { - // A record written before the version field existed reads as version 0 — "no configuration - // was ever fetched" — so an SDK upgrade changes nothing for a session already drawn. - preferences.edit().putString( - "test-key.draw", - """{"id":"session-1","sessionSampleRate":42.0}""" - ).apply() - - assertThat(testedStore().readDrawRecord()).isEqualTo( - DrawnConfiguration( - sessionId = "session-1", - version = 0, - sessionSampleRate = 42f - ) - ) - } - - @Test - fun `M answer no record W readDrawRecord() { storage holds something we did not write }`() { - preferences.edit().putString("test-key.draw", "not json").apply() - - assertThat(testedStore().readDrawRecord()).isNull() - } - - // endregion - private fun testedStore(): RemoteConfigStore = RemoteConfigStore(appContext, "test-key", mock()) From adce02c64cf8d1c5c709747078fe54b8136abdba Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 07:38:59 -0700 Subject: [PATCH 18/19] fix(rum): read a configuration that carries no schema stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A response with no schema stamp at all was refused as a shape this SDK cannot read, because the absent-value sentinel was compared against the supported version like any other number. A body without a stamp is, by construction, the shape that existed before the stamp did — which is the shape this reader was written against. Refusing it switches remote configuration silently off against a server that merely predates the field, and nothing says so: the refusal takes the same path as a body we genuinely cannot read, so there is no error to notice. Only a stamp that is present and unrecognised is a refusal now, which is what the web SDK already did. The two no longer disagree about the same response. --- .../internal/remoteconfig/RemoteConfigController.kt | 9 ++++++++- .../remoteconfig/RemoteConfigControllerTest.kt | 12 +++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index 09c6265f4b..b5c810e7ab 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -229,7 +229,14 @@ internal class RemoteConfigController( // and a reader that guesses instead of checking is exactly what this field exists to // prevent — which is why it has to be honoured by the first SDK that ships, not by a // later one: only code already on the device can refuse. - if (json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT) != SUPPORTED_SCHEMA_VERSION) { + // + // No stamp at all is not a refusal. A body without one is, by construction, the shape that + // existed before the stamp did, which is the shape this reader was written against; + // refusing it would switch remote configuration silently off against a server that merely + // predates the field. Only a stamp we can see and do not recognise is a reason to refuse. + if (json.has(FIELD_SCHEMA_VERSION) && + json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT) != SUPPORTED_SCHEMA_VERSION + ) { logUnsupportedSchema(json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT)) return Outcome.UNSUPPORTED_SCHEMA } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index 6c4153bdd8..7f2b89cc37 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -517,13 +517,19 @@ internal class RemoteConfigControllerTest { } @Test - fun `M refuse the whole configuration W apply() { no schema at all }`() { + fun `M read the configuration W apply() { no schema at all }`() { + // A body with no stamp is, by construction, the shape that existed before the stamp did — + // the shape this reader was written against. Refusing it would switch remote configuration + // silently off against a server that merely predates the field, with nothing to say so. val outcome = testedController.apply( body(rum = """"sessionSampleRate":42""", schemaVersion = null) ) - assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNSUPPORTED_SCHEMA) - verify(store, never()).store(any()) + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.APPLIED) + argumentCaptor { + verify(store).store(capture()) + assertThat(firstValue.sessionSampleRate).isEqualTo(42f) + } } @Test From 2665e54c1fc5915e2791a1cb93ffa5c0100ca453 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 08:21:56 -0700 Subject: [PATCH 19/19] refactor(rum): keep only the part of the draw record anything reads Removing the record's persistence left two of its three fields with no reader at all: the rate the draw used travels down the scope chain as sampleRate and is what every event already reports, and the session id was only ever the validity check for the storage that is gone. Keeping them would be two records of one fact, and one record of nothing. The stamp check is also made strict. optInt would quietly turn the string "1" into 1 and accept a body that iOS and HarmonyOS refuse, and a field whose whole purpose is that every reader agrees about the same response cannot be the one place they disagree. A stamp that is present but not a number is refused; an explicit null reads as no stamp at all, which is what the other two do. --- .../internal/domain/scope/RumSessionScope.kt | 12 ++++------ .../remoteconfig/DrawnConfiguration.kt | 18 +++++++-------- .../remoteconfig/RemoteConfigController.kt | 11 ++++++++-- .../domain/scope/RumSessionScopeTest.kt | 12 ++++------ .../internal/domain/scope/RumViewScopeTest.kt | 6 +---- .../RemoteConfigControllerTest.kt | 22 +++++++++++++++++++ 6 files changed, 49 insertions(+), 32 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index 6058a9c3bc..3370c161f9 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -340,15 +340,11 @@ internal class RumSessionScope( startReason = reason sessionState = if (keepSession) State.TRACKED else State.NOT_TRACKED sessionId = UUID.randomUUID().toString() - // FLASHCAT FORK - remember what this session was drawn under, married to its id: the - // events of this session report these values for as long as it lives, and the record left - // in storage is inert the moment another id is drawn. + // FLASHCAT FORK - remember which console configuration this session was drawn under: its + // events report that version for as long as it lives, so an auditor can recover the exact + // settings from the console's history. drawnConfiguration = remoteConfig?.let { config -> - DrawnConfiguration( - sessionId = sessionId, - version = config.appliedVersion() ?: 0, - sessionSampleRate = effectiveSampleRate - ) + DrawnConfiguration(version = config.appliedVersion() ?: 0) } childScope?.drawnConfiguration = drawnConfiguration sessionStartNs.set(time.nanoTime) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt index 8e21eac620..98c8c5f8dc 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt @@ -7,16 +7,16 @@ package com.datadog.android.rum.internal.remoteconfig /** - * FLASHCAT FORK - the configuration a session was drawn under: the rate actually used at the draw - * (the console's where it set one, the init value where it did not) and the remote settings - * version it came from. Events carry these instead of the init values, so server-side - * extrapolation and audits line up with the draw that kept the session — a session is never - * re-judged, so the metadata must be from its creation, not from whatever has arrived since. + * FLASHCAT FORK - which console configuration a session was drawn under. Events carry it so an + * auditor can recover the exact settings from the console's version history — a session is never + * re-judged, so the version must be the one in force at its creation, not whatever has arrived + * since. + * + * Only the version lives here. The rate the draw actually used travels down the scope chain as + * `sampleRate` and is what every event already reports, so keeping a second copy of it would be + * two records of one fact. */ internal data class DrawnConfiguration( - /** The session this record belongs to, so a record can never be read against another one. */ - val sessionId: String, /** The remote settings version the draw read, or 0 when none was ever fetched. */ - val version: Int, - val sessionSampleRate: Float + val version: Int ) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index b5c810e7ab..ab34b0f5a4 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -234,8 +234,15 @@ internal class RemoteConfigController( // existed before the stamp did, which is the shape this reader was written against; // refusing it would switch remote configuration silently off against a server that merely // predates the field. Only a stamp we can see and do not recognise is a reason to refuse. - if (json.has(FIELD_SCHEMA_VERSION) && - json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT) != SUPPORTED_SCHEMA_VERSION + // A stamp that is not a number is not a stamp: optInt would quietly turn the string "1" + // into 1 and accept a body the other SDKs refuse, and the point of this field is that + // every reader agrees about the same response. + val stamped = json.has(FIELD_SCHEMA_VERSION) && !json.isNull(FIELD_SCHEMA_VERSION) + if (stamped && + ( + json.opt(FIELD_SCHEMA_VERSION) !is Number || + json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT) != SUPPORTED_SCHEMA_VERSION + ) ) { logUnsupportedSchema(json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT)) return Outcome.UNSUPPORTED_SCHEMA diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index b7238e4ef8..6447711e56 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -1078,11 +1078,7 @@ internal class RumSessionScopeTest { // Then assertThat(testedScope.effectiveSampleRate).isEqualTo(42f) assertThat(testedScope.drawnConfiguration).isEqualTo( - DrawnConfiguration( - sessionId = context.sessionId, - version = 7, - sessionSampleRate = 42f - ) + DrawnConfiguration(version = 7) ) } @@ -1100,7 +1096,6 @@ internal class RumSessionScopeTest { // Then - the draw used the init values, and version 0 says no configuration was ever fetched assertThat(testedScope.effectiveSampleRate).isEqualTo(80f) assertThat(testedScope.drawnConfiguration?.version).isZero() - assertThat(testedScope.drawnConfiguration?.sessionSampleRate).isEqualTo(80f) } @Test @@ -1108,14 +1103,15 @@ internal class RumSessionScopeTest { // Given val remoteConfig = mock() whenever(remoteConfig.sessionSampleRate()) doReturn 42f + whenever(remoteConfig.appliedVersion()) doReturn 9 initializeTestedScope(remoteConfig = remoteConfig) // When testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) - // Then - the record is married to the session it drew, and the view scopes report from it + // Then - the version in force at the draw travels to the view scopes, which report it val record = testedScope.drawnConfiguration - assertThat(record?.sessionId).isEqualTo(testedScope.getRumContext().sessionId) + assertThat(record?.version).isEqualTo(9) verify(mockChildScope).drawnConfiguration = record } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 03d859b70f..b5d8152a40 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -654,11 +654,7 @@ internal class RumViewScopeTest { @Forgery key: RumScopeKey ) { // Given - val drawnConfiguration = DrawnConfiguration( - sessionId = fakeParentContext.sessionId, - version = 7, - sessionSampleRate = fakeSampleRate - ) + val drawnConfiguration = DrawnConfiguration(version = 7) testedScope = newRumViewScope(trackFrustrations = true, drawnConfiguration = drawnConfiguration) mockSessionReplayContext(testedScope) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index 7f2b89cc37..d63b0ae131 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -516,6 +516,28 @@ internal class RemoteConfigControllerTest { verify(store, never()).store(any()) } + @Test + fun `M refuse the whole configuration W apply() { schema is not a number }`() { + // org.json would turn "1" into 1 and accept a body the other SDKs refuse. The point of this + // field is that every reader agrees about the same response. + val outcome = testedController.apply( + """{"schema_version":"1","version":3,"enabled":true,"rum":{"sessionSampleRate":42}}""" + ) + + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNSUPPORTED_SCHEMA) + verify(store, never()).store(any()) + } + + @Test + fun `M read the configuration W apply() { schema is an explicit null }`() { + // Absent and null say the same thing: nothing was stamped. + val outcome = testedController.apply( + """{"schema_version":null,"version":3,"enabled":true,"rum":{"sessionSampleRate":42}}""" + ) + + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.APPLIED) + } + @Test fun `M read the configuration W apply() { no schema at all }`() { // A body with no stamp is, by construction, the shape that existed before the stamp did —