From 758845bf79935bd601a6aeffd342966f04c6afec Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 06:03:15 -0700 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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 — From dc0f36ce3ddfdfd752ffe428b1af1a06c766e823 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 06:54:26 -0700 Subject: [PATCH 20/30] fix(rum): sweep the stored configuration of versions the device no longer runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store key covers the app version, so every release a device installs leaves an entry behind, and nothing ever read or removed the one the release before it used. They accumulated for good inside a preferences file that is parsed in full at every launch. Every write now stamps when it happened, and the first fetch of a launch removes the entries nothing has refreshed for two days. Age is the only thing that can separate an abandoned entry from a live one, and the threshold has to clear the longest a live entry can legitimately stay silent: the longest session, after which a new one fetches again, plus the longest outage worth surviving, since a failed fetch stores nothing. An unchanged answer comes back as a 304 with no body, which was the one way to reach an entry and store nothing. A settled client meets it at almost every fetch, so its entry now has its age refreshed there too. Without that, two SDK instances in one app would each decide the other's settled entry was abandoned and delete it at every launch. This store's own entry is never a candidate, whatever its age says: it is certainly in use, and on a first launch it has no write time at all. The sweep runs on the worker thread, before anything is stored, and once per launch — repeating it would walk the preferences file again at every session start to learn nothing new. Releasing the in-flight flag moves into a finally while it does: every later fetch is gated on that flag, so anything that got out of fetchOnce without clearing it would have ended remote configuration for the rest of the process, silently. --- .../remoteconfig/RemoteConfigController.kt | 36 ++++- .../remoteconfig/RemoteConfigStore.kt | 123 +++++++++++++++++- .../RemoteConfigControllerTest.kt | 53 ++++++++ .../remoteconfig/RemoteConfigStoreTest.kt | 119 ++++++++++++++++- 4 files changed, 321 insertions(+), 10 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 ab34b0f5a4..89362e7d6d 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 @@ -59,6 +59,9 @@ internal class RemoteConfigController( private var refreshOnForeground: Boolean = false private val inFlight = AtomicBoolean(false) + + /** Whether the entries of app versions this device no longer runs have been cleared yet. */ + private val swept = AtomicBoolean(false) private var failedAttempts = 0 private var pendingRetry: ScheduledFuture<*>? = null @@ -117,6 +120,27 @@ internal class RemoteConfigController( @WorkerThread private fun fetchOnce() { + try { + fetchAndApply() + } finally { + // Released whatever happened above, because every later fetch — a new session, a + // return to the foreground, a retry — is gated on this flag. Anything that got out of + // here without clearing it would end remote configuration for the rest of the + // process's life, silently and with nothing left to ask again. + inFlight.set(false) + } + } + + @WorkerThread + private fun fetchAndApply() { + // Housekeeping, once per launch and here rather than at construction: this is the first + // place that is both off the main thread — nothing about remote configuration may hold up + // initialisation — and certain to run before anything is stored. Repeating it at every + // fetch would walk the preferences file again at every session start to learn nothing new. + if (swept.compareAndSet(false, true)) { + store.sweepAbandoned() + } + // 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() @@ -133,8 +157,13 @@ internal class RemoteConfigController( 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 + // but the ask itself succeeded, and no retry is owed. The entry is still marked + // as in use, because this is the one answer that stores nothing and the sweep + // reads nothing but age. + response.code == HTTP_NOT_MODIFIED -> { + store.touch() + true + } response.isSuccessful -> { val payload = response.body?.string() if (payload == null) { @@ -158,7 +187,8 @@ internal class RemoteConfigController( false } - inFlight.set(false) + // The flag this fetch holds is released by the caller's `finally`, not here: a retry only + // schedules work for later, and the runnable it schedules takes the flag for itself. if (!succeeded) scheduleRetry() } 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 62175c6260..17884f9496 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 @@ -10,6 +10,7 @@ import android.content.Context import android.content.SharedPreferences import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext +import java.util.concurrent.TimeUnit /** * Holds the remote configuration the console last sent for this application. @@ -24,7 +25,17 @@ import com.datadog.android.api.context.DatadogContext internal class RemoteConfigStore( appContext: Context, private val storeKey: String, - private val internalLogger: InternalLogger + private val internalLogger: InternalLogger, + /** + * Wall clock, deliberately not [android.os.SystemClock.elapsedRealtime]: an entry's age has to + * survive the process ending and the device rebooting, which is exactly what an elapsed-time + * clock forgets. + * + * A wall clock can be moved, and that is the accepted cost. Moved back, an abandoned entry + * looks younger and is swept later; moved forward, a live one may be swept early and its next + * session runs on the init values before it is written back. Both recover on their own. + */ + private val currentTimeMs: () -> Long = System::currentTimeMillis ) { private val preferences: SharedPreferences? = try { @@ -91,9 +102,73 @@ internal class RemoteConfigStore( } else { editor.putString(etagKey(), values.etag) } + editor.putLong(writeTimeKey(), currentTimeMs()) editor.apply() } + /** + * Records that this entry is still in use, without changing what it holds. + * + * There is one way to reach an entry and store nothing: an unchanged answer comes back as a + * 304 with no body. A client that has settled — the common case, since most fetches find the + * configuration unchanged — would otherwise never refresh its entry's age again, and two SDK + * instances in one app, each sweeping on behalf of its own key, would end up deleting each + * other's settled entry at every launch. + * + * Nothing guards against there being no entry: a 304 can only answer a request that carried an + * If-None-Match, which can only have come from a stored validator. + */ + fun touch() { + preferences?.edit()?.putLong(writeTimeKey(), currentTimeMs())?.apply() + } + + /** + * Removes the entries of app versions this device is no longer running. + * + * The key covers the app version, so every release the device installs leaves one behind, and + * nothing read or removed them again — they accumulated for good inside a preferences file + * that is parsed in full at every launch. Age is what separates an abandoned entry from a live + * one: a store that is still being read is also being written, at the latest by [touch] when + * its answer comes back unchanged. + * + * This store's own entry is never a candidate, whatever its age says. It is the one entry that + * is certainly in use — the caller is about to read it — and on a first launch it has no write + * time yet at all. + */ + fun sweepAbandoned() { + val preferences = this.preferences ?: return + val now = currentTimeMs() + + // Read once, into keys of our own, before anything is removed: the map a preferences + // implementation hands back may be its live one, and editing while walking it would be + // undefined. + val stored = preferences.all + val entryKeys = stored.keys.mapNotNull(::entryKeyOf).toSet() + + val editor = preferences.edit() + var abandoned = false + for (entryKey in entryKeys) { + if (entryKey == storeKey) continue + // Absent, or holding something we did not write, reads as older than any threshold. + val writtenAtMs = stored["$entryKey$SUFFIX_WRITE_TIME"] as? Long ?: NEVER_WRITTEN + if (now - writtenAtMs <= MAX_ENTRY_AGE_MS) continue + FIELD_SUFFIXES.forEach { editor.remove("$entryKey$it") } + abandoned = true + } + if (abandoned) editor.apply() + } + + /** + * The entry a stored key belongs to, or null when the key is not one of ours. Splitting on the + * known suffixes rather than on the last separator is what keeps a store key free to contain + * one: a service name and an app version both routinely do. + */ + private fun entryKeyOf(key: String): String? { + if (!key.startsWith(STORE_KEY_PREFIX)) return null + val suffix = FIELD_SUFFIXES.firstOrNull { key.endsWith(it) } ?: return null + return key.removeSuffix(suffix) + } + private fun read(key: String): Float? { val stored = preferences?.getFloat(key, ABSENT) ?: ABSENT return if (stored == ABSENT) null else stored @@ -107,13 +182,15 @@ internal class RemoteConfigStore( } } - private fun sessionKey() = "$storeKey.sessionSampleRate" + private fun sessionKey() = "$storeKey$SUFFIX_SESSION_SAMPLE_RATE" + + private fun versionKey() = "$storeKey$SUFFIX_VERSION" - private fun versionKey() = "$storeKey.version" + private fun customKey() = "$storeKey$SUFFIX_CUSTOM" - private fun customKey() = "$storeKey.custom" + private fun etagKey() = "$storeKey$SUFFIX_ETAG" - private fun etagKey() = "$storeKey.etag" + private fun writeTimeKey() = "$storeKey$SUFFIX_WRITE_TIME" companion object { @@ -132,6 +209,42 @@ internal class RemoteConfigStore( private const val ABSENT = -1f private const val ABSENT_VERSION = -1 + // One entry is spread over several keys, all of them derived from the store key by these + // suffixes. Named here once because two things read them: the accessors that build a key, + // and the sweep that has to take an entry apart again. + private const val SUFFIX_SESSION_SAMPLE_RATE = ".sessionSampleRate" + private const val SUFFIX_VERSION = ".version" + private const val SUFFIX_CUSTOM = ".custom" + private const val SUFFIX_ETAG = ".etag" + private const val SUFFIX_WRITE_TIME = ".writtenAt" + + private val FIELD_SUFFIXES = listOf( + SUFFIX_SESSION_SAMPLE_RATE, + SUFFIX_VERSION, + SUFFIX_CUSTOM, + SUFFIX_ETAG, + SUFFIX_WRITE_TIME + ) + + /** + * How long an entry may go unrefreshed before [sweepAbandoned] takes it for the entry of a + * version this device no longer runs. + * + * The threshold has to clear the longest a live entry can legitimately stay silent: the + * longest session (four hours — see `RumSessionScope.DEFAULT_SESSION_MAX_DURATION_NS`, + * after which a new session fetches again) plus the longest endpoint outage worth + * surviving, since a failed fetch stores nothing. Two days leaves better than a day and a + * half of outage. + * + * Erring long is deliberate: sweeping an entry too early costs the next session its + * remote rates, keeping a dead one costs a few hundred bytes. + */ + private val MAX_ENTRY_AGE_MS = TimeUnit.DAYS.toMillis(2) + + // Older than any threshold without reaching for a sentinel that could underflow the + // subtraction: an entry stamped at the epoch is abandoned by any reading. + private const val NEVER_WRITTEN = 0L + internal const val STORAGE_UNAVAILABLE_MESSAGE = "Unable to open the remote configuration store; the values passed to init will apply." 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 d63b0ae131..549697eb15 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 @@ -14,6 +14,7 @@ import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -251,6 +252,58 @@ internal class RemoteConfigControllerTest { verify(executor, never()).schedule(any(), any(), any()) } + @Test + fun `M mark the entry as still in use W fetch answers not modified`() { + // The one answer that stores nothing. A settled client meets it at almost every fetch, and + // the sweep reads nothing but age, so without this its entry would stop looking in use. + whenever(call.execute()).thenReturn(response(304, "")) + + runPendingFetch() + + verify(store).touch() + } + + @Test + fun `M clear the entries of versions the device no longer runs W the first fetch`() { + whenever(call.execute()).thenReturn(response(200, body())) + + runPendingFetch() + + verify(store).sweepAbandoned() + } + + @Test + fun `M go on fetching W housekeeping throws`() { + // Every later fetch is gated on the in-flight flag, so anything that escaped without + // clearing it would end remote configuration for the rest of the process — silently. + whenever(store.sweepAbandoned()).thenThrow(RuntimeException("preferences are having a day")) + whenever(call.execute()).thenReturn(response(200, body())) + + testedController.start() + argumentCaptor { + verify(executor).execute(capture()) + assertThatThrownBy { firstValue.run() }.isInstanceOf(RuntimeException::class.java) + } + testedController.onSessionStarted() + + verify(executor, times(2)).execute(any()) + } + + @Test + fun `M sweep once a launch W several fetches`() { + // Walking the preferences file again at every session start would learn nothing new. + whenever(call.execute()).thenReturn(response(200, body())) + + runPendingFetch() + testedController.onSessionStarted() + argumentCaptor { + verify(executor, times(2)).execute(capture()) + allValues.last().run() + } + + verify(store, times(1)).sweepAbandoned() + } + @Test fun `M store the validator the answer came with W fetch succeeds`() { whenever(call.execute()).thenReturn(response(200, body(), etag = "\"v42\"")) 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 7e8303ce1b..013613632b 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,8 +146,113 @@ internal class RemoteConfigStoreTest { // endregion - private fun testedStore(): RemoteConfigStore = - RemoteConfigStore(appContext, "test-key", mock()) + // region sweeping the entries of versions this device no longer runs + + @Test + fun `M remove an entry nothing has refreshed for longer than the threshold W sweepAbandoned()`() { + // The key covers the app version, so every release the device installs leaves one behind. + storeUnder(OTHER_VERSION_KEY, atMs = NOW - THREE_DAYS_MS) + val store = testedStore(nowMs = NOW) + + store.sweepAbandoned() + + assertThat(preferences.all.keys.filter { it.startsWith(OTHER_VERSION_KEY) }).isEmpty() + } + + @Test + fun `M keep an entry something refreshed recently W sweepAbandoned()`() { + storeUnder(OTHER_VERSION_KEY, atMs = NOW - ONE_DAY_MS) + val store = testedStore(nowMs = NOW) + + store.sweepAbandoned() + + assertThat(preferences.all.keys.filter { it.startsWith(OTHER_VERSION_KEY) }).isNotEmpty() + } + + @Test + fun `M remove every key of an abandoned entry W sweepAbandoned()`() { + // An entry is spread over several keys, and leaving any of them behind leaks just as well. + storeUnder(OTHER_VERSION_KEY, atMs = NOW - THREE_DAYS_MS) + val before = preferences.all.keys.count { it.startsWith(OTHER_VERSION_KEY) } + + testedStore(nowMs = NOW).sweepAbandoned() + + assertThat(before).isGreaterThan(1) + assertThat(preferences.all.keys.none { it.startsWith(OTHER_VERSION_KEY) }).isTrue() + } + + @Test + fun `M never remove its own entry W sweepAbandoned() { however old it looks }`() { + // The one entry certainly in use: the caller is about to read it. + storeUnder(STORE_KEY, atMs = NOW - THREE_DAYS_MS) + val store = testedStore(nowMs = NOW) + + store.sweepAbandoned() + + assertThat(store.appliedVersion()).isEqualTo(7) + assertThat(store.sessionSampleRate()).isEqualTo(42f) + } + + @Test + fun `M leave alone every key it did not write W sweepAbandoned()`() { + preferences.edit().putString("a-key-the-host-app-owns", "not ours").apply() + storeUnder(OTHER_VERSION_KEY, atMs = NOW - THREE_DAYS_MS) + + testedStore(nowMs = NOW).sweepAbandoned() + + assertThat(preferences.getString("a-key-the-host-app-owns", null)).isEqualTo("not ours") + } + + @Test + fun `M take a store key containing separators apart correctly W sweepAbandoned()`() { + // A service name and an app version both routinely contain dots, so the suffix cannot be + // found by splitting on the last one. + val dotted = "${RemoteConfigStore.STORE_KEY_PREFIX}host|app|com.example.shop|prod|1.2.3" + storeUnder(dotted, atMs = NOW - THREE_DAYS_MS) + + testedStore(nowMs = NOW).sweepAbandoned() + + assertThat(preferences.all.keys.none { it.startsWith(dotted) }).isTrue() + } + + @Test + fun `M record when it was written W store()`() { + val store = testedStore(nowMs = NOW) + + store.store(RemoteConfigValues(42f, 7)) + + assertThat(preferences.getLong("$STORE_KEY.writtenAt", 0L)).isEqualTo(NOW) + } + + @Test + fun `M refresh the write time without changing the values W touch()`() { + // The 304 path: the answer is unchanged, so nothing is stored, and age is all the sweep + // reads. Without this a settled client's entry would never look in use again. + val store = testedStore(nowMs = NOW - THREE_DAYS_MS) + store.store(RemoteConfigValues(42f, 7, custom = """{"debug":true}""", etag = "\"v7\"")) + + testedStore(nowMs = NOW).touch() + + assertThat(preferences.getLong("$STORE_KEY.writtenAt", 0L)).isEqualTo(NOW) + assertThat(store.sessionSampleRate()).isEqualTo(42f) + assertThat(store.appliedVersion()).isEqualTo(7) + assertThat(store.etag()).isEqualTo("\"v7\"") + } + + // endregion + + /** Writes a complete entry under [key], stamped as written at [atMs]. */ + private fun storeUnder(key: String, atMs: Long) { + RemoteConfigStore(appContext, key, mock()) { atMs } + .store(RemoteConfigValues(42f, 7, custom = """{"debug":true}""", etag = "\"v7\"")) + } + + private fun testedStore(nowMs: Long? = null): RemoteConfigStore = + if (nowMs == null) { + RemoteConfigStore(appContext, STORE_KEY, mock()) + } else { + RemoteConfigStore(appContext, STORE_KEY, mock()) { nowMs } + } private fun datadogContext(): DatadogContext { val context = mock() @@ -218,5 +323,15 @@ internal class RemoteConfigStoreTest { private const val ENV = "staging" private const val APP_VERSION = "1.2.3" private const val SDK_VERSION = "9.9.9" + + // Shaped like a real store key, and carrying the prefix, so the sweep considers it at all: + // a key it skips for the wrong reason would let the "never remove its own entry" test pass + // with the guard gone. + private val STORE_KEY = "${RemoteConfigStore.STORE_KEY_PREFIX}host|app|shop|prod|1.0.0" + private val OTHER_VERSION_KEY = "${RemoteConfigStore.STORE_KEY_PREFIX}host|app|shop|prod|0.9.0" + + private const val NOW = 1_800_000_000_000L + private const val ONE_DAY_MS = 24L * 60 * 60 * 1000 + private const val THREE_DAYS_MS = 3 * ONE_DAY_MS } } From 6be288ea30d9e5fd404b9d7a9eab2eb6e5a71e57 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 19:25:07 -0700 Subject: [PATCH 21/30] style(rum): drop a blank line ktlint counts twice --- .../android/rum/internal/remoteconfig/RemoteConfigStore.kt | 1 - 1 file changed, 1 deletion(-) 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 17884f9496..99186395ca 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 @@ -192,7 +192,6 @@ internal class RemoteConfigStore( private fun writeTimeKey() = "$storeKey$SUFFIX_WRITE_TIME" - companion object { private const val PREFERENCES_NAME = "flashcat-rum-remote-config" From 9a890fb76f075060770495b96f55740b4d4b06ae Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 19:32:54 -0700 Subject: [PATCH 22/30] style(rum): apply ktlint formatting to the files this branch changed --- .../com/datadog/android/rum/internal/RumFeature.kt | 1 + .../rum/internal/domain/scope/RumSessionScopeTest.kt | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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 8c521ce0b7..95bb7cbdf6 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 @@ -950,6 +950,7 @@ 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" 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 6447711e56..76006fa431 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 @@ -992,7 +992,12 @@ internal class RumSessionScopeTest { // 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) + testedScope.handleEvent( + RumRawEvent.StartView(key, emptyMap()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) val collectedSessionId = testedScope.getRumContext().sessionId assertThat(testedScope.getRumContext().sessionState).isEqualTo(RumSessionScope.State.TRACKED) @@ -1969,7 +1974,10 @@ internal class RumSessionScopeTest { initializeTestedScope( sampleRate = 100f, remoteConfig = remoteConfig, - beforeSampling = { seen = it; null } + beforeSampling = { + seen = it + null + } ) // When From eb9ec24176b091026a6ac2c71fb21e7791a0c451 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 3 Sep 2026 21:20:06 -0700 Subject: [PATCH 23/30] fix(rum): carry a new session's draw into the view that survives its renewal A renewal replaces the scopes of the views that are still open, and the session scope writes the new draw onto the view manager just before asking for that. The manager was not handing those two values down: `renew()` built the replacement from the ending view's own `sampleRate` and left `drawnConfiguration` at its default of null. That view is the first view of the new session, and the intake takes a session's sample rate and configuration version from its first view and never restates them, so the whole session was recorded under the rate of the session that had just ended and under no configuration version at all. Weighted counts came out wrong by the ratio between the two rates, and a session drawn under a newly published configuration was not counted as having reached it. It happens wherever a view is still open when a session renews: an inactivity or maximum-duration renewal in the foreground, a configuration published for immediate activation, and a session forced by the host application. `renew()` now takes the draw it is renewing into, and the manager passes the values it was just given. The existing test asserted that a renewed view kept the old scope's rate, which is the behaviour above; it now passes a rate and a configuration that differ from the ending view's and asserts the new one takes them. --- .../internal/domain/scope/RumViewManagerScope.kt | 5 ++++- .../rum/internal/domain/scope/RumViewScope.kt | 15 ++++++++++++++- .../rum/internal/domain/scope/RumViewScopeTest.kt | 13 +++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) 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 77e24fc3f9..961f267d04 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 @@ -161,7 +161,10 @@ internal class RumViewManagerScope( internal fun renewViewScopes(eventTime: Time) { val newChildScope = childrenScopes.map { rumViewScope -> - rumViewScope.renew(eventTime) + // FLASHCAT FORK - the session scope has just written this scope's `sampleRate` and + // `drawnConfiguration` with the draw the renewed views belong to; handing them down is + // what makes the surviving view report the new session rather than the one that ended. + rumViewScope.renew(eventTime, sampleRate, drawnConfiguration) } childrenScopes.clear() childrenScopes.addAll(newChildScope) 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 cf5135bf2a..a59e3deff6 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 @@ -449,7 +449,19 @@ internal open class RumViewScope( return !stopped } - internal fun renew(newEventTime: Time): RumViewScope { + /** + * FLASHCAT FORK - [sampleRate] and [drawnConfiguration] are passed in rather than copied from + * this scope, because a renewal is where a new session's draw takes effect: the view that + * survives it belongs to the session that has just been drawn, not to the one that ended. It is + * also that session's FIRST view, which is the one the intake reads the session's rate and + * configuration version from, so carrying this scope's own values across would misreport the + * whole session. + */ + internal fun renew( + newEventTime: Time, + sampleRate: Float, + drawnConfiguration: DrawnConfiguration? + ): RumViewScope { return RumViewScope( parentScope = this, sdkCore = sdkCore, @@ -466,6 +478,7 @@ internal open class RumViewScope( type = type, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledMetricResolver = networkSettledMetricResolver, viewEndedMetricDispatcher = viewEndedMetricDispatcher, 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 b5d8152a40..e364afe961 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 @@ -8486,9 +8486,15 @@ internal class RumViewScopeTest { fun `M return a new RumViewScope W renew the current one`() { // Given val expectedTime = Time(nanoTime = fakeEventTime.nanoTime) + // FLASHCAT FORK - both deliberately differ from what this scope holds. A renewal carries a + // view into the session that has just been drawn, so it must take the draw it is handed + // rather than copy the one that ended - and this scope's own values are exactly what it + // would copy if that regressed. + val expectedSampleRate = testedScope.sampleRate + 1f + val expectedDrawnConfiguration = DrawnConfiguration(version = 11) // When - val newScope = testedScope.renew(expectedTime) + val newScope = testedScope.renew(expectedTime, expectedSampleRate, expectedDrawnConfiguration) assertThat(newScope.key).isEqualTo(testedScope.key) assertThat(newScope.firstPartyHostHeaderTypeResolver).isEqualTo(testedScope.firstPartyHostHeaderTypeResolver) @@ -8496,7 +8502,10 @@ internal class RumViewScopeTest { assertThat(newScope.memoryVitalMonitor).isEqualTo(testedScope.memoryVitalMonitor) assertThat(newScope.frameRateVitalMonitor).isEqualTo(testedScope.frameRateVitalMonitor) assertThat(newScope.type).isEqualTo(testedScope.type) - assertThat(newScope.sampleRate).isEqualTo(testedScope.sampleRate) + assertThat(newScope.sampleRate).isEqualTo(expectedSampleRate) + assertThat(newScope.sampleRate).isNotEqualTo(testedScope.sampleRate) + assertThat(newScope.drawnConfiguration).isEqualTo(expectedDrawnConfiguration) + assertThat(newScope.drawnConfiguration).isNotEqualTo(testedScope.drawnConfiguration) assertThat(newScope.url).isEqualTo(testedScope.url) assertThat(newScope.viewAttributes).isEqualTo(testedScope.viewAttributes) assertThat(newScope.stoppedNanos).isEqualTo(expectedTime.nanoTime) From b19727a7e5bbd68e52cba0688d7862c0c9697a45 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 3 Sep 2026 21:20:20 -0700 Subject: [PATCH 24/30] fix(rum): make a forced session mean what its documentation says `setForcedSession()` promised that the forced state lasts for the process lifetime and that the current session is restarted. Neither was true, and a forced session also reported itself in a way no other SDK does. The flag lived on the session scope, so `stopSession()` - the ordinary thing to do on logout - left it behind: the next interaction built a fresh session scope that was drawn as if the application had never asked. It now lives on the application scope, which outlives any one session and hands it to every session it makes. The documentation's other claim is corrected instead: a session already being collected keeps running, because RUM cannot recover what a session already dropped. Events from a forced session reported the rate it would have been drawn at and the configuration version in force. It was not drawn, so it now reports the rate that describes it - every session like it is kept - and no version. Reporting the drawn rate had the intake weight one deliberately kept session as the whole population that rate implies, and left nothing to tell it apart from a lucky draw. Two events are also no longer acted on where doing so achieves nothing. A reset of a forced session would only replace it with an identical forced session, at the cost of the view the user is on, so it is ignored - which is the path a configuration published for immediate activation takes. A reset or a force that reaches a stopped, draining session scope would mint a session id under a scope whose own context already reports the session as inactive, and announce that session to the host application's listener, so both are ignored there too. Adds coverage for the receiving half of forced Session Replay, which had none: a renewal message carrying the forced flag records regardless of the replay sampler, and one without it - the shape of every message written before the flag existed - still does not. --- .../com/datadog/android/rum/RumMonitor.kt | 15 +++-- .../domain/scope/RumApplicationScope.kt | 16 ++++- .../internal/domain/scope/RumSessionScope.kt | 59 ++++++++++++++----- .../domain/scope/RumApplicationScopeTest.kt | 56 ++++++++++++++++++ .../domain/scope/RumSessionScopeTest.kt | 59 ++++++++++++++++++- .../internal/SessionReplayFeatureTest.kt | 54 +++++++++++++++++ 6 files changed, 235 insertions(+), 24 deletions(-) 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 b45050710a..0a68bf642d 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 @@ -303,11 +303,18 @@ interface RumMonitor { fun stopSession() /** - * Forces the session to be collected, with Session Replay, regardless of the configured sample + * Forces sessions 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. + * flow). + * + * A session already being collected keeps running: RUM cannot recover what a session already + * dropped, so cutting it in two would gain nothing. One that was not being collected is + * restarted straight away, so that a collected session takes its place. + * + * The forced state lasts for the process lifetime and survives [stopSession], so every session + * that follows is collected too; decide on each app start whether to call again. Events from a + * forced session report a sample rate of 100 and no configuration version, because the session + * was kept whatever the rates said. */ fun setForcedSession() 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 3d4a4b84b6..36a036dc4f 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 @@ -116,6 +116,13 @@ internal class RumApplicationScope( private var lastActiveViewInfo: RumViewInfo? = null private var isAppStartedEventSent = false + // FLASHCAT FORK - whether the host application has called `setForcedSession()`. It lives here + // rather than on the session scope because it has to outlive one: `stopSession()` leaves the + // session scope behind, and the next interaction builds a fresh one, which would otherwise be + // drawn as if the application had never asked. Set once and never cleared - the application + // decides on each launch whether to ask again. + private var forcedSession = false + // region RumScope @WorkerThread @@ -132,6 +139,12 @@ internal class RumApplicationScope( ) } + // FLASHCAT FORK - recorded before the event reaches the sessions, so that a session created + // by this very event is already drawn as forced. + if (event is RumRawEvent.SetForcedSession) { + forcedSession = true + } + val isInteraction = (event is RumRawEvent.StartView) || (event is RumRawEvent.StartAction) if (activeSession == null && isInteraction) { startNewSession(event, datadogContext, writeScope, writer) @@ -221,7 +234,8 @@ internal class RumApplicationScope( insightsCollector = insightsCollector, remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, - beforeSampling = beforeSampling + beforeSampling = beforeSampling, + forcedSession = forcedSession ) 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 3370c161f9..cb9d5eef39 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 @@ -76,12 +76,19 @@ internal class RumSessionScope( 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 + private val beforeSampling: BeforeSamplingCallback? = null, + // FLASHCAT FORK - whether the host application has asked for this user's sessions to be + // collected regardless of the rates. Passed in rather than owned here because it outlives any + // one session: the application asked for the user, not for whichever session happened to be + // running when it asked. [RumApplicationScope] holds it and hands it to every session it makes. + internal var forcedSession: Boolean = false ) : 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. + // FLASHCAT FORK - the rate the current session's events report as their configured sample + // rate. It is the rate the draw actually used - the console's where it set one, the app's hook + // where that had the last word - rather than whatever was passed to init, so that anything + // extrapolating from it lands on the population the session was drawn from. A forced session is + // the one case where the two part company: see `renewSession`. internal var effectiveSampleRate: Float = sampleRate // FLASHCAT FORK - the configuration the current session was drawn under, so its events can @@ -94,10 +101,6 @@ 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()) @@ -180,11 +183,19 @@ internal class RumSessionScope( writer: DataWriter ): RumScope? { if (event is RumRawEvent.ResetSession) { - renewSession(event.eventTime, StartReason.EXPLICIT_STOP) - } else if (event is RumRawEvent.SetForcedSession) { + // FLASHCAT FORK - two kinds of session must not be renewed here. A stopped one is + // draining: renewing it would mint a session id under a scope whose own context already + // reports the session as inactive, and would announce that session to the host + // application's listener. A forced one would only ever be replaced by an identical + // forced session, so the renewal buys nothing and costs the view the user is on. + if (isActive && !forcedSession) { + renewSession(event.eventTime, StartReason.EXPLICIT_STOP) + } + } else if (event is RumRawEvent.SetForcedSession && isActive) { // FLASHCAT FORK - the escape hatch for "collect this user NOW": the application knows // who needs debugging, the SDK only provides the switch. From here on every draw keeps - // the session, for the lifetime of the process. + // the session. [RumApplicationScope] remembers the same thing for the sessions that + // come after this one, including the ones that follow a `stopSession()`. 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 @@ -334,17 +345,29 @@ internal class RumSessionScope( // 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) + val drawRate = askBeforeSampling(remoteConfig?.sessionSampleRate() ?: sampleRate) + val keepSession = forcedSession || random.nextFloat() < drawRate.percent() + // FLASHCAT FORK - a forced session was not drawn, so it does not report a rate it was drawn + // at. It reports the rate that describes it: every session like it is kept. Reporting the + // rate it would have been drawn at instead would have the intake weight one deliberately + // kept session as the whole population that rate implies - a session forced at a rate of 1 + // would count as a hundred - and would leave nothing to tell it from a lucky draw. + effectiveSampleRate = if (forcedSession) FORCED_SAMPLE_RATE else drawRate childScope?.sampleRate = effectiveSampleRate - val keepSession = forcedSession || random.nextFloat() < effectiveSampleRate.percent() startReason = reason sessionState = if (keepSession) State.TRACKED else State.NOT_TRACKED sessionId = UUID.randomUUID().toString() // 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(version = config.appliedVersion() ?: 0) + // settings from the console's history. A forced session reports none: it was kept whatever + // the console said, so naming a version would credit that version with a session it did not + // decide. This is the reporting the other SDKs use. + drawnConfiguration = if (forcedSession) { + null + } else { + remoteConfig?.let { config -> + DrawnConfiguration(version = config.appliedVersion() ?: 0) + } } childScope?.drawnConfiguration = drawnConfiguration sessionStartNs.set(time.nanoTime) @@ -417,6 +440,10 @@ internal class RumSessionScope( private const val MAX_SAMPLE_RATE = 100f + // FLASHCAT FORK - what a forced session reports as its rate: it is kept unconditionally, + // which is what a rate of a hundred means. + internal const val FORCED_SAMPLE_RATE = 100f + internal const val BEFORE_SAMPLING_THREW_MESSAGE = "The beforeSampling callback failed; drawing this session at %s instead." diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt index 8c82526a3d..b1e4fb99b2 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt @@ -346,6 +346,62 @@ internal class RumApplicationScopeTest { assertThat(testedScope.childScopes.first()).isEqualTo(mockSession) } + @Test + fun `M carry the forced state into a new session W handleEvent { session was stopped }`( + @StringForgery viewKey: String, + @StringForgery viewName: String + ) { + // The host application asked for this user's sessions to be collected, not for whichever + // session happened to be running when it asked. A `stopSession()` - the ordinary thing to + // do on logout - leaves the session scope behind, and the session that replaces it must + // still be forced, or the promise breaks exactly when support needs it. + testedScope.handleEvent( + RumRawEvent.SetForcedSession(), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + testedScope.handleEvent(RumRawEvent.StopSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // When + testedScope.handleEvent( + RumRawEvent.StartView( + key = RumScopeKey.from(viewKey, viewName), + attributes = mapOf() + ), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + assertThat(testedScope.childScopes).hasSize(1) + assertThat(testedScope.childScopes.first().forcedSession).isTrue() + } + + @Test + fun `M leave a new session unforced W handleEvent { nothing ever forced }`( + @StringForgery viewKey: String, + @StringForgery viewName: String + ) { + // The negative control for the test above. + testedScope.handleEvent(RumRawEvent.StopSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // When + testedScope.handleEvent( + RumRawEvent.StartView( + key = RumScopeKey.from(viewKey, viewName), + attributes = mapOf() + ), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + assertThat(testedScope.childScopes.first().forcedSession).isFalse() + } + @Test fun `M create a new session W handleEvent { no active sessions, start view } `( @StringForgery viewKey: String, 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 76006fa431..83aa87bd22 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 @@ -1026,8 +1026,11 @@ internal class RumSessionScopeTest { } @Test - fun `M keep drawing tracked sessions W handleEvent(SetForcedSession) { later renewal }`() { - // Given + fun `M leave the forced session alone W handleEvent(ResetSession)`() { + // A reset would only replace a forced session with an identical forced session, so the + // renewal buys nothing and costs the view the user is on. This is the path a console + // publishing "apply immediately" takes, and it must not cut a session the host application + // deliberately asked to keep. initializeTestedScope(0f) testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) val forcedSessionId = testedScope.getRumContext().sessionId @@ -1037,10 +1040,60 @@ internal class RumSessionScopeTest { val context = testedScope.getRumContext() // Then - assertThat(context.sessionId).isNotEqualTo(forcedSessionId) + assertThat(context.sessionId).isEqualTo(forcedSessionId) assertThat(context.sessionState).isEqualTo(RumSessionScope.State.TRACKED) } + @Test + fun `M renew on a reset W handleEvent(ResetSession) { session is not forced }`() { + // The negative control for the test above: an ordinary session is still renewed, so the + // guard is about forcing and not about resets in general. + initializeTestedScope(100f) + val firstSessionId = testedScope.getRumContext().sessionId + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.getRumContext().sessionId).isNotEqualTo(firstSessionId) + } + + @Test + fun `M report a full rate and no configuration version W a forced session is drawn`() { + // A forced session was not drawn, so it does not report a rate it was drawn at: it reports + // the rate that describes it, which is that every session like it is kept. Reporting the + // rate it would have been drawn at would have the intake weight one deliberately kept + // session as the whole population that rate implies, and leave nothing to tell it from a + // lucky draw. This is the reporting the other SDKs use. + val mockRemoteConfig: RemoteConfigStore = mock() + whenever(mockRemoteConfig.sessionSampleRate()).thenReturn(1f) + whenever(mockRemoteConfig.appliedVersion()).thenReturn(7) + initializeTestedScope(1f, remoteConfig = mockRemoteConfig) + + // When + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.effectiveSampleRate).isEqualTo(100f) + assertThat(testedScope.drawnConfiguration).isNull() + } + + @Test + fun `M report the drawn rate and its version W a session is drawn without forcing`() { + // The negative control: the same store, the same rates, no forcing. + val mockRemoteConfig: RemoteConfigStore = mock() + whenever(mockRemoteConfig.sessionSampleRate()).thenReturn(1f) + whenever(mockRemoteConfig.appliedVersion()).thenReturn(7) + initializeTestedScope(1f, remoteConfig = mockRemoteConfig) + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.effectiveSampleRate).isEqualTo(1f) + assertThat(testedScope.drawnConfiguration).isEqualTo(DrawnConfiguration(version = 7)) + } + @Test fun `M tell Session Replay the session is forced W handleEvent(SetForcedSession)`() { // Given diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index 4a1a124830..0ecd0d9d97 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt @@ -506,6 +506,60 @@ internal class SessionReplayFeatureTest { verifyNoMoreInteractions(mockRecorder) } + @Test + fun `M startRecording W rum session updated { keep, forced, sampler would drop it }`() { + // FLASHCAT FORK - a forced session must come out with replay whatever the replay sampler + // says: the host application asked for this user to be recorded, and a rate the sampler + // happens to hold has nothing to say about that. + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val rumSessionUpdateBusMessage = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to + true, + SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to + true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + + // When + testedFeature.onReceive(rumSessionUpdateBusMessage) + + // Then + inOrder(mockRecorder) { + verify(mockRecorder).registerCallbacks() + verify(mockRecorder).resumeRecorders() + } + verifyNoMoreInteractions(mockRecorder) + } + + @Test + fun `M not startRecording W rum session updated { keep, not forced, sampler drops it }`() { + // FLASHCAT FORK - the negative control for the test above: with the same sampler and the + // same kept session, an unforced session is still left unrecorded. A message that omits + // the key entirely reads as not forced, which is what every message written before this + // existed looks like. + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val rumSessionUpdateBusMessage = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to + true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + + // When + testedFeature.onReceive(rumSessionUpdateBusMessage) + + // Then + verify(mockRecorder).registerCallbacks() + verifyNoMoreInteractions(mockRecorder) + } + @Test fun `M doNothing W rum session updated { keep, sessionId is null }`() { // Given From bde7544802d41acbd09f1a01a56285f7395e16b3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 3 Sep 2026 21:20:38 -0700 Subject: [PATCH 25/30] fix(rum): keep the refresh rhythm, and refuse a body that is not a configuration Four things the fetch path got wrong. The ttl and the permission to refresh on returning to the foreground were held in memory and set only when a body was read. An unchanged answer is a 304 with no body, which is the steady state the validator exists to produce, so every launch after the first ran on the defaults and the console's permission was silently forgotten. They are now stored beside the values and read back before every request, which also covers a launch whose first request fails. They are stored while the configuration is switched off too, because the server goes on saying when to ask again and a client that stopped honouring that would never learn it had been switched back on. The foreground counter had no floor. An app that initialises the SDK from an activity - the usual shape when initialisation waits on a consent prompt - has one running before the callback is registered, so the first stop it saw took the count negative and it could never reach the one that means the app came back. The class documentation also claimed rotation could not be mistaken for a return to the foreground; it can, and that is now stated rather than denied. An unstamped body was accepted. The schema stamp is the whole of what tells a configuration apart from any other JSON, because every other field is read with a default: an unrelated body - a proxy's block page, a reverse proxy answering /config with something else - came out as "switched off, no rates", which is a legitimate configuration, and storing it emptied the entry and dropped the client back to the rates it was built with. An unstamped body is now treated as a request that did not arrive: nothing is stored and it is asked again for. A stamp that is present and unrecognised is still refused without a retry, since that is an answer. Failures were logged only to a target that reaches logcat in debug builds of the SDK, so a device that quietly stopped taking the console's values said nothing to anyone; they now go to telemetry as well. Opening the store also caught only SecurityException, letting the IllegalStateException a direct-boot-aware component gets before the device is unlocked escape into initialisation. The two hand-written guards against a rejected submission are replaced by the core's own safe submission helpers, which log and never throw. Adds the coverage those paths lacked: a server error is not an answer and is retried, an unstamped body is refused and retried, and a process whose first answer is a 304 still refreshes on foreground when the store says it may - with the negative control that one which stored nothing does not. --- .../remoteconfig/ProcessForegroundCallback.kt | 20 ++- .../remoteconfig/RemoteConfigController.kt | 138 +++++++++++------- .../remoteconfig/RemoteConfigStore.kt | 77 +++++++++- .../ProcessForegroundCallbackTest.kt | 17 +++ .../RemoteConfigControllerTest.kt | 115 ++++++++++++--- 5 files changed, 287 insertions(+), 80 deletions(-) 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 index bd218744e3..9f01a5ac65 100644 --- 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 @@ -20,8 +20,11 @@ import java.util.concurrent.atomic.AtomicInteger * 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. + * Activity-to-activity navigation keeps the counter above zero, so it is not mistaken for a return + * to the foreground. A rotation is not covered: the system destroys the old activity before it + * creates the new one, so the count really does reach zero and the app really does look like it + * left. That costs at most one extra request, and only when the stored ttl says the configuration + * is stale anyway, so it is left alone rather than given a state machine of its own. */ internal class ProcessForegroundCallback( private val onForeground: () -> Unit @@ -38,7 +41,18 @@ internal class ProcessForegroundCallback( @MainThread override fun onActivityStopped(activity: Activity) { - startedActivities.decrementAndGet() + // Floored at zero, because the count starts from nothing while the app may already have + // started an activity: an app that initialises the SDK from an activity - the usual shape + // when initialisation waits on a consent prompt - has one running by the time this is + // registered. Without the floor that activity's stop would take the count negative, and it + // could never climb back to the one that means "the app is in the foreground again": this + // callback would be dead for the rest of the process. + // + // Read-then-decrement needs no atomic update because both callbacks arrive on the main + // thread; the counter is atomic only because [refreshIfStale] reads its effect elsewhere. + if (startedActivities.get() > 0) { + startedActivities.decrementAndGet() + } } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit 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 89362e7d6d..ff867dd1c9 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 @@ -10,13 +10,14 @@ import android.os.SystemClock import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.core.internal.utils.executeSafe +import com.datadog.android.core.internal.utils.scheduleSafe import okhttp3.Call import okhttp3.Request import org.json.JSONException 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.ScheduledFuture import java.util.concurrent.TimeUnit @@ -52,6 +53,12 @@ internal class RemoteConfigController( @Volatile private var lastFetchAtMs: Long = 0 + /** + * The refresh rhythm the server last asked for, mirrored from the store so that + * [refreshIfStale] can answer without touching the disk on the main thread it is called from. + * Re-read from the store before every request rather than only when a body arrives - see + * [fetchAndApply]. + */ @Volatile private var currentTtlSeconds: Long = DEFAULT_TTL_SECONDS @@ -109,13 +116,10 @@ internal class RemoteConfigController( failedAttempts = 0 } if (!inFlight.compareAndSet(false, true)) return - try { - executor.execute { fetchOnce() } - } catch (e: RejectedExecutionException) { - // The SDK is shutting down. Nothing to keep fresh. - inFlight.set(false) - logScheduleRejected(e) - } + // A submission the executor refuses is one made after `stop()`, and this controller does + // not outlive that: the feature drops it in the same breath, so there is nothing left for + // the flag to gate. + executor.executeSafe(FETCH_TASK_NAME, sdkCore.internalLogger) { fetchOnce() } } @WorkerThread @@ -141,14 +145,25 @@ internal class RemoteConfigController( store.sweepAbandoned() } + // The rhythm the console asked for lives on disk, so it survives the process that fetched + // it. Read here, before the request, because neither of the other two outcomes carries it: + // a 304 has no body to apply, and a failed request has nothing at all. A client that only + // ever sees those - the steady state, since the validator exists to produce it - would + // otherwise spend every launch after the first on the defaults, with the console's + // permission to refresh on foreground silently forgotten. + currentTtlSeconds = store.ttlSeconds() ?: DEFAULT_TTL_SECONDS + refreshOnForeground = store.refreshOnForeground() + // 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() 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. + // Which version this client is running, reported on every request whether or not its + // session was kept. Nothing reads it today - how far a change has reached is measured + // from the version stamped on the sessions themselves - but it is part of the request + // every SDK on this contract makes, and taking a parameter off the wire is a protocol + // change of its own. val url = store.appliedVersion()?.let { "$configUrl&applied_version=$it" } ?: configUrl val requestBuilder = Request.Builder().url(url).get() // The answer varies per caller, so the validator only means something paired with the @@ -202,16 +217,12 @@ internal class RemoteConfigController( 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) - } + pendingRetry = executor.scheduleSafe( + RETRY_TASK_NAME, + delaySeconds, + TimeUnit.SECONDS, + sdkCore.internalLogger + ) { if (inFlight.compareAndSet(false, true)) fetchOnce() } } } @@ -260,51 +271,61 @@ internal class RemoteConfigController( // 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. // - // 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. + // The stamp is required, and it is the whole of what tells a configuration apart from any + // other JSON. Every other field in the envelope is read with a default, so an unrelated + // body - a proxy's block page, a reverse proxy answering /config with something else - + // comes out as "enabled: false, no rates", which is a legitimate configuration meaning + // "stop using the console's values". Storing that empties the entry and drops the client + // back to the rates it was built with. So an unstamped body is treated as a request that + // did not arrive: nothing is stored, and it is asked again for. + // // 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 - ) + val stamp = json.opt(FIELD_SCHEMA_VERSION) + if (stamp == null || stamp == JSONObject.NULL) { + logUnstampedBody() + return Outcome.UNREADABLE + } + if (stamp !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 } - 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 = RemoteConfigValues(store.sessionSampleRate()) val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } - val after = if (enabled) { + val delivered = if (enabled) { 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(), - etag = etag + custom = json.optJSONObject(FIELD_CUSTOM)?.toString() ) } else { - EMPTY_VALUES.copy(version = version, etag = etag) + EMPTY_VALUES.copy(version = version) } + // The rhythm rides with the values instead of staying in memory, and it is stored whether + // or not the configuration is enabled: the server goes on describing when to ask again + // while the feature is switched off, and a client that stopped honouring that the moment it + // was switched off would never learn it had been switched back on. + val after = delivered.copy( + etag = etag, + ttlSeconds = json.optLong(FIELD_TTL, 0L).takeIf { it > 0 }, + refreshOnForeground = json.optBoolean(FIELD_REFRESH_ON_FOREGROUND, false) + ) store.store(after) + currentTtlSeconds = after.ttlSeconds ?: DEFAULT_TTL_SECONDS + refreshOnForeground = after.refreshOnForeground if (activation == ACTIVATION_IMMEDIATE && changesThisClient(before, after)) { restartSession() } - // 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 } @@ -330,37 +351,40 @@ internal class RemoteConfigController( (before.sessionSampleRate ?: initialSessionSampleRate) != (after.sessionSampleRate ?: initialSessionSampleRate) + // Every one of these goes to telemetry as well as to logcat. A device that quietly stops + // taking the console's values runs on the ones it was built with for the rest of its life, and + // a logcat line only a debug build of the SDK prints is not something anyone will ever see. + private fun logUnreadableBody(e: JSONException) { sdkCore.internalLogger.log( InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, + MAINTAINER_AND_TELEMETRY, { UNREADABLE_BODY_MESSAGE }, e ) } - private fun logUnsupportedSchema(received: Int) { + private fun logUnstampedBody() { sdkCore.internalLogger.log( InternalLogger.Level.WARN, - InternalLogger.Target.MAINTAINER, - { UNSUPPORTED_SCHEMA_MESSAGE.format(received, SUPPORTED_SCHEMA_VERSION) } + MAINTAINER_AND_TELEMETRY, + { UNSTAMPED_BODY_MESSAGE } ) } - private fun logFetchFailure(e: Throwable) { + private fun logUnsupportedSchema(received: Int) { sdkCore.internalLogger.log( - InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, - { FETCH_FAILED_MESSAGE }, - e + InternalLogger.Level.WARN, + MAINTAINER_AND_TELEMETRY, + { UNSUPPORTED_SCHEMA_MESSAGE.format(received, SUPPORTED_SCHEMA_VERSION) } ) } - private fun logScheduleRejected(e: RejectedExecutionException) { + private fun logFetchFailure(e: Throwable) { sdkCore.internalLogger.log( InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, - { "Remote configuration refresh not scheduled: executor is shutting down." }, + MAINTAINER_AND_TELEMETRY, + { FETCH_FAILED_MESSAGE }, e ) } @@ -372,6 +396,12 @@ internal class RemoteConfigController( private val RETRY_DELAYS_SECONDS = longArrayOf(5L, 60L) + private val MAINTAINER_AND_TELEMETRY = + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY) + + private const val FETCH_TASK_NAME = "RUM remote configuration fetch" + private const val RETRY_TASK_NAME = "RUM remote configuration retry" + private const val MAX_RATE = 100.0 private const val MILLIS_PER_SECOND = 1_000L private const val JITTER_FRACTION = 0.2 @@ -402,6 +432,10 @@ internal class RemoteConfigController( internal const val UNREADABLE_BODY_MESSAGE = "The remote configuration response was not readable; keeping the values already in use." + internal const val UNSTAMPED_BODY_MESSAGE = + "The remote configuration response carried no schema version, so it did not come from" + + " the configuration endpoint; 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." 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 99186395ca..a126d17280 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 @@ -38,15 +38,20 @@ internal class RemoteConfigStore( private val currentTimeMs: () -> Long = System::currentTimeMillis ) { + /** + * Null when the preferences file cannot be opened. Two failures are reachable and neither is + * worth failing an initialisation over: a [SecurityException] where the process may not open + * the file at all, and an [IllegalStateException] from a direct-boot-aware component that + * reaches credential-encrypted storage before the user has unlocked the device. Either way the + * values passed to init keep applying. + */ 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 - ) + logStorageUnavailable(internalLogger, e) + null + } catch (e: IllegalStateException) { + logStorageUnavailable(internalLogger, e) null } @@ -65,6 +70,25 @@ internal class RemoteConfigStore( */ fun etag(): String? = preferences?.getString(etagKey(), null) + /** + * How long the server asked this client to treat the stored configuration as fresh, or null + * when nothing has ever been stored. + * + * Kept on disk rather than in memory for the same reason the rates are: an unchanged answer + * comes back as a 304 with no body, which is the steady state the validator exists to produce. + * A memory-only copy would be back at its default on every launch after the first. + */ + fun ttlSeconds(): Long? { + val stored = preferences?.getLong(ttlKey(), ABSENT_TTL) ?: ABSENT_TTL + return if (stored == ABSENT_TTL) null else stored + } + + /** + * Whether the server allows this client to ask again when the app returns to the foreground. + * Absent reads as not allowed, which is also the server's own default. + */ + fun refreshOnForeground(): Boolean = preferences?.getBoolean(refreshOnForegroundKey(), false) ?: false + /** * 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 @@ -102,6 +126,15 @@ internal class RemoteConfigStore( } else { editor.putString(etagKey(), values.etag) } + // Written whether or not the configuration is enabled: the server keeps describing when to + // ask again while the feature is switched off, and a client that stopped honouring that the + // moment it was switched off would never learn it had been switched back on. + if (values.ttlSeconds == null) { + editor.remove(ttlKey()) + } else { + editor.putLong(ttlKey(), values.ttlSeconds) + } + editor.putBoolean(refreshOnForegroundKey(), values.refreshOnForeground) editor.putLong(writeTimeKey(), currentTimeMs()) editor.apply() } @@ -190,9 +223,27 @@ internal class RemoteConfigStore( private fun etagKey() = "$storeKey$SUFFIX_ETAG" + private fun ttlKey() = "$storeKey$SUFFIX_TTL" + + private fun refreshOnForegroundKey() = "$storeKey$SUFFIX_REFRESH_ON_FOREGROUND" + private fun writeTimeKey() = "$storeKey$SUFFIX_WRITE_TIME" companion object { + + /** + * Reported to telemetry as well as to logcat: a client whose store will not open runs on + * the values it was built with for its whole life, and nothing else would ever say so. + */ + private fun logStorageUnavailable(internalLogger: InternalLogger, e: Throwable) { + internalLogger.log( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + { STORAGE_UNAVAILABLE_MESSAGE }, + e + ) + } + private const val PREFERENCES_NAME = "flashcat-rum-remote-config" /** @@ -208,6 +259,10 @@ internal class RemoteConfigStore( private const val ABSENT = -1f private const val ABSENT_VERSION = -1 + // A ttl is a positive number of seconds, so a non-positive sentinel cannot collide with a + // stored value. + private const val ABSENT_TTL = -1L + // One entry is spread over several keys, all of them derived from the store key by these // suffixes. Named here once because two things read them: the accessors that build a key, // and the sweep that has to take an entry apart again. @@ -215,6 +270,8 @@ internal class RemoteConfigStore( private const val SUFFIX_VERSION = ".version" private const val SUFFIX_CUSTOM = ".custom" private const val SUFFIX_ETAG = ".etag" + private const val SUFFIX_TTL = ".ttl" + private const val SUFFIX_REFRESH_ON_FOREGROUND = ".refreshOnForeground" private const val SUFFIX_WRITE_TIME = ".writtenAt" private val FIELD_SUFFIXES = listOf( @@ -222,6 +279,8 @@ internal class RemoteConfigStore( SUFFIX_VERSION, SUFFIX_CUSTOM, SUFFIX_ETAG, + SUFFIX_TTL, + SUFFIX_REFRESH_ON_FOREGROUND, SUFFIX_WRITE_TIME ) @@ -287,5 +346,9 @@ internal data class RemoteConfigValues( /** 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 + val etag: String? = null, + /** How long the server asked this client to treat these values as fresh, or null when it did not say. */ + val ttlSeconds: Long? = null, + /** Whether the server allows a refresh when the app returns to the foreground. */ + val refreshOnForeground: Boolean = false ) 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 index e245dad904..ea043cb6a5 100644 --- 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 @@ -55,4 +55,21 @@ internal class ProcessForegroundCallbackTest { assertThat(foregroundCount).isEqualTo(2) } + + @Test + fun `M report a foreground W the SDK was registered while an activity was already started`() { + // An app that initialises the SDK from an activity - the usual shape when initialisation + // waits on a consent prompt - has one running before this callback exists, so the first + // stop it sees has no matching start. Without a floor the count would go negative and could + // never reach the one that means "the app is in the foreground again", leaving the callback + // dead for the rest of the process. + val activity = mock() + testedCallback.onActivityStopped(activity) + + // When the user leaves and comes back + testedCallback.onActivityStarted(activity) + + // Then + assertThat(foregroundCount).isOne() + } } 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 549697eb15..01fb185b9f 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 @@ -23,6 +23,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.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -81,14 +82,14 @@ internal class RemoteConfigControllerTest { fun `M store the rate the response carries W apply()`() { testedController.apply(body(rum = """"sessionSampleRate":42""")) - verify(store).store(RemoteConfigValues(42f, 3)) + verify(store).store(RemoteConfigValues(42f, 3, ttlSeconds = 300L)) } @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, 3)) + verify(store).store(RemoteConfigValues(0f, 3, ttlSeconds = 300L)) } @Test @@ -97,21 +98,21 @@ internal class RemoteConfigControllerTest { // would silently stop collection nobody asked to stop. testedController.apply(body(rum = "")) - verify(store).store(RemoteConfigValues(null, 3)) + verify(store).store(RemoteConfigValues(null, 3, ttlSeconds = 300L)) } @Test fun `M ignore a rate outside 0-100 W apply()`() { testedController.apply(body(rum = """"sessionSampleRate":420""")) - verify(store).store(RemoteConfigValues(null, 3)) + verify(store).store(RemoteConfigValues(null, 3, ttlSeconds = 300L)) } @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, 3)) + verify(store).store(RemoteConfigValues(null, 3, ttlSeconds = 300L)) } // endregion @@ -172,7 +173,7 @@ internal class RemoteConfigControllerTest { // the change that turned them off. testedController.apply(body(enabled = false)) - verify(store).store(RemoteConfigValues(null, 3)) + verify(store).store(RemoteConfigValues(null, 3, ttlSeconds = 300L)) } // region fetching @@ -194,13 +195,26 @@ internal class RemoteConfigControllerTest { verify(executor).execute(any()) } + @Test + fun `M keep the stored values and ask again W fetch() { server answers with an error }`() { + // Neither a 200 nor a 304 is an answer about the configuration. Nothing may be stored, and + // the ask is owed a retry - an endpoint having a bad minute must not move anybody's rates. + whenever(call.execute()).thenReturn(response(500, "")) + + runPendingFetch() + + verify(store, never()).store(any()) + verify(store, never()).touch() + verify(executor).schedule(any(), any(), any()) + } + @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, 3)) + verify(store).store(RemoteConfigValues(42f, 3, ttlSeconds = 300L)) } @Test @@ -242,6 +256,28 @@ internal class RemoteConfigControllerTest { } } + @Test + fun `M store the refresh rhythm alongside the values W apply()`() { + // Kept on disk rather than in memory because an unchanged answer is a 304 with no body: + // a memory-only copy is back at its default on every launch after the first. + testedController.apply(body(ttl = 60, refreshOnForeground = true, rum = """"sessionSampleRate":42""")) + + verify(store).store( + RemoteConfigValues(42f, 3, ttlSeconds = 60L, refreshOnForeground = true) + ) + } + + @Test + fun `M store the refresh rhythm W apply() { remote configuration switched off }`() { + // The server goes on saying when to ask again while the feature is off; a client that + // stopped honouring that would never learn it had been switched back on. + testedController.apply(body(enabled = false, ttl = 60, refreshOnForeground = true)) + + verify(store).store( + RemoteConfigValues(null, 3, ttlSeconds = 60L, refreshOnForeground = true) + ) + } + @Test fun `M keep the stored values and call it a success W fetch answers not modified`() { whenever(call.execute()).thenReturn(response(304, "")) @@ -453,6 +489,38 @@ internal class RemoteConfigControllerTest { verify(executor).execute(any()) } + @Test + fun `M honour the stored rhythm W refreshIfStale() { fresh process, first answer is 304 }`() { + // The case a settled fleet lives in. The console's permission was granted in some earlier + // process and is on disk; this process asks, the validator matches, and a 304 comes back + // with no body to read it from. A controller that only ever learned the rhythm from a body + // would spend this whole process on the defaults and never refresh on foreground again. + whenever(store.refreshOnForeground()).thenReturn(true) + whenever(store.ttlSeconds()).thenReturn(60L) + whenever(call.execute()).thenReturn(response(304, "")) + + runPendingFetch() + clearInvocations(executor) + elapsedMs = 61_000L + testedController.refreshIfStale() + + verify(executor).execute(any()) + } + + @Test + fun `M ask nothing W refreshIfStale() { fresh process, nothing was ever stored }`() { + // The negative control for the test above: with nothing on disk the permission is not + // assumed, so the very first launch of an app still makes no foreground request. + whenever(call.execute()).thenReturn(response(304, "")) + + runPendingFetch() + clearInvocations(executor) + elapsedMs = 61_000L + testedController.refreshIfStale() + + verify(executor, never()).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)) @@ -582,29 +650,40 @@ internal class RemoteConfigControllerTest { } @Test - fun `M read the configuration W apply() { schema is an explicit null }`() { + fun `M refuse the body 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) + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + verify(store, never()).store(any()) } @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 — - // 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. + fun `M refuse the body W apply() { no schema at all }`() { + // The stamp is the whole of what tells a configuration from any other JSON: every other + // field is read with a default, so an unrelated body would come out as "switched off, no + // rates" and empty the entry. Nothing is stored, and the request is asked again for. val outcome = testedController.apply( body(rum = """"sessionSampleRate":42""", schemaVersion = null) ) - assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.APPLIED) - argumentCaptor { - verify(store).store(capture()) - assertThat(firstValue.sessionSampleRate).isEqualTo(42f) - } + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + verify(store, never()).store(any()) + } + + @Test + fun `M keep the stored values and ask again W fetch() { body carries no schema }`() { + // The negative control for the refusal above: a body this SDK will not read must leave the + // rates that are working in place and be retried, exactly as an unreachable endpoint is. + whenever(call.execute()) + .thenReturn(response(200, body(rum = """"sessionSampleRate":42""", schemaVersion = null))) + + runPendingFetch() + + verify(store, never()).store(any()) + verify(executor).schedule(any(), any(), any()) } @Test From 6c935dd8fe1bd9c4877ba02dbf05ca24a6048f62 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 3 Sep 2026 21:20:38 -0700 Subject: [PATCH 26/30] docs: add the 0.7.0 changelog entry for remote configuration --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd7486dd1..f7e456a61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 0.7.0 / Unreleased + +* [FEATURE] RUM: take the session sample rate from the application's settings in the Flashcat console instead of only from the value passed to `RumConfiguration.Builder.setSessionSampleRate()`, so it can be changed without shipping a new release of the app. Off by default — enable it with `RumConfiguration.Builder.setRemoteConfigurationEnabled(true)`. Left off, the SDK makes no extra request, opens no extra storage, starts no extra thread, and behaves exactly as it did before. A change applies to sessions started after it arrives, so a session already under way is never redrawn; the console can ask for a change to take effect at once, in which case the running session ends and a new one starts under the new rate. The values passed to init keep applying until the first settings arrive and whenever the endpoint cannot be reached — a failed, timed-out or unreadable response never moves a rate. Events report the rate the session was actually drawn under, together with the settings version it came from, so server-side extrapolation and audits line up with the draw. + +* [FEATURE] RUM: add `RumConfiguration.Builder.setBeforeSampling()`, a callback consulted at each session draw 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 is the last step of the draw, after the console's rate, so an allow-list can keep collecting a user the console's rate would drop. A callback that throws, or returns a rate outside 0..100, is ignored and the incoming rate applies. + +* [FEATURE] RUM: add `RumMonitor.setForcedSession()` and `RumMonitor.getRemoteConfig()`. `setForcedSession()` collects the user's sessions with Session Replay regardless of the configured rates; the state lasts for the process lifetime and survives `stopSession()`. Events from a forced session report a sample rate of 100 and no settings version, because the session was kept whatever the rates said. `getRemoteConfig()` returns the application-defined values published in the console, delivered verbatim and never interpreted by the SDK. **Breaking:** `RumMonitor` is a public interface and gained two methods with no default implementation, so any class that implements it directly — a test double or a wrapper, most likely — must add them to compile. Code that only calls `GlobalRumMonitor.get()` is unaffected. + +* [IMPROVEMENT] RUM: `ViewEvent.Configuration` carries a new optional `rcVersion` field, so its constructor and `copy()` take one more argument. Kotlin callers that use named or default arguments are unaffected once recompiled; Java callers that construct it directly must pass the extra argument. + +--- + # 0.5.0 / 2026-07-28 * [IMPROVEMENT] Stop reading SIM carrier info (`TelephonyManager.simCarrierIdName` / `simCarrierId`) in `BroadcastReceiverNetworkInfoProvider`. This call path was already unreachable at runtime (the provider is only used below API 24, while the carrier branch required API 28+), so removing it has no functional impact but eliminates the telephony-API reference from the bytecode that privacy-compliance static scanners flag. From db36f0687268f6c1229bdc1f1669d6b32e06e38c Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 3 Sep 2026 22:19:02 -0700 Subject: [PATCH 27/30] docs: place the 0.7.0 entry above the released 0.6.0 one --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e456a61b..95be5bc47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ --- +# 0.6.0 / 2026-08-17 + +* [CHANGE] Change the default NTP servers from `0.datadog.pool.ntp.org` through `3.datadog.pool.ntp.org` to `ntp.aliyun.com`, `ntp1.aliyun.com`, `time1.cloud.tencent.com` and `cn.pool.ntp.org`. Measured from a mainland-China host, where this SDK is predominantly deployed, the new servers answer at stratum 2 within 12-40 ms, while the previous ones answer at stratum 3 within 41-241 ms and one of the four did not answer at all. The new defaults span two cloud providers and the community pool, so no single operator being unreachable stops the clock from synchronizing. Apps that need other servers can select them with `setNtpHosts`. + +* [FEATURE] Add `Configuration.Builder.setNtpHosts(List)` so the NTP servers used to synchronize the SDK clock can be chosen at initialization. The SDK previously always synchronized against a fixed set of public-internet NTP pool hosts. A deployment isolated from the public internet cannot reach those hosts, and some environments do not permit contacting them at all, yet there was no supported way to change or disable the behaviour. Pass the NTP servers reachable from the network the app runs on, or an empty list to skip clock synchronization entirely — events are then timestamped with the device clock, which is the same fallback the SDK already applied whenever synchronization failed. + +--- + # 0.5.0 / 2026-07-28 * [IMPROVEMENT] Stop reading SIM carrier info (`TelephonyManager.simCarrierIdName` / `simCarrierId`) in `BroadcastReceiverNetworkInfoProvider`. This call path was already unreachable at runtime (the provider is only used below API 24, while the carrier branch required API 28+), so removing it has no functional impact but eliminates the telephony-API reference from the bytecode that privacy-compliance static scanners flag. From 2d27107aa994009fc3c78fa91104921bbe09f609 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 4 Sep 2026 07:27:14 -0700 Subject: [PATCH 28/30] feat(rum): apply a rate crossing zero to the running session A rate published from the console reached the session already running only when the console marked the change for immediate activation. Both directions across zero now do so on their own, without the console having to ask. Moving TO zero is the emergency stop, and one that waited until the session happened to rotate would not be one -- until now the stop simply did not arrive for up to four hours. Moving AWAY from zero says nobody was in the draw at all and now could be: an application whose rate only ever comes from the console showed an operator who had just switched collection on nothing at all, which is indistinguishable from a broken integration. Every other rate stays silent about the running session. Whether it should still have been kept can only be answered by drawing again, and drawing twice turns a rate p into p squared. Zero is the one value with no winners to spare -- while it was in force nothing was collected -- so re-drawing everyone at the new rate lands exactly on it rather than above it. The rate that counts on each side is the one that would decide a draw made now: the console's where it published one, the value the app was initialised with where it did not, since clearing a knob hands the decision back to init. A forced session is left alone, as it already was, where the reset is handled. This brings Android in line with iOS, which decides the same two directions the same way. --- CHANGELOG.md | 2 +- .../datadog/android/rum/RumConfiguration.kt | 10 ++- .../remoteconfig/RemoteConfigController.kt | 43 ++++++++++- .../RemoteConfigControllerTest.kt | 75 ++++++++++++++++++- 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95be5bc47b..5d7e76687e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 0.7.0 / Unreleased -* [FEATURE] RUM: take the session sample rate from the application's settings in the Flashcat console instead of only from the value passed to `RumConfiguration.Builder.setSessionSampleRate()`, so it can be changed without shipping a new release of the app. Off by default — enable it with `RumConfiguration.Builder.setRemoteConfigurationEnabled(true)`. Left off, the SDK makes no extra request, opens no extra storage, starts no extra thread, and behaves exactly as it did before. A change applies to sessions started after it arrives, so a session already under way is never redrawn; the console can ask for a change to take effect at once, in which case the running session ends and a new one starts under the new rate. The values passed to init keep applying until the first settings arrive and whenever the endpoint cannot be reached — a failed, timed-out or unreadable response never moves a rate. Events report the rate the session was actually drawn under, together with the settings version it came from, so server-side extrapolation and audits line up with the draw. +* [FEATURE] RUM: take the session sample rate from the application's settings in the Flashcat console instead of only from the value passed to `RumConfiguration.Builder.setSessionSampleRate()`, so it can be changed without shipping a new release of the app. Off by default — enable it with `RumConfiguration.Builder.setRemoteConfigurationEnabled(true)`. Left off, the SDK makes no extra request, opens no extra storage, starts no extra thread, and behaves exactly as it did before. A change applies to sessions started after it arrives, so a session already under way is normally never redrawn. Two changes are exceptions and end the running session so the next one starts under the new rate: one the console marks for immediate activation, and a rate crossing zero in either direction — switching collection off is an emergency stop, and switching it back on has nothing to preserve, since while the rate was zero no session was being collected and none was drawn against a real rate. The values passed to init keep applying until the first settings arrive and whenever the endpoint cannot be reached — a failed, timed-out or unreadable response never moves a rate. Events report the rate the session was actually drawn under, together with the settings version it came from, so server-side extrapolation and audits line up with the draw. * [FEATURE] RUM: add `RumConfiguration.Builder.setBeforeSampling()`, a callback consulted at each session draw 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 is the last step of the draw, after the console's rate, so an allow-list can keep collecting a user the console's rate would drop. A callback that throws, or returns a rate outside 0..100, is ignored and the incoming rate applies. 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 43e711dcdf..2135a4b724 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 @@ -69,10 +69,12 @@ data class RumConfiguration internal constructor( * 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. + * the decision it was created with. Two changes do not wait: one the console marks for + * immediate activation, and a rate crossing zero in either direction — switching collection + * off is an emergency stop, and switching it back on has nothing to preserve, since while + * the rate was zero nothing was being collected. In both cases 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. 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 ff867dd1c9..49d570830f 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 @@ -322,7 +322,7 @@ internal class RemoteConfigController( currentTtlSeconds = after.ttlSeconds ?: DEFAULT_TTL_SECONDS refreshOnForeground = after.refreshOnForeground - if (activation == ACTIVATION_IMMEDIATE && changesThisClient(before, after)) { + if (appliesToRunningSession(activation, before, after)) { restartSession() } @@ -347,9 +347,44 @@ 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 = - (before.sessionSampleRate ?: initialSessionSampleRate) != - (after.sessionSampleRate ?: initialSessionSampleRate) + /** + * Whether a change lands on the session that is running rather than on the next one. + * + * The console can ask for that outright, and `immediate` is exactly that request: it carries no + * claim about what the new rate decides, only that the operator does not want to wait, so any + * real change is enough. + * + * Zero needs no such instruction, because it is the one rate that answers the question on its + * own — in both directions. Moving TO zero says nothing is to be collected any more, and an + * emergency stop that took until the session happened to rotate would not be one. Moving AWAY + * from zero says nobody was in the draw at all and now could be: without this, an application + * whose rate only ever comes from the console shows an operator who has just switched + * collection on precisely nothing until its sessions rotate, and nothing at all is + * indistinguishable from broken. + * + * Every other rate is silent about the running session. Whether it "should" still have been + * kept can only be answered by drawing again, and drawing twice turns a rate p into p². Zero is + * the one value with no winners to spare — while it was in force nothing was collected, so + * re-drawing everyone at the new rate lands exactly on it rather than above it. + * + * The rate that counts is the one that decides a draw made now: the console's where it + * published one, the value the app was initialised with where it did not, since clearing a knob + * hands the decision back to init. + * + * A forced session is left alone, and that is settled where the reset is handled rather than + * here — see `RumSessionScope`: it is collected whatever the rates say, so ending it would only + * buy an identical forced session. + */ + private fun appliesToRunningSession( + activation: String, + before: RemoteConfigValues, + after: RemoteConfigValues + ): Boolean { + val previousRate = before.sessionSampleRate ?: initialSessionSampleRate + val nextRate = after.sessionSampleRate ?: initialSessionSampleRate + if (previousRate == nextRate) return false + return activation == ACTIVATION_IMMEDIATE || (previousRate == 0f) != (nextRate == 0f) + } // Every one of these goes to telemetry as well as to logcat. A device that quietly stops // taking the console's values runs on the ones it was built with for the rest of its life, and 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 01fb185b9f..190725cfc2 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 @@ -61,13 +61,17 @@ internal class RemoteConfigControllerTest { callFactory = mock() call = mock() whenever(callFactory.newCall(any())).thenReturn(call) + testedController = controllerWithInitialRate(INIT_SESSION_RATE) + } + + private fun controllerWithInitialRate(initialSessionSampleRate: Float): RemoteConfigController { val sdkCore = mock() whenever(sdkCore.internalLogger).thenReturn(mock()) - testedController = RemoteConfigController( + return RemoteConfigController( sdkCore = sdkCore, configUrl = "https://example.com/api/v2/rum/config", store = store, - initialSessionSampleRate = INIT_SESSION_RATE, + initialSessionSampleRate = initialSessionSampleRate, callFactory = callFactory, executor = executor, restartSession = { restarts++ }, @@ -165,6 +169,73 @@ internal class RemoteConfigControllerTest { assertThat(restarts).isOne() } + @Test + fun `M restart the session W apply() { next_session but the rate leaves zero }`() { + // Nothing was being collected while the rate was zero, so there is no session worth + // preserving and no winner to spare by re-drawing everyone at the new rate. Waiting here + // would show an operator who has just switched collection on nothing at all. + whenever(store.sessionSampleRate()).thenReturn(0f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":100""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { next_session but the rate reaches zero }`() { + // The emergency stop. One that took until the session happened to rotate would not be one. + whenever(store.sessionSampleRate()).thenReturn(100f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":0""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { next_session and init never collected }`() { + // The application whose rate only ever comes from the console: nothing is stored yet, so + // the rate leaving zero is the init value being replaced rather than a stored one. + whenever(store.sessionSampleRate()).thenReturn(null) + testedController = controllerWithInitialRate(0f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":100""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { next_session and the kill switch hands zero back to init }`() { + // Switching remote configuration off returns the decision to the value the app was built + // with, and that is a rate leaving zero like any other. + whenever(store.sessionSampleRate()).thenReturn(0f) + + testedController.apply(body(activation = "next_session", enabled = false)) + + assertThat(restarts).isOne() + } + + @Test + fun `M leave the running session alone W apply() { next_session and the rate stays at zero }`() { + // Otherwise every announcement would cut one empty session after another in two for as long + // as collection stayed switched off. + whenever(store.sessionSampleRate()).thenReturn(0f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":0""")) + + assertThat(restarts).isZero() + } + + @Test + fun `M leave the running session alone W apply() { next_session and neither rate is zero }`() { + // No rate but zero says anything about whether THIS session should have been kept: only a + // second draw could, and drawing twice turns a rate p into p squared. + whenever(store.sessionSampleRate()).thenReturn(30f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":80""")) + + assertThat(restarts).isZero() + } + // endregion @Test From 3e2dbbe2565279431e85616e6b5733011c09fb79 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 5 Sep 2026 06:39:01 -0700 Subject: [PATCH 29/30] fix(rum): keep remote sampling snapshots consistent Read one configuration snapshot for each session draw and reject responses older than the cached configuration. Cover concurrent updates and version ordering with regression tests. --- .../internal/domain/scope/RumSessionScope.kt | 11 ++-- .../remoteconfig/RemoteConfigController.kt | 12 +++- .../remoteconfig/RemoteConfigStore.kt | 18 ++++++ .../domain/scope/RumSessionScopeTest.kt | 57 ++++++++++++++----- .../RemoteConfigControllerTest.kt | 38 ++++++++++++- .../remoteconfig/RemoteConfigStoreTest.kt | 19 +++++++ 6 files changed, 132 insertions(+), 23 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 cb9d5eef39..570427ec86 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 @@ -345,7 +345,8 @@ internal class RumSessionScope( // 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. - val drawRate = askBeforeSampling(remoteConfig?.sessionSampleRate() ?: sampleRate) + val remoteValues = remoteConfig?.snapshot() + val drawRate = askBeforeSampling(remoteValues?.sessionSampleRate ?: sampleRate, remoteValues?.custom) val keepSession = forcedSession || random.nextFloat() < drawRate.percent() // FLASHCAT FORK - a forced session was not drawn, so it does not report a rate it was drawn // at. It reports the rate that describes it: every session like it is kept. Reporting the @@ -365,8 +366,8 @@ internal class RumSessionScope( drawnConfiguration = if (forcedSession) { null } else { - remoteConfig?.let { config -> - DrawnConfiguration(version = config.appliedVersion() ?: 0) + remoteValues?.let { config -> + DrawnConfiguration(version = config.version ?: 0) } } childScope?.drawnConfiguration = drawnConfiguration @@ -393,10 +394,10 @@ internal class RumSessionScope( * 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 { + private fun askBeforeSampling(rate: Float, customJson: String?): Float { val hook = beforeSampling ?: return rate val override = try { - val custom = decodeCustomValues(remoteConfig?.custom()) + val custom = decodeCustomValues(customJson) hook.sampleRate(BeforeSamplingContext(sessionSampleRate = rate, custom = custom)) } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { sdkCore.internalLogger.log( 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 49d570830f..5705d0193a 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 @@ -228,12 +228,15 @@ 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. + * others 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, + /** An older response was ignored because the stored configuration is already newer. */ + STALE_VERSION, + /** * 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 @@ -297,8 +300,13 @@ internal class RemoteConfigController( val enabled = json.optBoolean(FIELD_ENABLED, false) val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) - val before = RemoteConfigValues(store.sessionSampleRate()) val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } + // Rollbacks are published under a new version. An older response must not replace the + // stored values or their validator, nor restart a session under superseded settings. + if ((version ?: 0) < (store.appliedVersion() ?: 0)) { + return Outcome.STALE_VERSION + } + val before = RemoteConfigValues(store.sessionSampleRate()) val delivered = if (enabled) { readValues(json.optJSONObject(FIELD_RUM)).copy( version = version, 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 a126d17280..f4b3a5a2f5 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 @@ -57,6 +57,24 @@ internal class RemoteConfigStore( fun sessionSampleRate(): Float? = read(sessionKey()) + /** + * Reads one committed preferences snapshot. A session must use the same response for its + * sampling rate, custom values and reported version, even if another response arrives during + * its draw. getAll() copies the preferences under their lock, paired with Editor.apply()'s + * atomic in-memory update; separate getters would each acquire that lock independently. + */ + fun snapshot(): RemoteConfigValues { + val stored = preferences?.all.orEmpty() + return RemoteConfigValues( + sessionSampleRate = (stored[sessionKey()] as? Float)?.takeUnless { it == ABSENT }, + version = (stored[versionKey()] as? Int)?.takeUnless { it == ABSENT_VERSION }, + custom = stored[customKey()] as? String, + etag = stored[etagKey()] as? String, + ttlSeconds = (stored[ttlKey()] as? Long)?.takeUnless { it == ABSENT_TTL }, + refreshOnForeground = stored[refreshOnForegroundKey()] as? Boolean ?: false + ) + } + /** * 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]. 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 83aa87bd22..7f14348f39 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 @@ -35,6 +35,7 @@ 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.remoteconfig.RemoteConfigValues import com.datadog.android.rum.internal.startup.RumAppStartupTelemetryReporter import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.startup.RumStartupScenario @@ -83,6 +84,7 @@ import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.lang.ref.WeakReference import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference @Extensions( ExtendWith(MockitoExtension::class), @@ -1066,8 +1068,7 @@ internal class RumSessionScopeTest { // session as the whole population that rate implies, and leave nothing to tell it from a // lucky draw. This is the reporting the other SDKs use. val mockRemoteConfig: RemoteConfigStore = mock() - whenever(mockRemoteConfig.sessionSampleRate()).thenReturn(1f) - whenever(mockRemoteConfig.appliedVersion()).thenReturn(7) + whenever(mockRemoteConfig.snapshot()).thenReturn(RemoteConfigValues(1f, 7)) initializeTestedScope(1f, remoteConfig = mockRemoteConfig) // When @@ -1082,8 +1083,7 @@ internal class RumSessionScopeTest { fun `M report the drawn rate and its version W a session is drawn without forcing`() { // The negative control: the same store, the same rates, no forcing. val mockRemoteConfig: RemoteConfigStore = mock() - whenever(mockRemoteConfig.sessionSampleRate()).thenReturn(1f) - whenever(mockRemoteConfig.appliedVersion()).thenReturn(7) + whenever(mockRemoteConfig.snapshot()).thenReturn(RemoteConfigValues(1f, 7)) initializeTestedScope(1f, remoteConfig = mockRemoteConfig) // When @@ -1121,12 +1121,44 @@ internal class RumSessionScopeTest { // region Remote Configuration + @Test + fun `M keep one snapshot per draw W a response arrives during beforeSampling`() { + val first = RemoteConfigValues(100f, 1, custom = """{"cohort":"first"}""") + val second = RemoteConfigValues(0f, 2, custom = """{"cohort":"second"}""") + val published = AtomicReference(first) + val remoteConfig = mock() + whenever(remoteConfig.snapshot()).thenAnswer { published.get() } + val seen = mutableListOf() + initializeTestedScope(remoteConfig = remoteConfig, beforeSampling = { + seen.add(it) + val responseThread = Thread { published.set(second) } + responseThread.start() + responseThread.join(5_000) + check(!responseThread.isAlive) + null + }) + + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + assertThat(published.get()).isEqualTo(second) + assertThat(seen.single().sessionSampleRate).isEqualTo(100f) + assertThat(seen.single().custom).isEqualTo(mapOf("cohort" to "first")) + assertThat(testedScope.effectiveSampleRate).isEqualTo(100f) + assertThat(testedScope.drawnConfiguration?.version).isEqualTo(1) + + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + assertThat(seen.last().sessionSampleRate).isEqualTo(0f) + assertThat(seen.last().custom).isEqualTo(mapOf("cohort" to "second")) + assertThat(testedScope.effectiveSampleRate).isEqualTo(0f) + assertThat(testedScope.drawnConfiguration?.version).isEqualTo(2) + } + @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.appliedVersion()) doReturn 7 + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(42f, 7) initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig) // When @@ -1144,8 +1176,7 @@ internal class RumSessionScopeTest { fun `M fall back to the init values W handleEvent { console set nothing }`() { // Given val remoteConfig = mock() - whenever(remoteConfig.sessionSampleRate()) doReturn null - whenever(remoteConfig.appliedVersion()) doReturn null + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(null) initializeTestedScope(sampleRate = 80f, remoteConfig = remoteConfig) // When @@ -1160,8 +1191,7 @@ internal class RumSessionScopeTest { fun `M remember the draw for the session's events W handleEvent { remote configuration on }`() { // Given val remoteConfig = mock() - whenever(remoteConfig.sessionSampleRate()) doReturn 42f - whenever(remoteConfig.appliedVersion()) doReturn 9 + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(42f, 9) initializeTestedScope(remoteConfig = remoteConfig) // When @@ -1204,7 +1234,7 @@ internal class RumSessionScopeTest { ) { // Given val remoteConfig = mock() - whenever(remoteConfig.sessionSampleRate()) doReturn 100f + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(100f) initializeTestedScope(withMockChildScope = false, remoteConfig = remoteConfig) // When @@ -2007,7 +2037,7 @@ internal class RumSessionScopeTest { fun `M draw with the hook's rate W handleEvent { beforeSampling overrides }`() { // Given val remoteConfig = mock() - whenever(remoteConfig.sessionSampleRate()) doReturn 1f + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(1f) initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig, beforeSampling = { 100f }) // When @@ -2021,8 +2051,7 @@ internal class RumSessionScopeTest { 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"]}""" + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(42f, custom = """{"vip":["a"]}""") var seen: BeforeSamplingContext? = null initializeTestedScope( sampleRate = 100f, 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 190725cfc2..0af9eb11c5 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 @@ -82,6 +82,39 @@ internal class RemoteConfigControllerTest { // region storing + @Test + fun `M discard an older response without retrying W fetch succeeds`() { + whenever(store.sessionSampleRate()).thenReturn(1f) + whenever(store.appliedVersion()).thenReturn(5) + whenever(call.execute()).thenReturn( + response(200, body(version = 3, activation = "immediate", rum = "\"sessionSampleRate\":100"), "\"v3\"") + ) + + runPendingFetch() + + verify(store, never()).store(any()) + verify(executor, never()).schedule(any(), any(), any()) + assertThat(restarts).isZero() + } + + @Test + fun `M accept the same version W apply()`() { + whenever(store.appliedVersion()).thenReturn(5) + + testedController.apply(body(version = 5, rum = "\"sessionSampleRate\":42")) + + verify(store).store(RemoteConfigValues(42f, 5, ttlSeconds = 300L)) + } + + @Test + fun `M accept a rollback published as a newer version W apply()`() { + whenever(store.appliedVersion()).thenReturn(5) + + testedController.apply(body(version = 6, rum = "\"sessionSampleRate\":100")) + + verify(store).store(RemoteConfigValues(100f, 6, ttlSeconds = 300L)) + } + @Test fun `M store the rate the response carries W apply()`() { testedController.apply(body(rum = """"sessionSampleRate":42""")) @@ -819,10 +852,11 @@ internal class RemoteConfigControllerTest { refreshOnForeground: Boolean = false, rum: String = "", custom: String? = null, - schemaVersion: Int? = RemoteConfigController.SUPPORTED_SCHEMA_VERSION + schemaVersion: Int? = RemoteConfigController.SUPPORTED_SCHEMA_VERSION, + version: Int = 3 ): String = "{" + (if (schemaVersion == null) "" else """"schema_version":$schemaVersion,""") + - """"version":3,"ttl":$ttl,"enabled":$enabled,"activation":"$activation",""" + + """"version":$version,"ttl":$ttl,"enabled":$enabled,"activation":"$activation",""" + """"refresh_on_foreground":$refreshOnForeground,"rum":{$rum}""" + (if (custom == null) "" else ""","custom":$custom""") + "}" 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 013613632b..b4c6a45a26 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 @@ -80,6 +80,25 @@ internal class RemoteConfigStoreTest { // region persistence + @Test + fun `M retain a snapshot after a later response is stored`() { + val store = testedStore() + val first = RemoteConfigValues(100f, 1, """{"cohort":"first"}""", "\"v1\"", 300L, true) + val second = RemoteConfigValues(0f, 2, """{"cohort":"second"}""", "\"v2\"", 600L, false) + store.store(first) + val snapshot = testedStore().snapshot() + + store.store(second) + + assertThat(snapshot).isEqualTo(first) + assertThat(store.snapshot()).isEqualTo(second) + } + + @Test + fun `M return absent values W snapshot before the first response`() { + assertThat(testedStore().snapshot()).isEqualTo(RemoteConfigValues(null)) + } + @Test fun `M read back on the next launch what a response stored W store()`() { testedStore().store( From b13247295404b177b0953fcbe7342b031f367efd Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 07:57:22 -0700 Subject: [PATCH 30/30] fix(rum): handle remote configuration lifecycle and sampling edges Preserve configured transport settings for feature requests and reject incomplete configuration envelopes. Cancel active requests when stopping, avoid renewing inactive sessions, and enable replay when an existing session becomes forced. Add regression coverage for these transitions. --- .../android/core/internal/CoreFeature.kt | 9 +- .../android/core/internal/CoreFeatureTest.kt | 64 ++++++ .../internal/domain/scope/RumSessionScope.kt | 15 +- .../remoteconfig/RemoteConfigController.kt | 103 +++++---- .../domain/scope/RumSessionScopeTest.kt | 204 ++++++++++++++++-- .../RemoteConfigControllerTest.kt | 168 +++++++++++++++ .../internal/SessionReplayFeature.kt | 28 ++- .../internal/SessionReplayFeatureTest.kt | 83 +++++++ 8 files changed, 603 insertions(+), 71 deletions(-) diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/CoreFeature.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/CoreFeature.kt index a6deb53201..ac90ef6cc1 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/CoreFeature.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/CoreFeature.kt @@ -341,7 +341,14 @@ internal class CoreFeature( fun createOkHttpCallFactory(block: OkHttpClient.Builder.() -> Unit): Call.Factory { return object : Call.Factory { // Create a new client that shares pools with the base client - private val client = lazySharedOkHttpClient.newBuilder() + private val client = callFactory.okhttpClient.newBuilder() + // Feature requests inherit transport settings, not upload encoding or logging. + .apply { + @Suppress("UnsafeThirdPartyFunctionCall") // Returns the builder's mutable list. + interceptors().clear() + @Suppress("UnsafeThirdPartyFunctionCall") // Returns the builder's mutable list. + networkInterceptors().clear() + } .apply(block) .build() diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/CoreFeatureTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/CoreFeatureTest.kt index 892a3e101e..e02074f78c 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/CoreFeatureTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/CoreFeatureTest.kt @@ -62,6 +62,8 @@ import okhttp3.CipherSuite import okhttp3.ConnectionSpec import okhttp3.Protocol import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.TlsVersion import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach @@ -1670,4 +1672,66 @@ internal class CoreFeatureTest { return listOf(appContext) } } + + @Test + fun `M remote factory preserves configured proxy W configuration changes`() { + val proxy = java.net.Proxy(java.net.Proxy.Type.HTTP, java.net.InetSocketAddress("127.0.0.1", 18888)) + val proxyAuth: Authenticator = mock() + testedFeature.initialize( + appContext.mockInstance, + fakeSdkInstanceId, + fakeConfig.copy(coreConfig = fakeConfig.coreConfig.copy(proxy = proxy, proxyAuth = proxyAuth)), + fakeConsent + ) + assertThat(testedFeature.callFactory.okhttpClient.proxy).isSameAs(proxy) + val call = testedFeature.createOkHttpCallFactory {}.newCall( + Request.Builder().url("https://example.com").build() + ) + val field = call.javaClass.getDeclaredField("client").apply { isAccessible = true } + val client = field.get(call) as okhttp3.OkHttpClient + assertThat(client.proxy).isSameAs(proxy) + assertThat(client.proxyAuthenticator).isSameAs(proxyAuth) + } + + @Test + fun `M remote factory preserves explicitly allowed cleartext W configuration changes`() { + testedFeature.initialize( + appContext.mockInstance, + fakeSdkInstanceId, + fakeConfig.copy(coreConfig = fakeConfig.coreConfig.copy(needsClearTextHttp = true)), + fakeConsent + ) + assertThat(testedFeature.callFactory.okhttpClient.connectionSpecs).contains(ConnectionSpec.CLEARTEXT) + val call = testedFeature.createOkHttpCallFactory { + }.newCall(Request.Builder().url("http://127.0.0.1:18889/config").build()) + val field = call.javaClass.getDeclaredField("client").apply { isAccessible = true } + val client = field.get(call) as okhttp3.OkHttpClient + assertThat(client.connectionSpecs).contains(ConnectionSpec.CLEARTEXT) + } + + @Test + fun `M preserve feature POST bodies W creating a configured client`() { + testedFeature.initialize(appContext.mockInstance, fakeSdkInstanceId, fakeConfig, fakeConsent) + val body = "plain feature payload".toByteArray() + val factory = testedFeature.createOkHttpCallFactory { + addInterceptor { chain -> + val request = chain.request() + val buffer = okio.Buffer() + request.body!!.writeTo(buffer) + assertThat(buffer.readByteArray()).isEqualTo(body) + assertThat(request.header("Content-Encoding")).isNull() + okhttp3.Response.Builder().request(request).protocol(Protocol.HTTP_1_1) + .code(200).message("OK").body("".toResponseBody()).build() + } + } + val call = factory.newCall( + Request.Builder().url("https://example.com/flags") + .post(body.toRequestBody()).build() + ) + val field = call.javaClass.getDeclaredField("client").apply { isAccessible = true } + val client = field.get(call) as okhttp3.OkHttpClient + assertThat(client.interceptors).hasSize(1) + assertThat(client.networkInterceptors).isEmpty() + assertThat(call.execute().code).isEqualTo(200) + } } 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 570427ec86..6b27bb8ba6 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 @@ -182,13 +182,19 @@ internal class RumSessionScope( writeScope: EventWriteScope, writer: DataWriter ): RumScope? { + val now = sdkCore.timeProvider.getDeviceElapsedTimeNanos() if (event is RumRawEvent.ResetSession) { // FLASHCAT FORK - two kinds of session must not be renewed here. A stopped one is // draining: renewing it would mint a session id under a scope whose own context already // reports the session as inactive, and would announce that session to the host // application's listener. A forced one would only ever be replaced by an identical // forced session, so the renewal buys nothing and costs the view the user is on. - if (isActive && !forcedSession) { + // A configuration change cannot create activity. Expired sessions are renewed by + // the next interaction; maximum-duration renewal keeps its normal start reason. + if (isActive && !forcedSession && sessionId != RumContext.NULL_UUID && + now - lastUserInteractionNs.get() < sessionInactivityNanos && + now - sessionStartNs.get() < sessionMaxDurationNanos + ) { renewSession(event.eventTime, StartReason.EXPLICIT_STOP) } } else if (event is RumRawEvent.SetForcedSession && isActive) { @@ -204,13 +210,13 @@ internal class RumSessionScope( 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()) + lastUserInteractionNs.set(now) } } else if (event is RumRawEvent.StopSession) { stopSession() } - updateSession(event) + updateSession(event, now) val actualWriter = if (sessionState == State.TRACKED) writer else noOpWriter @@ -293,8 +299,7 @@ internal class RumSessionScope( } @Suppress("ComplexMethod") - private fun updateSession(event: RumRawEvent) { - val nanoTime = sdkCore.timeProvider.getDeviceElapsedTimeNanos() + private fun updateSession(event: RumRawEvent, nanoTime: Long) { val isNewSession = sessionId == RumContext.NULL_UUID val timeSinceLastInteractionNs = nanoTime - lastUserInteractionNs.get() 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 5705d0193a..087afa8798 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 @@ -72,6 +72,10 @@ internal class RemoteConfigController( private var failedAttempts = 0 private var pendingRetry: ScheduledFuture<*>? = null + // Guarded by this controller's monitor, together with response commits and stop(). + private var stopped = false + private var activeCall: Call? = null + fun start() = triggerFetch() /** @@ -101,7 +105,11 @@ internal class RemoteConfigController( } } + @Synchronized fun stop() { + stopped = true + @Suppress("UnsafeThirdPartyFunctionCall") // OkHttp cancellation is idempotent and does not throw. + activeCall?.cancel() executor.shutdownNow() } @@ -110,11 +118,11 @@ internal class RemoteConfigController( * backoff, so a session starting in the middle of an outage does not wait out the patient * retry before asking again. */ + @Synchronized private fun triggerFetch() { - synchronized(this) { - pendingRetry?.cancel(false) - failedAttempts = 0 - } + if (stopped) return + pendingRetry?.cancel(false) + failedAttempts = 0 if (!inFlight.compareAndSet(false, true)) return // A submission the executor refuses is one made after `stop()`, and this controller does // not outlive that: the feature drops it in the same breath, so there is nothing left for @@ -127,6 +135,7 @@ internal class RemoteConfigController( try { fetchAndApply() } finally { + synchronized(this) { activeCall = null } // Released whatever happened above, because every later fetch — a new session, a // return to the foreground, a retry — is gated on this flag. Anything that got out of // here without clearing it would end remote configuration for the rest of the @@ -137,26 +146,29 @@ internal class RemoteConfigController( @WorkerThread private fun fetchAndApply() { - // Housekeeping, once per launch and here rather than at construction: this is the first - // place that is both off the main thread — nothing about remote configuration may hold up - // initialisation — and certain to run before anything is stored. Repeating it at every - // fetch would walk the preferences file again at every session start to learn nothing new. - if (swept.compareAndSet(false, true)) { - store.sweepAbandoned() - } - - // The rhythm the console asked for lives on disk, so it survives the process that fetched - // it. Read here, before the request, because neither of the other two outcomes carries it: - // a 304 has no body to apply, and a failed request has nothing at all. A client that only - // ever sees those - the steady state, since the validator exists to produce it - would - // otherwise spend every launch after the first on the defaults, with the console's - // permission to refresh on foreground silently forgotten. - currentTtlSeconds = store.ttlSeconds() ?: DEFAULT_TTL_SECONDS - refreshOnForeground = store.refreshOnForeground() + synchronized(this) { + if (stopped) return + // Housekeeping, once per launch and here rather than at construction: this is the first + // place that is both off the main thread — nothing about remote configuration may hold up + // initialisation — and certain to run before anything is stored. Repeating it at every + // fetch would walk the preferences file again at every session start to learn nothing new. + if (swept.compareAndSet(false, true)) { + store.sweepAbandoned() + } - // 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() + // The rhythm the console asked for lives on disk, so it survives the process that fetched + // it. Read here, before the request, because neither of the other two outcomes carries it: + // a 304 has no body to apply, and a failed request has nothing at all. A client that only + // ever sees those - the steady state, since the validator exists to produce it - would + // otherwise spend every launch after the first on the defaults, with the console's + // permission to refresh on foreground silently forgotten. + currentTtlSeconds = store.ttlSeconds() ?: DEFAULT_TTL_SECONDS + refreshOnForeground = store.refreshOnForeground() + + // 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() + } val succeeded = try { // Which version this client is running, reported on every request whether or not its @@ -169,14 +181,20 @@ internal class RemoteConfigController( // 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 -> + val call = synchronized(this) { + if (stopped) return + callFactory.newCall(requestBuilder.build()).also { activeCall = it } + } + call.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. The entry is still marked // as in use, because this is the one answer that stores nothing and the sweep // reads nothing but age. response.code == HTTP_NOT_MODIFIED -> { - store.touch() + synchronized(this) { + if (!stopped) store.touch() + } true } response.isSuccessful -> { @@ -214,6 +232,7 @@ internal class RemoteConfigController( */ private fun scheduleRetry() { synchronized(this) { + if (stopped) return if (failedAttempts >= RETRY_DELAYS_SECONDS.size) return val delaySeconds = jittered(RETRY_DELAYS_SECONDS[failedAttempts], jitter()) failedAttempts++ @@ -231,6 +250,9 @@ internal class RemoteConfigController( * others are answers, whether or not this SDK can act on them. */ internal enum class Outcome { + /** The controller stopped before this response could be committed. */ + STOPPED, + /** The body was read and its values are now stored. */ APPLIED, @@ -260,7 +282,9 @@ internal class RemoteConfigController( * Without that check, a console resending an unchanged configuration would cut every session in * two on every fetch. */ + @Synchronized internal fun apply(payload: String, etag: String? = null): Outcome { + if (stopped) return Outcome.STOPPED val json = try { @Suppress("UnsafeThirdPartyFunctionCall") // caught right here JSONObject(payload) @@ -274,13 +298,8 @@ internal class RemoteConfigController( // 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. // - // The stamp is required, and it is the whole of what tells a configuration apart from any - // other JSON. Every other field in the envelope is read with a default, so an unrelated - // body - a proxy's block page, a reverse proxy answering /config with something else - - // comes out as "enabled: false, no rates", which is a legitimate configuration meaning - // "stop using the console's values". Storing that empties the entry and drops the client - // back to the rates it was built with. So an unstamped body is treated as a request that - // did not arrive: nothing is stored, and it is asked again for. + // A schema stamp and a complete envelope are required before touching the stored values. + // Missing fields must not be mistaken for an instruction to clear a configuration. // // 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 @@ -291,16 +310,28 @@ internal class RemoteConfigController( return Outcome.UNREADABLE } if (stamp !is Number || - json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT) != SUPPORTED_SCHEMA_VERSION + stamp.toDouble() != SUPPORTED_SCHEMA_VERSION.toDouble() ) { logUnsupportedSchema(json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT)) return Outcome.UNSUPPORTED_SCHEMA } - val enabled = json.optBoolean(FIELD_ENABLED, false) + val rawVersion = json.opt(FIELD_VERSION) + val enabled = json.opt(FIELD_ENABLED) + val rum = json.opt(FIELD_RUM) + @Suppress("UnsafeThirdPartyFunctionCall") // JSON numeric conversion and exception construction do not throw. + if (rawVersion !is Number || + rawVersion.toDouble() !in 0.0..Int.MAX_VALUE.toDouble() || + rawVersion.toDouble() != rawVersion.toInt().toDouble() || + enabled !is Boolean || rum !is JSONObject + ) { + logUnreadableBody(JSONException("Invalid remote configuration envelope.")) + return Outcome.UNREADABLE + } val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) - val version = json.optInt(FIELD_VERSION, 0).takeIf { it > 0 } + @Suppress("UnsafeThirdPartyFunctionCall") // The parsed JSON number has been validated as an integer. + val version = rawVersion.toInt().takeIf { it > 0 } // Rollbacks are published under a new version. An older response must not replace the // stored values or their validator, nor restart a session under superseded settings. if ((version ?: 0) < (store.appliedVersion() ?: 0)) { @@ -308,7 +339,7 @@ internal class RemoteConfigController( } val before = RemoteConfigValues(store.sessionSampleRate()) val delivered = if (enabled) { - readValues(json.optJSONObject(FIELD_RUM)).copy( + readValues(rum).copy( version = version, // Stored as the raw string: the platform's job is delivery, the meaning belongs to // the host application. 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 7f14348f39..0b2f0a5c75 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 @@ -1051,6 +1051,12 @@ internal class RumSessionScopeTest { // The negative control for the test above: an ordinary session is still renewed, so the // guard is about forcing and not about resets in general. initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) val firstSessionId = testedScope.getRumContext().sessionId // When @@ -1087,7 +1093,12 @@ internal class RumSessionScopeTest { initializeTestedScope(1f, remoteConfig = mockRemoteConfig) // When - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) // Then assertThat(testedScope.effectiveSampleRate).isEqualTo(1f) @@ -1138,7 +1149,12 @@ internal class RumSessionScopeTest { null }) - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) assertThat(published.get()).isEqualTo(second) assertThat(seen.single().sessionSampleRate).isEqualTo(100f) @@ -1162,8 +1178,12 @@ internal class RumSessionScopeTest { initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig) // When - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) - val context = testedScope.getRumContext() + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) // Then assertThat(testedScope.effectiveSampleRate).isEqualTo(42f) @@ -1180,7 +1200,12 @@ internal class RumSessionScopeTest { initializeTestedScope(sampleRate = 80f, remoteConfig = remoteConfig) // When - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) // Then - the draw used the init values, and version 0 says no configuration was ever fetched assertThat(testedScope.effectiveSampleRate).isEqualTo(80f) @@ -1195,7 +1220,12 @@ internal class RumSessionScopeTest { initializeTestedScope(remoteConfig = remoteConfig) // When - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) // Then - the version in force at the draw travels to the view scopes, which report it val record = testedScope.drawnConfiguration @@ -1210,7 +1240,12 @@ internal class RumSessionScopeTest { initializeTestedScope(onSessionDrawn = { fetches++ }) // When - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) // Then assertThat(fetches).isOne() @@ -1222,7 +1257,12 @@ internal class RumSessionScopeTest { initializeTestedScope() // When - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) // Then - events keep reporting the init values, which are the values the draw used anyway assertThat(testedScope.drawnConfiguration).isNull() @@ -2041,7 +2081,12 @@ internal class RumSessionScopeTest { initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig, beforeSampling = { 100f }) // When - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) // Then assertThat(testedScope.effectiveSampleRate).isEqualTo(100f) @@ -2063,7 +2108,12 @@ internal class RumSessionScopeTest { ) // When - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) // Then // The hook is consulted AFTER the console, so what it sees is the rate that would apply. @@ -2075,7 +2125,12 @@ internal class RumSessionScopeTest { fun `M keep the incoming rate W handleEvent { beforeSampling returns nothing }`() { initializeTestedScope(sampleRate = 30f, beforeSampling = { null }) - testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) assertThat(testedScope.effectiveSampleRate).isEqualTo(30f) } @@ -2084,7 +2139,12 @@ internal class RumSessionScopeTest { 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) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) assertThat(testedScope.effectiveSampleRate).isEqualTo(30f) } @@ -2094,7 +2154,12 @@ internal class RumSessionScopeTest { // 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) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) assertThat(testedScope.effectiveSampleRate).isEqualTo(30f) } @@ -2166,4 +2231,117 @@ internal class RumSessionScopeTest { return forge.testRumStartupScenarios(weakActivity) } } + + @Test + fun `M idle reset must not announce an immediately expired session W configuration changes`() { + initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + val initial = testedScope.sessionId + org.mockito.kotlin.clearInvocations(mockSessionListener) + advanceTimeByMs(TEST_INACTIVITY_MS + 1) + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.EXPIRED) + verifyNoInteractions(mockSessionListener) + assertThat(testedScope.sessionId).isEqualTo(initial) + } + + @Test + fun `M active reset still renews as negative control W configuration changes`() { + initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + val initial = testedScope.sessionId + advanceTimeByMs(1) + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + assertThat(testedScope.sessionId).isNotEqualTo(initial) + } + + @Test + fun `M not create a session W remote reset before any activity`() { + var draws = 0 + initializeTestedScope(onSessionDrawn = { draws++ }) + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.sessionId).isEqualTo(RumContext.NULL_UUID) + assertThat(draws).isZero() + verifyNoInteractions(mockSessionListener) + } + + @Test + fun `M preserve maximum duration renewal W remote reset at the duration limit`() { + initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + repeat(4) { + advanceTimeByMs(100) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + } + org.mockito.kotlin.clearInvocations(mockSessionListener) + advanceTimeByMs(100) + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.getRumContext().sessionStartReason).isEqualTo(RumSessionScope.StartReason.MAX_DURATION) + verify(mockSessionListener).onSessionStarted(testedScope.sessionId, false) + } + + @Test + fun `M use one expiry decision W the sampling callback crosses the inactivity limit`() { + var advanceDuringDraw = false + initializeTestedScope(100f, beforeSampling = { + if (advanceDuringDraw) advanceTimeByMs(2) + null + }) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + advanceTimeByMs(TEST_INACTIVITY_MS - 1) + advanceDuringDraw = true + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + } } 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 0af9eb11c5..8b050f7130 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 @@ -865,4 +865,172 @@ internal class RemoteConfigControllerTest { companion object { private const val INIT_SESSION_RATE = 20f } + + @Test + fun `M rejects fractional schema instead of truncating W configuration changes`() { + val outcome = testedController.apply(body().replace("\"schema_version\":1", "\"schema_version\":1.5")) + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNSUPPORTED_SCHEMA) + verify(store, never()).store(any()) + } + + @Test + fun `M rejects incomplete stamped envelope without clearing rate W configuration changes`() { + whenever(store.sessionSampleRate()).thenReturn(0f) + whenever(store.appliedVersion()).thenReturn(3) + val outcome = testedController.apply("""{"schema_version":1,"version":4}""", "\"v4\"") + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + verify(store, never()).store(any()) + } + + @Test + fun `M preserves valid disable response as negative control W configuration changes`() { + whenever(store.sessionSampleRate()).thenReturn(0f) + whenever(store.appliedVersion()).thenReturn(3) + assertThat( + testedController.apply(body(version = 4, enabled = false)) + ).isEqualTo(RemoteConfigController.Outcome.APPLIED) + verify(store).store(RemoteConfigValues(null, 4, ttlSeconds = 300L)) + assertThat(restarts).isEqualTo(1) + } + + @Test + fun `M ignores an in flight response after stop W configuration changes`() { + whenever(call.execute()).thenAnswer { + testedController.stop() + response(200, body(rum = "\"sessionSampleRate\":0")) + } + runPendingFetch() + verify(store, never()).store(any()) + assertThat(restarts).isZero() + } + + @Test + fun `M applies response before stop as negative control W configuration changes`() { + whenever(call.execute()).thenReturn(response(200, body(rum = "\"sessionSampleRate\":0"))) + runPendingFetch() + verify(store).store(RemoteConfigValues(0f, 3, ttlSeconds = 300L)) + assertThat(restarts).isEqualTo(1) + } + + @Test + fun `M real executor ignores late response after stop W configuration changes`() { + val entered = java.util.concurrent.CountDownLatch(1) + val release = java.util.concurrent.CountDownLatch(1) + val realExecutor = java.util.concurrent.Executors.newSingleThreadScheduledExecutor() + val sdkCore = mock() + whenever(sdkCore.internalLogger).thenReturn(mock()) + val controller = RemoteConfigController( + sdkCore, + "https://example.com/config?x=1", + store, + INIT_SESSION_RATE, + callFactory, + realExecutor, + { restarts++ } + ) + whenever(call.execute()).thenAnswer { + entered.countDown() + while (release.count > 0) { + try { release.await(100, TimeUnit.MILLISECONDS) } catch (_: InterruptedException) { } + } + response(200, body(rum = "\"sessionSampleRate\":0")) + } + try { + controller.start() + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue() + controller.stop() + release.countDown() + assertThat(realExecutor.awaitTermination(5, TimeUnit.SECONDS)).isTrue() + verify(store, never()).store(any()) + assertThat(restarts).isZero() + } finally { + release.countDown() + realExecutor.shutdownNow() + } + } + + @Test + fun `M reject malformed mandatory fields W apply()`() { + val invalid = listOf( + """{"schema_version":1,"enabled":true,"rum":{}}""", + """{"schema_version":1,"version":-1,"enabled":true,"rum":{}}""", + """{"schema_version":1,"version":1.5,"enabled":true,"rum":{}}""", + """{"schema_version":1,"version":2147483648,"enabled":true,"rum":{}}""", + """{"schema_version":1,"version":"3","enabled":true,"rum":{}}""", + """{"schema_version":1,"version":3,"enabled":"false","rum":{}}""", + """{"schema_version":1,"version":3,"enabled":false,"rum":null}""" + ) + invalid.forEach { + assertThat(testedController.apply(it)).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + } + verify(store, never()).store(any()) + assertThat(restarts).isZero() + } + + @Test + fun `M accept an unpublished configuration W apply()`() { + assertThat(testedController.apply(body(version = 0, enabled = false))) + .isEqualTo(RemoteConfigController.Outcome.APPLIED) + verify(store).store(RemoteConfigValues(null, ttlSeconds = 300L)) + } + + @Test + fun `M cancel the request without touching the cache W stop before a 304`() { + whenever(call.execute()).thenAnswer { + testedController.stop() + response(304, "") + } + runPendingFetch() + verify(call).cancel() + verify(store, never()).touch() + verify(executor, never()).schedule(any(), any(), any()) + } + + @Test + fun `M discard queued work W stop before fetch starts`() { + testedController.start() + val queued = argumentCaptor() + verify(executor).execute(queued.capture()) + testedController.stop() + queued.firstValue.run() + testedController.start() + verify(store, never()).sweepAbandoned() + verify(callFactory, never()).newCall(any()) + verify(executor).execute(any()) + } + + @Test + fun `M finish committing before stop returns W response already applying`() { + val storing = java.util.concurrent.CountDownLatch(1) + val releaseStore = java.util.concurrent.CountDownLatch(1) + val stopping = java.util.concurrent.CountDownLatch(1) + val workers = java.util.concurrent.Executors.newFixedThreadPool(2) + whenever(store.store(any())).thenAnswer { + storing.countDown() + check(releaseStore.await(5, TimeUnit.SECONDS)) + Unit + } + try { + val applying = workers.submit { + testedController.apply(body(rum = "\"sessionSampleRate\":0")) + } + assertThat(storing.await(5, TimeUnit.SECONDS)).isTrue() + val stopped = workers.submit { + stopping.countDown() + testedController.stop() + restarts + } + assertThat(stopping.await(5, TimeUnit.SECONDS)).isTrue() + assertThatThrownBy { stopped.get(100, TimeUnit.MILLISECONDS) } + .isInstanceOf(java.util.concurrent.TimeoutException::class.java) + releaseStore.countDown() + assertThat(applying.get(5, TimeUnit.SECONDS)).isEqualTo(RemoteConfigController.Outcome.APPLIED) + stopped.get(5, TimeUnit.SECONDS) + assertThat(restarts).isEqualTo(1) + assertThat(testedController.apply(body())).isEqualTo(RemoteConfigController.Outcome.STOPPED) + } finally { + releaseStore.countDown() + workers.shutdownNow() + } + } } 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 b534808c5a..b4686bfa79 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,10 +112,6 @@ internal class SessionReplayFeature( // are we recording at the moment private val isRecording = AtomicBoolean(false) - // 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 - // is the current session sampled in private val isSessionSampledIn = AtomicBoolean(false) @@ -235,8 +231,9 @@ internal class SessionReplayFeature( parseSessionMetadata(sessionMetadata) ?.let { sessionData -> val alreadySeenSession = currentRumSessionId.get() == sessionData.sessionId - if (shouldHandleSession(alreadySeenSession)) { - applySampling(alreadySeenSession) + val forceSampling = sessionData.forced && !isSessionSampledIn.get() + if (!alreadySeenSession || forceSampling || userIntentToRecordChanged.get()) { + applySampling(alreadySeenSession, sessionData.forced) modifyShouldRecordState(sessionData) handleRecording(sessionData) } @@ -257,29 +254,28 @@ internal class SessionReplayFeature( private data class SessionData( val keepSession: Boolean, - val sessionId: String + val sessionId: String, + val forced: Boolean ) 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 - sessionForced = sessionMetadata[RUM_SESSION_FORCED_BUS_MESSAGE_KEY] as? Boolean ?: false if (keepSession == null || sessionId == null) { logEventMissingMandatoryFieldsError() return null } - return SessionData(keepSession, sessionId) - } - - private fun shouldHandleSession(alreadySeenSession: Boolean): Boolean { - return !alreadySeenSession || userIntentToRecordChanged.get() + val forced = sessionMetadata[RUM_SESSION_FORCED_BUS_MESSAGE_KEY] as? Boolean ?: false + return SessionData(keepSession, sessionId, forced) } - private fun applySampling(alreadySeenSession: Boolean) { - if (!alreadySeenSession) { - isSessionSampledIn.set(sessionForced || rateBasedSampler.sample(Unit)) + private fun applySampling(alreadySeenSession: Boolean, forced: Boolean) { + if (forced) { + isSessionSampledIn.set(true) + } else if (!alreadySeenSession) { + isSessionSampledIn.set(rateBasedSampler.sample(Unit)) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index 0ecd0d9d97..3ac4e7a8a2 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt @@ -1497,4 +1497,87 @@ internal class SessionReplayFeatureTest { ) } } + + @Test + fun `M forcing an existing sampled out replay starts recording W configuration changes`() { + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val message = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + testedFeature.onReceive(message) + org.mockito.kotlin.clearInvocations(mockRecorder) + testedFeature.onReceive(message + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true)) + verify(mockRecorder).resumeRecorders() + } + + @Test + fun `M forcing a new session starts recording as negative control W configuration changes`() { + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val message = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + testedFeature.onReceive(message) + org.mockito.kotlin.clearInvocations(mockRecorder) + testedFeature.onReceive( + message + mapOf( + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to java.util.UUID.randomUUID().toString(), + SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true + ) + ) + verify(mockRecorder).resumeRecorders() + } + + @Test + fun `M respect manual stop W an existing session becomes forced`() { + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val message = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + testedFeature.onReceive(message) + testedFeature.manuallyStopRecording() + org.mockito.kotlin.clearInvocations(mockRecorder) + testedFeature.onReceive(message + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true)) + verify(mockRecorder, never()).resumeRecorders() + testedFeature.manuallyStartRecording() + testedFeature.onReceive(message + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true)) + verify(mockRecorder).resumeRecorders() + testedFeature.onReceive(message + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true)) + verify(mockRecorder).resumeRecorders() + } + + @Test + fun `M ignore malformed forced messages W the current replay is sampled out`() { + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val message = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + testedFeature.onReceive(message) + org.mockito.kotlin.clearInvocations(mockRecorder) + testedFeature.onReceive( + (message - SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY) + + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true) + ) + testedFeature.onReceive(message) + verifyNoInteractions(mockRecorder) + } }