diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index f8f94b5168..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 @@ -62,6 +66,8 @@ data class com.datadog.android.rum.RumConfiguration class Builder 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 @@ -116,6 +122,8 @@ interface com.datadog.android.rum.RumMonitor fun getAttributes(): Map fun clearAttributes() fun stopSession() + fun setForcedSession() + fun getRemoteConfig(): Map? fun addViewLoadingTime(Boolean) fun addViewAttributes(Map) fun removeViewAttributes(Collection) @@ -1838,7 +1846,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 00238026f7..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,10 +120,12 @@ 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; 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; @@ -161,10 +180,12 @@ 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/util/Map; public abstract fun removeAttribute (Ljava/lang/String;)V 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 @@ -4942,18 +4963,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; 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/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 0537f21fa6..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 @@ -132,6 +132,11 @@ object Rum { sdkCore = sdkCore, 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() }, + 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 b491dba615..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 @@ -63,6 +63,45 @@ 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 + } + + /** + * 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/RumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt index dc98c911a7..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 @@ -302,6 +302,26 @@ 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() + + /** + * 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(): Map? + /** * 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/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 23f68aedbc..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 @@ -75,6 +76,9 @@ 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.startup.RumAppStartupDetector import com.datadog.android.rum.internal.startup.RumFirstDrawTimeReporter import com.datadog.android.rum.internal.startup.RumStartupScenario @@ -166,6 +170,15 @@ 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 remoteConfigStore: RemoteConfigStore? = null + internal var remoteConfigController: RemoteConfigController? = null + private var remoteConfigForegroundCallback: ProcessForegroundCallback? = null internal var initialResourceIdentifier: InitialResourceIdentifier = NoOpInitialResourceIdentifier() internal var lastInteractionIdentifier: LastInteractionIdentifier? = NoOpLastInteractionIdentifier() internal var slowFramesListener: SlowFramesListener? = null @@ -268,6 +281,8 @@ internal class RumFeature( initializeANRDetector() } + startRemoteConfiguration(appContext) + registerTrackingStrategies(appContext) sessionListener = configuration.sessionListener @@ -334,6 +349,12 @@ internal class RumFeature( override fun onStop() { sdkCore.removeEventReceiver(name) + remoteConfigForegroundCallback?.let { (appContext as? Application)?.unregisterActivityLifecycleCallbacks(it) } + remoteConfigForegroundCallback = null + remoteConfigController?.stop() + remoteConfigController = null + remoteConfigStore = null + rumContextUpdateReceivers.forEach { sdkCore.removeContextUpdateReceiver(it) } @@ -752,6 +773,63 @@ internal class RumFeature( ) } + /** + * 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 the values it was initialised with. Nothing here + * may delay initialisation or interrupt collection. + */ + 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 = RemoteConfigStore( + appContext = appContext, + storeKey = RemoteConfigStore.buildStoreKey( + context = context, + intakeUrl = intakeUrl, + applicationId = applicationId + ), + internalLogger = sdkCore.internalLogger + ) + remoteConfigStore = store + + remoteConfigController = RemoteConfigController( + sdkCore = sdkCore, + configUrl = RemoteConfigController.buildConfigUrl( + intakeUrl = intakeUrl, + clientToken = context.clientToken, + env = context.env, + appVersion = context.version, + sdkVersion = context.sdkVersion + ), + store = store, + initialSessionSampleRate = sampleRate, + callFactory = sdkCore.createOkHttpCallFactory(), + 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 = { + (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor)?.resetSession() + } + ).also { controller -> + controller.start() + + // 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 -> + val callback = ProcessForegroundCallback { controller.refreshIfStale() } + application.registerActivityLifecycleCallbacks(callback) + remoteConfigForegroundCallback = callback + } + } + } + // endregion internal data class Configuration( @@ -786,7 +864,12 @@ 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, + // 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 { @@ -867,6 +950,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..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 @@ -27,6 +28,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.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 @@ -54,7 +56,14 @@ 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 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 = {}, + // 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 @@ -67,6 +76,9 @@ internal class RumApplicationScope( sdkCore = sdkCore, sessionEndedMetricDispatcher = sessionEndedMetricDispatcher, sampleRate = sampleRate, + remoteConfig = remoteConfig, + onSessionDrawn = onSessionDrawn, + beforeSampling = beforeSampling, backgroundTrackingEnabled = backgroundTrackingEnabled, trackFrustrations = trackFrustrations, viewChangedListener = this, @@ -206,7 +218,10 @@ internal class RumApplicationScope( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + remoteConfig = remoteConfig, + 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/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 f308397e7e..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 @@ -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,6 +29,9 @@ 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.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 @@ -61,11 +67,37 @@ 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 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 = {}, + // 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 + // 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 + + // 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 + + // 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()) @@ -99,7 +131,7 @@ internal class RumSessionScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector + insightsCollector = insightsCollector ) internal val activeView: RumViewScope? @@ -149,6 +181,20 @@ 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. 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. + lastUserInteractionNs.set(sdkCore.timeProvider.getDeviceElapsedTimeNanos()) + } } else if (event is RumRawEvent.StopSession) { stopSession() } @@ -282,10 +328,25 @@ 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. + // 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 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) + } + childScope?.drawnConfiguration = drawnConfiguration sessionStartNs.set(time.nanoTime) rumSessionScopeStartupManager = rumSessionScopeStartupManagerFactory() childScope?.renewViewScopes(time) @@ -298,6 +359,36 @@ 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() + } + + /** + * 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) { @@ -306,6 +397,9 @@ 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 - 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 ) ) @@ -318,7 +412,14 @@ 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_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 179b1c885b..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 @@ -32,6 +32,7 @@ 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.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 @@ -52,7 +53,12 @@ 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, + // 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?, @@ -281,6 +287,7 @@ internal class RumViewManagerScope( frameRateVitalMonitor = frameRateVitalMonitor, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledResourceIdentifier = initialResourceIdentifier, slowFramesListener = slowFramesListener, @@ -364,6 +371,7 @@ internal class RumViewManagerScope( type = viewType, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledMetricResolver = networkSettledMetricResolver, viewEndedMetricDispatcher = viewEndedMetricDispatcher, @@ -407,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..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 @@ -43,6 +43,7 @@ import com.datadog.android.rum.internal.metric.networksettled.InternalResourceCo import com.datadog.android.rum.internal.metric.networksettled.NetworkSettledMetricResolver 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 @@ -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,17 @@ 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, + // 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(), service = datadogContext.service, @@ -1647,6 +1661,7 @@ internal open class RumViewScope( frameRateVitalMonitor: VitalMonitor, trackFrustrations: Boolean, sampleRate: Float, + drawnConfiguration: DrawnConfiguration? = null, interactionToNextViewMetricResolver: InteractionToNextViewMetricResolver, networkSettledResourceIdentifier: InitialResourceIdentifier, slowFramesListener: SlowFramesListener?, @@ -1683,6 +1698,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/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index 6deccb25b7..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 @@ -58,6 +59,8 @@ 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.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 @@ -99,7 +102,14 @@ 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. + 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 = {}, + // 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( @@ -122,7 +132,10 @@ internal class DatadogRumMonitor( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + remoteConfig = remoteConfig, + onSessionDrawn = onSessionDrawn, + beforeSampling = beforeSampling ) internal val keepAliveRunnable = Runnable { @@ -445,6 +458,16 @@ internal class DatadogRumMonitor( ) } + override fun setForcedSession() { + handleEvent( + RumRawEvent.SetForcedSession() + ) + } + + override fun getRemoteConfig(): Map? { + return decodeCustomValues(remoteConfig?.custom()) + } + @ExperimentalRumApi override fun reportAppFullyDisplayed() { handleEvent( 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/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..98c8c5f8dc --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/DrawnConfiguration.kt @@ -0,0 +1,22 @@ +/* + * 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 + +/** + * 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 remote settings version the draw read, or 0 when none was ever fetched. */ + val version: Int +) 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/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt new file mode 100644 index 0000000000..ab34b0f5a4 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -0,0 +1,426 @@ +/* + * 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.os.SystemClock +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.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 +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.random.Random + +/** + * Keeps the stored remote configuration in step with what the console says. + * + * 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, + private val configUrl: String, + private val store: RemoteConfigStore, + private val initialSessionSampleRate: Float, + private val callFactory: Call.Factory, + private val executor: ScheduledExecutorService, + private val restartSession: () -> Unit, + private val elapsedTimeMs: () -> Long = SystemClock::elapsedRealtime, + private val jitter: () -> Double = { Random.nextDouble() } +) { + + @Volatile + private var lastFetchAtMs: Long = 0 + + @Volatile + private var currentTtlSeconds: Long = DEFAULT_TTL_SECONDS + + @Volatile + private var refreshOnForeground: Boolean = false + + private val inFlight = AtomicBoolean(false) + private var failedAttempts = 0 + private var pendingRetry: ScheduledFuture<*>? = null + + fun start() = triggerFetch() + + /** + * 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. 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)) { + triggerFetch() + } + } + + fun stop() { + executor.shutdownNow() + } + + /** + * 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.execute { fetchOnce() } + } catch (e: RejectedExecutionException) { + // The SDK is shutting down. Nothing to keep fresh. + inFlight.set(false) + logScheduleRejected(e) + } + } + + @WorkerThread + private fun fetchOnce() { + // 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. + 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 + // 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 { + // 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 + } + } + } catch (e: IOException) { + logFetchFailure(e) + false + } catch (e: IllegalStateException) { + logFetchFailure(e) + false + } + + 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) + } + } + } + + /** + * 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. + * + * 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 fetch. + */ + 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. + // + // 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. + // 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 + } + + 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) { + 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 + ) + } else { + EMPTY_VALUES.copy(version = version, etag = etag) + } + store.store(after) + + 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 + } + + private fun readValues(rum: JSONObject?): RemoteConfigValues { + if (rum == null) return EMPTY_VALUES + return RemoteConfigValues( + sessionSampleRate = readRate(rum) + ) + } + + /** + * 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. + */ + 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() + } + + private fun changesThisClient(before: RemoteConfigValues, after: RemoteConfigValues): Boolean = + (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, + InternalLogger.Target.MAINTAINER, + { FETCH_FAILED_MESSAGE }, + e + ) + } + + 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" + + private 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_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" + 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 val EMPTY_VALUES = RemoteConfigValues(null) + + private const val HTTP_NOT_MODIFIED = 304 + 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." + + /** + * 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, + 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 + } + + 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 + + /** + * 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/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 new file mode 100644 index 0000000000..62175c6260 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -0,0 +1,179 @@ +/* + * 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 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 + * 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 RemoteConfigStore( + 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()) + + /** + * 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) + + /** + * 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 + * 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 + * knob back to the value the app was initialised with. + */ + fun store(values: RemoteConfigValues) { + val editor = preferences?.edit() ?: return + write(editor, sessionKey(), values.sessionSampleRate) + // 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 (values.version == null) { + editor.remove(versionKey()) + } else { + editor.putInt(versionKey(), values.version) + } + if (values.custom == null) { + editor.remove(customKey()) + } else { + editor.putString(customKey(), values.custom) + } + if (values.etag == null) { + editor.remove(etagKey()) + } else { + editor.putString(etagKey(), values.etag) + } + 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 versionKey() = "$storeKey.version" + + 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 + private const val ABSENT_VERSION = -1 + + internal const val STORAGE_UNAVAILABLE_MESSAGE = + "Unable to open the remote configuration store; the values passed to init will apply." + + /** + * 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 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, 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 values carried by one configuration response. Null means the console did not set that knob. + */ +internal data class RemoteConfigValues( + val sessionSampleRate: 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 +) 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..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 @@ -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 @@ -31,6 +33,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 @@ -63,6 +67,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 +968,212 @@ 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 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 + 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_SESSION_FORCED_BUS_MESSAGE_KEY to true, + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to + testedScope.getRumContext().sessionId + ) + ) + } + + // 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.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(version = 7) + ) + } + + @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.appliedVersion()) doReturn null + 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() + } + + @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 + whenever(remoteConfig.appliedVersion()) doReturn 9 + initializeTestedScope(remoteConfig = remoteConfig) + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then - the version in force at the draw travels to the view scopes, which report it + val record = testedScope.drawnConfiguration + assertThat(record?.version).isEqualTo(9) + 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 + 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_SESSION_FORCED_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to + testedScope.getRumContext().sessionId + ) + ) + } + + // endregion + // region Active View @Test @@ -1199,6 +1410,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1208,6 +1422,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1244,6 +1461,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1253,6 +1473,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to testedScope.getRumContext().sessionId ) @@ -1283,6 +1506,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1291,6 +1517,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1320,6 +1549,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1329,6 +1561,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1360,6 +1595,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) @@ -1369,6 +1607,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1378,6 +1619,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1409,6 +1653,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1417,6 +1664,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) @@ -1448,6 +1698,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1456,6 +1709,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1487,6 +1743,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId ) ) @@ -1495,6 +1754,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1503,6 +1765,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_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId ) ) @@ -1678,10 +1943,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 + backgroundTrackingEnabled: Boolean? = null, + remoteConfig: RemoteConfigStore? = null, + onSessionDrawn: () -> Unit = {}, + beforeSampling: BeforeSamplingCallback? = null ) { testedScope = RumSessionScope( parentScope = mockParentScope, @@ -1707,7 +2043,10 @@ internal class RumSessionScopeTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = { mockRumSessionScopeStartupManager }, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + remoteConfig = remoteConfig, + 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 92a34301af..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 @@ -59,6 +59,7 @@ import com.datadog.android.rum.internal.metric.networksettled.NetworkSettledMetr import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener 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 @@ -648,6 +649,53 @@ 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(version = 7) + 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?.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?.rcVersion).isNull() + } + assertThat(result).isNull() + } + @Test fun `M send event once W handleEvent(StartView) twice on active view`( @Forgery key: RumScopeKey, @@ -9157,6 +9205,7 @@ internal class RumViewScopeTest { type: RumViewType = fakeViewType, trackFrustrations: Boolean = fakeTrackFrustrations, sampleRate: Float = fakeSampleRate, + drawnConfiguration: DrawnConfiguration? = null, interactionNextViewMetricResolver: InteractionToNextViewMetricResolver = mockInteractionToNextViewMetricResolver, networkSettledMetricResolver: NetworkSettledMetricResolver = mockNetworkSettledMetricResolver, @@ -9178,6 +9227,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/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() + } +} 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/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt new file mode 100644 index 0000000000..d63b0ae131 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -0,0 +1,631 @@ +/* + * 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 okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +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 +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 +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) +@MockitoSettings(strictness = Strictness.LENIENT) +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 + + @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) + 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 = sdkCore, + configUrl = "https://example.com/api/v2/rum/config", + store = store, + initialSessionSampleRate = INIT_SESSION_RATE, + callFactory = callFactory, + executor = executor, + restartSession = { restarts++ }, + elapsedTimeMs = { elapsedMs }, + jitter = { 0.5 } + ) + } + + // region storing + + @Test + fun `M store the rate the response carries W apply()`() { + testedController.apply(body(rum = """"sessionSampleRate":42""")) + + 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, 3)) + } + + @Test + 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 = "")) + + 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, 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, 3)) + } + + // 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 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 the kill switch takes the rates away }`() { + whenever(store.sessionSampleRate()).thenReturn(100f) + + testedController.apply(body(activation = "immediate", enabled = false)) + + assertThat(restarts).isOne() + } + + // 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(RemoteConfigValues(null, 3)) + } + + // region fetching + + @Test + fun `M fetch right away W start()`() { + testedController.start() + + verify(executor).execute(any()) + } + + @Test + 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()) + } + + @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)) + } + + @Test + fun `M tell the server which version is applied W fetch`() { + whenever(store.appliedVersion()).thenReturn(7) + whenever(call.execute()).thenReturn(response(200, body())) + + runPendingFetch() + + argumentCaptor { + verify(callFactory).newCall(capture()) + assertThat(firstValue.url.toString()).contains("applied_version=7") + } + } + + @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())) + + 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 }`() { + testedController.apply(body(ttl = 60, refreshOnForeground = true)) + + elapsedMs = 61_000L + testedController.refreshIfStale() + + 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.apply(body(ttl = 60)) + + elapsedMs = 61_000L + testedController.refreshIfStale() + + 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.apply(body(ttl = 300, refreshOnForeground = true)) + + elapsedMs = 10_000L + testedController.refreshIfStale() + + 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 + + // region url + + @Test + fun `M put the configuration beside the intake W buildConfigUrl()`() { + val url = RemoteConfigController.buildConfigUrl( + intakeUrl = "https://rum.example.com/api/v2/rum", + clientToken = "token", + env = "staging", + appVersion = "1.2.3", + sdkVersion = "2.26.0" + ) + + 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") + assertThat(url).contains("sdk_version=2.26.0") + } + + @Test + fun `M leave out what the app did not set W buildConfigUrl()`() { + val url = RemoteConfigController.buildConfigUrl( + intakeUrl = "https://rum.example.com/api/v2/rum", + clientToken = "token", + env = "", + appVersion = "", + sdkVersion = "" + ) + + assertThat(url).doesNotContain("env=") + assertThat(url).doesNotContain("app_version=") + assertThat(url).doesNotContain("sdk_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 + + // 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() { 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 — + // 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.APPLIED) + argumentCaptor { + verify(store).store(capture()) + assertThat(firstValue.sessionSampleRate).isEqualTo(42f) + } + } + + @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 + + /** + * 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, 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() + + private fun body( + ttl: Int = 300, + enabled: Boolean = true, + activation: String = "next_session", + refreshOnForeground: Boolean = false, + rum: String = "", + custom: String? = null, + schemaVersion: Int? = RemoteConfigController.SUPPORTED_SCHEMA_VERSION + ): String = + "{" + (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""") + "}" + + // endregion + + companion object { + private const val INIT_SESSION_RATE = 20f + } +} 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..7e8303ce1b --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt @@ -0,0 +1,222 @@ +/* + * 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, + 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.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.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, 3, custom = """{"debug":true}""", etag = "\"v3\"")) + + store.store(RemoteConfigValues(null, 4)) + + assertThat(store.sessionSampleRate()).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, 3)) + + store.store(RemoteConfigValues(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, 3)) + + assertThat(store.sessionSampleRate()).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" + } +} 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..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,6 +112,10 @@ 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) @@ -259,6 +263,7 @@ 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 + sessionForced = sessionMetadata[RUM_SESSION_FORCED_BUS_MESSAGE_KEY] as? Boolean ?: false if (keepSession == null || sessionId == null) { logEventMissingMandatoryFieldsError() @@ -274,7 +279,7 @@ internal class SessionReplayFeature( private fun applySampling(alreadySeenSession: Boolean) { if (!alreadySeenSession) { - isSessionSampledIn.set(rateBasedSampler.sample(Unit)) + isSessionSampledIn.set(sessionForced || rateBasedSampler.sample(Unit)) } } @@ -431,6 +436,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_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"