diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ddb051a6f..5d7e76687e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 0.7.0 / Unreleased + +* [FEATURE] RUM: take the session sample rate from the application's settings in the Flashcat console instead of only from the value passed to `RumConfiguration.Builder.setSessionSampleRate()`, so it can be changed without shipping a new release of the app. Off by default — enable it with `RumConfiguration.Builder.setRemoteConfigurationEnabled(true)`. Left off, the SDK makes no extra request, opens no extra storage, starts no extra thread, and behaves exactly as it did before. A change applies to sessions started after it arrives, so a session already under way is normally never redrawn. Two changes are exceptions and end the running session so the next one starts under the new rate: one the console marks for immediate activation, and a rate crossing zero in either direction — switching collection off is an emergency stop, and switching it back on has nothing to preserve, since while the rate was zero no session was being collected and none was drawn against a real rate. The values passed to init keep applying until the first settings arrive and whenever the endpoint cannot be reached — a failed, timed-out or unreadable response never moves a rate. Events report the rate the session was actually drawn under, together with the settings version it came from, so server-side extrapolation and audits line up with the draw. + +* [FEATURE] RUM: add `RumConfiguration.Builder.setBeforeSampling()`, a callback consulted at each session draw with the rate that would apply and the console's custom values. Return a rate to override it, or `null` to leave it alone. It is the last step of the draw, after the console's rate, so an allow-list can keep collecting a user the console's rate would drop. A callback that throws, or returns a rate outside 0..100, is ignored and the incoming rate applies. + +* [FEATURE] RUM: add `RumMonitor.setForcedSession()` and `RumMonitor.getRemoteConfig()`. `setForcedSession()` collects the user's sessions with Session Replay regardless of the configured rates; the state lasts for the process lifetime and survives `stopSession()`. Events from a forced session report a sample rate of 100 and no settings version, because the session was kept whatever the rates said. `getRemoteConfig()` returns the application-defined values published in the console, delivered verbatim and never interpreted by the SDK. **Breaking:** `RumMonitor` is a public interface and gained two methods with no default implementation, so any class that implements it directly — a test double or a wrapper, most likely — must add them to compile. Code that only calls `GlobalRumMonitor.get()` is unaffected. + +* [IMPROVEMENT] RUM: `ViewEvent.Configuration` carries a new optional `rcVersion` field, so its constructor and `copy()` take one more argument. Kotlin callers that use named or default arguments are unaffected once recompiled; Java callers that construct it directly must pass the extra argument. + +--- + # 0.6.0 / 2026-08-17 * [CHANGE] Change the default NTP servers from `0.datadog.pool.ntp.org` through `3.datadog.pool.ntp.org` to `ntp.aliyun.com`, `ntp1.aliyun.com`, `time1.cloud.tencent.com` and `cn.pool.ntp.org`. Measured from a mainland-China host, where this SDK is predominantly deployed, the new servers answer at stratum 2 within 12-40 ms, while the previous ones answer at stratum 3 within 41-241 ms and one of the four did not answer at all. The new defaults span two cloud providers and the community pool, so no single operator being unreachable stops the clock from synchronizing. Apps that need other servers can select them with `setNtpHosts`. diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/CoreFeature.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/CoreFeature.kt index a6deb53201..ac90ef6cc1 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/CoreFeature.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/CoreFeature.kt @@ -341,7 +341,14 @@ internal class CoreFeature( fun createOkHttpCallFactory(block: OkHttpClient.Builder.() -> Unit): Call.Factory { return object : Call.Factory { // Create a new client that shares pools with the base client - private val client = lazySharedOkHttpClient.newBuilder() + private val client = callFactory.okhttpClient.newBuilder() + // Feature requests inherit transport settings, not upload encoding or logging. + .apply { + @Suppress("UnsafeThirdPartyFunctionCall") // Returns the builder's mutable list. + interceptors().clear() + @Suppress("UnsafeThirdPartyFunctionCall") // Returns the builder's mutable list. + networkInterceptors().clear() + } .apply(block) .build() diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/CoreFeatureTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/CoreFeatureTest.kt index 892a3e101e..e02074f78c 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/CoreFeatureTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/CoreFeatureTest.kt @@ -62,6 +62,8 @@ import okhttp3.CipherSuite import okhttp3.ConnectionSpec import okhttp3.Protocol import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.TlsVersion import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach @@ -1670,4 +1672,66 @@ internal class CoreFeatureTest { return listOf(appContext) } } + + @Test + fun `M remote factory preserves configured proxy W configuration changes`() { + val proxy = java.net.Proxy(java.net.Proxy.Type.HTTP, java.net.InetSocketAddress("127.0.0.1", 18888)) + val proxyAuth: Authenticator = mock() + testedFeature.initialize( + appContext.mockInstance, + fakeSdkInstanceId, + fakeConfig.copy(coreConfig = fakeConfig.coreConfig.copy(proxy = proxy, proxyAuth = proxyAuth)), + fakeConsent + ) + assertThat(testedFeature.callFactory.okhttpClient.proxy).isSameAs(proxy) + val call = testedFeature.createOkHttpCallFactory {}.newCall( + Request.Builder().url("https://example.com").build() + ) + val field = call.javaClass.getDeclaredField("client").apply { isAccessible = true } + val client = field.get(call) as okhttp3.OkHttpClient + assertThat(client.proxy).isSameAs(proxy) + assertThat(client.proxyAuthenticator).isSameAs(proxyAuth) + } + + @Test + fun `M remote factory preserves explicitly allowed cleartext W configuration changes`() { + testedFeature.initialize( + appContext.mockInstance, + fakeSdkInstanceId, + fakeConfig.copy(coreConfig = fakeConfig.coreConfig.copy(needsClearTextHttp = true)), + fakeConsent + ) + assertThat(testedFeature.callFactory.okhttpClient.connectionSpecs).contains(ConnectionSpec.CLEARTEXT) + val call = testedFeature.createOkHttpCallFactory { + }.newCall(Request.Builder().url("http://127.0.0.1:18889/config").build()) + val field = call.javaClass.getDeclaredField("client").apply { isAccessible = true } + val client = field.get(call) as okhttp3.OkHttpClient + assertThat(client.connectionSpecs).contains(ConnectionSpec.CLEARTEXT) + } + + @Test + fun `M preserve feature POST bodies W creating a configured client`() { + testedFeature.initialize(appContext.mockInstance, fakeSdkInstanceId, fakeConfig, fakeConsent) + val body = "plain feature payload".toByteArray() + val factory = testedFeature.createOkHttpCallFactory { + addInterceptor { chain -> + val request = chain.request() + val buffer = okio.Buffer() + request.body!!.writeTo(buffer) + assertThat(buffer.readByteArray()).isEqualTo(body) + assertThat(request.header("Content-Encoding")).isNull() + okhttp3.Response.Builder().request(request).protocol(Protocol.HTTP_1_1) + .code(200).message("OK").body("".toResponseBody()).build() + } + } + val call = factory.newCall( + Request.Builder().url("https://example.com/flags") + .post(body.toRequestBody()).build() + ) + val field = call.javaClass.getDeclaredField("client").apply { isAccessible = true } + val client = field.get(call) as okhttp3.OkHttpClient + assertThat(client.interceptors).hasSize(1) + assertThat(client.networkInterceptors).isEmpty() + assertThat(call.execute().code).isEqualTo(200) + } } diff --git a/features/dd-sdk-android-rum/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..2135a4b724 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumConfiguration.kt @@ -63,6 +63,47 @@ 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. Two changes do not wait: one the console marks for + * immediate activation, and a rate crossing zero in either direction — switching collection + * off is an emergency stop, and switching it back on has nothing to preserve, since while + * the rate was zero nothing was being collected. In both cases the running session ends and + * a new one starts under the new rates. The values set here stay in use until the first + * settings arrive, and whenever they cannot be reached. + * + * Disabled by default: left off, the SDK makes no extra request and behaves exactly as it + * did before this existed. + * + * @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..0a68bf642d 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt @@ -302,6 +302,33 @@ interface RumMonitor { */ fun stopSession() + /** + * Forces sessions to be collected, with Session Replay, regardless of the configured sample + * rates. Call it when your own code decides a user needs debugging (an allow-list, a support + * flow). + * + * A session already being collected keeps running: RUM cannot recover what a session already + * dropped, so cutting it in two would gain nothing. One that was not being collected is + * restarted straight away, so that a collected session takes its place. + * + * The forced state lasts for the process lifetime and survives [stopSession], so every session + * that follows is collected too; decide on each app start whether to call again. Events from a + * forced session report a sample rate of 100 and no configuration version, because the session + * was kept whatever the rates said. + */ + fun setForcedSession() + + /** + * 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..95bb7cbdf6 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -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,11 @@ 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..36a036dc4f 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt @@ -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, @@ -104,6 +116,13 @@ internal class RumApplicationScope( private var lastActiveViewInfo: RumViewInfo? = null private var isAppStartedEventSent = false + // FLASHCAT FORK - whether the host application has called `setForcedSession()`. It lives here + // rather than on the session scope because it has to outlive one: `stopSession()` leaves the + // session scope behind, and the next interaction builds a fresh one, which would otherwise be + // drawn as if the application had never asked. Set once and never cleared - the application + // decides on each launch whether to ask again. + private var forcedSession = false + // region RumScope @WorkerThread @@ -120,6 +139,12 @@ internal class RumApplicationScope( ) } + // FLASHCAT FORK - recorded before the event reaches the sessions, so that a session created + // by this very event is already drawn as forced. + if (event is RumRawEvent.SetForcedSession) { + forcedSession = true + } + val isInteraction = (event is RumRawEvent.StartView) || (event is RumRawEvent.StartAction) if (activeSession == null && isInteraction) { startNewSession(event, datadogContext, writeScope, writer) @@ -206,7 +231,11 @@ internal class RumApplicationScope( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + remoteConfig = remoteConfig, + onSessionDrawn = onSessionDrawn, + beforeSampling = beforeSampling, + forcedSession = forcedSession ) childScopes.add(newSession) if (event !is RumRawEvent.StartView) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/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..6b27bb8ba6 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -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,40 @@ 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, + // FLASHCAT FORK - whether the host application has asked for this user's sessions to be + // collected regardless of the rates. Passed in rather than owned here because it outlives any + // one session: the application asked for the user, not for whichever session happened to be + // running when it asked. [RumApplicationScope] holds it and hands it to every session it makes. + internal var forcedSession: Boolean = false ) : RumScope { + // FLASHCAT FORK - the rate the current session's events report as their configured sample + // rate. It is the rate the draw actually used - the console's where it set one, the app's hook + // where that had the last word - rather than whatever was passed to init, so that anything + // extrapolating from it lands on the population the session was drawn from. A forced session is + // the one case where the two part company: see `renewSession`. + internal var effectiveSampleRate: Float = sampleRate + + // FLASHCAT FORK - the configuration the current session was drawn under, so its events can + // 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 + private var startReason: StartReason = StartReason.USER_APP_LAUNCH internal var isActive: Boolean = true private val sessionStartNs = AtomicLong(sdkCore.timeProvider.getDeviceElapsedTimeNanos()) @@ -99,7 +134,7 @@ internal class RumSessionScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector + insightsCollector = insightsCollector ) internal val activeView: RumViewScope? @@ -147,13 +182,41 @@ internal class RumSessionScope( writeScope: EventWriteScope, writer: DataWriter ): RumScope? { + val now = sdkCore.timeProvider.getDeviceElapsedTimeNanos() if (event is RumRawEvent.ResetSession) { - renewSession(event.eventTime, StartReason.EXPLICIT_STOP) + // FLASHCAT FORK - two kinds of session must not be renewed here. A stopped one is + // draining: renewing it would mint a session id under a scope whose own context already + // reports the session as inactive, and would announce that session to the host + // application's listener. A forced one would only ever be replaced by an identical + // forced session, so the renewal buys nothing and costs the view the user is on. + // A configuration change cannot create activity. Expired sessions are renewed by + // the next interaction; maximum-duration renewal keeps its normal start reason. + if (isActive && !forcedSession && sessionId != RumContext.NULL_UUID && + now - lastUserInteractionNs.get() < sessionInactivityNanos && + now - sessionStartNs.get() < sessionMaxDurationNanos + ) { + renewSession(event.eventTime, StartReason.EXPLICIT_STOP) + } + } else if (event is RumRawEvent.SetForcedSession && isActive) { + // 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. [RumApplicationScope] remembers the same thing for the sessions that + // come after this one, including the ones that follow a `stopSession()`. + forcedSession = true + // A session already being collected keeps running: RUM cannot retro-collect what a + // running session already dropped, so cutting it in two would gain nothing. One that + // 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(now) + } } else if (event is RumRawEvent.StopSession) { stopSession() } - updateSession(event) + updateSession(event, now) val actualWriter = if (sessionState == State.TRACKED) writer else noOpWriter @@ -236,8 +299,7 @@ internal class RumSessionScope( } @Suppress("ComplexMethod") - private fun updateSession(event: RumRawEvent) { - val nanoTime = sdkCore.timeProvider.getDeviceElapsedTimeNanos() + private fun updateSession(event: RumRawEvent, nanoTime: Long) { val isNewSession = sessionId == RumContext.NULL_UUID val timeSinceLastInteractionNs = nanoTime - lastUserInteractionNs.get() @@ -282,10 +344,38 @@ 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. + val remoteValues = remoteConfig?.snapshot() + val drawRate = askBeforeSampling(remoteValues?.sessionSampleRate ?: sampleRate, remoteValues?.custom) + val keepSession = forcedSession || random.nextFloat() < drawRate.percent() + // FLASHCAT FORK - a forced session was not drawn, so it does not report a rate it was drawn + // at. It reports the rate that describes it: every session like it is kept. Reporting the + // rate it would have been drawn at instead would have the intake weight one deliberately + // kept session as the whole population that rate implies - a session forced at a rate of 1 + // would count as a hundred - and would leave nothing to tell it from a lucky draw. + effectiveSampleRate = if (forcedSession) FORCED_SAMPLE_RATE else drawRate + childScope?.sampleRate = effectiveSampleRate 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. A forced session reports none: it was kept whatever + // the console said, so naming a version would credit that version with a session it did not + // decide. This is the reporting the other SDKs use. + drawnConfiguration = if (forcedSession) { + null + } else { + remoteValues?.let { config -> + DrawnConfiguration(version = config.version ?: 0) + } + } + childScope?.drawnConfiguration = drawnConfiguration sessionStartNs.set(time.nanoTime) rumSessionScopeStartupManager = rumSessionScopeStartupManagerFactory() childScope?.renewViewScopes(time) @@ -298,6 +388,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, customJson: String?): Float { + val hook = beforeSampling ?: return rate + val override = try { + val custom = decodeCustomValues(customJson) + 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 +426,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 +441,18 @@ 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 + + // FLASHCAT FORK - what a forced session reports as its rate: it is kept unconditionally, + // which is what a rate of a hundred means. + internal const val FORCED_SAMPLE_RATE = 100f + + internal const val BEFORE_SAMPLING_THREW_MESSAGE = + "The beforeSampling callback failed; drawing this session at %s instead." + 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..961f267d04 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt @@ -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?, @@ -155,7 +161,10 @@ internal class RumViewManagerScope( internal fun renewViewScopes(eventTime: Time) { val newChildScope = childrenScopes.map { rumViewScope -> - rumViewScope.renew(eventTime) + // FLASHCAT FORK - the session scope has just written this scope's `sampleRate` and + // `drawnConfiguration` with the draw the renewed views belong to; handing them down is + // what makes the surviving view report the new session rather than the one that ended. + rumViewScope.renew(eventTime, sampleRate, drawnConfiguration) } childrenScopes.clear() childrenScopes.addAll(newChildScope) @@ -281,6 +290,7 @@ internal class RumViewManagerScope( frameRateVitalMonitor = frameRateVitalMonitor, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledResourceIdentifier = initialResourceIdentifier, slowFramesListener = slowFramesListener, @@ -364,6 +374,7 @@ internal class RumViewManagerScope( type = viewType, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledMetricResolver = networkSettledMetricResolver, viewEndedMetricDispatcher = viewEndedMetricDispatcher, @@ -407,6 +418,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..a59e3deff6 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt @@ -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?, @@ -445,7 +449,19 @@ internal open class RumViewScope( return !stopped } - internal fun renew(newEventTime: Time): RumViewScope { + /** + * FLASHCAT FORK - [sampleRate] and [drawnConfiguration] are passed in rather than copied from + * this scope, because a renewal is where a new session's draw takes effect: the view that + * survives it belongs to the session that has just been drawn, not to the one that ended. It is + * also that session's FIRST view, which is the one the intake reads the session's rate and + * configuration version from, so carrying this scope's own values across would misreport the + * whole session. + */ + internal fun renew( + newEventTime: Time, + sampleRate: Float, + drawnConfiguration: DrawnConfiguration? + ): RumViewScope { return RumViewScope( parentScope = this, sdkCore = sdkCore, @@ -462,6 +478,7 @@ internal open class RumViewScope( type = type, trackFrustrations = trackFrustrations, sampleRate = sampleRate, + drawnConfiguration = drawnConfiguration, interactionToNextViewMetricResolver = interactionToNextViewMetricResolver, networkSettledMetricResolver = networkSettledMetricResolver, viewEndedMetricDispatcher = viewEndedMetricDispatcher, @@ -1346,7 +1363,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 +1674,7 @@ internal open class RumViewScope( frameRateVitalMonitor: VitalMonitor, trackFrustrations: Boolean, sampleRate: Float, + drawnConfiguration: DrawnConfiguration? = null, interactionToNextViewMetricResolver: InteractionToNextViewMetricResolver, networkSettledResourceIdentifier: InitialResourceIdentifier, slowFramesListener: SlowFramesListener?, @@ -1683,6 +1711,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..9f01a5ac65 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt @@ -0,0 +1,63 @@ +/* + * 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. + * + * Activity-to-activity navigation keeps the counter above zero, so it is not mistaken for a return + * to the foreground. A rotation is not covered: the system destroys the old activity before it + * creates the new one, so the count really does reach zero and the app really does look like it + * left. That costs at most one extra request, and only when the stored ttl says the configuration + * is stale anyway, so it is left alone rather than given a state machine of its own. + */ +internal class ProcessForegroundCallback( + private val onForeground: () -> Unit +) : Application.ActivityLifecycleCallbacks { + + private val startedActivities = AtomicInteger(0) + + @MainThread + override fun onActivityStarted(activity: Activity) { + if (startedActivities.incrementAndGet() == 1) { + onForeground() + } + } + + @MainThread + override fun onActivityStopped(activity: Activity) { + // Floored at zero, because the count starts from nothing while the app may already have + // started an activity: an app that initialises the SDK from an activity - the usual shape + // when initialisation waits on a consent prompt - has one running by the time this is + // registered. Without the floor that activity's stop would take the count negative, and it + // could never climb back to the one that means "the app is in the foreground again": this + // callback would be dead for the rest of the process. + // + // Read-then-decrement needs no atomic update because both callbacks arrive on the main + // thread; the counter is atomic only because [refreshIfStale] reads its effect elsewhere. + if (startedActivities.get() > 0) { + startedActivities.decrementAndGet() + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + 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..087afa8798 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -0,0 +1,564 @@ +/* + * 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 com.datadog.android.core.internal.utils.executeSafe +import com.datadog.android.core.internal.utils.scheduleSafe +import okhttp3.Call +import okhttp3.Request +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.URLEncoder +import java.util.concurrent.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 + + /** + * The refresh rhythm the server last asked for, mirrored from the store so that + * [refreshIfStale] can answer without touching the disk on the main thread it is called from. + * Re-read from the store before every request rather than only when a body arrives - see + * [fetchAndApply]. + */ + @Volatile + private var currentTtlSeconds: Long = DEFAULT_TTL_SECONDS + + @Volatile + private var refreshOnForeground: Boolean = false + + private val inFlight = AtomicBoolean(false) + + /** Whether the entries of app versions this device no longer runs have been cleared yet. */ + private val swept = AtomicBoolean(false) + private var failedAttempts = 0 + private var pendingRetry: ScheduledFuture<*>? = null + + // Guarded by this controller's monitor, together with response commits and stop(). + private var stopped = false + private var activeCall: Call? = 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() + } + } + + @Synchronized + fun stop() { + stopped = true + @Suppress("UnsafeThirdPartyFunctionCall") // OkHttp cancellation is idempotent and does not throw. + activeCall?.cancel() + 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. + */ + @Synchronized + private fun triggerFetch() { + if (stopped) return + pendingRetry?.cancel(false) + failedAttempts = 0 + if (!inFlight.compareAndSet(false, true)) return + // A submission the executor refuses is one made after `stop()`, and this controller does + // not outlive that: the feature drops it in the same breath, so there is nothing left for + // the flag to gate. + executor.executeSafe(FETCH_TASK_NAME, sdkCore.internalLogger) { fetchOnce() } + } + + @WorkerThread + private fun fetchOnce() { + try { + fetchAndApply() + } finally { + synchronized(this) { activeCall = null } + // Released whatever happened above, because every later fetch — a new session, a + // return to the foreground, a retry — is gated on this flag. Anything that got out of + // here without clearing it would end remote configuration for the rest of the + // process's life, silently and with nothing left to ask again. + inFlight.set(false) + } + } + + @WorkerThread + private fun fetchAndApply() { + synchronized(this) { + if (stopped) return + // Housekeeping, once per launch and here rather than at construction: this is the first + // place that is both off the main thread — nothing about remote configuration may hold up + // initialisation — and certain to run before anything is stored. Repeating it at every + // fetch would walk the preferences file again at every session start to learn nothing new. + if (swept.compareAndSet(false, true)) { + store.sweepAbandoned() + } + + // The rhythm the console asked for lives on disk, so it survives the process that fetched + // it. Read here, before the request, because neither of the other two outcomes carries it: + // a 304 has no body to apply, and a failed request has nothing at all. A client that only + // ever sees those - the steady state, since the validator exists to produce it - would + // otherwise spend every launch after the first on the defaults, with the console's + // permission to refresh on foreground silently forgotten. + currentTtlSeconds = store.ttlSeconds() ?: DEFAULT_TTL_SECONDS + refreshOnForeground = store.refreshOnForeground() + + // Stamped before the request goes out, so a request that never comes back still counts as + // an attempt for the staleness gate instead of leaving the app on whatever it last knew. + lastFetchAtMs = elapsedTimeMs() + } + + val succeeded = try { + // Which version this client is running, reported on every request whether or not its + // session was kept. Nothing reads it today - how far a change has reached is measured + // from the version stamped on the sessions themselves - but it is part of the request + // every SDK on this contract makes, and taking a parameter off the wire is a protocol + // change of its own. + val url = store.appliedVersion()?.let { "$configUrl&applied_version=$it" } ?: configUrl + val requestBuilder = Request.Builder().url(url).get() + // The answer varies per caller, so the validator only means something paired with the + // configuration it validated: it is stored beside it and echoed back exactly as sent. + store.etag()?.let { requestBuilder.header(HEADER_IF_NONE_MATCH, it) } + val call = synchronized(this) { + if (stopped) return + callFactory.newCall(requestBuilder.build()).also { activeCall = it } + } + call.execute().use { response -> + when { + // Unchanged: what is stored is still the answer, so there is nothing to apply — + // but the ask itself succeeded, and no retry is owed. The entry is still marked + // as in use, because this is the one answer that stores nothing and the sweep + // reads nothing but age. + response.code == HTTP_NOT_MODIFIED -> { + synchronized(this) { + if (!stopped) store.touch() + } + 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 + } + + // The flag this fetch holds is released by the caller's `finally`, not here: a retry only + // schedules work for later, and the runnable it schedules takes the flag for itself. + if (!succeeded) scheduleRetry() + } + + /** + * 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 (stopped) return + if (failedAttempts >= RETRY_DELAYS_SECONDS.size) return + val delaySeconds = jittered(RETRY_DELAYS_SECONDS[failedAttempts], jitter()) + failedAttempts++ + pendingRetry = executor.scheduleSafe( + RETRY_TASK_NAME, + delaySeconds, + TimeUnit.SECONDS, + sdkCore.internalLogger + ) { if (inFlight.compareAndSet(false, true)) fetchOnce() } + } + } + + /** + * What reading one response body came to. Only [UNREADABLE] is worth asking again for: the + * others are answers, whether or not this SDK can act on them. + */ + internal enum class Outcome { + /** The controller stopped before this response could be committed. */ + STOPPED, + + /** The body was read and its values are now stored. */ + APPLIED, + + /** An older response was ignored because the stored configuration is already newer. */ + STALE_VERSION, + + /** + * The body was not a configuration at all — not JSON, or truncated. A captive portal + * answering 200 with a login page looks exactly like this, so it is treated as a request + * 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. + */ + @Synchronized + internal fun apply(payload: String, etag: String? = null): Outcome { + if (stopped) return Outcome.STOPPED + val json = try { + @Suppress("UnsafeThirdPartyFunctionCall") // caught right here + JSONObject(payload) + } 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. + // + // A schema stamp and a complete envelope are required before touching the stored values. + // Missing fields must not be mistaken for an instruction to clear a configuration. + // + // A stamp that is not a number is not a stamp: optInt would quietly turn the string "1" + // into 1 and accept a body the other SDKs refuse, and the point of this field is that + // every reader agrees about the same response. + val stamp = json.opt(FIELD_SCHEMA_VERSION) + if (stamp == null || stamp == JSONObject.NULL) { + logUnstampedBody() + return Outcome.UNREADABLE + } + if (stamp !is Number || + stamp.toDouble() != SUPPORTED_SCHEMA_VERSION.toDouble() + ) { + logUnsupportedSchema(json.optInt(FIELD_SCHEMA_VERSION, SCHEMA_VERSION_ABSENT)) + return Outcome.UNSUPPORTED_SCHEMA + } + + val rawVersion = json.opt(FIELD_VERSION) + val enabled = json.opt(FIELD_ENABLED) + val rum = json.opt(FIELD_RUM) + @Suppress("UnsafeThirdPartyFunctionCall") // JSON numeric conversion and exception construction do not throw. + if (rawVersion !is Number || + rawVersion.toDouble() !in 0.0..Int.MAX_VALUE.toDouble() || + rawVersion.toDouble() != rawVersion.toInt().toDouble() || + enabled !is Boolean || rum !is JSONObject + ) { + logUnreadableBody(JSONException("Invalid remote configuration envelope.")) + return Outcome.UNREADABLE + } + val activation = json.optString(FIELD_ACTIVATION, ACTIVATION_NEXT_SESSION) + + @Suppress("UnsafeThirdPartyFunctionCall") // The parsed JSON number has been validated as an integer. + val version = rawVersion.toInt().takeIf { it > 0 } + // Rollbacks are published under a new version. An older response must not replace the + // stored values or their validator, nor restart a session under superseded settings. + if ((version ?: 0) < (store.appliedVersion() ?: 0)) { + return Outcome.STALE_VERSION + } + val before = RemoteConfigValues(store.sessionSampleRate()) + val delivered = if (enabled) { + readValues(rum).copy( + version = version, + // Stored as the raw string: the platform's job is delivery, the meaning belongs to + // the host application. + custom = json.optJSONObject(FIELD_CUSTOM)?.toString() + ) + } else { + EMPTY_VALUES.copy(version = version) + } + // The rhythm rides with the values instead of staying in memory, and it is stored whether + // or not the configuration is enabled: the server goes on describing when to ask again + // while the feature is switched off, and a client that stopped honouring that the moment it + // was switched off would never learn it had been switched back on. + val after = delivered.copy( + etag = etag, + ttlSeconds = json.optLong(FIELD_TTL, 0L).takeIf { it > 0 }, + refreshOnForeground = json.optBoolean(FIELD_REFRESH_ON_FOREGROUND, false) + ) + store.store(after) + currentTtlSeconds = after.ttlSeconds ?: DEFAULT_TTL_SECONDS + refreshOnForeground = after.refreshOnForeground + + if (appliesToRunningSession(activation, before, after)) { + restartSession() + } + + 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() + } + + /** + * Whether a change lands on the session that is running rather than on the next one. + * + * The console can ask for that outright, and `immediate` is exactly that request: it carries no + * claim about what the new rate decides, only that the operator does not want to wait, so any + * real change is enough. + * + * Zero needs no such instruction, because it is the one rate that answers the question on its + * own — in both directions. Moving TO zero says nothing is to be collected any more, and an + * emergency stop that took until the session happened to rotate would not be one. Moving AWAY + * from zero says nobody was in the draw at all and now could be: without this, an application + * whose rate only ever comes from the console shows an operator who has just switched + * collection on precisely nothing until its sessions rotate, and nothing at all is + * indistinguishable from broken. + * + * Every other rate is silent about the running session. Whether it "should" still have been + * kept can only be answered by drawing again, and drawing twice turns a rate p into p². Zero is + * the one value with no winners to spare — while it was in force nothing was collected, so + * re-drawing everyone at the new rate lands exactly on it rather than above it. + * + * The rate that counts is the one that decides a draw made now: the console's where it + * published one, the value the app was initialised with where it did not, since clearing a knob + * hands the decision back to init. + * + * A forced session is left alone, and that is settled where the reset is handled rather than + * here — see `RumSessionScope`: it is collected whatever the rates say, so ending it would only + * buy an identical forced session. + */ + private fun appliesToRunningSession( + activation: String, + before: RemoteConfigValues, + after: RemoteConfigValues + ): Boolean { + val previousRate = before.sessionSampleRate ?: initialSessionSampleRate + val nextRate = after.sessionSampleRate ?: initialSessionSampleRate + if (previousRate == nextRate) return false + return activation == ACTIVATION_IMMEDIATE || (previousRate == 0f) != (nextRate == 0f) + } + + // Every one of these goes to telemetry as well as to logcat. A device that quietly stops + // taking the console's values runs on the ones it was built with for the rest of its life, and + // a logcat line only a debug build of the SDK prints is not something anyone will ever see. + + private fun logUnreadableBody(e: JSONException) { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + MAINTAINER_AND_TELEMETRY, + { UNREADABLE_BODY_MESSAGE }, + e + ) + } + + private fun logUnstampedBody() { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + MAINTAINER_AND_TELEMETRY, + { UNSTAMPED_BODY_MESSAGE } + ) + } + + private fun logUnsupportedSchema(received: Int) { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + MAINTAINER_AND_TELEMETRY, + { UNSUPPORTED_SCHEMA_MESSAGE.format(received, SUPPORTED_SCHEMA_VERSION) } + ) + } + + private fun logFetchFailure(e: Throwable) { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + MAINTAINER_AND_TELEMETRY, + { FETCH_FAILED_MESSAGE }, + e + ) + } + + companion object { + internal const val DEFAULT_TTL_SECONDS = 300L + internal const val ACTIVATION_NEXT_SESSION = "next_session" + internal const val ACTIVATION_IMMEDIATE = "immediate" + + private val RETRY_DELAYS_SECONDS = longArrayOf(5L, 60L) + + private val MAINTAINER_AND_TELEMETRY = + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY) + + private const val FETCH_TASK_NAME = "RUM remote configuration fetch" + private const val RETRY_TASK_NAME = "RUM remote configuration retry" + + private const val MAX_RATE = 100.0 + private const val MILLIS_PER_SECOND = 1_000L + private const val JITTER_FRACTION = 0.2 + 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 UNSTAMPED_BODY_MESSAGE = + "The remote configuration response carried no schema version, so it did not come from" + + " the configuration endpoint; keeping the values already in use." + + internal const val UNSUPPORTED_SCHEMA_MESSAGE = + "Ignoring a remote configuration written to schema version %d; this SDK reads version" + + " %d. Update the SDK to take the console's settings again." + + 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..f4b3a5a2f5 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -0,0 +1,372 @@ +/* + * 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 java.util.concurrent.TimeUnit + +/** + * 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, + /** + * Wall clock, deliberately not [android.os.SystemClock.elapsedRealtime]: an entry's age has to + * survive the process ending and the device rebooting, which is exactly what an elapsed-time + * clock forgets. + * + * A wall clock can be moved, and that is the accepted cost. Moved back, an abandoned entry + * looks younger and is swept later; moved forward, a live one may be swept early and its next + * session runs on the init values before it is written back. Both recover on their own. + */ + private val currentTimeMs: () -> Long = System::currentTimeMillis +) { + + /** + * Null when the preferences file cannot be opened. Two failures are reachable and neither is + * worth failing an initialisation over: a [SecurityException] where the process may not open + * the file at all, and an [IllegalStateException] from a direct-boot-aware component that + * reaches credential-encrypted storage before the user has unlocked the device. Either way the + * values passed to init keep applying. + */ + private val preferences: SharedPreferences? = try { + appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + } catch (e: SecurityException) { + logStorageUnavailable(internalLogger, e) + null + } catch (e: IllegalStateException) { + logStorageUnavailable(internalLogger, e) + null + } + + fun sessionSampleRate(): Float? = read(sessionKey()) + + /** + * Reads one committed preferences snapshot. A session must use the same response for its + * sampling rate, custom values and reported version, even if another response arrives during + * its draw. getAll() copies the preferences under their lock, paired with Editor.apply()'s + * atomic in-memory update; separate getters would each acquire that lock independently. + */ + fun snapshot(): RemoteConfigValues { + val stored = preferences?.all.orEmpty() + return RemoteConfigValues( + sessionSampleRate = (stored[sessionKey()] as? Float)?.takeUnless { it == ABSENT }, + version = (stored[versionKey()] as? Int)?.takeUnless { it == ABSENT_VERSION }, + custom = stored[customKey()] as? String, + etag = stored[etagKey()] as? String, + ttlSeconds = (stored[ttlKey()] as? Long)?.takeUnless { it == ABSENT_TTL }, + refreshOnForeground = stored[refreshOnForegroundKey()] as? Boolean ?: false + ) + } + + /** + * The application-defined bag the console last published, as the raw JSON object string, or + * null when none is published. The platform never interprets it — see [RumMonitor.getRemoteConfig]. + */ + 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) + + /** + * How long the server asked this client to treat the stored configuration as fresh, or null + * when nothing has ever been stored. + * + * Kept on disk rather than in memory for the same reason the rates are: an unchanged answer + * comes back as a 304 with no body, which is the steady state the validator exists to produce. + * A memory-only copy would be back at its default on every launch after the first. + */ + fun ttlSeconds(): Long? { + val stored = preferences?.getLong(ttlKey(), ABSENT_TTL) ?: ABSENT_TTL + return if (stored == ABSENT_TTL) null else stored + } + + /** + * Whether the server allows this client to ask again when the app returns to the foreground. + * Absent reads as not allowed, which is also the server's own default. + */ + fun refreshOnForeground(): Boolean = preferences?.getBoolean(refreshOnForegroundKey(), false) ?: false + + /** + * Which version of the settings the stored rates came from, or null before the first answer. + * Reported back on the next request so the console can say how far a change has reached — a + * 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) + } + // Written whether or not the configuration is enabled: the server keeps describing when to + // ask again while the feature is switched off, and a client that stopped honouring that the + // moment it was switched off would never learn it had been switched back on. + if (values.ttlSeconds == null) { + editor.remove(ttlKey()) + } else { + editor.putLong(ttlKey(), values.ttlSeconds) + } + editor.putBoolean(refreshOnForegroundKey(), values.refreshOnForeground) + editor.putLong(writeTimeKey(), currentTimeMs()) + editor.apply() + } + + /** + * Records that this entry is still in use, without changing what it holds. + * + * There is one way to reach an entry and store nothing: an unchanged answer comes back as a + * 304 with no body. A client that has settled — the common case, since most fetches find the + * configuration unchanged — would otherwise never refresh its entry's age again, and two SDK + * instances in one app, each sweeping on behalf of its own key, would end up deleting each + * other's settled entry at every launch. + * + * Nothing guards against there being no entry: a 304 can only answer a request that carried an + * If-None-Match, which can only have come from a stored validator. + */ + fun touch() { + preferences?.edit()?.putLong(writeTimeKey(), currentTimeMs())?.apply() + } + + /** + * Removes the entries of app versions this device is no longer running. + * + * The key covers the app version, so every release the device installs leaves one behind, and + * nothing read or removed them again — they accumulated for good inside a preferences file + * that is parsed in full at every launch. Age is what separates an abandoned entry from a live + * one: a store that is still being read is also being written, at the latest by [touch] when + * its answer comes back unchanged. + * + * This store's own entry is never a candidate, whatever its age says. It is the one entry that + * is certainly in use — the caller is about to read it — and on a first launch it has no write + * time yet at all. + */ + fun sweepAbandoned() { + val preferences = this.preferences ?: return + val now = currentTimeMs() + + // Read once, into keys of our own, before anything is removed: the map a preferences + // implementation hands back may be its live one, and editing while walking it would be + // undefined. + val stored = preferences.all + val entryKeys = stored.keys.mapNotNull(::entryKeyOf).toSet() + + val editor = preferences.edit() + var abandoned = false + for (entryKey in entryKeys) { + if (entryKey == storeKey) continue + // Absent, or holding something we did not write, reads as older than any threshold. + val writtenAtMs = stored["$entryKey$SUFFIX_WRITE_TIME"] as? Long ?: NEVER_WRITTEN + if (now - writtenAtMs <= MAX_ENTRY_AGE_MS) continue + FIELD_SUFFIXES.forEach { editor.remove("$entryKey$it") } + abandoned = true + } + if (abandoned) editor.apply() + } + + /** + * The entry a stored key belongs to, or null when the key is not one of ours. Splitting on the + * known suffixes rather than on the last separator is what keeps a store key free to contain + * one: a service name and an app version both routinely do. + */ + private fun entryKeyOf(key: String): String? { + if (!key.startsWith(STORE_KEY_PREFIX)) return null + val suffix = FIELD_SUFFIXES.firstOrNull { key.endsWith(it) } ?: return null + return key.removeSuffix(suffix) + } + + private fun read(key: String): Float? { + val stored = preferences?.getFloat(key, ABSENT) ?: ABSENT + return if (stored == ABSENT) null else stored + } + + 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$SUFFIX_SESSION_SAMPLE_RATE" + + private fun versionKey() = "$storeKey$SUFFIX_VERSION" + + private fun customKey() = "$storeKey$SUFFIX_CUSTOM" + + private fun etagKey() = "$storeKey$SUFFIX_ETAG" + + private fun ttlKey() = "$storeKey$SUFFIX_TTL" + + private fun refreshOnForegroundKey() = "$storeKey$SUFFIX_REFRESH_ON_FOREGROUND" + + private fun writeTimeKey() = "$storeKey$SUFFIX_WRITE_TIME" + + companion object { + + /** + * Reported to telemetry as well as to logcat: a client whose store will not open runs on + * the values it was built with for its whole life, and nothing else would ever say so. + */ + private fun logStorageUnavailable(internalLogger: InternalLogger, e: Throwable) { + internalLogger.log( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + { STORAGE_UNAVAILABLE_MESSAGE }, + e + ) + } + + private const val PREFERENCES_NAME = "flashcat-rum-remote-config" + + /** + * 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 + + // A ttl is a positive number of seconds, so a non-positive sentinel cannot collide with a + // stored value. + private const val ABSENT_TTL = -1L + + // One entry is spread over several keys, all of them derived from the store key by these + // suffixes. Named here once because two things read them: the accessors that build a key, + // and the sweep that has to take an entry apart again. + private const val SUFFIX_SESSION_SAMPLE_RATE = ".sessionSampleRate" + private const val SUFFIX_VERSION = ".version" + private const val SUFFIX_CUSTOM = ".custom" + private const val SUFFIX_ETAG = ".etag" + private const val SUFFIX_TTL = ".ttl" + private const val SUFFIX_REFRESH_ON_FOREGROUND = ".refreshOnForeground" + private const val SUFFIX_WRITE_TIME = ".writtenAt" + + private val FIELD_SUFFIXES = listOf( + SUFFIX_SESSION_SAMPLE_RATE, + SUFFIX_VERSION, + SUFFIX_CUSTOM, + SUFFIX_ETAG, + SUFFIX_TTL, + SUFFIX_REFRESH_ON_FOREGROUND, + SUFFIX_WRITE_TIME + ) + + /** + * How long an entry may go unrefreshed before [sweepAbandoned] takes it for the entry of a + * version this device no longer runs. + * + * The threshold has to clear the longest a live entry can legitimately stay silent: the + * longest session (four hours — see `RumSessionScope.DEFAULT_SESSION_MAX_DURATION_NS`, + * after which a new session fetches again) plus the longest endpoint outage worth + * surviving, since a failed fetch stores nothing. Two days leaves better than a day and a + * half of outage. + * + * Erring long is deliberate: sweeping an entry too early costs the next session its + * remote rates, keeping a dead one costs a few hundred bytes. + */ + private val MAX_ENTRY_AGE_MS = TimeUnit.DAYS.toMillis(2) + + // Older than any threshold without reaching for a sentinel that could underflow the + // subtraction: an entry stamped at the epoch is abandoned by any reading. + private const val NEVER_WRITTEN = 0L + + internal const val STORAGE_UNAVAILABLE_MESSAGE = + "Unable to open the remote configuration store; the values passed to init will apply." + + /** + * 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, + /** How long the server asked this client to treat these values as fresh, or null when it did not say. */ + val ttlSeconds: Long? = null, + /** Whether the server allows a refresh when the app returns to the foreground. */ + val refreshOnForeground: Boolean = false +) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt index 8c82526a3d..b1e4fb99b2 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt @@ -346,6 +346,62 @@ internal class RumApplicationScopeTest { assertThat(testedScope.childScopes.first()).isEqualTo(mockSession) } + @Test + fun `M carry the forced state into a new session W handleEvent { session was stopped }`( + @StringForgery viewKey: String, + @StringForgery viewName: String + ) { + // The host application asked for this user's sessions to be collected, not for whichever + // session happened to be running when it asked. A `stopSession()` - the ordinary thing to + // do on logout - leaves the session scope behind, and the session that replaces it must + // still be forced, or the promise breaks exactly when support needs it. + testedScope.handleEvent( + RumRawEvent.SetForcedSession(), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + testedScope.handleEvent(RumRawEvent.StopSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // When + testedScope.handleEvent( + RumRawEvent.StartView( + key = RumScopeKey.from(viewKey, viewName), + attributes = mapOf() + ), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + assertThat(testedScope.childScopes).hasSize(1) + assertThat(testedScope.childScopes.first().forcedSession).isTrue() + } + + @Test + fun `M leave a new session unforced W handleEvent { nothing ever forced }`( + @StringForgery viewKey: String, + @StringForgery viewName: String + ) { + // The negative control for the test above. + testedScope.handleEvent(RumRawEvent.StopSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // When + testedScope.handleEvent( + RumRawEvent.StartView( + key = RumScopeKey.from(viewKey, viewName), + attributes = mapOf() + ), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + assertThat(testedScope.childScopes.first().forcedSession).isFalse() + } + @Test fun `M create a new session W handleEvent { no active sessions, start view } `( @StringForgery viewKey: String, diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index e45020987e..0b2f0a5c75 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -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,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.RemoteConfigValues import com.datadog.android.rum.internal.startup.RumAppStartupTelemetryReporter import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.startup.RumStartupScenario @@ -63,6 +68,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 @@ -78,6 +84,7 @@ import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.lang.ref.WeakReference import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference @Extensions( ExtendWith(MockitoExtension::class), @@ -963,6 +970,338 @@ 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 leave the forced session alone W handleEvent(ResetSession)`() { + // A reset would only replace a forced session with an identical forced session, so the + // renewal buys nothing and costs the view the user is on. This is the path a console + // publishing "apply immediately" takes, and it must not cut a session the host application + // deliberately asked to keep. + initializeTestedScope(0f) + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + val forcedSessionId = testedScope.getRumContext().sessionId + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + val context = testedScope.getRumContext() + + // Then + assertThat(context.sessionId).isEqualTo(forcedSessionId) + assertThat(context.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + } + + @Test + fun `M renew on a reset W handleEvent(ResetSession) { session is not forced }`() { + // The negative control for the test above: an ordinary session is still renewed, so the + // guard is about forcing and not about resets in general. + initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + val firstSessionId = testedScope.getRumContext().sessionId + + // When + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.getRumContext().sessionId).isNotEqualTo(firstSessionId) + } + + @Test + fun `M report a full rate and no configuration version W a forced session is drawn`() { + // A forced session was not drawn, so it does not report a rate it was drawn at: it reports + // the rate that describes it, which is that every session like it is kept. Reporting the + // rate it would have been drawn at would have the intake weight one deliberately kept + // session as the whole population that rate implies, and leave nothing to tell it from a + // lucky draw. This is the reporting the other SDKs use. + val mockRemoteConfig: RemoteConfigStore = mock() + whenever(mockRemoteConfig.snapshot()).thenReturn(RemoteConfigValues(1f, 7)) + initializeTestedScope(1f, remoteConfig = mockRemoteConfig) + + // When + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.effectiveSampleRate).isEqualTo(100f) + assertThat(testedScope.drawnConfiguration).isNull() + } + + @Test + fun `M report the drawn rate and its version W a session is drawn without forcing`() { + // The negative control: the same store, the same rates, no forcing. + val mockRemoteConfig: RemoteConfigStore = mock() + whenever(mockRemoteConfig.snapshot()).thenReturn(RemoteConfigValues(1f, 7)) + initializeTestedScope(1f, remoteConfig = mockRemoteConfig) + + // When + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + assertThat(testedScope.effectiveSampleRate).isEqualTo(1f) + assertThat(testedScope.drawnConfiguration).isEqualTo(DrawnConfiguration(version = 7)) + } + + @Test + fun `M tell Session Replay the session is forced W handleEvent(SetForcedSession)`() { + // Given + 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 keep one snapshot per draw W a response arrives during beforeSampling`() { + val first = RemoteConfigValues(100f, 1, custom = """{"cohort":"first"}""") + val second = RemoteConfigValues(0f, 2, custom = """{"cohort":"second"}""") + val published = AtomicReference(first) + val remoteConfig = mock() + whenever(remoteConfig.snapshot()).thenAnswer { published.get() } + val seen = mutableListOf() + initializeTestedScope(remoteConfig = remoteConfig, beforeSampling = { + seen.add(it) + val responseThread = Thread { published.set(second) } + responseThread.start() + responseThread.join(5_000) + check(!responseThread.isAlive) + null + }) + + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + assertThat(published.get()).isEqualTo(second) + assertThat(seen.single().sessionSampleRate).isEqualTo(100f) + assertThat(seen.single().custom).isEqualTo(mapOf("cohort" to "first")) + assertThat(testedScope.effectiveSampleRate).isEqualTo(100f) + assertThat(testedScope.drawnConfiguration?.version).isEqualTo(1) + + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + assertThat(seen.last().sessionSampleRate).isEqualTo(0f) + assertThat(seen.last().custom).isEqualTo(mapOf("cohort" to "second")) + assertThat(testedScope.effectiveSampleRate).isEqualTo(0f) + assertThat(testedScope.drawnConfiguration?.version).isEqualTo(2) + } + + @Test + fun `M draw the session with the console's rates W handleEvent { remote configuration stored }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(42f, 7) + initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig) + + // When + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // 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.snapshot()) doReturn RemoteConfigValues(null) + initializeTestedScope(sampleRate = 80f, remoteConfig = remoteConfig) + + // When + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then - the draw used the init values, and version 0 says no configuration was ever fetched + assertThat(testedScope.effectiveSampleRate).isEqualTo(80f) + 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.snapshot()) doReturn RemoteConfigValues(42f, 9) + initializeTestedScope(remoteConfig = remoteConfig) + + // When + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then - the version in force at the draw travels to the view scopes, which report it + val record = testedScope.drawnConfiguration + 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.SdkInit(true, currentFakeTime()), + 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.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then - events keep reporting the init values, which are the values the draw used anyway + assertThat(testedScope.drawnConfiguration).isNull() + } + + @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.snapshot()) doReturn RemoteConfigValues(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 +1538,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 +1550,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 +1589,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 +1601,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 +1634,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 +1645,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 +1677,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 +1689,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 +1723,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 +1735,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 +1747,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 +1781,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 +1792,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 +1826,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 +1837,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 +1871,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 +1882,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 +1893,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 +2071,108 @@ internal class RumSessionScopeTest { ) } + // region beforeSampling + + @Test + fun `M draw with the hook's rate W handleEvent { beforeSampling overrides }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(1f) + initializeTestedScope(sampleRate = 100f, remoteConfig = remoteConfig, beforeSampling = { 100f }) + + // When + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + 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.snapshot()) doReturn RemoteConfigValues(42f, custom = """{"vip":["a"]}""") + var seen: BeforeSamplingContext? = null + initializeTestedScope( + sampleRate = 100f, + remoteConfig = remoteConfig, + beforeSampling = { + seen = it + null + } + ) + + // When + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + // The hook is consulted AFTER the console, so what it sees is the rate that would apply. + 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.SdkInit(true, currentFakeTime()), + 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.SdkInit(true, currentFakeTime()), + 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.SdkInit(true, currentFakeTime()), + 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 +2198,10 @@ internal class RumSessionScopeTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = { mockRumSessionScopeStartupManager }, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + remoteConfig = remoteConfig, + onSessionDrawn = onSessionDrawn, + beforeSampling = beforeSampling ) if (withMockChildScope) { @@ -1737,4 +2231,117 @@ internal class RumSessionScopeTest { return forge.testRumStartupScenarios(weakActivity) } } + + @Test + fun `M idle reset must not announce an immediately expired session W configuration changes`() { + initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + val initial = testedScope.sessionId + org.mockito.kotlin.clearInvocations(mockSessionListener) + advanceTimeByMs(TEST_INACTIVITY_MS + 1) + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.EXPIRED) + verifyNoInteractions(mockSessionListener) + assertThat(testedScope.sessionId).isEqualTo(initial) + } + + @Test + fun `M active reset still renews as negative control W configuration changes`() { + initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + val initial = testedScope.sessionId + advanceTimeByMs(1) + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + assertThat(testedScope.sessionId).isNotEqualTo(initial) + } + + @Test + fun `M not create a session W remote reset before any activity`() { + var draws = 0 + initializeTestedScope(onSessionDrawn = { draws++ }) + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.sessionId).isEqualTo(RumContext.NULL_UUID) + assertThat(draws).isZero() + verifyNoInteractions(mockSessionListener) + } + + @Test + fun `M preserve maximum duration renewal W remote reset at the duration limit`() { + initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + repeat(4) { + advanceTimeByMs(100) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + } + org.mockito.kotlin.clearInvocations(mockSessionListener) + advanceTimeByMs(100) + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.getRumContext().sessionStartReason).isEqualTo(RumSessionScope.StartReason.MAX_DURATION) + verify(mockSessionListener).onSessionStarted(testedScope.sessionId, false) + } + + @Test + fun `M use one expiry decision W the sampling callback crosses the inactivity limit`() { + var advanceDuringDraw = false + initializeTestedScope(100f, beforeSampling = { + if (advanceDuringDraw) advanceTimeByMs(2) + null + }) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + advanceTimeByMs(TEST_INACTIVITY_MS - 1) + advanceDuringDraw = true + testedScope.handleEvent( + RumRawEvent.ResetSession(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 92a34301af..e364afe961 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -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, @@ -8438,9 +8486,15 @@ internal class RumViewScopeTest { fun `M return a new RumViewScope W renew the current one`() { // Given val expectedTime = Time(nanoTime = fakeEventTime.nanoTime) + // FLASHCAT FORK - both deliberately differ from what this scope holds. A renewal carries a + // view into the session that has just been drawn, so it must take the draw it is handed + // rather than copy the one that ended - and this scope's own values are exactly what it + // would copy if that regressed. + val expectedSampleRate = testedScope.sampleRate + 1f + val expectedDrawnConfiguration = DrawnConfiguration(version = 11) // When - val newScope = testedScope.renew(expectedTime) + val newScope = testedScope.renew(expectedTime, expectedSampleRate, expectedDrawnConfiguration) assertThat(newScope.key).isEqualTo(testedScope.key) assertThat(newScope.firstPartyHostHeaderTypeResolver).isEqualTo(testedScope.firstPartyHostHeaderTypeResolver) @@ -8448,7 +8502,10 @@ internal class RumViewScopeTest { assertThat(newScope.memoryVitalMonitor).isEqualTo(testedScope.memoryVitalMonitor) assertThat(newScope.frameRateVitalMonitor).isEqualTo(testedScope.frameRateVitalMonitor) assertThat(newScope.type).isEqualTo(testedScope.type) - assertThat(newScope.sampleRate).isEqualTo(testedScope.sampleRate) + assertThat(newScope.sampleRate).isEqualTo(expectedSampleRate) + assertThat(newScope.sampleRate).isNotEqualTo(testedScope.sampleRate) + assertThat(newScope.drawnConfiguration).isEqualTo(expectedDrawnConfiguration) + assertThat(newScope.drawnConfiguration).isNotEqualTo(testedScope.drawnConfiguration) assertThat(newScope.url).isEqualTo(testedScope.url) assertThat(newScope.viewAttributes).isEqualTo(testedScope.viewAttributes) assertThat(newScope.stoppedNanos).isEqualTo(expectedTime.nanoTime) @@ -9157,6 +9214,7 @@ internal class RumViewScopeTest { type: RumViewType = fakeViewType, trackFrustrations: Boolean = fakeTrackFrustrations, sampleRate: Float = fakeSampleRate, + drawnConfiguration: DrawnConfiguration? = null, interactionNextViewMetricResolver: InteractionToNextViewMetricResolver = mockInteractionToNextViewMetricResolver, networkSettledMetricResolver: NetworkSettledMetricResolver = mockNetworkSettledMetricResolver, @@ -9178,6 +9236,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..ea043cb6a5 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt @@ -0,0 +1,75 @@ +/* + * 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) + } + + @Test + fun `M report a foreground W the SDK was registered while an activity was already started`() { + // An app that initialises the SDK from an activity - the usual shape when initialisation + // waits on a consent prompt - has one running before this callback exists, so the first + // stop it sees has no matching start. Without a floor the count would go negative and could + // never reach the one that means "the app is in the foreground again", leaving the callback + // dead for the rest of the process. + val activity = mock() + testedCallback.onActivityStopped(activity) + + // When the user leaves and comes back + testedCallback.onActivityStarted(activity) + + // Then + assertThat(foregroundCount).isOne() + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt new file mode 100644 index 0000000000..8b050f7130 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -0,0 +1,1036 @@ +/* + * 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.assertj.core.api.Assertions.assertThatThrownBy +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.clearInvocations +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) + testedController = controllerWithInitialRate(INIT_SESSION_RATE) + } + + private fun controllerWithInitialRate(initialSessionSampleRate: Float): RemoteConfigController { + val sdkCore = mock() + whenever(sdkCore.internalLogger).thenReturn(mock()) + return RemoteConfigController( + sdkCore = sdkCore, + configUrl = "https://example.com/api/v2/rum/config", + store = store, + initialSessionSampleRate = initialSessionSampleRate, + callFactory = callFactory, + executor = executor, + restartSession = { restarts++ }, + elapsedTimeMs = { elapsedMs }, + jitter = { 0.5 } + ) + } + + // region storing + + @Test + fun `M discard an older response without retrying W fetch succeeds`() { + whenever(store.sessionSampleRate()).thenReturn(1f) + whenever(store.appliedVersion()).thenReturn(5) + whenever(call.execute()).thenReturn( + response(200, body(version = 3, activation = "immediate", rum = "\"sessionSampleRate\":100"), "\"v3\"") + ) + + runPendingFetch() + + verify(store, never()).store(any()) + verify(executor, never()).schedule(any(), any(), any()) + assertThat(restarts).isZero() + } + + @Test + fun `M accept the same version W apply()`() { + whenever(store.appliedVersion()).thenReturn(5) + + testedController.apply(body(version = 5, rum = "\"sessionSampleRate\":42")) + + verify(store).store(RemoteConfigValues(42f, 5, ttlSeconds = 300L)) + } + + @Test + fun `M accept a rollback published as a newer version W apply()`() { + whenever(store.appliedVersion()).thenReturn(5) + + testedController.apply(body(version = 6, rum = "\"sessionSampleRate\":100")) + + verify(store).store(RemoteConfigValues(100f, 6, ttlSeconds = 300L)) + } + + @Test + fun `M store the rate the response carries W apply()`() { + testedController.apply(body(rum = """"sessionSampleRate":42""")) + + verify(store).store(RemoteConfigValues(42f, 3, ttlSeconds = 300L)) + } + + @Test + fun `M store a zero rate W apply() { zero is a setting, not a missing value }`() { + testedController.apply(body(rum = """"sessionSampleRate":0""")) + + verify(store).store(RemoteConfigValues(0f, 3, ttlSeconds = 300L)) + } + + @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, ttlSeconds = 300L)) + } + + @Test + fun `M ignore a rate outside 0-100 W apply()`() { + testedController.apply(body(rum = """"sessionSampleRate":420""")) + + verify(store).store(RemoteConfigValues(null, 3, ttlSeconds = 300L)) + } + + @Test + fun `M forget the rates W apply() { remote configuration switched off }`() { + testedController.apply(body(enabled = false, rum = """"sessionSampleRate":42""")) + + verify(store).store(RemoteConfigValues(null, 3, ttlSeconds = 300L)) + } + + // 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() + } + + @Test + fun `M restart the session W apply() { next_session but the rate leaves zero }`() { + // Nothing was being collected while the rate was zero, so there is no session worth + // preserving and no winner to spare by re-drawing everyone at the new rate. Waiting here + // would show an operator who has just switched collection on nothing at all. + whenever(store.sessionSampleRate()).thenReturn(0f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":100""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { next_session but the rate reaches zero }`() { + // The emergency stop. One that took until the session happened to rotate would not be one. + whenever(store.sessionSampleRate()).thenReturn(100f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":0""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { next_session and init never collected }`() { + // The application whose rate only ever comes from the console: nothing is stored yet, so + // the rate leaving zero is the init value being replaced rather than a stored one. + whenever(store.sessionSampleRate()).thenReturn(null) + testedController = controllerWithInitialRate(0f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":100""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { next_session and the kill switch hands zero back to init }`() { + // Switching remote configuration off returns the decision to the value the app was built + // with, and that is a rate leaving zero like any other. + whenever(store.sessionSampleRate()).thenReturn(0f) + + testedController.apply(body(activation = "next_session", enabled = false)) + + assertThat(restarts).isOne() + } + + @Test + fun `M leave the running session alone W apply() { next_session and the rate stays at zero }`() { + // Otherwise every announcement would cut one empty session after another in two for as long + // as collection stayed switched off. + whenever(store.sessionSampleRate()).thenReturn(0f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":0""")) + + assertThat(restarts).isZero() + } + + @Test + fun `M leave the running session alone W apply() { next_session and neither rate is zero }`() { + // No rate but zero says anything about whether THIS session should have been kept: only a + // second draw could, and drawing twice turns a rate p into p squared. + whenever(store.sessionSampleRate()).thenReturn(30f) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":80""")) + + assertThat(restarts).isZero() + } + + // endregion + + @Test + 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, ttlSeconds = 300L)) + } + + // 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 keep the stored values and ask again W fetch() { server answers with an error }`() { + // Neither a 200 nor a 304 is an answer about the configuration. Nothing may be stored, and + // the ask is owed a retry - an endpoint having a bad minute must not move anybody's rates. + whenever(call.execute()).thenReturn(response(500, "")) + + runPendingFetch() + + verify(store, never()).store(any()) + verify(store, never()).touch() + verify(executor).schedule(any(), any(), any()) + } + + @Test + fun `M store what the server answered W fetch succeeds`() { + whenever(call.execute()).thenReturn(response(200, body(rum = """"sessionSampleRate":42"""))) + + runPendingFetch() + + verify(store).store(RemoteConfigValues(42f, 3, ttlSeconds = 300L)) + } + + @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 store the refresh rhythm alongside the values W apply()`() { + // Kept on disk rather than in memory because an unchanged answer is a 304 with no body: + // a memory-only copy is back at its default on every launch after the first. + testedController.apply(body(ttl = 60, refreshOnForeground = true, rum = """"sessionSampleRate":42""")) + + verify(store).store( + RemoteConfigValues(42f, 3, ttlSeconds = 60L, refreshOnForeground = true) + ) + } + + @Test + fun `M store the refresh rhythm W apply() { remote configuration switched off }`() { + // The server goes on saying when to ask again while the feature is off; a client that + // stopped honouring that would never learn it had been switched back on. + testedController.apply(body(enabled = false, ttl = 60, refreshOnForeground = true)) + + verify(store).store( + RemoteConfigValues(null, 3, ttlSeconds = 60L, refreshOnForeground = true) + ) + } + + @Test + fun `M keep the stored values and call it a success W fetch answers not modified`() { + whenever(call.execute()).thenReturn(response(304, "")) + + runPendingFetch() + + verify(store, never()).store(any()) + verify(executor, never()).schedule(any(), any(), any()) + } + + @Test + fun `M mark the entry as still in use W fetch answers not modified`() { + // The one answer that stores nothing. A settled client meets it at almost every fetch, and + // the sweep reads nothing but age, so without this its entry would stop looking in use. + whenever(call.execute()).thenReturn(response(304, "")) + + runPendingFetch() + + verify(store).touch() + } + + @Test + fun `M clear the entries of versions the device no longer runs W the first fetch`() { + whenever(call.execute()).thenReturn(response(200, body())) + + runPendingFetch() + + verify(store).sweepAbandoned() + } + + @Test + fun `M go on fetching W housekeeping throws`() { + // Every later fetch is gated on the in-flight flag, so anything that escaped without + // clearing it would end remote configuration for the rest of the process — silently. + whenever(store.sweepAbandoned()).thenThrow(RuntimeException("preferences are having a day")) + whenever(call.execute()).thenReturn(response(200, body())) + + testedController.start() + argumentCaptor { + verify(executor).execute(capture()) + assertThatThrownBy { firstValue.run() }.isInstanceOf(RuntimeException::class.java) + } + testedController.onSessionStarted() + + verify(executor, times(2)).execute(any()) + } + + @Test + fun `M sweep once a launch W several fetches`() { + // Walking the preferences file again at every session start would learn nothing new. + whenever(call.execute()).thenReturn(response(200, body())) + + runPendingFetch() + testedController.onSessionStarted() + argumentCaptor { + verify(executor, times(2)).execute(capture()) + allValues.last().run() + } + + verify(store, times(1)).sweepAbandoned() + } + + @Test + fun `M store the validator the answer came with W fetch succeeds`() { + whenever(call.execute()).thenReturn(response(200, body(), etag = "\"v42\"")) + + 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 honour the stored rhythm W refreshIfStale() { fresh process, first answer is 304 }`() { + // The case a settled fleet lives in. The console's permission was granted in some earlier + // process and is on disk; this process asks, the validator matches, and a 304 comes back + // with no body to read it from. A controller that only ever learned the rhythm from a body + // would spend this whole process on the defaults and never refresh on foreground again. + whenever(store.refreshOnForeground()).thenReturn(true) + whenever(store.ttlSeconds()).thenReturn(60L) + whenever(call.execute()).thenReturn(response(304, "")) + + runPendingFetch() + clearInvocations(executor) + elapsedMs = 61_000L + testedController.refreshIfStale() + + verify(executor).execute(any()) + } + + @Test + fun `M ask nothing W refreshIfStale() { fresh process, nothing was ever stored }`() { + // The negative control for the test above: with nothing on disk the permission is not + // assumed, so the very first launch of an app still makes no foreground request. + whenever(call.execute()).thenReturn(response(304, "")) + + runPendingFetch() + clearInvocations(executor) + elapsedMs = 61_000L + testedController.refreshIfStale() + + verify(executor, never()).execute(any()) + } + + @Test + fun `M fall back to the default ttl for staleness W refreshIfStale() { server sent none }`() { + testedController.apply(body(ttl = 0, refreshOnForeground = true)) + + 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 refuse the body W apply() { schema is an explicit null }`() { + // Absent and null say the same thing: nothing was stamped. + val outcome = testedController.apply( + """{"schema_version":null,"version":3,"enabled":true,"rum":{"sessionSampleRate":42}}""" + ) + + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + verify(store, never()).store(any()) + } + + @Test + fun `M refuse the body W apply() { no schema at all }`() { + // The stamp is the whole of what tells a configuration from any other JSON: every other + // field is read with a default, so an unrelated body would come out as "switched off, no + // rates" and empty the entry. Nothing is stored, and the request is asked again for. + val outcome = testedController.apply( + body(rum = """"sessionSampleRate":42""", schemaVersion = null) + ) + + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + verify(store, never()).store(any()) + } + + @Test + fun `M keep the stored values and ask again W fetch() { body carries no schema }`() { + // The negative control for the refusal above: a body this SDK will not read must leave the + // rates that are working in place and be retried, exactly as an unreachable endpoint is. + whenever(call.execute()) + .thenReturn(response(200, body(rum = """"sessionSampleRate":42""", schemaVersion = null))) + + runPendingFetch() + + verify(store, never()).store(any()) + verify(executor).schedule(any(), any(), any()) + } + + @Test + 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, + version: Int = 3 + ): String = + "{" + (if (schemaVersion == null) "" else """"schema_version":$schemaVersion,""") + + """"version":$version,"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 + } + + @Test + fun `M rejects fractional schema instead of truncating W configuration changes`() { + val outcome = testedController.apply(body().replace("\"schema_version\":1", "\"schema_version\":1.5")) + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNSUPPORTED_SCHEMA) + verify(store, never()).store(any()) + } + + @Test + fun `M rejects incomplete stamped envelope without clearing rate W configuration changes`() { + whenever(store.sessionSampleRate()).thenReturn(0f) + whenever(store.appliedVersion()).thenReturn(3) + val outcome = testedController.apply("""{"schema_version":1,"version":4}""", "\"v4\"") + assertThat(outcome).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + verify(store, never()).store(any()) + } + + @Test + fun `M preserves valid disable response as negative control W configuration changes`() { + whenever(store.sessionSampleRate()).thenReturn(0f) + whenever(store.appliedVersion()).thenReturn(3) + assertThat( + testedController.apply(body(version = 4, enabled = false)) + ).isEqualTo(RemoteConfigController.Outcome.APPLIED) + verify(store).store(RemoteConfigValues(null, 4, ttlSeconds = 300L)) + assertThat(restarts).isEqualTo(1) + } + + @Test + fun `M ignores an in flight response after stop W configuration changes`() { + whenever(call.execute()).thenAnswer { + testedController.stop() + response(200, body(rum = "\"sessionSampleRate\":0")) + } + runPendingFetch() + verify(store, never()).store(any()) + assertThat(restarts).isZero() + } + + @Test + fun `M applies response before stop as negative control W configuration changes`() { + whenever(call.execute()).thenReturn(response(200, body(rum = "\"sessionSampleRate\":0"))) + runPendingFetch() + verify(store).store(RemoteConfigValues(0f, 3, ttlSeconds = 300L)) + assertThat(restarts).isEqualTo(1) + } + + @Test + fun `M real executor ignores late response after stop W configuration changes`() { + val entered = java.util.concurrent.CountDownLatch(1) + val release = java.util.concurrent.CountDownLatch(1) + val realExecutor = java.util.concurrent.Executors.newSingleThreadScheduledExecutor() + val sdkCore = mock() + whenever(sdkCore.internalLogger).thenReturn(mock()) + val controller = RemoteConfigController( + sdkCore, + "https://example.com/config?x=1", + store, + INIT_SESSION_RATE, + callFactory, + realExecutor, + { restarts++ } + ) + whenever(call.execute()).thenAnswer { + entered.countDown() + while (release.count > 0) { + try { release.await(100, TimeUnit.MILLISECONDS) } catch (_: InterruptedException) { } + } + response(200, body(rum = "\"sessionSampleRate\":0")) + } + try { + controller.start() + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue() + controller.stop() + release.countDown() + assertThat(realExecutor.awaitTermination(5, TimeUnit.SECONDS)).isTrue() + verify(store, never()).store(any()) + assertThat(restarts).isZero() + } finally { + release.countDown() + realExecutor.shutdownNow() + } + } + + @Test + fun `M reject malformed mandatory fields W apply()`() { + val invalid = listOf( + """{"schema_version":1,"enabled":true,"rum":{}}""", + """{"schema_version":1,"version":-1,"enabled":true,"rum":{}}""", + """{"schema_version":1,"version":1.5,"enabled":true,"rum":{}}""", + """{"schema_version":1,"version":2147483648,"enabled":true,"rum":{}}""", + """{"schema_version":1,"version":"3","enabled":true,"rum":{}}""", + """{"schema_version":1,"version":3,"enabled":"false","rum":{}}""", + """{"schema_version":1,"version":3,"enabled":false,"rum":null}""" + ) + invalid.forEach { + assertThat(testedController.apply(it)).isEqualTo(RemoteConfigController.Outcome.UNREADABLE) + } + verify(store, never()).store(any()) + assertThat(restarts).isZero() + } + + @Test + fun `M accept an unpublished configuration W apply()`() { + assertThat(testedController.apply(body(version = 0, enabled = false))) + .isEqualTo(RemoteConfigController.Outcome.APPLIED) + verify(store).store(RemoteConfigValues(null, ttlSeconds = 300L)) + } + + @Test + fun `M cancel the request without touching the cache W stop before a 304`() { + whenever(call.execute()).thenAnswer { + testedController.stop() + response(304, "") + } + runPendingFetch() + verify(call).cancel() + verify(store, never()).touch() + verify(executor, never()).schedule(any(), any(), any()) + } + + @Test + fun `M discard queued work W stop before fetch starts`() { + testedController.start() + val queued = argumentCaptor() + verify(executor).execute(queued.capture()) + testedController.stop() + queued.firstValue.run() + testedController.start() + verify(store, never()).sweepAbandoned() + verify(callFactory, never()).newCall(any()) + verify(executor).execute(any()) + } + + @Test + fun `M finish committing before stop returns W response already applying`() { + val storing = java.util.concurrent.CountDownLatch(1) + val releaseStore = java.util.concurrent.CountDownLatch(1) + val stopping = java.util.concurrent.CountDownLatch(1) + val workers = java.util.concurrent.Executors.newFixedThreadPool(2) + whenever(store.store(any())).thenAnswer { + storing.countDown() + check(releaseStore.await(5, TimeUnit.SECONDS)) + Unit + } + try { + val applying = workers.submit { + testedController.apply(body(rum = "\"sessionSampleRate\":0")) + } + assertThat(storing.await(5, TimeUnit.SECONDS)).isTrue() + val stopped = workers.submit { + stopping.countDown() + testedController.stop() + restarts + } + assertThat(stopping.await(5, TimeUnit.SECONDS)).isTrue() + assertThatThrownBy { stopped.get(100, TimeUnit.MILLISECONDS) } + .isInstanceOf(java.util.concurrent.TimeoutException::class.java) + releaseStore.countDown() + assertThat(applying.get(5, TimeUnit.SECONDS)).isEqualTo(RemoteConfigController.Outcome.APPLIED) + stopped.get(5, TimeUnit.SECONDS) + assertThat(restarts).isEqualTo(1) + assertThat(testedController.apply(body())).isEqualTo(RemoteConfigController.Outcome.STOPPED) + } finally { + releaseStore.countDown() + workers.shutdownNow() + } + } +} diff --git a/features/dd-sdk-android-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..b4c6a45a26 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt @@ -0,0 +1,356 @@ +/* + * 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 retain a snapshot after a later response is stored`() { + val store = testedStore() + val first = RemoteConfigValues(100f, 1, """{"cohort":"first"}""", "\"v1\"", 300L, true) + val second = RemoteConfigValues(0f, 2, """{"cohort":"second"}""", "\"v2\"", 600L, false) + store.store(first) + val snapshot = testedStore().snapshot() + + store.store(second) + + assertThat(snapshot).isEqualTo(first) + assertThat(store.snapshot()).isEqualTo(second) + } + + @Test + fun `M return absent values W snapshot before the first response`() { + assertThat(testedStore().snapshot()).isEqualTo(RemoteConfigValues(null)) + } + + @Test + fun `M read back on the next launch what a response stored W store()`() { + testedStore().store( + 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 + + // region sweeping the entries of versions this device no longer runs + + @Test + fun `M remove an entry nothing has refreshed for longer than the threshold W sweepAbandoned()`() { + // The key covers the app version, so every release the device installs leaves one behind. + storeUnder(OTHER_VERSION_KEY, atMs = NOW - THREE_DAYS_MS) + val store = testedStore(nowMs = NOW) + + store.sweepAbandoned() + + assertThat(preferences.all.keys.filter { it.startsWith(OTHER_VERSION_KEY) }).isEmpty() + } + + @Test + fun `M keep an entry something refreshed recently W sweepAbandoned()`() { + storeUnder(OTHER_VERSION_KEY, atMs = NOW - ONE_DAY_MS) + val store = testedStore(nowMs = NOW) + + store.sweepAbandoned() + + assertThat(preferences.all.keys.filter { it.startsWith(OTHER_VERSION_KEY) }).isNotEmpty() + } + + @Test + fun `M remove every key of an abandoned entry W sweepAbandoned()`() { + // An entry is spread over several keys, and leaving any of them behind leaks just as well. + storeUnder(OTHER_VERSION_KEY, atMs = NOW - THREE_DAYS_MS) + val before = preferences.all.keys.count { it.startsWith(OTHER_VERSION_KEY) } + + testedStore(nowMs = NOW).sweepAbandoned() + + assertThat(before).isGreaterThan(1) + assertThat(preferences.all.keys.none { it.startsWith(OTHER_VERSION_KEY) }).isTrue() + } + + @Test + fun `M never remove its own entry W sweepAbandoned() { however old it looks }`() { + // The one entry certainly in use: the caller is about to read it. + storeUnder(STORE_KEY, atMs = NOW - THREE_DAYS_MS) + val store = testedStore(nowMs = NOW) + + store.sweepAbandoned() + + assertThat(store.appliedVersion()).isEqualTo(7) + assertThat(store.sessionSampleRate()).isEqualTo(42f) + } + + @Test + fun `M leave alone every key it did not write W sweepAbandoned()`() { + preferences.edit().putString("a-key-the-host-app-owns", "not ours").apply() + storeUnder(OTHER_VERSION_KEY, atMs = NOW - THREE_DAYS_MS) + + testedStore(nowMs = NOW).sweepAbandoned() + + assertThat(preferences.getString("a-key-the-host-app-owns", null)).isEqualTo("not ours") + } + + @Test + fun `M take a store key containing separators apart correctly W sweepAbandoned()`() { + // A service name and an app version both routinely contain dots, so the suffix cannot be + // found by splitting on the last one. + val dotted = "${RemoteConfigStore.STORE_KEY_PREFIX}host|app|com.example.shop|prod|1.2.3" + storeUnder(dotted, atMs = NOW - THREE_DAYS_MS) + + testedStore(nowMs = NOW).sweepAbandoned() + + assertThat(preferences.all.keys.none { it.startsWith(dotted) }).isTrue() + } + + @Test + fun `M record when it was written W store()`() { + val store = testedStore(nowMs = NOW) + + store.store(RemoteConfigValues(42f, 7)) + + assertThat(preferences.getLong("$STORE_KEY.writtenAt", 0L)).isEqualTo(NOW) + } + + @Test + fun `M refresh the write time without changing the values W touch()`() { + // The 304 path: the answer is unchanged, so nothing is stored, and age is all the sweep + // reads. Without this a settled client's entry would never look in use again. + val store = testedStore(nowMs = NOW - THREE_DAYS_MS) + store.store(RemoteConfigValues(42f, 7, custom = """{"debug":true}""", etag = "\"v7\"")) + + testedStore(nowMs = NOW).touch() + + assertThat(preferences.getLong("$STORE_KEY.writtenAt", 0L)).isEqualTo(NOW) + assertThat(store.sessionSampleRate()).isEqualTo(42f) + assertThat(store.appliedVersion()).isEqualTo(7) + assertThat(store.etag()).isEqualTo("\"v7\"") + } + + // endregion + + /** Writes a complete entry under [key], stamped as written at [atMs]. */ + private fun storeUnder(key: String, atMs: Long) { + RemoteConfigStore(appContext, key, mock()) { atMs } + .store(RemoteConfigValues(42f, 7, custom = """{"debug":true}""", etag = "\"v7\"")) + } + + private fun testedStore(nowMs: Long? = null): RemoteConfigStore = + if (nowMs == null) { + RemoteConfigStore(appContext, STORE_KEY, mock()) + } else { + RemoteConfigStore(appContext, STORE_KEY, mock()) { nowMs } + } + + private fun datadogContext(): DatadogContext { + val context = mock() + 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" + + // Shaped like a real store key, and carrying the prefix, so the sweep considers it at all: + // a key it skips for the wrong reason would let the "never remove its own entry" test pass + // with the guard gone. + private val STORE_KEY = "${RemoteConfigStore.STORE_KEY_PREFIX}host|app|shop|prod|1.0.0" + private val OTHER_VERSION_KEY = "${RemoteConfigStore.STORE_KEY_PREFIX}host|app|shop|prod|0.9.0" + + private const val NOW = 1_800_000_000_000L + private const val ONE_DAY_MS = 24L * 60 * 60 * 1000 + private const val THREE_DAYS_MS = 3 * ONE_DAY_MS + } +} 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..b4686bfa79 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -231,8 +231,9 @@ internal class SessionReplayFeature( parseSessionMetadata(sessionMetadata) ?.let { sessionData -> val alreadySeenSession = currentRumSessionId.get() == sessionData.sessionId - if (shouldHandleSession(alreadySeenSession)) { - applySampling(alreadySeenSession) + val forceSampling = sessionData.forced && !isSessionSampledIn.get() + if (!alreadySeenSession || forceSampling || userIntentToRecordChanged.get()) { + applySampling(alreadySeenSession, sessionData.forced) modifyShouldRecordState(sessionData) handleRecording(sessionData) } @@ -253,7 +254,8 @@ internal class SessionReplayFeature( private data class SessionData( val keepSession: Boolean, - val sessionId: String + val sessionId: String, + val forced: Boolean ) private fun parseSessionMetadata(sessionMetadata: Map<*, *>): SessionData? { @@ -265,15 +267,14 @@ internal class SessionReplayFeature( return null } - return SessionData(keepSession, sessionId) + val forced = sessionMetadata[RUM_SESSION_FORCED_BUS_MESSAGE_KEY] as? Boolean ?: false + return SessionData(keepSession, sessionId, forced) } - private fun shouldHandleSession(alreadySeenSession: Boolean): Boolean { - return !alreadySeenSession || userIntentToRecordChanged.get() - } - - private fun applySampling(alreadySeenSession: Boolean) { - if (!alreadySeenSession) { + private fun applySampling(alreadySeenSession: Boolean, forced: Boolean) { + if (forced) { + isSessionSampledIn.set(true) + } else if (!alreadySeenSession) { isSessionSampledIn.set(rateBasedSampler.sample(Unit)) } } @@ -431,6 +432,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" diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index 4a1a124830..3ac4e7a8a2 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt @@ -506,6 +506,60 @@ internal class SessionReplayFeatureTest { verifyNoMoreInteractions(mockRecorder) } + @Test + fun `M startRecording W rum session updated { keep, forced, sampler would drop it }`() { + // FLASHCAT FORK - a forced session must come out with replay whatever the replay sampler + // says: the host application asked for this user to be recorded, and a rate the sampler + // happens to hold has nothing to say about that. + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val rumSessionUpdateBusMessage = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to + true, + SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to + true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + + // When + testedFeature.onReceive(rumSessionUpdateBusMessage) + + // Then + inOrder(mockRecorder) { + verify(mockRecorder).registerCallbacks() + verify(mockRecorder).resumeRecorders() + } + verifyNoMoreInteractions(mockRecorder) + } + + @Test + fun `M not startRecording W rum session updated { keep, not forced, sampler drops it }`() { + // FLASHCAT FORK - the negative control for the test above: with the same sampler and the + // same kept session, an unforced session is still left unrecorded. A message that omits + // the key entirely reads as not forced, which is what every message written before this + // existed looks like. + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val rumSessionUpdateBusMessage = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to + true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + + // When + testedFeature.onReceive(rumSessionUpdateBusMessage) + + // Then + verify(mockRecorder).registerCallbacks() + verifyNoMoreInteractions(mockRecorder) + } + @Test fun `M doNothing W rum session updated { keep, sessionId is null }`() { // Given @@ -1443,4 +1497,87 @@ internal class SessionReplayFeatureTest { ) } } + + @Test + fun `M forcing an existing sampled out replay starts recording W configuration changes`() { + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val message = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + testedFeature.onReceive(message) + org.mockito.kotlin.clearInvocations(mockRecorder) + testedFeature.onReceive(message + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true)) + verify(mockRecorder).resumeRecorders() + } + + @Test + fun `M forcing a new session starts recording as negative control W configuration changes`() { + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val message = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + testedFeature.onReceive(message) + org.mockito.kotlin.clearInvocations(mockRecorder) + testedFeature.onReceive( + message + mapOf( + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to java.util.UUID.randomUUID().toString(), + SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true + ) + ) + verify(mockRecorder).resumeRecorders() + } + + @Test + fun `M respect manual stop W an existing session becomes forced`() { + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val message = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + testedFeature.onReceive(message) + testedFeature.manuallyStopRecording() + org.mockito.kotlin.clearInvocations(mockRecorder) + testedFeature.onReceive(message + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true)) + verify(mockRecorder, never()).resumeRecorders() + testedFeature.manuallyStartRecording() + testedFeature.onReceive(message + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true)) + verify(mockRecorder).resumeRecorders() + testedFeature.onReceive(message + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true)) + verify(mockRecorder).resumeRecorders() + } + + @Test + fun `M ignore malformed forced messages W the current replay is sampled out`() { + whenever(mockSampler.sample(any())).thenReturn(false) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val message = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + testedFeature.onReceive(message) + org.mockito.kotlin.clearInvocations(mockRecorder) + testedFeature.onReceive( + (message - SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY) + + (SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to true) + ) + testedFeature.onReceive(message) + verifyNoInteractions(mockRecorder) + } }