diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index a7c9047051..43f210afba 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -68,6 +68,8 @@ data class com.datadog.android.rum.RumConfiguration fun setSessionSampleRate(Float): Builder fun setRemoteConfigurationEnabled(Boolean): Builder fun setBeforeSampling(BeforeSamplingCallback): Builder + fun setSessionOnError(Boolean): Builder + fun setSessionReplayOnError(Boolean): Builder fun collectAccessibility(Boolean): Builder fun setTelemetrySampleRate(Float): Builder fun trackUserInteractions(Array = emptyArray(), com.datadog.android.rum.tracking.InteractionPredicate = NoOpInteractionPredicate()): Builder @@ -1659,7 +1661,7 @@ data class com.datadog.android.rum.model.ViewEvent fun fromJson(kotlin.String): Application fun fromJsonObject(com.google.gson.JsonObject): Application data class ViewEventSession - constructor(kotlin.String, ViewEventSessionType, kotlin.Boolean? = null, kotlin.Boolean? = true, kotlin.Boolean? = null) + constructor(kotlin.String, ViewEventSessionType, kotlin.Boolean? = null, kotlin.Boolean? = true, kotlin.Boolean? = null, kotlin.Boolean? = null, kotlin.Boolean? = null) fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): ViewEventSession 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 1cf1e491e4..0f5ef5d5e2 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 @@ -128,6 +128,8 @@ public final class com/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 setSessionOnError (Z)Lcom/datadog/android/rum/RumConfiguration$Builder; + public final fun setSessionReplayOnError (Z)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setSessionSampleRate (F)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setSlowFramesConfiguration (Lcom/datadog/android/rum/configuration/SlowFramesConfiguration;)Lcom/datadog/android/rum/RumConfiguration$Builder; public final fun setTelemetrySampleRate (F)Lcom/datadog/android/rum/RumConfiguration$Builder; @@ -6037,20 +6039,24 @@ public final class com/datadog/android/rum/model/ViewEvent$Usr$Companion { public final class com/datadog/android/rum/model/ViewEvent$ViewEventSession { public static final field Companion Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession$Companion; - public fun (Ljava/lang/String;Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;)V - public synthetic fun (Ljava/lang/String;Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;)V + public synthetic fun (Ljava/lang/String;Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType; public final fun component3 ()Ljava/lang/Boolean; public final fun component4 ()Ljava/lang/Boolean; public final fun component5 ()Ljava/lang/Boolean; - public final fun copy (Ljava/lang/String;Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;)Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession; - public static synthetic fun copy$default (Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession;Ljava/lang/String;Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession; + public final fun component6 ()Ljava/lang/Boolean; + public final fun component7 ()Ljava/lang/Boolean; + public final fun copy (Ljava/lang/String;Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;)Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession; + public static synthetic fun copy$default (Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession;Ljava/lang/String;Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/rum/model/ViewEvent$ViewEventSession; public final fun getHasReplay ()Ljava/lang/Boolean; public final fun getId ()Ljava/lang/String; + public final fun getSampledForError ()Ljava/lang/Boolean; + public final fun getSampledForErrorReplay ()Ljava/lang/Boolean; public final fun getSampledForReplay ()Ljava/lang/Boolean; public final fun getType ()Lcom/datadog/android/rum/model/ViewEvent$ViewEventSessionType; public fun hashCode ()I 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 50bc152bb9..73b33ec450 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 @@ -420,6 +420,16 @@ "type": "boolean", "description": "Whether this session has been sampled for replay", "readOnly": true + }, + "sampled_for_error": { + "type": "boolean", + "description": "FlashCat fork - whether this session was kept only because it reported an error; its detail starts where the withheld buffer reached", + "readOnly": true + }, + "sampled_for_error_replay": { + "type": "boolean", + "description": "FlashCat fork - whether the replay of this session was kept only because the session reported an error", + "readOnly": true } }, "readOnly": true 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 53ecee42b2..13008ae3e6 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 @@ -127,6 +127,10 @@ object Rum { val rumAppStartupTelemetryReporter = RumAppStartupTelemetryReporter.create(sdkCore = sdkCore) + val handler = Handler(Looper.getMainLooper()) + + val withheldEvents = rumFeature.withheldEvents + return DatadogRumMonitor( applicationId = rumFeature.applicationId, sdkCore = sdkCore, @@ -137,8 +141,11 @@ object Rum { // 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()), + writer = withheldEvents ?: rumFeature.dataWriter, + withheldEvents = withheldEvents, + sessionOnError = rumFeature.configuration.sessionOnError, + sessionReplayOnError = rumFeature.configuration.sessionReplayOnError, + handler = handler, telemetryEventHandler = TelemetryEventHandler( sdkCore = sdkCore, eventSampler = RateBasedSampler(rumFeature.telemetrySampleRate), 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 cb65538c8d..1699aa9cfb 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 @@ -104,6 +104,51 @@ data class RumConfiguration internal constructor( return this } + /** + * Also keep the sessions the session sample rate does not keep, but only those that + * report an error. + * + * Such a session is collected in memory without uploading anything, keeping only its last + * minute. If it reports an error, that minute is uploaded and the session carries on like + * any collected one; if it ends without one, what it held is thrown away and nothing of it + * is ever sent. Its events report a session sample rate of 0 - it stands for itself rather + * than for the population a rate would imply - and its view events carry + * `session.sampled_for_error`. An error dropped by an event mapper does not count, and + * neither does a session whose rate [setBeforeSampling] set to 0. + * + * A native crash is reported at the next launch with the last view, like for any collected + * session, but without the minute leading up to it. + * + * Disabled by default. With [setRemoteConfigurationEnabled], the console's value wins over + * this one. + * + * @param enabled whether to keep the sessions that report an error. + */ + fun setSessionOnError(enabled: Boolean): Builder { + rumConfig = rumConfig.copy(sessionOnError = enabled) + return this + } + + /** + * Also keep the replays the Session Replay sample rate does not keep, but only those of + * sessions that report an error. + * + * Such a replay is recorded in memory without uploading anything, keeping only its last + * minute; it is uploaded if the session reports an error, and thrown away if it ends + * without one. A session kept only through [setSessionOnError] holds its replay until its + * events are released, whether or not the replay rate kept it. View events of a session + * whose replay is kept this way carry `session.sampled_for_error_replay`. + * + * Needs the Session Replay feature. Disabled by default. With + * [setRemoteConfigurationEnabled], the console's value wins over this one. + * + * @param enabled whether to keep the replays of sessions that report an error. + */ + fun setSessionReplayOnError(enabled: Boolean): Builder { + rumConfig = rumConfig.copy(sessionReplayOnError = enabled) + return this + } + /** * Whether to collect accessibility attributes - this is disabled by default. * diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/DatadogLateCrashReporter.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/DatadogLateCrashReporter.kt index a5be64dba9..3085b19ec7 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/DatadogLateCrashReporter.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/DatadogLateCrashReporter.kt @@ -95,7 +95,7 @@ internal class DatadogLateCrashReporter( ) writeScope { rumWriter.write(it, toSendErrorEvent, EventType.CRASH) - if (lastViewEvent.isWithinSessionAvailability) { + if (lastViewEvent.isWithinSessionAvailability || lastViewEvent.isOnlyKnownHere) { val updatedViewEvent = updateViewEvent(lastViewEvent) rumWriter.write(it, updatedViewEvent, EventType.CRASH) } @@ -156,7 +156,7 @@ internal class DatadogLateCrashReporter( // RUM events are written sdkCore.writeLastFatalAnrSent(anrExitInfo.timestamp) rumWriter.write(it, toSendErrorEvent, EventType.CRASH) - if (lastViewEvent.isWithinSessionAvailability) { + if (lastViewEvent.isWithinSessionAvailability || lastViewEvent.isOnlyKnownHere) { val updatedViewEvent = updateViewEvent(lastViewEvent) rumWriter.write(it, updatedViewEvent, EventType.CRASH) } @@ -326,6 +326,13 @@ internal class DatadogLateCrashReporter( private val ViewEvent.sampleRate: Float get() = dd.configuration?.sessionSampleRate?.toFloat() ?: 0f + /** + * FLASHCAT FORK - a session kept only on error uploaded nothing before it crashed: the view + * written locally is the only one the intake will ever get, however old it is by now. + */ + private val ViewEvent.isOnlyKnownHere: Boolean + get() = session.sampledForError == true + private val ViewEvent.isWithinSessionAvailability: Boolean get() { val now = sdkCore.timeProvider.getDeviceTimestampMillis() diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/FeaturesContextResolver.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/FeaturesContextResolver.kt index 8af287ce33..2cd59abc83 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/FeaturesContextResolver.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/FeaturesContextResolver.kt @@ -27,8 +27,37 @@ internal class FeaturesContextResolver { return (sessionReplayMetadata?.get(VIEW_RECORDS_COUNT_KEY) as? Long) ?: 0L } + /** + * FLASHCAT FORK - whether the replay of this session is kept only because it reports an error, + * or null when Session Replay is not enabled. + */ + fun resolveSampledForErrorReplay(datadogContext: DatadogContext, sessionId: String): Boolean? { + val sessionReplayContext = + datadogContext.featuresContext[Feature.SESSION_REPLAY_FEATURE_NAME] ?: return null + return sessionReplayContext[REPLAY_ON_ERROR_SESSION_KEY] == sessionId + } + + /** + * FLASHCAT FORK - whether this session is sampled for replay, reported only where an on-error + * mode is in play for it (null otherwise, so nothing changes for anyone who did not opt in). A + * replay still withheld counts when the session's events are withheld too: it is released along + * with them, so if these events ever reach the intake, so does the replay. + */ + fun resolveSampledForReplay(datadogContext: DatadogContext, sessionId: String, sampledForError: Boolean): Boolean? { + val sessionReplayContext = + datadogContext.featuresContext[Feature.SESSION_REPLAY_FEATURE_NAME] ?: return null + if (!sampledForError && sessionReplayContext[REPLAY_ON_ERROR_SESSION_KEY] != sessionId) return null + val isRecording = sessionReplayContext[REPLAY_ENABLED_KEY] as? Boolean ?: false + val isWithheld = sessionReplayContext[REPLAY_WITHHELD_KEY] as? Boolean ?: false + return isRecording && (!isWithheld || sampledForError) + } + companion object { internal const val HAS_REPLAY_KEY = "has_replay" internal const val VIEW_RECORDS_COUNT_KEY = "records_count" + internal const val VIEW_WITHHELD_RECORDS_COUNT_KEY = "withheld_records_count" + internal const val REPLAY_ENABLED_KEY = "session_replay_is_enabled" + internal const val REPLAY_ON_ERROR_SESSION_KEY = "session_replay_on_error_session_id" + internal const val REPLAY_WITHHELD_KEY = "session_replay_withheld" } } 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 382c535914..d101ff3a95 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 @@ -20,10 +20,12 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureContextUpdateReceiver import com.datadog.android.api.feature.FeatureEventReceiver +import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.feature.StorageBackedFeature import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.DataWriter +import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.FeatureStorageConfiguration import com.datadog.android.api.storage.NoOpDataWriter import com.datadog.android.core.InternalSdkCore @@ -36,6 +38,8 @@ 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.privacy.TrackingConsent +import com.datadog.android.privacy.TrackingConsentProviderCallback import com.datadog.android.rum.BeforeSamplingCallback import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumErrorSource @@ -47,6 +51,7 @@ import com.datadog.android.rum.internal.anr.ANRDetectorRunnable import com.datadog.android.rum.internal.debug.UiRumDebugListener import com.datadog.android.rum.internal.domain.InfoProvider import com.datadog.android.rum.internal.domain.RumDataWriter +import com.datadog.android.rum.internal.domain.WithheldEventWriter import com.datadog.android.rum.internal.domain.accessibility.AccessibilityInfo import com.datadog.android.rum.internal.domain.accessibility.AccessibilitySnapshotManager import com.datadog.android.rum.internal.domain.accessibility.DefaultAccessibilityReader @@ -64,6 +69,7 @@ import com.datadog.android.rum.internal.domain.event.RumEventMetaDeserializer import com.datadog.android.rum.internal.domain.event.RumEventMetaSerializer import com.datadog.android.rum.internal.domain.event.RumEventSerializer import com.datadog.android.rum.internal.domain.event.RumViewEventFilter +import com.datadog.android.rum.internal.domain.scope.RumSessionScope import com.datadog.android.rum.internal.instrumentation.MainLooperLongTaskStrategy import com.datadog.android.rum.internal.instrumentation.UserActionTrackingStrategyApi29 import com.datadog.android.rum.internal.instrumentation.UserActionTrackingStrategyLegacy @@ -123,6 +129,7 @@ import com.datadog.android.rum.tracking.ViewAttributesProvider import com.datadog.android.rum.tracking.ViewTrackingStrategy import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import java.util.Locale +import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit @@ -141,7 +148,7 @@ internal class RumFeature( DatadogLateCrashReporter(it) }, private val buildSdkVersionProvider: BuildSdkVersionProvider = BuildSdkVersionProvider.DEFAULT -) : StorageBackedFeature, FeatureEventReceiver { +) : StorageBackedFeature, FeatureEventReceiver, TrackingConsentProviderCallback { internal var dataWriter: DataWriter = NoOpDataWriter() internal val initialized = AtomicBoolean(false) @@ -179,6 +186,18 @@ internal class RumFeature( internal var remoteConfigStore: RemoteConfigStore? = null internal var remoteConfigController: RemoteConfigController? = null private var remoteConfigForegroundCallback: ProcessForegroundCallback? = null + + /** + * FLASHCAT FORK - where the sessions kept only on error hold their events, see + * `RumConfiguration.Builder.setSessionOnError`. Every RUM event of a collected session passes + * through it. + */ + internal var withheldEvents: WithheldEventWriter? = null + private var withheldEventsBackgroundCallback: ProcessForegroundCallback? = null + + // The core takes the feature out of its registry before it stops it, so a stop can no longer + // look the scope up: it is kept from the first time it was looked up. + private var rumFeatureScope: FeatureScope? = null internal var initialResourceIdentifier: InitialResourceIdentifier = NoOpInitialResourceIdentifier() internal var lastInteractionIdentifier: LastInteractionIdentifier? = NoOpLastInteractionIdentifier() internal var slowFramesListener: SlowFramesListener? = null @@ -218,10 +237,14 @@ internal class RumFeature( lastInteractionIdentifier = configuration.lastInteractionIdentifier insightsCollector = configuration.insightsCollector - dataWriter = createDataWriter( + val rumDataWriter = createDataWriter( configuration, sdkCore as InternalSdkCore ) + dataWriter = rumDataWriter + withheldEvents = createWithheldEventWriter(rumDataWriter, appContext) + // Registered before it is initialized, so it can be looked up now - and must be, see onStop. + rumFeatureScope = sdkCore.getFeature(Feature.RUM_FEATURE_NAME) sampleRate = if (sdkCore.isDeveloperModeEnabled) { sdkCore.internalLogger.log( @@ -355,6 +378,24 @@ internal class RumFeature( remoteConfigController = null remoteConfigStore = null + withheldEventsBackgroundCallback?.let { (appContext as? Application)?.unregisterActivityLifecycleCallbacks(it) } + withheldEventsBackgroundCallback = null + // The withheld session ends with the SDK: released if it errored, thrown away if not. The + // write scope only queues the work, and the core shuts its executors down without draining + // them once the features are stopped, so this waits for the work to have run - but never + // without a bound: a stop asked for from the RUM thread would otherwise wait on a context + // thread that is itself waiting on the RUM thread. + withheldEvents?.let { writer -> + val settled = CountDownLatch(1) + withRumWriteScope { + writer.stop(it) + settled.countDown() + } + waitForStop(settled) + } + withheldEvents = null + rumFeatureScope = null + rumContextUpdateReceivers.forEach { sdkCore.removeContextUpdateReceiver(it) } @@ -402,10 +443,107 @@ internal class RumFeature( displayInfoProvider = NoOpDisplayInfoProvider() } + /** + * FLASHCAT FORK - the buffer the sessions kept only on error hold their events in. Its release + * timer only hands the release back to the storage thread, so the main looper is enough to carry + * it; and a release still waiting for the timer goes at once when the app leaves the foreground, + * since the process may not live to see the timer fire. + */ + private fun createWithheldEventWriter(rumDataWriter: RumDataWriter, appContext: Context): WithheldEventWriter { + val handler = Handler(Looper.getMainLooper()) + val writer = WithheldEventWriter( + delegate = rumDataWriter, + internalLogger = sdkCore.internalLogger, + elapsedTimeNs = { sdkCore.timeProvider.getDeviceElapsedTimeNanos() }, + scheduleRelease = { delayMs, release -> handler.postDelayed({ withRumWriteScope(release) }, delayMs) }, + replayRecordsCount = ::resolveReplayRecordsCount, + releaseReplay = { sessionId -> + tellSessionReplay( + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE, + sessionId + ) + }, + discardReplay = { sessionId -> + tellSessionReplay( + RumSessionScope.RUM_SESSION_DISCARDED_BUS_MESSAGE, + sessionId + ) + }, + expectReplayRelease = { sessionId -> + tellSessionReplay( + RumSessionScope.RUM_SESSION_ERRORED_BUS_MESSAGE, + sessionId + ) + } + ) + (appContext as? Application)?.let { application -> + val callback = ProcessForegroundCallback( + onForeground = {}, + onBackground = { withRumWriteScope(writer::flushScheduledRelease) } + ) + application.registerActivityLifecycleCallbacks(callback) + withheldEventsBackgroundCallback = callback + } + return writer + } + + /** FLASHCAT FORK - runs the block on the storage thread, after the RUM writes submitted so far. */ + private fun withRumWriteScope(block: (EventBatchWriter) -> Unit) { + rumFeatureScope()?.withWriteContext { _, writeScope -> writeScope(block) } + } + + private fun waitForStop(settled: CountDownLatch) { + val done = try { + settled.await(STOP_DRAIN_WAIT_MS, TimeUnit.MILLISECONDS) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + false + } + if (!done) { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { STOP_DRAIN_FAILED_MESSAGE } + ) + } + } + + private fun rumFeatureScope(): FeatureScope? { + rumFeatureScope = rumFeatureScope ?: sdkCore.getFeature(Feature.RUM_FEATURE_NAME) + return rumFeatureScope + } + + // FLASHCAT FORK - the withheld events are not in the storage consent governs: what was held + // under the consent now withdrawn is dropped here, whether or not an event follows to see it. + override fun onConsentUpdated(previousConsent: TrackingConsent, newConsent: TrackingConsent) { + if (newConsent != TrackingConsent.NOT_GRANTED) return + withheldEvents?.let { writer -> withRumWriteScope { writer.dropHeldForConsent() } } + } + + /** FLASHCAT FORK - what became of a withheld session's events, for the replay held with them. */ + private fun tellSessionReplay(message: String, sessionId: String) { + sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.sendEvent( + mapOf( + RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to message, + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to sessionId + ) + ) + } + + /** FLASHCAT FORK - the replay records a view has, sent or still held by Session Replay. */ + private fun resolveReplayRecordsCount(viewId: String): Long { + val sessionReplayContext = + sdkCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME, useContextThread = false) + val viewMetadata = sessionReplayContext[viewId] as? Map<*, *> ?: return 0L + val sent = viewMetadata[FeaturesContextResolver.VIEW_RECORDS_COUNT_KEY] as? Long ?: 0L + val held = viewMetadata[FeaturesContextResolver.VIEW_WITHHELD_RECORDS_COUNT_KEY] as? Long ?: 0L + return sent + held + } + private fun createDataWriter( configuration: Configuration, sdkCore: InternalSdkCore - ): DataWriter { + ): RumDataWriter { return RumDataWriter( eventSerializer = MapperSerializer( RumEventMapper( @@ -809,6 +947,7 @@ internal class RumFeature( ), store = store, initialSessionSampleRate = sampleRate, + initialSessionOnError = configuration.sessionOnError, callFactory = sdkCore.createOkHttpCallFactory(), executor = sdkCore.createScheduledExecutorService("rum-remote-config"), // Looked up when it fires rather than captured now: the monitor is registered after @@ -824,7 +963,7 @@ internal class RumFeature( // 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() } + val callback = ProcessForegroundCallback(onForeground = { controller.refreshIfStale() }) application.registerActivityLifecycleCallbacks(callback) remoteConfigForegroundCallback = callback } @@ -870,10 +1009,19 @@ internal class RumFeature( 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 + val beforeSampling: BeforeSamplingCallback? = null, + // FLASHCAT FORK - keep, withheld until they report an error, the sessions the session + // sample rate does not keep. + val sessionOnError: Boolean = false, + // FLASHCAT FORK - the same for the replay of a collected session the replay rate does not + // keep. + val sessionReplayOnError: Boolean = false ) internal companion object { + private const val STOP_DRAIN_WAIT_MS = 2_000L + internal const val STOP_DRAIN_FAILED_MESSAGE = + "Could not wait for the withheld session to be settled before the SDK stopped." internal const val NDK_CRASH_BUS_MESSAGE_TYPE = "ndk_crash" internal const val LOGGER_ERROR_BUS_MESSAGE_TYPE = "logger_error" diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt index 44e59599ea..a0022bef23 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt @@ -25,7 +25,10 @@ internal data class RumContext( val syntheticsResultId: String? = null, val viewTimestamp: Long = 0L, val viewTimestampOffset: Long = 0L, - val hasReplay: Boolean = false + val hasReplay: Boolean = false, + // FLASHCAT FORK - whether the session is kept only because it reported an error. Read by the + // scopes below the session one; not published to the other features. + val sampledForError: Boolean = false ) { fun toMap(): Map { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumDataWriter.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumDataWriter.kt index 1736fb03b9..2286520d04 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumDataWriter.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumDataWriter.kt @@ -27,12 +27,31 @@ internal class RumDataWriter( @WorkerThread override fun write(writer: EventBatchWriter, element: Any, eventType: EventType): Boolean { + val batchEvent = serialize(element) ?: return false + val result = writeSerialized(writer, batchEvent, eventType) + if (result) { + onDataWritten(element, batchEvent.data) + } + return result + } + + // endregion + + // region Internal + + /** + * FLASHCAT FORK - runs the event mappers and serializes, exactly as [write] does before it + * writes. Null when a mapper dropped the event. Split out so that [WithheldEventWriter] can hold + * what the batch would have received, judged after the mappers had their say. + */ + @WorkerThread + internal fun serialize(element: Any): RawBatchEvent? { val byteArray = eventSerializer.serializeToByteArray( element, sdkCore.internalLogger - ) ?: return false + ) ?: return null - val batchEvent = if (element is ViewEvent) { + return if (element is ViewEvent) { val hasAccessibility = element.view.accessibility != null val eventMeta = RumEventMeta.View( @@ -50,19 +69,25 @@ internal class RumDataWriter( } else { RawBatchEvent(data = byteArray) } + } + /** FLASHCAT FORK - writes an event [serialize] already produced. */ + @WorkerThread + internal fun writeSerialized(writer: EventBatchWriter, batchEvent: RawBatchEvent, eventType: EventType): Boolean { synchronized(this) { - val result = writer.write(batchEvent, null, eventType) - if (result) { - onDataWritten(element, byteArray) - } - return result + return writer.write(batchEvent, null, eventType) } } - // endregion - - // region Internal + /** + * FLASHCAT FORK - forgets the view written locally for the native crash reporter: the session + * it belongs to was thrown away, and a crash reported against it at the next launch would + * bring to the intake a session that never errored. + */ + @WorkerThread + internal fun deleteLastViewEvent() { + sdkCore.deleteLastViewEvent() + } @WorkerThread internal fun onDataWritten(data: Any, rawData: ByteArray) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriter.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriter.kt new file mode 100644 index 0000000000..34a8f81704 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriter.kt @@ -0,0 +1,622 @@ +/* + * 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.domain + +import androidx.annotation.WorkerThread +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.storage.DataWriter +import com.datadog.android.api.storage.EventBatchWriter +import com.datadog.android.api.storage.EventType +import com.datadog.android.api.storage.RawBatchEvent +import com.datadog.android.rum.model.ActionEvent +import com.datadog.android.rum.model.ErrorEvent +import com.datadog.android.rum.model.LongTaskEvent +import com.datadog.android.rum.model.ResourceEvent +import com.datadog.android.rum.model.ViewEvent +import com.datadog.android.rum.model.VitalAppLaunchEvent +import com.datadog.android.rum.model.VitalOperationStepEvent +import com.google.gson.JsonParser +import java.util.concurrent.TimeUnit +import kotlin.math.absoluteValue + +/** + * FLASHCAT FORK - holds the events of a session kept only in case it reports an error (see + * `RumConfiguration.Builder.setSessionOnError`), and lets them through once it does. + * + * Nothing of such a session reaches the batch until then, and if it never errors nothing ever does: + * when it ends, what was held is thrown away. Once it errors, the last minute leading up to the + * error is released in one go and the session carries on like any collected one. + * + * Events are held as the batch would have received them - after the event mappers, serialized - so + * an error a mapper drops releases nothing (a session billed for an error nobody can find would be + * worse than none), and the memory budget is the size actually uploaded. + * + * Every RUM event of a collected session passes through here, so that the events of a session + * whose buffer was thrown away are thrown away too when they arrive late - see [discardedSessionIds]. + * What belongs to no withheld session goes straight to [delegate]. + * + * All calls but [isReleased] run on the storage thread, in the order they were submitted, which is + * what keeps a session's control calls in step with its own writes. + */ +internal class WithheldEventWriter( + private val delegate: RumDataWriter, + private val internalLogger: InternalLogger, + private val elapsedTimeNs: () -> Long, + /** + * Runs the release after the given delay, on the storage thread, with a writer for whatever + * the tracking consent is by then. + */ + private val scheduleRelease: (delayMs: Long, release: (EventBatchWriter) -> Unit) -> Unit, + /** + * How many replay records a view holds or has sent, so released events can claim the replay + * that goes out with them. + */ + private val replayRecordsCount: (viewId: String) -> Long, + /** + * Tells Session Replay the session's events are released, so the replay it holds for that + * session goes out too - never ahead of the events it attaches to. + */ + private val releaseReplay: (sessionId: String) -> Unit, + /** + * Tells Session Replay the session ended without an error, so the replay it holds for that + * session goes too. Session Replay keeps a replay whose events are withheld until it hears one + * or the other: the next session can announce itself before this one's fate is settled here. + */ + private val discardReplay: (sessionId: String) -> Unit, + /** + * Tells Session Replay the session reported its error, the moment it is seen: the replay goes + * out when the events do, but a stop that comes first must still know to write it. + */ + private val expectReplayRelease: (sessionId: String) -> Unit +) : DataWriter { + + private class HeldView(val viewId: String, val date: Long, val event: RawBatchEvent, val eventType: EventType) + + private class HeldEvent( + val viewId: String?, + val event: RawBatchEvent, + val eventType: EventType, + val heldAtNs: Long, + val tier: EvictionTier + ) + + /** What goes first when the buffer is over budget. */ + private enum class EvictionTier { + /** Long tasks, and requests that succeeded without complaint. */ + FIRST, + + /** Actions, vitals and failed requests: they explain what the user was doing. */ + LAST, + + /** + * Errors are the reason the session is kept at all, so they go only once nothing else is + * left - newest first, because the earliest error is the one the session is about. + */ + LAST_RESORT, + + /** + * A crash is never evicted: the process is going down, the history is released with it at + * once, and it is what that release exists to deliver. + */ + CRASH + } + + private var withheldForSessionId: String? = null + + /** + * A collected session whose replay only is kept on error: nothing of it is held, but its first + * error still has to be told apart, after the mappers, like a withheld session's. + */ + private var watchedSessionId: String? = null + + /** + * The session that earned its release, by an error or by being forced. Marked the moment the + * error is seen, ahead of the release itself, so a session that ends while its release still + * waits for the jitter is released rather than thrown away. + */ + private var releasedSessionId: String? = null + + /** The session whose replay was told to go out: its events have actually been released. */ + private var replayReleasedSessionId: String? = null + + /** Latest event per view, least recently updated first. */ + private val views = LinkedHashMap() + private val details = ArrayList() + private var bytes = 0L + private var droppedCount = 0 + private var currentViewId: String? = null + private var currentViewDate = Long.MIN_VALUE + + /** When the release was scheduled, which is what freezes the window - see [prune]. */ + private var releaseScheduledAtNs: Long? = null + + /** Bumped whenever the buffer is cleared, so a release scheduled for its previous content is inert. */ + private var generation = 0 + + /** + * The sessions whose buffers were thrown away. A request that completes after its session ended + * still carries that session's id, and letting it through would store the very session the + * withholding avoided. + */ + private val discardedSessionIds = ArrayDeque() + + // region DataWriter + + @WorkerThread + override fun write(writer: EventBatchWriter, element: Any, eventType: EventType): Boolean { + val sessionId = sessionIdOf(element) ?: return delegate.write(writer, element, eventType) + synchronized(this) { + if (sessionId in discardedSessionIds) { + // Accepted and dropped, as the batch would have done had the session never been + // collected. + return true + } + if (sessionId == watchedSessionId && element is ErrorEvent) { + // The error the console opens the replay from claims it, if its view has records. + val claimed = if (replayRecordsCount(element.view.id) > 0) { + element.copy(session = element.session.copy(hasReplay = true)) + } else { + element + } + val written = delegate.write(writer, claimed, eventType) + // Only the replay is told: [releasedSessionId] belongs to the withheld session, + // which may be the one running now while this error drains from a stopped one. + if (written && replayReleasedSessionId != sessionId) { + notifyReplayReleased(sessionId) + } + return written + } + if (sessionId != withheldForSessionId) { + // A view of a session whose replay was released claims it as soon as the records + // exist, held or sent: a final view assembled before they were written would + // otherwise take the place of the released one that claimed it. + val claimed = if ( + sessionId == replayReleasedSessionId && element is ViewEvent && + element.session.hasReplay != true && replayRecordsCount(element.view.id) > 0 + ) { + element.copy(session = element.session.copy(hasReplay = true)) + } else { + element + } + return delegate.write(writer, claimed, eventType) + } + val batchEvent = delegate.serialize(element) ?: return false + if (element is ErrorEvent) { + if (releasedSessionId != sessionId) { + releasedSessionId = sessionId + expectReplayRelease(sessionId) + } + if (batchEvent.data.size > BYTES_LIMIT) { + // The session has earned its release. An error larger than the whole budget + // could only be held by evicting the history it explains, so it goes to the + // batch on its own; the history still leaves behind the jitter - unless this + // is a crash, after which no timer would ever fire. + delegate.writeSerialized(writer, claimReplay(batchEvent, element.view.id), eventType) + if (element.error.isCrash == true) release(writer) else scheduleReleaseOnce(sessionId) + return true + } + } + hold(element, batchEvent, eventType) + if (releasedSessionId == sessionId) { + if (element is ErrorEvent && element.error.isCrash == true) { + // The process is going down: the crash handler waits for this write and no + // timer would ever fire. What was held goes now, with the crash. + release(writer) + } else { + scheduleReleaseOnce(sessionId) + } + } + return true + } + } + + // endregion + + // region Session lifecycle + + /** Whether the given session earned its release. Read by the session scope to stop withholding. */ + fun isReleased(sessionId: String): Boolean = synchronized(this) { releasedSessionId == sessionId } + + /** + * Whether the replay of the given session may go out: its events have actually left - after the + * jitter for a withheld session, at its first error for a session whose replay only is withheld. + */ + fun isReplayReleased(sessionId: String): Boolean = synchronized(this) { replayReleasedSessionId == sessionId } + + /** + * The app went to the background: a release waiting for the jitter goes now, since the process + * may not live to see the timer. A buffer that has not earned its release stays held - the app + * often comes straight back, and an error then needs the history before it. + */ + @WorkerThread + fun flushScheduledRelease(writer: EventBatchWriter) { + synchronized(this) { + if (releaseScheduledAtNs != null) release(writer) + } + } + + /** + * The SDK is stopping: the session ends with it. What a released session still holds goes + * now, whether or not the jitter has run out; a session that never errored is thrown away, + * along with the view it wrote locally, so that a crash after the stop is not reported + * against it at the next launch. + */ + @WorkerThread + fun stop(writer: EventBatchWriter) { + synchronized(this) { + withheldForSessionId?.let { endSession(it, writer) } + watchedSessionId?.let { endSession(it, writer) } + } + } + + /** Watches a collected session whose replay only is kept on error for its first error. */ + @WorkerThread + fun watchForError(sessionId: String, writer: EventBatchWriter) { + synchronized(this) { + endPreviousSessions(sessionId, writer) + watchedSessionId = sessionId + } + } + + /** Starts withholding the events of a session just drawn as one kept only on error. */ + @WorkerThread + fun startWithholding(sessionId: String, writer: EventBatchWriter) { + synchronized(this) { + endPreviousSessions(sessionId, writer) + withheldForSessionId = sessionId + } + } + + /** A session still held or watched when another one is drawn ended without a word; it ends now. */ + private fun endPreviousSessions(sessionId: String, writer: EventBatchWriter) { + withheldForSessionId?.takeIf { it != sessionId }?.let { endSession(it, writer) } + watchedSessionId?.takeIf { it != sessionId }?.let { endSession(it, writer) } + } + + /** + * The session ended - expired, renewed or stopped. If it had reported an error, what it holds + * is released now, whether or not the jitter has run out. If not, it never will, so what it holds + * is thrown away, and so is anything of it that arrives later. A watched session holds nothing + * here, but Session Replay holds its replay and waits for the same word. + */ + @WorkerThread + fun endSession(sessionId: String, writer: EventBatchWriter) { + synchronized(this) { + if (watchedSessionId == sessionId) { + watchedSessionId = null + if (replayReleasedSessionId != sessionId) discardReplay(sessionId) + return + } + if (withheldForSessionId != sessionId) return + if (releasedSessionId == sessionId) { + release(writer) + } else { + discardedSessionIds.addLast(sessionId) + if (discardedSessionIds.size > DISCARDED_SESSIONS_REMEMBERED) { + discardedSessionIds.removeFirstOrNull() + } + clear() + withheldForSessionId = null + // The last view it wrote locally would otherwise have the native crash reporter + // attach a crash of a later, uncollected session to it at the next launch. + delegate.deleteLastViewEvent() + discardReplay(sessionId) + } + } + } + + /** The host application asked for this session: no reason to wait for an error, nor for the jitter. */ + @WorkerThread + fun forceRelease(sessionId: String, writer: EventBatchWriter) { + synchronized(this) { + if (withheldForSessionId != sessionId) return + releasedSessionId = sessionId + release(writer) + } + } + + /** + * Tracking consent was withdrawn while the session was still withholding. What it holds was + * collected under the consent now withdrawn, so it is dropped; the session itself goes on, and + * holds again from scratch should consent be granted back. + */ + @WorkerThread + fun dropHeld(sessionId: String) { + synchronized(this) { + if (withheldForSessionId != sessionId) return + dropForConsent() + } + } + + /** + * Tracking consent was withdrawn: whatever is held goes, released or not. A release still + * waiting for its jitter would only reach the writer consent now denies, and consent may be + * granted back before any event comes by to notice. + */ + @WorkerThread + fun dropHeldForConsent() { + synchronized(this) { dropForConsent() } + } + + /** + * A session already released holds nothing more after this: its events go to the batch, where + * consent decides, rather than through here, where a release granted later would carry out + * what was collected while consent was withdrawn. + */ + private fun dropForConsent() { + clear() + if (releasedSessionId == withheldForSessionId) withheldForSessionId = null + } + + // endregion + + // region Internal + + @WorkerThread + private fun hold(element: Any, batchEvent: RawBatchEvent, eventType: EventType) { + if (element is ViewEvent) { + holdView(element, batchEvent, eventType) + return + } + val size = batchEvent.data.size + if (size > BYTES_LIMIT) { + // Holding it would evict the whole minute before it to make room it never fits into. + droppedCount++ + return + } + details.add( + HeldEvent( + viewId = viewIdOf(element), + event = batchEvent, + eventType = eventType, + heldAtNs = elapsedTimeNs(), + tier = evictionTierOf(element) + ) + ) + bytes += size + prune() + while (details.size > EVENTS_LIMIT || bytes > BYTES_LIMIT) { + if (!evictOne()) break + } + } + + @WorkerThread + private fun holdView(element: ViewEvent, batchEvent: RawBatchEvent, eventType: EventType) { + val viewId = element.view.id + // A view event is cumulative, so the latest supersedes the ones before. Removed first so the + // map orders views by their last update. + views.remove(viewId) + views[viewId] = HeldView(viewId, element.date, batchEvent, eventType) + // Written locally like any view the batch takes, and only locally: a native crash is + // reported at the next launch from this file, and is exactly the error such a session is + // kept for. + delegate.onDataWritten(element, batchEvent.data) + // A late update of a view that already ended must not make it current again, or `prune` + // would drop the view the next error hangs from. + if (element.date >= currentViewDate) { + currentViewDate = element.date + currentViewId = viewId + } + while (views.size > VIEWS_LIMIT) { + val oldest = views.keys.firstOrNull { it != currentViewId } ?: break + views.remove(oldest) + } + prune() + } + + /** Drops what has aged out of the window, and the views left with nothing in it. */ + private fun prune() { + // Once a release is scheduled the window stops moving: a delayed timer must not throw away + // the very minute before the error that it exists to deliver. + val now = releaseScheduledAtNs ?: elapsedTimeNs() + val oldestAllowed = now - WINDOW_NS + val iterator = details.iterator() + while (iterator.hasNext()) { + val held = iterator.next() + if (held.heldAtNs >= oldestAllowed) break + bytes -= held.event.data.size + droppedCount++ + iterator.remove() + } + // A view is kept as the container of its detail; the one in progress always stays, it is + // the container the error will hang from. + val viewsWithDetail = details.mapNotNullTo(HashSet()) { it.viewId } + views.keys.retainAll { it == currentViewId || it in viewsWithDetail } + } + + private fun evictOne(): Boolean { + for (tier in EVICTED_BEFORE_ERRORS) { + val index = details.indexOfFirst { it.tier == tier } + if (index != -1) { + evictAt(index) + return true + } + } + val index = details.indexOfLast { it.tier == EvictionTier.LAST_RESORT } + if (index == -1) return false + evictAt(index) + return true + } + + private fun evictAt(index: Int) { + bytes -= details.removeAt(index).event.data.size + droppedCount++ + } + + @Suppress("ThreadSafety") // the scheduled release is handed a writer on the storage thread + private fun scheduleReleaseOnce(sessionId: String) { + if (releaseScheduledAtNs != null) return + releaseScheduledAtNs = elapsedTimeNs() + val scheduledGeneration = generation + scheduleRelease(computeReleaseDelayMs(sessionId)) { writer -> + synchronized(this) { + if (generation == scheduledGeneration && withheldForSessionId == sessionId) { + release(writer) + } + } + } + } + + @WorkerThread + private fun release(writer: EventBatchWriter) { + val sessionId = withheldForSessionId + prune() + // Views only set the order: the session is built out of whichever of its views arrives + // first, so that one has to be the earliest. Every detail goes, whether or not its view is + // still held - an error raised before the first view, or whose view was evicted, is still + // the error the session is kept for. Then the errors - a release at exit leaves in as many + // requests as the process still gets to send, and the error is what the session is kept + // for - then the rest. + views.values.sortedBy { it.date }.forEach { write(writer, it.event, it.eventType, it.viewId) } + val (errors, others) = details.partition { it.tier >= EvictionTier.LAST_RESORT } + errors.forEach { write(writer, it.event, it.eventType, it.viewId) } + others.forEach { write(writer, it.event, it.eventType, it.viewId) } + + // Without this the promise of a minute of history before the error could not be checked. + internalLogger.log( + level = InternalLogger.Level.INFO, + target = InternalLogger.Target.TELEMETRY, + messageBuilder = { RELEASED_MESSAGE }, + throwable = null, + onlyOnce = false, + additionalProperties = mapOf( + "buffer.views_count" to views.size, + "buffer.events_count" to details.size, + "buffer.dropped_count" to droppedCount, + "buffer.bytes" to bytes + ) + ) + + clear() + withheldForSessionId = null + // After the events, so the replay never reaches the intake ahead of the session it belongs + // to. After a JVM crash this is as far as it gets: the replay is still held in memory and + // goes down with the process - only the events, written in the crash's own write, survive. + sessionId?.let(::notifyReplayReleased) + } + + private fun notifyReplayReleased(sessionId: String) { + replayReleasedSessionId = sessionId + releaseReplay(sessionId) + } + + private fun write(writer: EventBatchWriter, event: RawBatchEvent, eventType: EventType, viewId: String?) { + delegate.writeSerialized(writer, viewId?.let { claimReplay(event, it) } ?: event, eventType) + } + + /** + * An event assembled while the replay was withheld could not claim it then, since the replay + * might have been dropped. It goes out now alongside its records, so it claims it if its view + * has any. + */ + private fun claimReplay(event: RawBatchEvent, viewId: String): RawBatchEvent { + if (replayRecordsCount(viewId) <= 0) return event + val json = try { + JsonParser.parseString(String(event.data, Charsets.UTF_8)).asJsonObject + } catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) { + return event + } + val session = json.getAsJsonObject(SESSION_KEY) ?: return event + session.addProperty(HAS_REPLAY_KEY, true) + return event.copy(data = json.toString().toByteArray(Charsets.UTF_8)) + } + + private fun clear() { + generation++ + releaseScheduledAtNs = null + views.clear() + details.clear() + bytes = 0L + droppedCount = 0 + currentViewId = null + currentViewDate = Long.MIN_VALUE + } + + // endregion + + internal companion object { + + /** How much history a withheld session keeps: the minute leading up to its error. */ + internal val WINDOW_NS = TimeUnit.SECONDS.toNanos(60) + + /** Memory bound on everything but views. Above it the least valuable events go first. */ + internal const val BYTES_LIMIT = 64 * 1024 + internal const val EVENTS_LIMIT = 200 + + /** + * Views are the containers their events hang from, so they are kept out of the budget above; + * this only bounds pathological view counts. + */ + internal const val VIEWS_LIMIT = 50 + + /** + * Correlated errors make a whole fleet release at the same instant, right when whatever + * caused them is under strain. Releases are spread over this window instead. + */ + internal const val RELEASE_MAX_DELAY_MS = 3_000L + + /** + * A stopped session keeps draining its pending requests while the sessions after it come and + * go, so more are remembered than a browser tab would need. + */ + private const val DISCARDED_SESSIONS_REMEMBERED = 64 + + private const val HTTP_ERROR_STATUS = 400L + + private val EVICTED_BEFORE_ERRORS = listOf(EvictionTier.FIRST, EvictionTier.LAST) + + private const val SESSION_KEY = "session" + private const val HAS_REPLAY_KEY = "has_replay" + + internal const val RELEASED_MESSAGE = "Error session event buffer released" + + /** + * Deterministic per session. Multiplicative (String.hashCode is `h * 31 + c`) rather than a + * sum of characters: session ids are same-length strings over one small alphabet, and summing + * them lands nearly every session within the same few hundred milliseconds. + */ + internal fun computeReleaseDelayMs(sessionId: String): Long = + sessionId.hashCode().toLong().absoluteValue % RELEASE_MAX_DELAY_MS + + private fun sessionIdOf(element: Any): String? = when (element) { + is ViewEvent -> element.session.id + is ActionEvent -> element.session.id + is ResourceEvent -> element.session.id + is ErrorEvent -> element.session.id + is LongTaskEvent -> element.session.id + is VitalAppLaunchEvent -> element.session.id + is VitalOperationStepEvent -> element.session.id + else -> null + } + + private fun viewIdOf(element: Any): String? = when (element) { + is ActionEvent -> element.view.id + is ResourceEvent -> element.view.id + is ErrorEvent -> element.view.id + is LongTaskEvent -> element.view.id + is VitalAppLaunchEvent -> element.view?.id + is VitalOperationStepEvent -> element.view.id + else -> null + } + + private fun evictionTierOf(element: Any): EvictionTier = when (element) { + is ErrorEvent -> if (element.error.isCrash == true) EvictionTier.CRASH else EvictionTier.LAST_RESORT + is LongTaskEvent -> EvictionTier.FIRST + is ResourceEvent -> { + // A request that failed is part of how the error happened; one that succeeded + // rarely is. An unknown status is treated like an ordinary success. + val statusCode = element.resource.statusCode + if (statusCode == 0L || (statusCode != null && statusCode >= HTTP_ERROR_STATUS)) { + EvictionTier.LAST + } else { + EvictionTier.FIRST + } + } + else -> EvictionTier.LAST + } + } +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumViewEventFilter.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumViewEventFilter.kt index 8a9d9555da..eb99c1854f 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumViewEventFilter.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumViewEventFilter.kt @@ -33,18 +33,29 @@ internal class RumViewEventFilter( } } - return batch.filter { - if (viewMetaByEvent.containsKey(it)) { - @Suppress("UnsafeThirdPartyFunctionCall") // we checked the key before - val viewMeta = viewMetaByEvent.getValue(it) - + // FLASHCAT FORK - the surviving version of a view takes the place of the view's first + // occurrence rather than staying where it was written. A session kept on error releases its + // views first, oldest first, and their live updates follow in the same batch: keeping each + // latest version where it was written would push the views behind the events they contain + // and out of start order, while the intake builds the session out of the first view it sees. + val latestByViewId = mutableMapOf() + viewMetaByEvent.forEach { (event, viewMeta) -> + @Suppress("UnsafeThirdPartyFunctionCall") // if there is a meta, there is a max doc version + if (viewMeta.documentVersion == maxDocVersionByViewId.getValue(viewMeta.viewId)) { + latestByViewId[viewMeta.viewId] = event + } + } + val placed = mutableSetOf() + val emitted = mutableSetOf() + return batch.mapNotNull { + val viewMeta = viewMetaByEvent[it] + when { + viewMeta == null -> it // we need to leave only view events with accessibility OR view event with a max doc version // for a give viewId in the batch, because backend will do the same during the reduce process - @Suppress("UnsafeThirdPartyFunctionCall") // if there is a meta, there is a max doc version - viewMeta.hasAccessibility == true || - viewMeta.documentVersion == maxDocVersionByViewId.getValue(viewMeta.viewId) - } else { - true + viewMeta.hasAccessibility == true -> it.takeIf { event -> emitted.add(event) } + placed.add(viewMeta.viewId) -> latestByViewId[viewMeta.viewId]?.takeIf { event -> emitted.add(event) } + else -> null } } } 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 36a036dc4f..93f2fbfe4b 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 @@ -22,6 +22,7 @@ import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.domain.InfoProvider import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time +import com.datadog.android.rum.internal.domain.WithheldEventWriter import com.datadog.android.rum.internal.domain.accessibility.AccessibilitySnapshotManager import com.datadog.android.rum.internal.domain.battery.BatteryInfo import com.datadog.android.rum.internal.domain.display.DisplayInfo @@ -63,7 +64,12 @@ internal class RumApplicationScope( // 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 + private val beforeSampling: BeforeSamplingCallback? = null, + // FLASHCAT FORK - see `RumConfiguration.Builder.setSessionOnError`. One buffer for all the + // sessions this scope makes, so it remembers the sessions it threw away across them. + private val withheldEvents: WithheldEventWriter? = null, + private val sessionOnError: Boolean = false, + private val sessionReplayOnError: Boolean = false ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -79,6 +85,9 @@ internal class RumApplicationScope( remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, beforeSampling = beforeSampling, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError, + sessionReplayOnError = sessionReplayOnError, backgroundTrackingEnabled = backgroundTrackingEnabled, trackFrustrations = trackFrustrations, viewChangedListener = this, @@ -235,7 +244,10 @@ internal class RumApplicationScope( remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, beforeSampling = beforeSampling, - forcedSession = forcedSession + forcedSession = forcedSession, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError, + sessionReplayOnError = sessionReplayOnError ) childScopes.add(newSession) if (event !is RumRawEvent.StartView) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index 6b27bb8ba6..0e30f50297 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 @@ -16,6 +16,7 @@ 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.privacy.TrackingConsent import com.datadog.android.rum.BeforeSamplingCallback import com.datadog.android.rum.BeforeSamplingContext import com.datadog.android.rum.RumSessionListener @@ -23,6 +24,7 @@ import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.domain.InfoProvider import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time +import com.datadog.android.rum.internal.domain.WithheldEventWriter import com.datadog.android.rum.internal.domain.accessibility.AccessibilitySnapshotManager import com.datadog.android.rum.internal.domain.battery.BatteryInfo import com.datadog.android.rum.internal.domain.display.DisplayInfo @@ -31,6 +33,7 @@ import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener import com.datadog.android.rum.internal.remoteconfig.DrawnConfiguration import com.datadog.android.rum.internal.remoteconfig.RemoteConfigStore +import com.datadog.android.rum.internal.remoteconfig.RemoteConfigValues import com.datadog.android.rum.internal.remoteconfig.decodeCustomValues import com.datadog.android.rum.internal.startup.RumSessionScopeStartupManager import com.datadog.android.rum.internal.utils.percent @@ -81,7 +84,14 @@ internal class RumSessionScope( // 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 + internal var forcedSession: Boolean = false, + // FLASHCAT FORK - where a session kept only on error holds its events until it reports one. + // Null where there is nothing to buffer with, and then no session is drawn on error. + private val withheldEvents: WithheldEventWriter? = null, + // FLASHCAT FORK - the init values of `sessionOnError` and `sessionReplayOnError`; the console's + // values win over them. + private val sessionOnError: Boolean = false, + private val sessionReplayOnError: Boolean = false ) : RumScope { // FLASHCAT FORK - the rate the current session's events report as their configured sample @@ -101,6 +111,21 @@ internal class RumSessionScope( internal var sessionId = RumContext.NULL_UUID internal var sessionState: State = State.NOT_TRACKED + // FLASHCAT FORK - whether the current session is kept only because of `sessionOnError`. Unlike + // [State.WITHHELD] it stays true once the session has reported its error, so what is stored can + // be told apart from a plainly sampled session - its detail only starts where the buffer reached. + internal var sampledForError: Boolean = false + private set + + // FLASHCAT FORK - the replay switch the current session was drawn under. Session Replay draws + // the replay itself and decides from this whether a replay it did not keep is kept on error. + private var replayOnError: Boolean = false + + // FLASHCAT FORK - whether the current session is a collected one whose replay may be kept on + // error: its end is reported to the buffer like a withheld session's, so Session Replay hears + // what became of the replay it holds. + private var replayWatched: Boolean = false + private var startReason: StartReason = StartReason.USER_APP_LAUNCH internal var isActive: Boolean = true private val sessionStartNs = AtomicLong(sdkCore.timeProvider.getDeviceElapsedTimeNanos()) @@ -147,6 +172,11 @@ internal class RumSessionScope( enum class State(val asString: String) { NOT_TRACKED("NOT_TRACKED"), TRACKED("TRACKED"), + + // FLASHCAT FORK - collected but withheld until the session reports an error, at which point + // it becomes TRACKED. Nothing of it reaches the intake before then, so to everything outside + // RUM - and to the host application - it reads as not tracked. + WITHHELD("WITHHELD"), EXPIRED("EXPIRED"); companion object { @@ -193,9 +223,10 @@ internal class RumSessionScope( // 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 + now - sessionStartNs.get() < sessionMaxDurationNanos && + !isKeptOnErrorUnderCurrentSettings() ) { - renewSession(event.eventTime, StartReason.EXPLICIT_STOP) + renewSession(event.eventTime, StartReason.EXPLICIT_STOP, writeScope) } } else if (event is RumRawEvent.SetForcedSession && isActive) { // FLASHCAT FORK - the escape hatch for "collect this user NOW": the application knows @@ -204,25 +235,55 @@ internal class RumSessionScope( // 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) + // running session already dropped, so cutting it in two would gain nothing. One kept + // only on error has collected everything, so it is simply released now, without waiting + // for an error or for the jitter. One that was NOT collected restarts now, so a + // collected one takes its place. + if (sampledForError && (sessionState == State.TRACKED || sessionState == State.WITHHELD)) { + val releasedSessionId = sessionId + writeScope { withheldEvents?.forceRelease(releasedSessionId, it) } + if (sessionState == State.WITHHELD) sessionState = State.TRACKED + } else if (sessionState != State.TRACKED) { + renewSession(event.eventTime, StartReason.EXPLICIT_STOP, writeScope) // 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() + stopSession(writeScope) } - updateSession(event, now) + // FLASHCAT FORK - the session reported its error since the last event: it is a collected + // session from here on. Its events still pass through the buffer, which holds them until the + // release it scheduled has gone out, so they leave in order. + if (sessionState == State.WITHHELD && withheldEvents?.isReleased(sessionId) == true) { + sessionState = State.TRACKED + } - val actualWriter = if (sessionState == State.TRACKED) writer else noOpWriter + updateSession(event, now, writeScope) + + val actualWriter = when (sessionState) { + State.TRACKED -> writer + // FLASHCAT FORK - a stopped session that never errored is thrown away at the stop; what + // its views still drain has nowhere to go. Nothing may be held while consent is + // withdrawn either, and what was held under the consent now withdrawn goes too. + State.WITHHELD -> if (!isActive) { + noOpWriter + } else if (datadogContext.trackingConsent == TrackingConsent.NOT_GRANTED) { + val heldSessionId = sessionId + writeScope { withheldEvents?.dropHeld(heldSessionId) } + noOpWriter + } else { + withheldEvents ?: noOpWriter + } + else -> noOpWriter + } val rumContext = activeView?.getRumContext() ?: getRumContext() when (event) { + // FLASHCAT FORK - app launch vitals stay with plainly collected sessions: the launch + // profile they hand to the profiler is uploaded on its own and cannot be withheld. is RumRawEvent.AppStartTTIDEvent -> { if (sessionState == State.TRACKED) { rumSessionScopeStartupManager?.onTTIDEvent( @@ -277,7 +338,8 @@ internal class RumSessionScope( sessionId = sessionId, sessionState = sessionState, sessionStartReason = startReason, - isSessionActive = isActive + isSessionActive = isActive, + sampledForError = sampledForError ) } @@ -289,9 +351,35 @@ internal class RumSessionScope( // region Internal - private fun stopSession() { + private fun stopSession(writeScope: EventWriteScope) { isActive = false sessionEndedMetricDispatcher.onSessionStopped(sessionId) + endWithheldSession(writeScope) + } + + /** + * FLASHCAT FORK - the current session ends: what it withheld is released if it reported an + * error, and thrown away if not. Posted to the write scope so it lands after the session's own + * pending writes. + */ + private fun endWithheldSession(writeScope: EventWriteScope) { + if (!sampledForError && !replayWatched) return + val endedSessionId = sessionId + writeScope { withheldEvents?.endSession(endedSessionId, it) } + } + + /** + * FLASHCAT FORK - whether a reset asked for by the console must leave this session alone: a + * session kept on error, under anything but the emergency stop. It is a collected session, + * and redrawing it under a new rate would throw away what the switch exists to keep - on every + * first fetch and every release of the app, when the console says a zero rate with the switch + * on, which is the ordinary configuration for "only the sessions that error". Only the stop - + * a zero rate with the switch off - ends it, as it ends every collected session. + */ + private fun isKeptOnErrorUnderCurrentSettings(): Boolean { + if (!sampledForError) return false + val sampling = resolveSampling(remoteConfig?.snapshot()) + return !(sampling.rate == 0f && !sampling.onError) } private fun isSessionComplete(): Boolean { @@ -299,7 +387,7 @@ internal class RumSessionScope( } @Suppress("ComplexMethod") - private fun updateSession(event: RumRawEvent, nanoTime: Long) { + private fun updateSession(event: RumRawEvent, nanoTime: Long, writeScope: EventWriteScope) { val isNewSession = sessionId == RumContext.NULL_UUID val timeSinceLastInteractionNs = nanoTime - lastUserInteractionNs.get() @@ -317,6 +405,11 @@ internal class RumSessionScope( sessionEndedMetricDispatcher.endMetric(sessionId, sdkCore.time.serverTimeOffsetMs) } + // FLASHCAT FORK - a stopped session drains alongside the one that replaced it; the events + // it still sees must not have it draw a new session on top of the one actually running. + // It keeps its own state: what it still drains belongs to the session it was. + if (!isActive && (isExpired || isTimedOut)) return + if (isInteraction || isSdkInitInForeground) { if (isNewSession || isExpired || isTimedOut) { val reason = if (isNewSession) { @@ -326,24 +419,27 @@ internal class RumSessionScope( } else { StartReason.MAX_DURATION } - renewSession(event.eventTime, reason) + renewSession(event.eventTime, reason, writeScope) } lastUserInteractionNs.set(nanoTime) } else if (isExpired) { if (backgroundTrackingEnabled && (isBackgroundEvent || isSdkInitInBackground)) { - renewSession(event.eventTime, StartReason.BACKGROUND_LAUNCH) + renewSession(event.eventTime, StartReason.BACKGROUND_LAUNCH, writeScope) lastUserInteractionNs.set(nanoTime) } else { + if (sessionState != State.EXPIRED) endWithheldSession(writeScope) sessionState = State.EXPIRED } } else if (isTimedOut) { - renewSession(event.eventTime, StartReason.MAX_DURATION) + renewSession(event.eventTime, StartReason.MAX_DURATION, writeScope) } updateSessionStateForSessionReplay(sessionState, sessionId) } - private fun renewSession(time: Time, reason: StartReason) { + @Suppress("LongMethod") + private fun renewSession(time: Time, reason: StartReason, writeScope: EventWriteScope) { + endWithheldSession(writeScope) // 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. @@ -351,18 +447,41 @@ internal class RumSessionScope( // 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 sampling = resolveSampling(remoteValues) + val drawRate = sampling.rate val keepSession = forcedSession || random.nextFloat() < drawRate.percent() + // FLASHCAT FORK - only for sessions the rate missed, so a session is never counted by both. + sampledForError = !keepSession && sampling.onError && withheldEvents != null // 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 + // A session kept only because it errored reports 0 for the same reason: it stands for + // itself, and 0 is what the intake reads as "one session, do not scale". + effectiveSampleRate = when { + forcedSession -> FORCED_SAMPLE_RATE + sampledForError -> 0f + else -> drawRate + } childScope?.sampleRate = effectiveSampleRate startReason = reason - sessionState = if (keepSession) State.TRACKED else State.NOT_TRACKED + sessionState = when { + keepSession -> State.TRACKED + sampledForError -> State.WITHHELD + else -> State.NOT_TRACKED + } sessionId = UUID.randomUUID().toString() + replayOnError = withheldEvents != null && (remoteValues?.sessionReplayOnError ?: sessionReplayOnError) + val drawnSessionId = sessionId + replayWatched = !sampledForError && keepSession && replayOnError + if (sampledForError) { + writeScope { withheldEvents?.startWithholding(drawnSessionId, it) } + } else if (replayWatched) { + // The replay may be kept on error only, and the error that releases it is judged + // after the mappers like the events' own. + writeScope { withheldEvents?.watchForError(drawnSessionId, it) } + } // 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 @@ -387,6 +506,8 @@ internal class RumSessionScope( backgroundEventTracking = backgroundTrackingEnabled ) } + // FLASHCAT FORK - a session kept on error reports as discarded: until it errors it does not + // exist at the intake, and an id handed out now could not be looked up. 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 @@ -395,12 +516,33 @@ internal class RumSessionScope( } /** - * 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. + * FLASHCAT FORK - what a draw made now would use: the console's values where it set them, the + * init values where it did not, and the host application's hook with the last word on the + * rate. The hook's documented contract is that 0 never collects, so a rate it sets to 0 turns + * the on-error switch off too - "never" must not quietly become "on error". A rate it leaves + * alone leaves the switch alone. + */ + private fun resolveSampling(remoteValues: RemoteConfigValues?): Sampling { + val rate = remoteValues?.sessionSampleRate ?: sampleRate + val onError = remoteValues?.sessionOnError ?: sessionOnError + val override = askBeforeSampling(rate, remoteValues?.custom) + return if (override == null) { + Sampling(rate, onError) + } else { + Sampling(override, onError && override != 0f) + } + } + + private data class Sampling(val rate: Float, val onError: Boolean) + + /** + * FLASHCAT FORK - asks the host application's hook for the rate to draw with, or null to keep + * the incoming one. 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 + private fun askBeforeSampling(rate: Float, customJson: String?): Float? { + val hook = beforeSampling ?: return null val override = try { val custom = decodeCustomValues(customJson) hook.sampleRate(BeforeSamplingContext(sessionSampleRate = rate, custom = custom)) @@ -414,13 +556,17 @@ internal class RumSessionScope( null } return if (override == null || override.isNaN() || override < 0f || override > MAX_SAMPLE_RATE) { - rate + null } else { override } } private fun updateSessionStateForSessionReplay(state: State, sessionId: String) { + // FLASHCAT FORK - a stopped session drains alongside the one that replaced it, and has + // no business announcing itself as current: Session Replay would take each turn as a new + // session, and throw away what it holds for the one actually running. + if (!isActive) return val keepSession = (state == State.TRACKED) sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.sendEvent( mapOf( @@ -429,7 +575,13 @@ internal class RumSessionScope( // 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 + RUM_SESSION_ID_BUS_MESSAGE_KEY to sessionId, + // FLASHCAT FORK - what Session Replay needs to keep a replay on error: whether the + // session's events are kept on error (its replay then waits with them), the replay + // switch it was drawn under, and whether its replay may go out. + RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to sampledForError, + RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to replayOnError, + RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to (withheldEvents?.isReplayReleased(sessionId) == true) ) ) } @@ -443,6 +595,12 @@ internal class RumSessionScope( 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" + internal const val RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY = "sessionOnError" + internal const val RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY = "sessionReplayOnError" + internal const val RUM_SESSION_RELEASED_BUS_MESSAGE_KEY = "sessionReleased" + internal const val RUM_SESSION_RELEASED_BUS_MESSAGE = "rum_session_released" + internal const val RUM_SESSION_DISCARDED_BUS_MESSAGE = "rum_session_discarded" + internal const val RUM_SESSION_ERRORED_BUS_MESSAGE = "rum_session_errored" private const val MAX_SAMPLE_RATE = 100f 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 a59e3deff6..fb1ca6d67b 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 @@ -136,6 +136,12 @@ internal open class RumViewScope( internal val featureFlags: MutableMap = mutableMapOf() internal var hasReplay = false + // FLASHCAT FORK - a session's replay draw is made once: these stay true once resolved so, so a + // late update of this view, written after another session took over the replay context, + // still describes its own session. + private var sampledForReplay: Boolean? = null + private var sampledForErrorReplay: Boolean? = null + internal var stopped: Boolean = false // region Vitals Fields @@ -252,8 +258,12 @@ internal open class RumViewScope( } return if (isViewComplete()) { - sdkCore.updateFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME) { - it.remove(viewId) + // FLASHCAT FORK - a view of a withheld session may still be released, and claims the + // replay held for it from this entry: Session Replay drops it with the held records. + if (getRumContext().sessionState != RumSessionScope.State.WITHHELD) { + sdkCore.updateFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME) { + it.remove(viewId) + } } null } else { @@ -1206,7 +1216,11 @@ internal open class RumViewScope( val slowFramesRate = if (viewComplete) uiSlownessReport?.slowFramesRate(stoppedNanos) else null insightsCollector.onSlowFrameRate(uiSlownessReport?.slowFramesRate(stoppedNanos)) - if (viewComplete && getRumContext().sessionState != RumSessionScope.State.NOT_TRACKED) { + val sessionState = getRumContext().sessionState + // FLASHCAT FORK - a withheld session is not collected yet, and may never be. + if (viewComplete && sessionState != RumSessionScope.State.NOT_TRACKED && + sessionState != RumSessionScope.State.WITHHELD + ) { viewEndedMetricDispatcher.sendViewEnded( interactionToNextViewMetricResolver.getState(viewId), networkSettledMetricResolver.getState() @@ -1332,7 +1346,12 @@ internal open class RumViewScope( id = rumContext.sessionId, type = sessionType, hasReplay = hasReplay, - isActive = rumContext.isSessionActive + isActive = rumContext.isSessionActive, + // FLASHCAT FORK - tells the intake this session's detail only starts where the + // withheld buffer reached. Absent, rather than false, for every other session. + sampledForError = rumContext.sampledForError.takeIf { it }, + sampledForReplay = resolveSampledForReplay(datadogContext, rumContext), + sampledForErrorReplay = resolveSampledForErrorReplay(datadogContext, rumContext) ), synthetics = syntheticsAttribute, source = ViewEvent.ViewEventSource.tryFromSource( @@ -1612,6 +1631,26 @@ internal open class RumViewScope( ) } + private fun resolveSampledForReplay(datadogContext: DatadogContext, rumContext: RumContext): Boolean? { + if (sampledForReplay != true) { + sampledForReplay = featuresContextResolver.resolveSampledForReplay( + datadogContext, + rumContext.sessionId, + rumContext.sampledForError + ) + } + return sampledForReplay + } + + private fun resolveSampledForErrorReplay(datadogContext: DatadogContext, rumContext: RumContext): Boolean? { + if (sampledForErrorReplay != true) { + sampledForErrorReplay = featuresContextResolver + .resolveSampledForErrorReplay(datadogContext, rumContext.sessionId) + ?.takeIf { it } + } + return sampledForErrorReplay + } + private fun isViewComplete(): Boolean { val pending = pendingActionCount + pendingResourceCount + diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumVitalAppLaunchEventHelper.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumVitalAppLaunchEventHelper.kt index ce67da23a7..8cc7274b5b 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumVitalAppLaunchEventHelper.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumVitalAppLaunchEventHelper.kt @@ -87,7 +87,11 @@ internal class RumVitalAppLaunchEventHelper( session = VitalAppLaunchEvent.DdSession( sessionPrecondition = rumContext.sessionStartReason.toVitalAppLaunchSessionPrecondition() ), - configuration = VitalAppLaunchEvent.Configuration(sessionSampleRate = sampleRate), + // FLASHCAT FORK - a session kept only because it errored stands for itself: 0 is + // what the intake reads as "one session, do not scale", like its other events report. + configuration = VitalAppLaunchEvent.Configuration( + sessionSampleRate = if (rumContext.sampledForError) 0f else sampleRate + ), profiling = VitalAppLaunchEvent.Profiling( status = profilingStatus ) 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 4f31497531..c5be934877 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 @@ -47,6 +47,7 @@ import com.datadog.android.rum.internal.debug.RumDebugListener import com.datadog.android.rum.internal.domain.InfoProvider import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time +import com.datadog.android.rum.internal.domain.WithheldEventWriter import com.datadog.android.rum.internal.domain.accessibility.AccessibilitySnapshotManager import com.datadog.android.rum.internal.domain.asTime import com.datadog.android.rum.internal.domain.battery.BatteryInfo @@ -109,7 +110,12 @@ internal class DatadogRumMonitor( // 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 + private val beforeSampling: BeforeSamplingCallback? = null, + // FLASHCAT FORK - see `RumConfiguration.Builder.setSessionOnError`. The buffer is also + // [writer]; null where there is nothing to buffer with, and then no session is drawn on error. + private val withheldEvents: WithheldEventWriter? = null, + private val sessionOnError: Boolean = false, + private val sessionReplayOnError: Boolean = false ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -135,7 +141,10 @@ internal class DatadogRumMonitor( insightsCollector = insightsCollector, remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, - beforeSampling = beforeSampling + beforeSampling = beforeSampling, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError, + sessionReplayOnError = sessionReplayOnError ) internal val keepAliveRunnable = Runnable { @@ -165,7 +174,10 @@ internal class DatadogRumMonitor( ?.getRumContext() ?.let { val sessionId = it.sessionId + // FLASHCAT FORK - a withheld session does not exist at the intake until it + // reports an error, so it has no id to hand out yet. if (it.sessionState == RumSessionScope.State.NOT_TRACKED || + it.sessionState == RumSessionScope.State.WITHHELD || sessionId == RumContext.NULL_UUID ) { null diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt index 9f01a5ac65..a6da3688e7 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallback.kt @@ -27,7 +27,10 @@ import java.util.concurrent.atomic.AtomicInteger * is stale anyway, so it is left alone rather than given a state machine of its own. */ internal class ProcessForegroundCallback( - private val onForeground: () -> Unit + private val onForeground: () -> Unit, + // FLASHCAT FORK - called whenever a stop leaves no activity started, including the stop of an + // activity started before this was registered. + private val onBackground: () -> Unit = {} ) : Application.ActivityLifecycleCallbacks { private val startedActivities = AtomicInteger(0) @@ -53,6 +56,9 @@ internal class ProcessForegroundCallback( if (startedActivities.get() > 0) { startedActivities.decrementAndGet() } + if (startedActivities.get() == 0) { + onBackground() + } } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index b467a52c7a..1f5271c25a 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt @@ -47,7 +47,9 @@ internal class RemoteConfigController( private val executor: ScheduledExecutorService, private val restartSession: () -> Unit, private val elapsedTimeMs: () -> Long = SystemClock::elapsedRealtime, - private val jitter: () -> Double = { Random.nextDouble() } + private val jitter: () -> Double = { Random.nextDouble() }, + // The init value of the on-error switch, which a cleared knob hands back to. + private val initialSessionOnError: Boolean = false ) { @Volatile @@ -340,7 +342,11 @@ internal class RemoteConfigController( if ((version ?: 0) < (store.appliedVersion() ?: 0)) { return Outcome.STALE_VERSION } - val before = RemoteConfigValues(store.sessionSampleRate()) + val before = RemoteConfigValues( + sessionSampleRate = store.sessionSampleRate(), + sessionOnError = store.sessionOnError(), + sessionReplayOnError = store.sessionReplayOnError() + ) val delivered = if (enabled) { readValues(rum as? JSONObject).copy( version = version, @@ -374,10 +380,18 @@ internal class RemoteConfigController( private fun readValues(rum: JSONObject?): RemoteConfigValues { if (rum == null) return EMPTY_VALUES return RemoteConfigValues( - sessionSampleRate = readRate(rum) + sessionSampleRate = readRate(rum), + sessionOnError = readSwitch(rum, FIELD_SESSION_ON_ERROR), + sessionReplayOnError = readSwitch(rum, FIELD_SESSION_REPLAY_ON_ERROR) ) } + /** + * Like a rate, a switch the response did not send stays absent, and so does one that is not a + * boolean: a value we cannot read is not one to decide a customer's collection with. + */ + private fun readSwitch(rum: JSONObject, field: String): Boolean? = rum.opt(field) as? Boolean + /** * 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 @@ -413,9 +427,15 @@ internal class RemoteConfigController( * published one, the value the app was initialised with where it did not, since clearing a knob * hands the decision back to init. * + * The on-error switch counts the same way where the rate is zero, because there it is what + * decides whether anything is collected at all. Switched on, nobody was in a draw - a session + * drawn at zero was not kept - and now could be, exactly like a rate leaving zero. Switched off, + * the sessions kept only because of it are what an operator is stopping. + * * 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. + * buy an identical forced session. So is a session kept on error when the settings arriving + * are a zero rate with the switch on: that is the switch's ordinary configuration, not a stop. */ private fun appliesToRunningSession( activation: String, @@ -424,8 +444,14 @@ internal class RemoteConfigController( ): Boolean { val previousRate = before.sessionSampleRate ?: initialSessionSampleRate val nextRate = after.sessionSampleRate ?: initialSessionSampleRate - if (previousRate == nextRate) return false - return activation == ACTIVATION_IMMEDIATE || (previousRate == 0f) != (nextRate == 0f) + val switchChanged = + (before.sessionOnError ?: initialSessionOnError) != (after.sessionOnError ?: initialSessionOnError) + // The replay switch only decides a replay draw, which a running session has already made: + // it applies to the next session, whatever the activation says. + if (previousRate == nextRate && !switchChanged) return false + return activation == ACTIVATION_IMMEDIATE || + (previousRate == 0f) != (nextRate == 0f) || + (nextRate == 0f && switchChanged) } // Every one of these goes to telemetry as well as to logcat. A device that quietly stops @@ -491,6 +517,8 @@ internal class RemoteConfigController( private const val FIELD_CUSTOM = "custom" private const val FIELD_RUM = "rum" private const val FIELD_SESSION_SAMPLE_RATE = "sessionSampleRate" + private const val FIELD_SESSION_ON_ERROR = "sessionOnError" + private const val FIELD_SESSION_REPLAY_ON_ERROR = "sessionReplayOnError" private val EMPTY_VALUES = RemoteConfigValues(null) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt index f4b3a5a2f5..47309c1fac 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStore.kt @@ -57,6 +57,10 @@ internal class RemoteConfigStore( fun sessionSampleRate(): Float? = read(sessionKey()) + fun sessionOnError(): Boolean? = readSwitch(sessionOnErrorKey()) + + fun sessionReplayOnError(): Boolean? = readSwitch(sessionReplayOnErrorKey()) + /** * 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 @@ -67,6 +71,8 @@ internal class RemoteConfigStore( val stored = preferences?.all.orEmpty() return RemoteConfigValues( sessionSampleRate = (stored[sessionKey()] as? Float)?.takeUnless { it == ABSENT }, + sessionOnError = stored[sessionOnErrorKey()] as? Boolean, + sessionReplayOnError = stored[sessionReplayOnErrorKey()] as? Boolean, version = (stored[versionKey()] as? Int)?.takeUnless { it == ABSENT_VERSION }, custom = stored[customKey()] as? String, etag = stored[etagKey()] as? String, @@ -126,6 +132,8 @@ internal class RemoteConfigStore( fun store(values: RemoteConfigValues) { val editor = preferences?.edit() ?: return write(editor, sessionKey(), values.sessionSampleRate) + write(editor, sessionOnErrorKey(), values.sessionOnError) + write(editor, sessionReplayOnErrorKey(), values.sessionReplayOnError) // 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. @@ -220,6 +228,8 @@ internal class RemoteConfigStore( return key.removeSuffix(suffix) } + private fun readSwitch(key: String): Boolean? = preferences?.all?.get(key) as? Boolean + private fun read(key: String): Float? { val stored = preferences?.getFloat(key, ABSENT) ?: ABSENT return if (stored == ABSENT) null else stored @@ -233,8 +243,21 @@ internal class RemoteConfigStore( } } + // FLASHCAT FORK - absent like a rate: a switch the console did not send hands it back to init. + private fun write(editor: SharedPreferences.Editor, key: String, enabled: Boolean?) { + if (enabled == null) { + editor.remove(key) + } else { + editor.putBoolean(key, enabled) + } + } + private fun sessionKey() = "$storeKey$SUFFIX_SESSION_SAMPLE_RATE" + private fun sessionOnErrorKey() = "$storeKey$SUFFIX_SESSION_ON_ERROR" + + private fun sessionReplayOnErrorKey() = "$storeKey$SUFFIX_SESSION_REPLAY_ON_ERROR" + private fun versionKey() = "$storeKey$SUFFIX_VERSION" private fun customKey() = "$storeKey$SUFFIX_CUSTOM" @@ -285,6 +308,8 @@ internal class RemoteConfigStore( // 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_SESSION_ON_ERROR = ".sessionOnError" + private const val SUFFIX_SESSION_REPLAY_ON_ERROR = ".sessionReplayOnError" private const val SUFFIX_VERSION = ".version" private const val SUFFIX_CUSTOM = ".custom" private const val SUFFIX_ETAG = ".etag" @@ -294,6 +319,8 @@ internal class RemoteConfigStore( private val FIELD_SUFFIXES = listOf( SUFFIX_SESSION_SAMPLE_RATE, + SUFFIX_SESSION_ON_ERROR, + SUFFIX_SESSION_REPLAY_ON_ERROR, SUFFIX_VERSION, SUFFIX_CUSTOM, SUFFIX_ETAG, @@ -368,5 +395,9 @@ internal data class RemoteConfigValues( /** 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 + val refreshOnForeground: Boolean = false, + /** Whether a session the rate does not keep is kept on error; null leaves the init value. */ + val sessionOnError: Boolean? = null, + /** Whether a replay the replay rate does not keep is kept on error; null leaves the init value. */ + val sessionReplayOnError: Boolean? = null ) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/DatadogLateCrashReporterTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/DatadogLateCrashReporterTest.kt index 46b52983bf..3be5c6ae51 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/DatadogLateCrashReporterTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/DatadogLateCrashReporterTest.kt @@ -235,6 +235,84 @@ internal class DatadogLateCrashReporterTest { } } + @Test + fun `M report the view of a session kept on error however old W handleNdkCrashEvent()`( + @StringForgery crashMessage: String, + @LongForgery(min = 1) fakeTimestamp: Long, + @StringForgery fakeSignalName: String, + @StringForgery fakeStacktrace: String, + @Forgery viewEvent: ViewEvent, + forge: Forge + ) { + // Given - a withheld session uploaded nothing: this view is the only one the intake will get + val fakeViewEvent = viewEvent.copy( + date = fakeCurrentTimeMs - forge.aLong( + min = DatadogLateCrashReporter.VIEW_EVENT_AVAILABILITY_TIME_THRESHOLD + 1, + max = DatadogLateCrashReporter.VIEW_EVENT_AVAILABILITY_TIME_THRESHOLD * 4 + ), + session = viewEvent.session.copy(sampledForError = true) + ) + val fakeViewEventJson = fakeViewEvent.toJson().asJsonObject + whenever(mockRumEventDeserializer.deserialize(fakeViewEventJson)) doReturn fakeViewEvent + val fakeEvent = mapOf( + "timestamp" to fakeTimestamp, + "signalName" to fakeSignalName, + "stacktrace" to fakeStacktrace, + "message" to crashMessage, + "lastViewEvent" to fakeViewEventJson + ) + + // When + testedHandler.handleNdkCrashEvent(fakeEvent, mockRumWriter) + + // Then - the crash and the view, unlike a collected session's view this old + argumentCaptor { + verify(mockRumWriter, times(2)).write(eq(mockEventBatchWriter), capture(), eq(EventType.CRASH)) + assertThat(firstValue).isInstanceOf(ErrorEvent::class.java) + assertThat((secondValue as ViewEvent).session.sampledForError).isTrue + } + } + + @Test + fun `M report the crash of a session kept on error W handleNdkCrashEvent()`( + @StringForgery crashMessage: String, + @LongForgery(min = 1) fakeTimestamp: Long, + @StringForgery fakeSignalName: String, + @StringForgery fakeStacktrace: String, + @Forgery viewEvent: ViewEvent + ) { + // Given - the last view a withheld session wrote locally: rate 0 and the marker + val fakeViewEvent = viewEvent.copy( + date = fakeCurrentTimeMs - 1000, + session = viewEvent.session.copy(sampledForError = true), + dd = viewEvent.dd.copy(configuration = ViewEvent.Configuration(sessionSampleRate = 0f)) + ) + val fakeViewEventJson = fakeViewEvent.toJson().asJsonObject + whenever(mockRumEventDeserializer.deserialize(fakeViewEventJson)) doReturn fakeViewEvent + val fakeEvent = mapOf( + "timestamp" to fakeTimestamp, + "signalName" to fakeSignalName, + "stacktrace" to fakeStacktrace, + "message" to crashMessage, + "lastViewEvent" to fakeViewEventJson + ) + + // When + testedHandler.handleNdkCrashEvent(fakeEvent, mockRumWriter) + + // Then - the crash is reported, standing for itself, and the view keeps the marker + argumentCaptor { + verify(mockRumWriter, times(2)).write(eq(mockEventBatchWriter), capture(), eq(EventType.CRASH)) + val error = firstValue as ErrorEvent + assertThat(error.session.id).isEqualTo(fakeViewEvent.session.id) + assertThat(error.dd.configuration?.sessionSampleRate).isEqualTo(0f) + val view = secondValue as ViewEvent + assertThat(view.session.sampledForError).isTrue + assertThat(view.dd.configuration?.sessionSampleRate).isEqualTo(0f) + assertThat(view.view.crash?.count).isEqualTo((fakeViewEvent.view.crash?.count ?: 0) + 1) + } + } + @Test fun `M send RUM view+error W handleNdkCrashEvent() {source_type set}`( @StringForgery crashMessage: String, diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/FeaturesContextResolverTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/FeaturesContextResolverTest.kt index 3b77b359d3..f7dcca0797 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/FeaturesContextResolverTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/FeaturesContextResolverTest.kt @@ -265,4 +265,53 @@ internal class FeaturesContextResolverTest { // Then assertThat(viewRecordsCount).isEqualTo(0L) } + + // region Replay on error + + private fun contextWith(forge: Forge, sessionReplay: Map?): DatadogContext = + forge.getForgery().copy( + featuresContext = sessionReplay?.let { mapOf(Feature.SESSION_REPLAY_FEATURE_NAME to it) } ?: emptyMap() + ) + + @Test + fun `M tell the replay kept on error W resolveSampledForErrorReplay()`(forge: Forge) { + val context = contextWith(forge, mapOf(FeaturesContextResolver.REPLAY_ON_ERROR_SESSION_KEY to "s1")) + + assertThat(testedFeaturesContextResolver.resolveSampledForErrorReplay(context, "s1")).isTrue + assertThat(testedFeaturesContextResolver.resolveSampledForErrorReplay(context, "s2")).isFalse + assertThat(testedFeaturesContextResolver.resolveSampledForErrorReplay(contextWith(forge, null), "s1")).isNull() + } + + @Test + fun `M count a held replay as sampled only with held events W resolveSampledForReplay()`(forge: Forge) { + val onError = FeaturesContextResolver.REPLAY_ON_ERROR_SESSION_KEY to "s1" + val recording = mapOf(FeaturesContextResolver.REPLAY_ENABLED_KEY to true, onError) + val withheld = recording + (FeaturesContextResolver.REPLAY_WITHHELD_KEY to true) + val stopped = mapOf(FeaturesContextResolver.REPLAY_ENABLED_KEY to false) + + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, recording), "s1", false)) + .isTrue + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, withheld), "s1", false)) + .isFalse + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, withheld), "s1", true)) + .isTrue + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, stopped), "s1", true)) + .isFalse + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, null), "s1", true)) + .isNull() + } + + @Test + fun `M report nothing W resolveSampledForReplay() { no on-error mode for this session }`(forge: Forge) { + // A Session Replay customer who did not opt in: views keep the exact shape they had. + val recording = mapOf(FeaturesContextResolver.REPLAY_ENABLED_KEY to true) + val otherSession = recording + (FeaturesContextResolver.REPLAY_ON_ERROR_SESSION_KEY to "s0") + + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, recording), "s1", false)) + .isNull() + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, otherSession), "s1", false)) + .isNull() + } + + // endregion } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt index ddda27938f..36b47b9f39 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt @@ -14,7 +14,12 @@ import android.content.ContentResolver import android.content.Context import android.content.res.Resources 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 import com.datadog.android.api.feature.FeatureContextUpdateReceiver +import com.datadog.android.api.feature.FeatureScope +import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.NoOpDataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.feature.event.JvmCrash @@ -89,6 +94,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer @@ -840,6 +846,64 @@ internal class RumFeatureTest { assertThat(testedFeature.dataWriter).isInstanceOf(NoOpDataWriter::class.java) } + @Test + fun `M settle the withheld session and wait for storage W onStop()`() { + // Given - the work runs on another thread, as the real write scope's does + val mockRumScope = mock() + val mockBatchWriter = mock() + val storage = java.util.concurrent.Executors.newSingleThreadExecutor() + val ranOn = java.util.concurrent.atomic.AtomicReference() + whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumScope + whenever(mockRumScope.withWriteContext(any(), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) + val writeScope = object : EventWriteScope { + override fun invoke(block: (EventBatchWriter) -> Unit) { + storage.execute { + ranOn.set(Thread.currentThread()) + block(mockBatchWriter) + } + } + } + callback.invoke(mock(), writeScope) + } + testedFeature.onInitialize(appContext.mockInstance) + // the core takes the feature out of its registry before stopping it + whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn null + + // When + testedFeature.onStop() + + // Then - the stop returned only once the queued work had run + assertThat(ranOn.get()).isNotNull.isNotSameAs(Thread.currentThread()) + assertThat(testedFeature.withheldEvents).isNull() + storage.shutdown() + } + + @Test + fun `M not wait forever W onStop() { the write scope never runs }`() { + // Given - a stop asked for from the RUM thread, whose context thread waits on it + val mockRumScope = mock() + whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumScope + whenever(mockRumScope.withWriteContext(any(), any())) doAnswer { Unit } + testedFeature.onInitialize(appContext.mockInstance) + + // When + val started = System.nanoTime() + testedFeature.onStop() + + // Then + assertThat(java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)) + .isLessThan(10_000) + verify(mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.MAINTAINER), + any(), + anyOrNull(), + any(), + anyOrNull() + ) + } + @Test fun `M remove associated monitor W onStop()`() { // Given diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriterTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriterTest.kt new file mode 100644 index 0000000000..257f1fe6de --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriterTest.kt @@ -0,0 +1,1093 @@ +/* + * 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.domain + +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.storage.EventBatchWriter +import com.datadog.android.api.storage.EventType +import com.datadog.android.api.storage.RawBatchEvent +import com.datadog.android.core.InternalSdkCore +import com.datadog.android.core.persistence.Serializer +import com.datadog.android.rum.internal.domain.event.RumEventMeta +import com.datadog.android.rum.model.ActionEvent +import com.datadog.android.rum.model.ErrorEvent +import com.datadog.android.rum.model.LongTaskEvent +import com.datadog.android.rum.model.ResourceEvent +import com.datadog.android.rum.model.ViewEvent +import com.datadog.android.rum.utils.forge.Configurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +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.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.IdentityHashMap +import java.util.UUID +import java.util.concurrent.TimeUnit + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class WithheldEventWriterTest { + + private lateinit var testedWriter: WithheldEventWriter + + @Mock + lateinit var mockSdkCore: InternalSdkCore + + @Mock + lateinit var mockInternalLogger: InternalLogger + + private lateinit var forge: Forge + + /** What each event serializes to; an event mapped to null is one a mapper dropped. */ + private val payloads = IdentityHashMap() + + private val written = mutableListOf() + private val batchWriter = object : EventBatchWriter { + override fun currentMetadata(): ByteArray? = null + override fun write(event: RawBatchEvent, batchMetadata: ByteArray?, eventType: EventType): Boolean { + written.add(String(event.data, Charsets.UTF_8)) + return true + } + } + + private var nowNs = TimeUnit.HOURS.toNanos(1) + private val scheduled = mutableListOf Unit>>() + + private val sessionId = UUID.randomUUID().toString() + private var nextDate = 1_000L + + @BeforeEach + fun `set up`(forge: Forge) { + this.forge = forge + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + val serializer = object : Serializer { + override fun serialize(model: Any): String? { + if (model is ErrorEvent) claimedErrors.add(model) + if (model is ViewEvent && model.session.hasReplay == true) claimedViews.add(model) + // A copy claiming the replay serializes like the event it was copied from. + val key = payloads.keys.firstOrNull { it === model } + ?: (model as? ErrorEvent)?.let { error -> + payloads.keys.firstOrNull { + it == error.copy(session = error.session.copy(hasReplay = null)) + } + } + ?: (model as? ViewEvent)?.let { view -> + payloads.keys.firstOrNull { + it is ViewEvent && + it == view.copy(session = view.session.copy(hasReplay = it.session.hasReplay)) + } + } + return if (key != null) payloads[key] else "untracked" + } + } + val metaSerializer = object : Serializer { + override fun serialize(model: RumEventMeta): String = "meta" + } + testedWriter = WithheldEventWriter( + delegate = RumDataWriter(serializer, metaSerializer, mockSdkCore), + internalLogger = mockInternalLogger, + elapsedTimeNs = { nowNs }, + scheduleRelease = { delayMs, release -> scheduled.add(delayMs to release) }, + replayRecordsCount = { viewId -> replayRecords[viewId] ?: 0L }, + releaseReplay = { releasedReplays.add(it) }, + discardReplay = { discardedReplays.add(it) }, + expectReplayRelease = { expectedReplays.add(it) } + ) + } + + private val expectedReplays = mutableListOf() + + @Test + fun `M tell Session Replay to expect the release W write() {first error}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e2", "v1"), EventType.DEFAULT) + + // Then - told once, ahead of the release itself + assertThat(expectedReplays).containsExactly(sessionId) + assertThat(releasedReplays).isEmpty() + } + + private val replayRecords = mutableMapOf() + private val claimedErrors = mutableListOf() + private val claimedViews = mutableListOf() + private val releasedReplays = mutableListOf() + private val discardedReplays = mutableListOf() + + // region Withholding + + @Test + fun `M write straight through W write() {session not withheld}`() { + // Given + val view = view("v1", session = UUID.randomUUID().toString()) + + // When + val result = testedWriter.write(batchWriter, view, EventType.DEFAULT) + + // Then + assertThat(result).isTrue + assertThat(written).containsExactly("v1") + } + + @Test + fun `M write nothing W write() {withheld session, no error}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + + // When + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, resource("r1", "v1", 200), EventType.DEFAULT) + + // Then + assertThat(written).isEmpty() + assertThat(scheduled).isEmpty() + assertThat(testedWriter.isReleased(sessionId)).isFalse + } + + @Test + fun `M discard and drop stragglers W endSession() {no error}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + val straggler = resource("late", "v1", 200) + val result = testedWriter.write(batchWriter, straggler, EventType.DEFAULT) + + // Then + assertThat(result).isTrue + assertThat(written).isEmpty() + } + + @Test + fun `M tell Session Replay the session is discarded W endSession() {no error}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + + // Then + assertThat(discardedReplays).containsExactly(sessionId) + assertThat(releasedReplays).isEmpty() + } + + @Test + fun `M forget the view written locally W endSession() {no error}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + + // Then + verify(mockSdkCore).deleteLastViewEvent() + } + + @Test + fun `M keep the view written locally W endSession() {session had errored}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + + // Then + verify(mockSdkCore, never()).deleteLastViewEvent() + } + + @Test + fun `M throw the session away and forget its view W stop() {no error}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.stop(batchWriter) + + // Then + assertThat(written).isEmpty() + verify(mockSdkCore).deleteLastViewEvent() + assertThat(discardedReplays).containsExactly(sessionId) + } + + @Test + fun `M release at once W stop() {release waiting for the jitter}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.stop(batchWriter) + + // Then + assertThat(written).containsExactly("v1", "e1") + verify(mockSdkCore, never()).deleteLastViewEvent() + } + + @Test + fun `M tell Session Replay the session is released W endSession() {session had errored}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + + // Then + assertThat(releasedReplays).containsExactly(sessionId) + assertThat(discardedReplays).isEmpty() + } + + @Test + fun `M write stragglers W endSession() {session had errored}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + testedWriter.write(batchWriter, resource("late", "v1", 200), EventType.DEFAULT) + + // Then + assertThat(written).containsExactly("v1", "e1", "late") + } + + @Test + fun `M remember only the last sixty-four discarded sessions W endSession()`() { + // The list covers the writes still in flight behind a session's end; a stopped session's + // scope feeds nothing more into this writer, see RumSessionScope. + // Given + val sessions = List(65) { UUID.randomUUID().toString() } + sessions.forEach { + testedWriter.startWithholding(it, batchWriter) + testedWriter.endSession(it, batchWriter) + } + + // When + testedWriter.write(batchWriter, view("first", session = sessions[0]), EventType.DEFAULT) + testedWriter.write(batchWriter, view("second", session = sessions[1]), EventType.DEFAULT) + + // Then + assertThat(written).containsExactly("first") + } + + @Test + fun `M keep the session withholding W dropHeld()`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + + // When + testedWriter.dropHeld(sessionId) + testedWriter.write(batchWriter, view("v2"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v2"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("v2", "e1") + } + + @Test + fun `M write the held view locally W write() {view}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + + // When + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // Then + verify(mockSdkCore).writeLastViewEvent("v1".toByteArray()) + assertThat(written).isEmpty() + } + + // endregion + + // region Replay + + @Test + fun `M release the replay only once the events are W write() {error, then jitter}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + val beforeJitter = releasedReplays.toList() + scheduled.single().second(batchWriter) + + // Then + assertThat(beforeJitter).isEmpty() + assertThat(releasedReplays).containsExactly(sessionId) + assertThat(testedWriter.isReplayReleased(sessionId)).isTrue + } + + @Test + fun `M claim the replay of released events W release() {view has replay records}`() { + // Given + replayRecords["v1"] = 3L + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1", payload = jsonPayload("v1")), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1", payload = jsonPayload("a1")), EventType.DEFAULT) + testedWriter.write(batchWriter, view("v2", payload = jsonPayload("v2")), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a2", "v2", payload = jsonPayload("a2")), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e1", "v1", payload = jsonPayload("e1")), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + val hasReplay = written.associate { + val json = com.google.gson.JsonParser.parseString(it).asJsonObject + json.get("tag").asString to json.getAsJsonObject("session").get("has_replay")?.asBoolean + } + assertThat(hasReplay).containsEntry("v1", true).containsEntry("e1", true).containsEntry("a1", true) + assertThat(hasReplay).containsEntry("v2", null).containsEntry("a2", null) + } + + @Test + fun `M claim the replay for the releasing error W write() {watched session, view has records}`() { + // Given + replayRecords["v1"] = 2L + testedWriter.watchForError(sessionId, batchWriter) + val releasing = error("e1", "v1") + + // When + testedWriter.write(batchWriter, releasing, EventType.DEFAULT) + + // Then + assertThat(claimedErrors.single().session.hasReplay).isTrue + assertThat(releasedReplays).containsExactly(sessionId) + } + + private fun jsonPayload(tag: String) = "{\"tag\":\"$tag\",\"session\":{\"id\":\"$sessionId\"}}" + + // endregion + + // region Background + + @Test + fun `M release at once W flushScheduledRelease() {release waiting for the jitter}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.flushScheduledRelease(batchWriter) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("v1", "e1") + } + + @Test + fun `M keep holding W flushScheduledRelease() {no error yet}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.flushScheduledRelease(batchWriter) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then - still held after the background, so the error that follows has its history + assertThat(written).containsExactly("v1", "e1") + } + + // endregion + + // region Details without a held view + + @Test + fun `M release the error W release() {no view was ever held}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, action("a1", "unknown-view"), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e1", "unknown-view"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("e1", "a1") + } + + @Test + fun `M release a detail whose view was evicted W release()`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, action("a0", "evicted"), EventType.DEFAULT) + testedWriter.write(batchWriter, view("evicted", date = 0), EventType.DEFAULT) + testedWriter.write(batchWriter, view("current", date = Long.MAX_VALUE / 2), EventType.DEFAULT) + repeat(WithheldEventWriter.VIEWS_LIMIT) { + testedWriter.write(batchWriter, action("x$it", "old$it"), EventType.DEFAULT) + testedWriter.write(batchWriter, view("old$it", date = it + 1L), EventType.DEFAULT) + } + + // When + testedWriter.write(batchWriter, error("e1", "evicted"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).doesNotContain("evicted") + assertThat(written).contains("e1", "a0") + } + + // endregion + + // region Replay-only sessions + + @Test + fun `M write through and mark the error W write() {watched session}`() { + // Given + testedWriter.watchForError(sessionId, batchWriter) + + // When + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + val before = testedWriter.isReplayReleased(sessionId) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // Then + assertThat(before).isFalse + assertThat(testedWriter.isReplayReleased(sessionId)).isTrue + assertThat(releasedReplays).containsExactly(sessionId) + assertThat(written).containsExactly("v1", "e1") + assertThat(scheduled).isEmpty() + } + + @Test + fun `M keep the withheld session released W write() {late error of a watched session}`() { + // Given - a stopped watched session still drains while the withheld one runs and errors + val watched = UUID.randomUUID().toString() + testedWriter.watchForError(watched, batchWriter) + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("late", "v0", session = watched), EventType.DEFAULT) + testedWriter.endSession(sessionId, batchWriter) + + // Then + assertThat(testedWriter.isReleased(sessionId)).isTrue + assertThat(written).containsExactly("late", "v1", "e1") + assertThat(discardedReplays).doesNotContain(sessionId) + } + + @Test + fun `M tell Session Replay the session is discarded W endSession() {watched session, no error}`() { + // Given + testedWriter.watchForError(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + + // Then + assertThat(discardedReplays).containsExactly(sessionId) + assertThat(releasedReplays).isEmpty() + } + + @Test + fun `M not tell Session Replay twice W endSession() {watched session that errored}`() { + // Given + testedWriter.watchForError(sessionId, batchWriter) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + + // Then + assertThat(releasedReplays).containsExactly(sessionId) + assertThat(discardedReplays).isEmpty() + } + + @Test + fun `M end a watched session left behind W startWithholding()`() { + // Given + val watched = UUID.randomUUID().toString() + testedWriter.watchForError(watched, batchWriter) + + // When + testedWriter.startWithholding(sessionId, batchWriter) + + // Then + assertThat(discardedReplays).containsExactly(watched) + } + + @Test + fun `M hold nothing more W dropHeldForConsent() {released session, events while consent is withdrawn}`() { + // Given - the session earned its release, then consent was withdrawn + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + testedWriter.dropHeldForConsent() + + // When - events of the released session arrive, then consent is granted back + val result = testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + scheduled.forEach { it.second(batchWriter) } + + // Then - they went to the batch at once, where consent decides, rather than being held + assertThat(result).isTrue + assertThat(written).containsExactly("a1") + assertThat(scheduled).hasSize(1) + } + + @Test + fun `M drop everything held W dropHeldForConsent() {release waiting for the jitter}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.dropHeldForConsent() + scheduled.first().second(batchWriter) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + + // Then - the held minute is gone, the session goes on as a released one + assertThat(scheduled).hasSize(1) + assertThat(written).containsExactly("a1") + } + + @Test + fun `M claim the replay for a later view W write() {session whose replay was released}`() { + // Given - the final view of a stopped session is assembled before the replay records land + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + replayRecords["v1"] = 3L + testedWriter.endSession(sessionId, batchWriter) + + // When + val later = view("v1") + testedWriter.write(batchWriter, later, EventType.DEFAULT) + + // Then + assertThat(claimedViews.map { it.view.id }).contains("v1") + assertThat(claimedViews.last().session.hasReplay).isTrue + } + + @Test + fun `M not mark W write() {watched session, error dropped by a mapper}`() { + // Given + testedWriter.watchForError(sessionId, batchWriter) + val dropped = error("e1", "v1") + payloads[dropped] = null + + // When + testedWriter.write(batchWriter, dropped, EventType.DEFAULT) + + // Then + assertThat(testedWriter.isReplayReleased(sessionId)).isFalse + assertThat(releasedReplays).isEmpty() + } + + // endregion + + // region Release + + @Test + fun `M release in order after the jitter W write() {error}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1", date = 10), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, view("v2", date = 20), EventType.DEFAULT) + testedWriter.write(batchWriter, resource("r1", "v2", 200), EventType.DEFAULT) + // a late update of the first view, which must not become current nor go first + testedWriter.write(batchWriter, view("v1", date = 10, payload = "v1-late"), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e1", "v2"), EventType.DEFAULT) + + // Then + assertThat(written).isEmpty() + assertThat(testedWriter.isReleased(sessionId)).isTrue + assertThat(scheduled).hasSize(1) + assertThat(scheduled.single().first).isEqualTo(WithheldEventWriter.computeReleaseDelayMs(sessionId)) + + // When + testedWriter.write(batchWriter, action("a2", "v2"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + testedWriter.write(batchWriter, action("a3", "v2"), EventType.DEFAULT) + + // Then + assertThat(written).containsExactly("v1-late", "v2", "e1", "a1", "r1", "a2", "a3") + verify(mockInternalLogger).log( + eq(InternalLogger.Level.INFO), + eq(InternalLogger.Target.TELEMETRY), + any(), + anyOrNull(), + eq(false), + eq( + mapOf( + "buffer.views_count" to 2, + "buffer.events_count" to 4, + "buffer.dropped_count" to 0, + "buffer.bytes" to 8L + ) + ) + ) + } + + @Test + fun `M release nothing W write() {error dropped by a mapper}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + val dropped = error("e1", "v1") + payloads[dropped] = null + + // When + val result = testedWriter.write(batchWriter, dropped, EventType.DEFAULT) + + // Then + assertThat(result).isFalse + assertThat(scheduled).isEmpty() + assertThat(testedWriter.isReleased(sessionId)).isFalse + assertThat(written).isEmpty() + } + + @Test + fun `M release nothing W write() {error of another session}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e1", "v1", session = UUID.randomUUID().toString()), EventType.DEFAULT) + + // Then + assertThat(scheduled).isEmpty() + assertThat(written).containsExactly("e1") + } + + @Test + fun `M release at once without jitter W write() {crash}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("crash", "v1", isCrash = true), EventType.CRASH) + + // Then + assertThat(scheduled).isEmpty() + assertThat(written).containsExactly("v1", "crash", "a1") + } + + @Test + fun `M release at once W write() {crash while a release waits for the jitter}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("crash", "v1", isCrash = true), EventType.CRASH) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("v1", "e1", "crash") + } + + @Test + fun `M release at once W endSession() {release waiting for the jitter}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + testedWriter.endSession(sessionId, batchWriter) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("v1", "e1") + } + + @Test + fun `M release at once W forceRelease()`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + + // When + testedWriter.forceRelease(sessionId, batchWriter) + testedWriter.write(batchWriter, action("a2", "v1"), EventType.DEFAULT) + + // Then + assertThat(scheduled).isEmpty() + assertThat(testedWriter.isReleased(sessionId)).isTrue + assertThat(written).containsExactly("v1", "a1", "a2") + } + + @Test + fun `M release the previous session W startWithholding() {previous one errored}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + val nextSessionId = UUID.randomUUID().toString() + testedWriter.startWithholding(nextSessionId, batchWriter) + testedWriter.write(batchWriter, view("v2", session = nextSessionId), EventType.DEFAULT) + + // Then + assertThat(written).containsExactly("v1", "e1") + } + + @Test + fun `M never evict the crash W write() {crash and an earlier error over the budget together}`() { + // Given - each fits the budget on its own, both do not + val chunk = WithheldEventWriter.BYTES_LIMIT * 2 / 3 + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1", payload = "1".repeat(chunk)), EventType.DEFAULT) + + // When + testedWriter.write( + batchWriter, + error("c1", "v1", isCrash = true, payload = "C".repeat(chunk)), + EventType.CRASH + ) + + // Then - the earlier error made room for the crash, not the other way round + assertThat(written.map { it.first() }).containsExactly('v', 'C') + } + + @Test + fun `M release the history at once W write() {oversized crash}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + val hugeCrash = error("c1", "v1", isCrash = true, payload = "C".repeat(WithheldEventWriter.BYTES_LIMIT + 1)) + + // When + testedWriter.write(batchWriter, hugeCrash, EventType.CRASH) + + // Then + assertThat(scheduled).isEmpty() + assertThat(written.drop(1)).containsExactly("v1", "a1") + } + + @Test + fun `M send an oversized error on its own W write() {error over the budget}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + val hugeError = error("e1", "v1", payload = "E".repeat(WithheldEventWriter.BYTES_LIMIT + 1)) + + // When + testedWriter.write(batchWriter, hugeError, EventType.DEFAULT) + + // Then + assertThat(written).hasSize(1) + assertThat(written.single()).startsWith("EEE") + assertThat(scheduled).hasSize(1) + + // When + scheduled.single().second(batchWriter) + + // Then + assertThat(written.drop(1)).containsExactly("v1", "a1") + } + + @Test + fun `M drop an oversized event and keep the history W write() {non-error over the budget}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + + // When + testedWriter.write( + batchWriter, + action("huge", "v1", payload = "A".repeat(WithheldEventWriter.BYTES_LIMIT + 1)), + EventType.DEFAULT + ) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("v1", "e1", "a1") + } + + // endregion + + // region Window and budget + + @Test + fun `M keep only the last minute W release()`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("old", "v1"), EventType.DEFAULT) + nowNs += TimeUnit.SECONDS.toNanos(30) + testedWriter.write(batchWriter, action("recent", "v1"), EventType.DEFAULT) + nowNs += TimeUnit.SECONDS.toNanos(31) + + // When + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("v1", "e1", "recent") + } + + @Test + fun `M freeze the window when the release is scheduled W release() {timer late}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + nowNs += TimeUnit.SECONDS.toNanos(50) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + nowNs += TimeUnit.MINUTES.toNanos(2) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("v1", "e1", "a1") + } + + @Test + fun `M drop views left with nothing in the window W release()`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1", date = 1), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) + nowNs += TimeUnit.SECONDS.toNanos(61) + testedWriter.write(batchWriter, view("v2", date = 2), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e1", "v2"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).containsExactly("v2", "e1") + } + + @Test + fun `M evict successful requests and long tasks first W write() {over the event count}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, resource("ok", "v1", 200), EventType.DEFAULT) + testedWriter.write(batchWriter, longTask("lt", "v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, resource("failed", "v1", 500), EventType.DEFAULT) + repeat(WithheldEventWriter.EVENTS_LIMIT - 3) { + testedWriter.write(batchWriter, action("a$it", "v1"), EventType.DEFAULT) + } + + // When + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, action("last", "v1"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written).doesNotContain("ok", "lt") + assertThat(written).contains("failed", "last") + assertThat(written.take(2)).containsExactly("v1", "e1") + assertThat(written).hasSize(1 + WithheldEventWriter.EVENTS_LIMIT) + } + + @Test + fun `M evict by bytes keeping errors W write() {over the byte budget}`() { + // Given + val chunk = WithheldEventWriter.BYTES_LIMIT / 4 + 1 + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, resource("ok", "v1", 204, payload = "R".repeat(chunk)), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1", payload = "1".repeat(chunk)), EventType.DEFAULT) + testedWriter.write(batchWriter, action("act", "v1", payload = "A".repeat(chunk)), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e2", "v1", payload = "2".repeat(chunk)), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written.map { it.first() }).containsExactly('v', '1', '2', 'A') + } + + @Test + fun `M evict the newest error first W write() {only errors left}`() { + // Given + val chunk = WithheldEventWriter.BYTES_LIMIT / 3 + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1", payload = "1".repeat(chunk)), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e2", "v1", payload = "2".repeat(chunk)), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e3", "v1", payload = "3".repeat(chunk)), EventType.DEFAULT) + + // When + testedWriter.write(batchWriter, error("e4", "v1", payload = "4".repeat(chunk)), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + assertThat(written.map { it.first() }).containsExactly('v', '1', '2', '3') + } + + @Test + fun `M never evict the current view W write() {over the view count}`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("current", date = Long.MAX_VALUE / 2), EventType.DEFAULT) + testedWriter.write(batchWriter, action("a0", "current"), EventType.DEFAULT) + repeat(WithheldEventWriter.VIEWS_LIMIT + 5) { + // ended views updated late, each with something in the window + testedWriter.write(batchWriter, action("a${it + 1}", "old$it"), EventType.DEFAULT) + testedWriter.write(batchWriter, view("old$it", date = it.toLong()), EventType.DEFAULT) + } + + // When + testedWriter.write(batchWriter, error("e1", "current"), EventType.DEFAULT) + scheduled.single().second(batchWriter) + + // Then + val views = written.filter { it == "current" || it.startsWith("old") } + assertThat(views).hasSize(WithheldEventWriter.VIEWS_LIMIT) + assertThat(views.last()).isEqualTo("current") + assertThat(written).contains("e1", "a0") + // the evicted view is gone; its detail still goes, views only order the release + assertThat(written).doesNotContain("old0") + assertThat(written).contains("a1") + } + + // endregion + + @Test + fun `M spread releases over the whole window W computeReleaseDelayMs()`() { + // When + val delays = List(2_000) { WithheldEventWriter.computeReleaseDelayMs(UUID.randomUUID().toString()) } + + // Then + assertThat(delays).allMatch { it in 0 until WithheldEventWriter.RELEASE_MAX_DELAY_MS } + val buckets = delays.groupBy { it / 300 } + assertThat(buckets.keys).containsExactlyInAnyOrder(0L, 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L) + assertThat(buckets.values.map { it.size }).allMatch { it in 100..300 } + assertThat(WithheldEventWriter.computeReleaseDelayMs(sessionId)) + .isEqualTo(WithheldEventWriter.computeReleaseDelayMs(sessionId)) + } + + @Test + fun `M not write the last view W release()`() { + // Given + testedWriter.startWithholding(sessionId, batchWriter) + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // When + scheduled.single().second(batchWriter) + + // Then + val captor = argumentCaptor() + verify(mockSdkCore).writeLastViewEvent(captor.capture()) + assertThat(captor.allValues).hasSize(1) + verify(mockSdkCore, never()).writeLastViewEvent(eq("e1".toByteArray())) + } + + // region Helpers + + private fun view( + id: String, + session: String = sessionId, + date: Long = nextDate++, + payload: String = id + ): ViewEvent { + val base = forge.getForgery(ViewEvent::class.java) + return base.copy( + date = date, + session = base.session.copy(id = session), + view = base.view.copy(id = id) + ).also { payloads[it] = payload } + } + + private fun action(name: String, viewId: String, payload: String = name): ActionEvent { + val base = forge.getForgery(ActionEvent::class.java) + return base.copy( + session = base.session.copy(id = sessionId), + view = base.view.copy(id = viewId) + ).also { payloads[it] = payload } + } + + private fun resource(name: String, viewId: String, status: Long, payload: String = name): ResourceEvent { + val base = forge.getForgery(ResourceEvent::class.java) + return base.copy( + session = base.session.copy(id = sessionId), + view = base.view.copy(id = viewId), + resource = base.resource.copy(statusCode = status) + ).also { payloads[it] = payload } + } + + private fun longTask(name: String, viewId: String): LongTaskEvent { + val base = forge.getForgery(LongTaskEvent::class.java) + return base.copy( + session = base.session.copy(id = sessionId), + view = base.view.copy(id = viewId) + ).also { payloads[it] = name } + } + + private fun error( + name: String, + viewId: String, + session: String = sessionId, + isCrash: Boolean = false, + payload: String = name + ): ErrorEvent { + val base = forge.getForgery(ErrorEvent::class.java) + return base.copy( + session = base.session.copy(id = session), + view = base.view.copy(id = viewId), + error = base.error.copy(isCrash = isCrash) + ).also { payloads[it] = payload } + } + + // endregion +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumViewEventFilterTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumViewEventFilterTest.kt index 7209909253..b433d731a7 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumViewEventFilterTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumViewEventFilterTest.kt @@ -149,7 +149,8 @@ internal class RumViewEventFilterTest { it.toBytes().contentEquals(viewEvent.metadata) } } - val expectedResult = batch.filter { !expectedViewEventsToDrop.contains(it) } + val expectedResult = + placeAtFirstOccurrence(batch, batch.filter { !expectedViewEventsToDrop.contains(it) }, viewEventMetas) // When val result = testedFilter.filterOutRedundantViewEvents(batch) @@ -191,7 +192,8 @@ internal class RumViewEventFilterTest { it.toBytes().contentEquals(viewEvent.metadata) } } - val expectedResult = batch.filter { !expectedViewEventsToDrop.contains(it) } + val expectedResult = + placeAtFirstOccurrence(batch, batch.filter { !expectedViewEventsToDrop.contains(it) }, viewEventMetas) // When val result = testedFilter.filterOutRedundantViewEvents(batch) @@ -310,4 +312,55 @@ internal class RumViewEventFilterTest { private fun RumEventMeta.View.toBytes() = toJson().toString().toByteArray() // endregion + + @Test + fun `M keep views ahead of the events they contain W filterOutRedundantViewEvents() { released, then updated }`() { + // Given - a release writes its views first, oldest first; their live updates follow + val first = RumEventMeta.View(viewId = "first", documentVersion = 3, hasAccessibility = false) + val second = RumEventMeta.View(viewId = "second", documentVersion = 2, hasAccessibility = false) + val firstUpdate = first.copy(documentVersion = 5) + val secondUpdate = second.copy(documentVersion = 4) + listOf(first, second, firstUpdate, secondUpdate).forEach { + whenever(mockEventMetaDeserializer.deserialize(it.toBytes())) doReturn it + } + fun event(name: String, meta: RumEventMeta.View? = null) = + RawBatchEvent(data = name.toByteArray(), metadata = meta?.toBytes() ?: ByteArray(0)) + val batch = listOf( + event("first", first), + event("second", second), + event("error"), + event("action"), + event("second+", secondUpdate), + event("first+", firstUpdate) + ) + + // When + val result = testedFilter.filterOutRedundantViewEvents(batch) + + // Then + assertThat(result.map { String(it.data) }).containsExactly("first+", "second+", "error", "action") + } + + /** + * The kept events, each kept view moved to where its view first occurs in the batch, and + * everything else in its own order. + */ + private fun placeAtFirstOccurrence( + batch: List, + kept: List, + metas: List + ): List { + fun metaOf(event: RawBatchEvent) = metas.firstOrNull { it.toBytes().contentEquals(event.metadata) } + val keptByView = kept.filter { metaOf(it)?.hasAccessibility == false }.associateBy { metaOf(it)!!.viewId } + val placed = mutableSetOf() + return batch.mapNotNull { event -> + val meta = metaOf(event) + when { + meta == null -> event + meta.hasAccessibility == true -> event.takeIf { it in kept } + placed.add(meta.viewId) -> keptByView[meta.viewId] + else -> null + } + } + } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index 138c95d08a..cfc3a7fdec 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,7 @@ 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.privacy.TrackingConsent import com.datadog.android.rum.BeforeSamplingCallback import com.datadog.android.rum.BeforeSamplingContext import com.datadog.android.rum.RumSessionListener @@ -27,6 +28,7 @@ import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.domain.InfoProvider import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time +import com.datadog.android.rum.internal.domain.WithheldEventWriter import com.datadog.android.rum.internal.domain.accessibility.AccessibilitySnapshotManager import com.datadog.android.rum.internal.domain.battery.BatteryInfo import com.datadog.android.rum.internal.domain.display.DisplayInfo @@ -199,8 +201,11 @@ internal class RumSessionScopeTest { private lateinit var stubTimeProvider: StubTimeProvider + private lateinit var forge: Forge + @BeforeEach fun `set up`(forge: Forge) { + this.forge = forge stubTimeProvider = StubTimeProvider(elapsedTimeNs = TEST_INACTIVITY_NS + 1) fakeInitialViewEvent = forge.startViewEvent() @@ -498,7 +503,8 @@ internal class RumSessionScopeTest { when (context.sessionState) { RumSessionScope.State.NOT_TRACKED -> untracked++ RumSessionScope.State.TRACKED -> tracked++ - RumSessionScope.State.EXPIRED -> other++ + RumSessionScope.State.EXPIRED, + RumSessionScope.State.WITHHELD -> other++ } } @@ -1123,7 +1129,10 @@ internal class RumSessionScopeTest { 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 + testedScope.getRumContext().sessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -1295,7 +1304,10 @@ internal class RumSessionScopeTest { 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 + testedScope.getRumContext().sessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -1542,7 +1554,10 @@ internal class RumSessionScopeTest { // 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 + testedScope.getRumContext().sessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.secondValue).isEqualTo( @@ -1554,7 +1569,10 @@ internal class RumSessionScopeTest { // 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 + testedScope.getRumContext().sessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -1593,7 +1611,10 @@ internal class RumSessionScopeTest { // 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 + testedScope.getRumContext().sessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.secondValue).isEqualTo( @@ -1605,7 +1626,10 @@ internal class RumSessionScopeTest { // 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 + testedScope.getRumContext().sessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -1637,7 +1661,10 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.secondValue).isEqualTo( @@ -1648,7 +1675,10 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -1681,7 +1711,10 @@ internal class RumSessionScopeTest { // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to - firstSessionId + firstSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.secondValue).isEqualTo( @@ -1693,7 +1726,10 @@ internal class RumSessionScopeTest { // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to - secondSessionId + secondSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -1727,7 +1763,10 @@ internal class RumSessionScopeTest { // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to - firstSessionId + firstSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.secondValue).isEqualTo( @@ -1739,7 +1778,10 @@ internal class RumSessionScopeTest { // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to - secondSessionId + secondSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.thirdValue).isEqualTo( @@ -1751,7 +1793,10 @@ internal class RumSessionScopeTest { // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to - secondSessionId + secondSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -1784,7 +1829,10 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.secondValue).isEqualTo( @@ -1795,7 +1843,13 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId, + + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) @@ -1829,7 +1883,10 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.secondValue).isEqualTo( @@ -1840,7 +1897,10 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -1874,7 +1934,10 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to firstSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.secondValue).isEqualTo( @@ -1885,7 +1948,10 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) assertThat(argumentCaptor.thirdValue).isEqualTo( @@ -1896,7 +1962,10 @@ internal class RumSessionScopeTest { // No remote sampling configured here, so Session Replay is told to keep using the // rate the app was built with. RumSessionScope.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to false, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to secondSessionId, + RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to false, + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to false ) ) } @@ -2071,6 +2140,453 @@ internal class RumSessionScopeTest { ) } + // region Session on error + + private fun startWithheldSession( + sampleRate: Float = 0f, + sessionOnError: Boolean = true, + remoteConfig: RemoteConfigStore? = null, + beforeSampling: BeforeSamplingCallback? = null + ): WithheldEventWriter { + val withheldEvents = mock() + fakeDatadogContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + initializeTestedScope( + sampleRate = sampleRate, + remoteConfig = remoteConfig, + beforeSampling = beforeSampling, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError + ) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + return withheldEvents + } + + @Test + fun `M withhold the session W draw { rate missed, sessionOnError on }`() { + // When + val withheldEvents = startWithheldSession() + + // Then + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) + assertThat(testedScope.sampledForError).isTrue + assertThat(testedScope.getRumContext().sampledForError).isTrue + assertThat(testedScope.effectiveSampleRate).isEqualTo(0f) + verify(mockChildScope).sampleRate = 0f + verify(withheldEvents).startWithholding(testedScope.sessionId, mockEventBatchWriter) + verify(mockSessionListener).onSessionStarted(testedScope.sessionId, true) + verify(mockSessionEndedMetricDispatcher, never()).startMetric(any(), any(), any(), any()) + } + + @Test + fun `M not withhold W draw { rate kept the session, sessionOnError on }`() { + // When + val withheldEvents = startWithheldSession(sampleRate = 100f) + + // Then + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + assertThat(testedScope.sampledForError).isFalse + assertThat(testedScope.getRumContext().sampledForError).isFalse + assertThat(testedScope.effectiveSampleRate).isEqualTo(100f) + verify(withheldEvents, never()).startWithholding(any(), any()) + } + + @Test + fun `M not track W draw { rate missed, sessionOnError off }`() { + // When + startWithheldSession(sessionOnError = false) + + // Then + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.NOT_TRACKED) + assertThat(testedScope.sampledForError).isFalse + } + + @Test + fun `M not withhold W draw { nothing to buffer with }`() { + // Given + fakeDatadogContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + initializeTestedScope(sampleRate = 0f, sessionOnError = true) + + // When + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.NOT_TRACKED) + } + + @Test + fun `M take the console's switch over init W draw { remote sessionOnError }`() { + // Given + val remoteOn = mock() + whenever(remoteOn.snapshot()) doReturn RemoteConfigValues(0f, 1, sessionOnError = true) + val remoteOff = mock() + whenever(remoteOff.snapshot()) doReturn RemoteConfigValues(0f, 1, sessionOnError = false) + + // When + startWithheldSession(sessionOnError = false, remoteConfig = remoteOn) + val switchedOn = testedScope.sessionState + startWithheldSession(sessionOnError = true, remoteConfig = remoteOff) + val switchedOff = testedScope.sessionState + + // Then + assertThat(switchedOn).isEqualTo(RumSessionScope.State.WITHHELD) + assertThat(switchedOff).isEqualTo(RumSessionScope.State.NOT_TRACKED) + } + + @Test + fun `M turn the switch off W draw { beforeSampling sets the rate to 0 }`() { + // When + startWithheldSession(sampleRate = 50f, beforeSampling = { 0f }) + + // Then + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.NOT_TRACKED) + } + + @Test + fun `M keep the switch W draw { beforeSampling leaves the rate alone }`() { + // When + startWithheldSession(beforeSampling = { null }) + + // Then + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) + } + + @Test + fun `M hand the buffer to the views W handleEvent { withheld session }`() { + // Given + val withheldEvents = startWithheldSession() + val event = forge.startViewEvent() + + // When + testedScope.handleEvent(event, fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + verify(mockChildScope).handleEvent(event, fakeDatadogContext, mockEventWriteScope, withheldEvents) + } + + @Test + fun `M hand the views nothing to write with W handleEvent { stopped withheld session }`() { + // Given - the session was thrown away at the stop; what its views still drain has nowhere to go + startWithheldSession() + testedScope.handleEvent(RumRawEvent.StopSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // When + val event = forge.stopViewEvent() + testedScope.handleEvent(event, fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + verify( + mockChildScope + ).handleEvent(eq(event), eq(fakeDatadogContext), eq(mockEventWriteScope), isA>()) + } + + @Test + fun `M hold nothing and drop what was held W handleEvent { consent withdrawn }`() { + // Given + val withheldEvents = startWithheldSession() + val event = forge.startViewEvent() + val notGranted = fakeDatadogContext.copy(trackingConsent = TrackingConsent.NOT_GRANTED) + + // When + testedScope.handleEvent(event, notGranted, mockEventWriteScope, mockWriter) + + // Then + verify(withheldEvents).dropHeld(testedScope.sessionId) + verify( + mockChildScope + ).handleEvent(eq(event), eq(notGranted), eq(mockEventWriteScope), isA>()) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) + } + + @Test + fun `M become a collected session W handleEvent { the session reported its error }`() { + // Given + val withheldEvents = startWithheldSession() + val sessionId = testedScope.sessionId + whenever(withheldEvents.isReleased(sessionId)) doReturn true + val event = forge.startViewEvent() + + // When + testedScope.handleEvent(event, fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.sessionId).isEqualTo(sessionId) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + assertThat(testedScope.sampledForError).isTrue + assertThat(testedScope.effectiveSampleRate).isEqualTo(0f) + verify(mockChildScope).handleEvent(event, fakeDatadogContext, mockEventWriteScope, mockWriter) + } + + @Test + fun `M release at once and keep the session W handleEvent(SetForcedSession) { withheld session }`() { + // Given + val withheldEvents = startWithheldSession() + val sessionId = testedScope.sessionId + + // When + testedScope.handleEvent(RumRawEvent.SetForcedSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + verify(withheldEvents).forceRelease(sessionId, mockEventBatchWriter) + assertThat(testedScope.sessionId).isEqualTo(sessionId) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + assertThat(testedScope.effectiveSampleRate).isEqualTo(0f) + } + + @Test + fun `M end the withheld session W handleEvent { session expires }`() { + // Given + val withheldEvents = startWithheldSession() + val sessionId = testedScope.sessionId + advanceTimeByMs(TEST_INACTIVITY_MS + 1) + + // When + testedScope.handleEvent( + RumRawEvent.KeepAlive(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.EXPIRED) + verify(withheldEvents).endSession(sessionId, mockEventBatchWriter) + } + + @Test + fun `M end the withheld session W handleEvent { session renewed }`() { + // Given + val withheldEvents = startWithheldSession() + val sessionId = testedScope.sessionId + advanceTimeByMs(TEST_MAX_DURATION_MS + 1) + + // When + testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.sessionId).isNotEqualTo(sessionId) + verify(withheldEvents).endSession(sessionId, mockEventBatchWriter) + verify(withheldEvents).startWithholding(testedScope.sessionId, mockEventBatchWriter) + } + + @Test + fun `M end the withheld session W handleEvent(StopSession)`() { + // Given + val withheldEvents = startWithheldSession() + val sessionId = testedScope.sessionId + + // When + testedScope.handleEvent(RumRawEvent.StopSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + verify(withheldEvents).endSession(sessionId, mockEventBatchWriter) + } + + @Test + fun `M keep the session W handleEvent(ResetSession) { zero rate with the switch on }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(20f, 1, sessionOnError = true) + val withheldEvents = mock() + fakeDatadogContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + initializeTestedScope( + sampleRate = 100f, + remoteConfig = remoteConfig, + withheldEvents = withheldEvents, + sessionOnError = false + ) + // a session that lost a draw at 20 with the switch on + do { + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) + } while (testedScope.sessionState != RumSessionScope.State.WITHHELD) + val sessionId = testedScope.sessionId + + // When + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(0f, 2, sessionOnError = true) + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.sessionId).isEqualTo(sessionId) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) + } + + @Test + fun `M keep the session W handleEvent(ResetSession) { withheld session, the console rate leaves zero }`() { + // Given - a collected session is never redrawn under a new rate, and a withheld one is collected + val remoteConfig = mock() + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(0f, 1, sessionOnError = true) + val withheldEvents = startWithheldSession(remoteConfig = remoteConfig) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) + val sessionId = testedScope.sessionId + + // When + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(100f, 2, sessionOnError = true) + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.sessionId).isEqualTo(sessionId) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) + verify(withheldEvents, never()).endSession(any(), any()) + } + + @Test + fun `M not announce a stopped session to Session Replay W handleEvent { after StopSession }`() { + // Given - the stopped session keeps draining its views while the next session runs + initializeTestedScope(100f) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + testedScope.handleEvent(RumRawEvent.StopSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + Mockito.clearInvocations(mockSessionReplayFeatureScope) + + // When + testedScope.handleEvent( + RumRawEvent.KeepAlive(currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + verify(mockSessionReplayFeatureScope, never()).sendEvent(any()) + } + + @Test + fun `M end the watched session W handleEvent { session renewed }`() { + // Given + val withheldEvents = mock() + fakeDatadogContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + initializeTestedScope(sampleRate = 100f, withheldEvents = withheldEvents, sessionReplayOnError = true) + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + val sessionId = testedScope.sessionId + advanceTimeByMs(TEST_MAX_DURATION_MS + 1) + + // When + testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.sessionId).isNotEqualTo(sessionId) + verify(withheldEvents).endSession(sessionId, mockEventBatchWriter) + } + + @Test + fun `M not draw a new session W handleEvent { stopped session, interaction after inactivity }`() { + // Given - the stopped session still sees the interactions of the one that replaced it + val withheldEvents = startWithheldSession() + val sessionId = testedScope.sessionId + testedScope.handleEvent(RumRawEvent.StopSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + Mockito.clearInvocations(withheldEvents, mockSessionListener) + advanceTimeByMs(TEST_INACTIVITY_MS + 1) + + // When + testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then - and keeps its own state for what it still drains + assertThat(testedScope.sessionId).isEqualTo(sessionId) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) + verify(withheldEvents, never()).startWithholding(any(), any()) + verify(mockSessionListener, never()).onSessionStarted(any(), any()) + } + + @Test + fun `M end the session W handleEvent(ResetSession) { zero rate with the switch off }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(0f, 1, sessionOnError = true) + val withheldEvents = startWithheldSession(remoteConfig = remoteConfig) + val sessionId = testedScope.sessionId + + // When + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(0f, 2, sessionOnError = false) + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.sessionId).isNotEqualTo(sessionId) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.NOT_TRACKED) + verify(withheldEvents).endSession(sessionId, mockEventBatchWriter) + } + + @Test + fun `M end a fully collected session W handleEvent(ResetSession) { zero rate with the switch on }`() { + // Given + val remoteConfig = mock() + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(100f, 1, sessionOnError = true) + startWithheldSession(remoteConfig = remoteConfig) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.TRACKED) + val sessionId = testedScope.sessionId + + // When + whenever(remoteConfig.snapshot()) doReturn RemoteConfigValues(0f, 2, sessionOnError = true) + testedScope.handleEvent(RumRawEvent.ResetSession(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + assertThat(testedScope.sessionId).isNotEqualTo(sessionId) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) + } + + @Test + fun `M watch a collected session and tell Session Replay W draw { replay switch on }`() { + // Given + val withheldEvents = mock() + fakeDatadogContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + initializeTestedScope(sampleRate = 100f, withheldEvents = withheldEvents, sessionReplayOnError = true) + + // When + testedScope.handleEvent( + RumRawEvent.SdkInit(true, currentFakeTime()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + whenever(withheldEvents.isReplayReleased(testedScope.sessionId)) doReturn true + testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + verify(withheldEvents).watchForError(testedScope.sessionId, mockEventBatchWriter) + val captor = argumentCaptor() + verify(mockSessionReplayFeatureScope, atLeastOnce()).sendEvent(captor.capture()) + val first = captor.firstValue as Map<*, *> + val last = captor.lastValue as Map<*, *> + assertThat(first[RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY]).isEqualTo(true) + assertThat(first[RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY]).isEqualTo(false) + assertThat(first[RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY]).isEqualTo(false) + assertThat(last[RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY]).isEqualTo(true) + } + + @Test + fun `M tell Session Replay the events are withheld W draw { rate missed, sessionOnError on }`() { + // When + startWithheldSession() + + // Then + val captor = argumentCaptor() + verify(mockSessionReplayFeatureScope, atLeastOnce()).sendEvent(captor.capture()) + val last = captor.lastValue as Map<*, *> + assertThat(last[RumSessionScope.RUM_KEEP_SESSION_BUS_MESSAGE_KEY]).isEqualTo(false) + assertThat(last[RumSessionScope.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY]).isEqualTo(true) + assertThat(last[RumSessionScope.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY]).isEqualTo(false) + } + + // endregion + // region beforeSampling @Test @@ -2172,7 +2688,10 @@ internal class RumSessionScopeTest { backgroundTrackingEnabled: Boolean? = null, remoteConfig: RemoteConfigStore? = null, onSessionDrawn: () -> Unit = {}, - beforeSampling: BeforeSamplingCallback? = null + beforeSampling: BeforeSamplingCallback? = null, + withheldEvents: WithheldEventWriter? = null, + sessionOnError: Boolean = false, + sessionReplayOnError: Boolean = false ) { testedScope = RumSessionScope( parentScope = mockParentScope, @@ -2201,7 +2720,10 @@ internal class RumSessionScopeTest { insightsCollector = mockInsightsCollector, remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, - beforeSampling = beforeSampling + beforeSampling = beforeSampling, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError, + sessionReplayOnError = sessionReplayOnError ) if (withMockChildScope) { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index e364afe961..8eb912b977 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 @@ -675,6 +675,144 @@ internal class RumViewScopeTest { assertThat(result).isNull() } + @Test + fun `M mark the session sampled for error W handleEvent(StartView) { session kept on error }`( + @Forgery key: RumScopeKey + ) { + // Given + fakeParentContext = fakeParentContext.copy(sampledForError = true) + whenever(mockParentScope.getRumContext()) doReturn fakeParentContext + testedScope = newRumViewScope(trackFrustrations = true) + mockSessionReplayContext(testedScope) + + // When + testedScope.handleEvent( + RumRawEvent.StartView(key, emptyMap()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.session.sampledForError).isTrue + } + } + + @Test + fun `M report the replay markers Session Replay resolves W handleEvent(StartView)`( + @Forgery key: RumScopeKey + ) { + // Given + testedScope = newRumViewScope(trackFrustrations = true) + whenever(mockFeaturesContextResolver.resolveSampledForReplay(any(), any(), any())) doReturn true + whenever(mockFeaturesContextResolver.resolveSampledForErrorReplay(any(), eq(fakeParentContext.sessionId))) + .doReturn(true) + + // When + testedScope.handleEvent( + RumRawEvent.StartView(key, emptyMap()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.session.sampledForReplay).isTrue + assertThat(lastValue.session.sampledForErrorReplay).isTrue + } + } + + @Test + fun `M keep the replay markers W handleEvent { another session took over the replay context }`( + @Forgery key: RumScopeKey + ) { + // Given - the markers were resolved once, then the context started describing another session + testedScope = newRumViewScope(trackFrustrations = true) + whenever(mockFeaturesContextResolver.resolveSampledForReplay(any(), any(), any())) + .doReturn(true, false) + whenever(mockFeaturesContextResolver.resolveSampledForErrorReplay(any(), eq(fakeParentContext.sessionId))) + .doReturn(true, false) + testedScope.handleEvent( + RumRawEvent.AddCustomTiming("t"), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // When - another view starts, which stops this one with a last update + testedScope.handleEvent( + RumRawEvent.StartView(key, emptyMap()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter, times(2)).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.session.sampledForReplay).isTrue + assertThat(lastValue.session.sampledForErrorReplay).isTrue + } + } + + @Test + fun `M keep the Session Replay entry W handleEvent(any) on stopped view { withheld session }`() { + // Given - the view may still be released, and claims the replay held for it from the entry + fakeParentContext = fakeParentContext.copy(sessionState = RumSessionScope.State.WITHHELD) + whenever(mockParentScope.getRumContext()) doReturn fakeParentContext + testedScope = newRumViewScope(trackFrustrations = true) + testedScope.stopped = true + fakeEvent = mock() + + // When + testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + verify(rumMonitorConfiguration.mockSdkCore, never()).updateFeatureContext( + eq(Feature.SESSION_REPLAY_FEATURE_NAME), + any(), + any() + ) + } + + @Test + fun `M not mark the session W handleEvent(StartView) { plainly sampled session }`( + @Forgery key: RumScopeKey + ) { + // Given + fakeParentContext = fakeParentContext.copy(sampledForError = false) + whenever(mockParentScope.getRumContext()) doReturn fakeParentContext + testedScope = newRumViewScope(trackFrustrations = true, featuresContextResolver = FeaturesContextResolver()) + fakeDatadogContext = fakeDatadogContext.copy( + featuresContext = mapOf( + Feature.SESSION_REPLAY_FEATURE_NAME to mapOf(FeaturesContextResolver.REPLAY_ENABLED_KEY to true) + ) + ) + mockSessionReplayContext(testedScope) + + // When + testedScope.handleEvent( + RumRawEvent.StartView(key, emptyMap()), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then - absent rather than false + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.session.sampledForError).isNull() + assertThat(lastValue.session.sampledForErrorReplay).isNull() + // the view a Session Replay customer who did not opt in sends is unchanged + assertThat(lastValue.toJson().asJsonObject.getAsJsonObject("session").keySet()) + .containsExactlyInAnyOrder("id", "type", "has_replay", "is_active") + } + } + @Test fun `M report no draw W handleEvent(StartView) { the app did not opt in }`( @Forgery key: RumScopeKey diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt index ea043cb6a5..296a0281a5 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/ProcessForegroundCallbackTest.kt @@ -23,7 +23,7 @@ internal class ProcessForegroundCallbackTest { @BeforeEach fun setUp() { foregroundCount = 0 - testedCallback = ProcessForegroundCallback { foregroundCount++ } + testedCallback = ProcessForegroundCallback(onForeground = { foregroundCount++ }) } @Test @@ -72,4 +72,37 @@ internal class ProcessForegroundCallbackTest { // Then assertThat(foregroundCount).isOne() } + + @Test + fun `M report the background W the last started activity stops`() { + // Given + var backgroundCount = 0 + testedCallback = ProcessForegroundCallback(onForeground = {}, onBackground = { backgroundCount++ }) + val first = mock() + val second = mock() + testedCallback.onActivityStarted(first) + testedCallback.onActivityStarted(second) + + // When + testedCallback.onActivityStopped(first) + val whileOneIsLeft = backgroundCount + testedCallback.onActivityStopped(second) + + // Then + assertThat(whileOneIsLeft).isZero() + assertThat(backgroundCount).isOne() + } + + @Test + fun `M report the background W an activity started before registration stops`() { + // Given + var backgroundCount = 0 + testedCallback = ProcessForegroundCallback(onForeground = {}, onBackground = { backgroundCount++ }) + + // When + testedCallback.onActivityStopped(mock()) + + // Then + assertThat(backgroundCount).isOne() + } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index a77058c084..6f66194be3 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt @@ -154,8 +154,111 @@ internal class RemoteConfigControllerTest { // endregion + @Test + fun `M store the on-error switches the response carries W apply()`() { + testedController.apply( + body(rum = """"sessionSampleRate":0,"sessionOnError":true,"sessionReplayOnError":false""") + ) + + verify(store).store( + RemoteConfigValues(0f, 3, ttlSeconds = 300L, sessionOnError = true, sessionReplayOnError = false) + ) + } + + @Test + fun `M leave a switch absent W apply() { omitted or not a boolean }`() { + testedController.apply(body(rum = """"sessionOnError":"true","sessionReplayOnError":1""")) + + verify(store).store(RemoteConfigValues(null, 3, ttlSeconds = 300L)) + } + // region activation + @Test + fun `M restart the session W apply() { next_session, zero rate and the switch turns on }`() { + // A session drawn at zero with the switch off was in no draw at all; with the switch on it + // could be kept, exactly like a rate leaving zero. + whenever(store.sessionSampleRate()).thenReturn(0f) + whenever(store.sessionOnError()).thenReturn(false) + + testedController.apply( + body(activation = "next_session", rum = """"sessionSampleRate":0,"sessionOnError":true""") + ) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { next_session, zero rate and the switch turns off }`() { + whenever(store.sessionSampleRate()).thenReturn(0f) + whenever(store.sessionOnError()).thenReturn(true) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":0""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M restart the session W apply() { next_session, the switch hands an init value back }`() { + whenever(store.sessionSampleRate()).thenReturn(0f) + whenever(store.sessionOnError()).thenReturn(false) + val sdkCore = mock() + whenever(sdkCore.internalLogger).thenReturn(mock()) + testedController = RemoteConfigController( + sdkCore = sdkCore, + configUrl = "https://example.com/api/v2/rum/config", + store = store, + initialSessionSampleRate = INIT_SESSION_RATE, + callFactory = callFactory, + executor = executor, + restartSession = { restarts++ }, + initialSessionOnError = true + ) + + testedController.apply(body(activation = "next_session", rum = """"sessionSampleRate":0""")) + + assertThat(restarts).isOne() + } + + @Test + fun `M leave the running session alone W apply() { next_session, the switch changes under a non-zero rate }`() { + // Negative control: away from zero the switch only shapes the next draw. + whenever(store.sessionSampleRate()).thenReturn(20f) + whenever(store.sessionOnError()).thenReturn(false) + + testedController.apply( + body(activation = "next_session", rum = """"sessionSampleRate":20,"sessionOnError":true""") + ) + + assertThat(restarts).isZero() + } + + @Test + fun `M leave the running session alone W apply() { zero rate and the switch stays on }`() { + // The configuration for "only the sessions that error", fetched again: nothing changed. + whenever(store.sessionSampleRate()).thenReturn(0f) + whenever(store.sessionOnError()).thenReturn(true) + + testedController.apply( + body(activation = "immediate", rum = """"sessionSampleRate":0,"sessionOnError":true""") + ) + + assertThat(restarts).isZero() + } + + @Test + fun `M leave the running session alone W apply() { immediate and only the replay switch changed }`() { + // A replay draw is made once per session: the switch applies to the next one. + whenever(store.sessionSampleRate()).thenReturn(20f) + whenever(store.sessionReplayOnError()).thenReturn(null) + + testedController.apply( + body(activation = "immediate", rum = """"sessionSampleRate":20,"sessionReplayOnError":true""") + ) + + assertThat(restarts).isZero() + } + @Test fun `M leave the running session alone W apply() { activation is next_session }`() { whenever(store.sessionSampleRate()).thenReturn(10f) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt index b4c6a45a26..1d3d002591 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigStoreTest.kt @@ -94,6 +94,23 @@ internal class RemoteConfigStoreTest { assertThat(store.snapshot()).isEqualTo(second) } + @Test + fun `M keep the on-error switches W store() then snapshot()`() { + val store = testedStore() + store.store(RemoteConfigValues(0f, 1, sessionOnError = true, sessionReplayOnError = false)) + + assertThat(testedStore().snapshot().sessionOnError).isTrue + assertThat(testedStore().snapshot().sessionReplayOnError).isFalse + assertThat(testedStore().sessionOnError()).isTrue + assertThat(testedStore().sessionReplayOnError()).isFalse + + // A later response that omits them hands them back to the init values. + store.store(RemoteConfigValues(0f, 2)) + + assertThat(testedStore().snapshot().sessionOnError).isNull() + assertThat(testedStore().sessionReplayOnError()).isNull() + } + @Test fun `M return absent values W snapshot before the first response`() { assertThat(testedStore().snapshot()).isEqualTo(RemoteConfigValues(null)) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumSessionScopeStartupManagerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumSessionScopeStartupManagerTest.kt index 56fd0093eb..c8b9e471e1 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumSessionScopeStartupManagerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumSessionScopeStartupManagerTest.kt @@ -45,6 +45,7 @@ import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +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 @@ -250,6 +251,64 @@ internal class RumSessionScopeStartupManagerTest { verifyNoMoreInteractions(mockWriter, mockRumAppStartupTelemetryReporter) } + @ParameterizedTest + @MethodSource("testScenarios") + fun `M report a zero sample rate W onTTIDEvent { session kept on error }`( + scenario: RumStartupScenario, + forge: Forge + ) { + // Given + val event = RumRawEvent.AppStartTTIDEvent( + info = RumTTIDInfo(scenario = scenario, durationNs = forge.aLong(min = 0, max = 10000)) + ) + + // When + manager.onAppStartEvent(mock()) + manager.onTTIDEvent( + event = event, + datadogContext = fakeDatadogContext, + writeScope = mockEventWriteScope, + writer = mockWriter, + rumContext = rumContext.copy(sampledForError = true), + customAttributes = fakeParentAttributes + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.dd.configuration?.sessionSampleRate?.toFloat()).isEqualTo(0f) + } + } + + @ParameterizedTest + @MethodSource("testScenarios") + fun `M report the configured sample rate W onTTIDEvent { plainly sampled session }`( + scenario: RumStartupScenario, + forge: Forge + ) { + // Given - the negative control + val event = RumRawEvent.AppStartTTIDEvent( + info = RumTTIDInfo(scenario = scenario, durationNs = forge.aLong(min = 0, max = 10000)) + ) + + // When + manager.onAppStartEvent(mock()) + manager.onTTIDEvent( + event = event, + datadogContext = fakeDatadogContext, + writeScope = mockEventWriteScope, + writer = mockWriter, + rumContext = rumContext, + customAttributes = fakeParentAttributes + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.dd.configuration?.sessionSampleRate?.toFloat()).isEqualTo(fakeSampleRate) + } + } + @ParameterizedTest @MethodSource("testScenarios") fun `M stop profiler W onTTIDEvent`( diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/RumContextForgeryFactory.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/RumContextForgeryFactory.kt index e9409c60fb..9b128d9ada 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/RumContextForgeryFactory.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/RumContextForgeryFactory.kt @@ -23,7 +23,8 @@ internal class RumContextForgeryFactory : ForgeryFactory { viewName = forge.aNullable { forge.anAlphaNumericalString() }, viewUrl = forge.aStringMatching("http(s?)://[a-z]+\\.com/[a-z]+"), actionId = forge.aNullable { getForgery().toString() }, - sessionState = forge.aValueFrom(RumSessionScope.State::class.java), + // FLASHCAT FORK - a withheld session is set up on purpose by the tests that need one. + sessionState = forge.aValueFrom(RumSessionScope.State::class.java, listOf(RumSessionScope.State.WITHHELD)), sessionStartReason = forge.aValueFrom(RumSessionScope.StartReason::class.java), viewType = forge.aValueFrom(RumViewType::class.java), syntheticsTestId = forge.aNullable { forge.anAlphaNumericalString() }, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/RecordCallback.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/RecordCallback.kt index 40521d674d..5041e242c9 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/RecordCallback.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/RecordCallback.kt @@ -19,4 +19,17 @@ internal interface RecordCallback { * @param record as [EnrichedRecord] */ fun onRecordForViewSent(record: EnrichedRecord) + + /** + * FLASHCAT FORK - notifies when a record is held rather than sent, because its session's + * replay is kept only in case it reports an error. + * @param record as [EnrichedRecord] + */ + fun onRecordForViewWithheld(record: EnrichedRecord) + + /** + * FLASHCAT FORK - notifies when held records stop being held: sent, or thrown away. + * @param records the records no longer held + */ + fun onWithheldRecordsCleared(records: List) } 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 b4686bfa79..ae550397e4 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 @@ -18,6 +18,8 @@ import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.FeatureStorageConfiguration import com.datadog.android.core.sampling.RateBasedSampler import com.datadog.android.core.sampling.Sampler +import com.datadog.android.privacy.TrackingConsent +import com.datadog.android.privacy.TrackingConsentProviderCallback import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.SessionReplayInternalCallback @@ -33,10 +35,13 @@ import com.datadog.android.sessionreplay.internal.resources.ResourceHashesEntryD import com.datadog.android.sessionreplay.internal.resources.ResourceHashesEntrySerializer import com.datadog.android.sessionreplay.internal.storage.NoOpRecordWriter import com.datadog.android.sessionreplay.internal.storage.RecordWriter +import com.datadog.android.sessionreplay.internal.storage.ResourcesWriter import com.datadog.android.sessionreplay.internal.storage.SessionReplayRecordWriter import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector import com.datadog.android.sessionreplay.utils.DrawableToColorMapper import java.util.Locale +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -54,7 +59,7 @@ internal class SessionReplayFeature( private val rateBasedSampler: Sampler, private val startRecordingImmediately: Boolean, private val recorderProvider: RecorderProvider -) : StorageBackedFeature, FeatureEventReceiver { +) : StorageBackedFeature, FeatureEventReceiver, TrackingConsentProviderCallback { private val currentRumSessionId = AtomicReference() @@ -115,6 +120,11 @@ internal class SessionReplayFeature( // is the current session sampled in private val isSessionSampledIn = AtomicBoolean(false) + // FLASHCAT FORK - the session whose replay is kept only in case it reports an error, for as long + // as it lives (it stays set once the replay is released), and whether its records are still held. + private val onErrorReplaySessionId = AtomicReference() + private val isReplayWithheld = AtomicBoolean(false) + internal var sessionReplayRecorder: Recorder = NoOpRecorder() internal var dataWriter: RecordWriter = NoOpRecordWriter() internal val initialized = AtomicBoolean(false) @@ -141,12 +151,15 @@ internal class SessionReplayFeature( resourceHashesDeserializer = ResourceHashesEntryDeserializer(internalLogger = sdkCore.internalLogger) ) - dataWriter = createDataWriter() + // FLASHCAT FORK - resources go through the record writer, which holds them with the records + // of a session whose replay is withheld. + val recordWriter = createDataWriter(resourcesFeature.dataWriter) + dataWriter = recordWriter sdkCore.setContextUpdateReceiver(rumContextProvider) sessionReplayRecorder = recorderProvider.provideSessionReplayRecorder( resourceDataStoreManager = resourceDataStoreManager, - resourceWriter = resourcesFeature.dataWriter, + resourceWriter = recordWriter, recordWriter = dataWriter, rumContextProvider = rumContextProvider, application = appContext @@ -178,6 +191,24 @@ internal class SessionReplayFeature( sdkCore.removeContextUpdateReceiver(rumContextProvider) sessionReplayRecorder.unregisterCallbacks() sessionReplayRecorder.stopProcessingRecords() + // FLASHCAT FORK - a replay held for a session that reported its error goes out with the + // stop, since RUM may be stopped after this feature and could not tell it to any more; the + // write is queued, so this waits - with a bound - for it to have run. + val settled = CountDownLatch(1) + dataWriter.stop { settled.countDown() } + val done = try { + settled.await(STOP_WAIT_MS, TimeUnit.MILLISECONDS) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + false + } + if (!done) { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { STOP_WAIT_FAILED_MESSAGE } + ) + } dataWriter = NoOpRecordWriter() sessionReplayRecorder = NoOpRecorder() initialized.set(false) @@ -206,6 +237,16 @@ internal class SessionReplayFeature( // endregion + // region TrackingConsentProviderCallback + + // FLASHCAT FORK - a replay held in memory is not in the storage consent governs: what was held + // under the consent now withdrawn is dropped here, whether or not a record follows to see it. + override fun onConsentUpdated(previousConsent: TrackingConsent, newConsent: TrackingConsent) { + if (newConsent == TrackingConsent.NOT_GRANTED) dataWriter.dropForConsent() + } + + // endregion + // region Manual Recording internal fun manuallyStopRecording() { @@ -224,6 +265,9 @@ internal class SessionReplayFeature( // region Internal + // FLASHCAT FORK - serialized: a session announces itself from the RUM thread, while what became + // of a withheld one arrives from the storage thread, and the two must not interleave. + @Synchronized private fun handleRumSession(sessionMetadata: Map<*, *>) { if (sessionMetadata[SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY] == RUM_SESSION_RENEWED_BUS_MESSAGE @@ -232,12 +276,34 @@ internal class SessionReplayFeature( ?.let { sessionData -> val alreadySeenSession = currentRumSessionId.get() == sessionData.sessionId val forceSampling = sessionData.forced && !isSessionSampledIn.get() - if (!alreadySeenSession || forceSampling || userIntentToRecordChanged.get()) { + // FLASHCAT FORK - a held replay is released once the session's events are (or by + // forcing). + val released = isReplayWithheld.get() && (sessionData.released || sessionData.forced) + if (!alreadySeenSession || forceSampling || released || userIntentToRecordChanged.get()) { applySampling(alreadySeenSession, sessionData.forced) - modifyShouldRecordState(sessionData) + val withhold = shouldWithhold(sessionData) + modifyShouldRecordState(sessionData, withhold) + updateWithholding(sessionData.sessionId, withhold) handleRecording(sessionData) } } + } else if (sessionMetadata[SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY] == RUM_SESSION_RELEASED_BUS_MESSAGE) { + // FLASHCAT FORK - the session's events have just been released: the replay held for it + // goes out now. For the session still current, recording carries on as for any collected + // session; a session that ended before its word arrived only has what it held to send. + val sessionId = sessionMetadata[RUM_SESSION_ID_BUS_MESSAGE_KEY] as? String ?: return + if (isReplayWithheld.get() && currentRumSessionId.get() == sessionId) { + updateWithholding(sessionId, withhold = false) + } else { + dataWriter.release(sessionId) + } + } else if (sessionMetadata[SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY] == RUM_SESSION_ERRORED_BUS_MESSAGE) { + // FLASHCAT FORK - the session reported its error: its replay goes out once its events + // do, or with the stop if that comes first. + (sessionMetadata[RUM_SESSION_ID_BUS_MESSAGE_KEY] as? String)?.let { dataWriter.expectRelease(it) } + } else if (sessionMetadata[SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY] == RUM_SESSION_DISCARDED_BUS_MESSAGE) { + // FLASHCAT FORK - the session ended without an error: what was held for it goes. + (sessionMetadata[RUM_SESSION_ID_BUS_MESSAGE_KEY] as? String)?.let { dataWriter.discard(it) } } else { sdkCore.internalLogger.log( InternalLogger.Level.WARN, @@ -255,7 +321,11 @@ internal class SessionReplayFeature( private data class SessionData( val keepSession: Boolean, val sessionId: String, - val forced: Boolean + val forced: Boolean, + // FLASHCAT FORK - see `RumSessionScope.updateSessionStateForSessionReplay`. + val eventsOnError: Boolean, + val replayOnError: Boolean, + val released: Boolean ) private fun parseSessionMetadata(sessionMetadata: Map<*, *>): SessionData? { @@ -268,7 +338,14 @@ internal class SessionReplayFeature( } val forced = sessionMetadata[RUM_SESSION_FORCED_BUS_MESSAGE_KEY] as? Boolean ?: false - return SessionData(keepSession, sessionId, forced) + return SessionData( + keepSession = keepSession, + sessionId = sessionId, + forced = forced, + eventsOnError = sessionMetadata[RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY] as? Boolean ?: false, + replayOnError = sessionMetadata[RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY] as? Boolean ?: false, + released = sessionMetadata[RUM_SESSION_RELEASED_BUS_MESSAGE_KEY] as? Boolean ?: false + ) } private fun applySampling(alreadySeenSession: Boolean, forced: Boolean) { @@ -279,8 +356,46 @@ internal class SessionReplayFeature( } } - private fun modifyShouldRecordState(sessionData: SessionData) { - val isSessionEligible = sessionData.keepSession && isSessionSampledIn.get() + /** + * FLASHCAT FORK - whether this session's records are held until it reports an error. Two + * sessions are: a collected one whose replay the rate missed while the replay switch is on, and + * one whose events are themselves held - its replay waits with them whichever way the replay + * draw went, because until the events are released the session does not exist at the intake and + * a replay uploaded before then would have nothing to attach to. + */ + private fun shouldWithhold(sessionData: SessionData): Boolean { + if (sessionData.released || sessionData.forced) return false + return if (sessionData.eventsOnError) { + isSessionSampledIn.get() || sessionData.replayOnError + } else { + sessionData.keepSession && !isSessionSampledIn.get() && sessionData.replayOnError + } + } + + private fun updateWithholding(sessionId: String, withhold: Boolean) { + if (withhold) { + onErrorReplaySessionId.set(sessionId) + dataWriter.withhold(sessionId) + } else { + // Releases what this session held, if it held anything; what another session still + // holds is kept for RUM's word or thrown away, see the writer. + dataWriter.stopWithholding(sessionId) + } + val wasWithheld = isReplayWithheld.getAndSet(withhold) + val onErrorSessionId = onErrorReplaySessionId.get() + // Nothing ever withheld: the context stays exactly as it is for everyone who did not opt in. + if (!withhold && !wasWithheld && onErrorSessionId == null) return + sdkCore.updateFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME) { + it[SESSION_REPLAY_ON_ERROR_SESSION_KEY] = onErrorSessionId?.takeIf { id -> id == sessionId } + it[SESSION_REPLAY_WITHHELD_KEY] = withhold + } + } + + private fun modifyShouldRecordState(sessionData: SessionData, withhold: Boolean) { + // A replay kept on error stays eligible once released, whether or not the rate drew it. + val keptOnError = onErrorReplaySessionId.get() == sessionData.sessionId + val isSessionEligible = withhold || + (sessionData.keepSession && (isSessionSampledIn.get() || keptOnError)) if (isSessionEligible) { shouldRecord.set(userIntentToRecord.get()) } else { @@ -365,9 +480,11 @@ internal class SessionReplayFeature( } } - private fun createDataWriter(): RecordWriter { + private fun createDataWriter(resourcesWriter: ResourcesWriter): SessionReplayRecordWriter { val recordCallback = SessionReplayRecordCallback(sdkCore) - return SessionReplayRecordWriter(sdkCore, recordCallback) + return SessionReplayRecordWriter(sdkCore, recordCallback, resourcesWriter) { resourceIds -> + sessionReplayRecorder.forgetResources(resourceIds) + } } /** @@ -434,6 +551,20 @@ internal class SessionReplayFeature( 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" + const val RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY = "sessionOnError" + const val RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY = "sessionReplayOnError" + const val RUM_SESSION_RELEASED_BUS_MESSAGE_KEY = "sessionReleased" + const val RUM_SESSION_RELEASED_BUS_MESSAGE = "rum_session_released" + const val RUM_SESSION_DISCARDED_BUS_MESSAGE = "rum_session_discarded" + const val RUM_SESSION_ERRORED_BUS_MESSAGE = "rum_session_errored" + private const val STOP_WAIT_MS = 2_000L + internal const val STOP_WAIT_FAILED_MESSAGE = + "Could not wait for the held replay to be settled before Session Replay stopped." + + // FLASHCAT FORK - read by RUM to mark view events: the current session when its replay is + // kept only on error, and whether its records are still held. + internal const val SESSION_REPLAY_ON_ERROR_SESSION_KEY = "session_replay_on_error_session_id" + internal const val SESSION_REPLAY_WITHHELD_KEY = "session_replay_withheld" 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" internal const val SESSION_REPLAY_IMAGE_PRIVACY_KEY = "session_replay_image_privacy" diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt index 3b83fde495..aa83989cb6 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt @@ -30,6 +30,42 @@ internal class SessionReplayRecordCallback( } } + // FLASHCAT FORK - held records are counted apart, under a key RUM reads only to claim the + // replay for events released alongside them: `has_replay` and `records_count` stay for records + // actually sent. + @Suppress("UNCHECKED_CAST") + override fun onRecordForViewWithheld(record: EnrichedRecord) { + val recordsSize = record.records.size + if (recordsSize == 0) return + featureSdkCore.updateFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME, useContextThread = false) { + val viewMetadata = (it[record.viewId] as? MutableMap) ?: mutableMapOf() + viewMetadata[VIEW_WITHHELD_RECORDS_COUNT_KEY] = + (viewMetadata[VIEW_WITHHELD_RECORDS_COUNT_KEY] as? Long ?: 0L) + recordsSize + it[record.viewId] = viewMetadata + } + } + + @Suppress("UNCHECKED_CAST") + override fun onWithheldRecordsCleared(records: List) { + val clearedByView = records.groupBy { it.viewId } + .mapValues { (_, cleared) -> cleared.sumOf { it.records.size } } + if (clearedByView.values.all { it == 0 }) return + featureSdkCore.updateFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME, useContextThread = false) { + clearedByView.forEach { (viewId, cleared) -> + val viewMetadata = it[viewId] as? MutableMap ?: return@forEach + val remaining = (viewMetadata[VIEW_WITHHELD_RECORDS_COUNT_KEY] as? Long ?: 0L) - cleared + if (remaining > 0) { + viewMetadata[VIEW_WITHHELD_RECORDS_COUNT_KEY] = remaining + } else { + viewMetadata.remove(VIEW_WITHHELD_RECORDS_COUNT_KEY) + // RUM leaves the entry of a completed view in place while its session is + // withheld; nothing sent and nothing held means nothing to keep it for. + if (viewMetadata.isEmpty()) it.remove(viewId) + } + } + } + } + private fun updateRecordsCount( viewMetadata: MutableMap, recordsCount: Int @@ -42,5 +78,6 @@ internal class SessionReplayRecordCallback( companion object { internal const val HAS_REPLAY_KEY = "has_replay" internal const val VIEW_RECORDS_COUNT_KEY = "records_count" + internal const val VIEW_WITHHELD_RECORDS_COUNT_KEY = "withheld_records_count" } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt index 65fd6adef4..c3e5b81c9e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt @@ -44,19 +44,21 @@ internal class RecordedDataProcessor( val isKnownResource = resourceDataStoreManager.isPreviouslySentResource(resourceHash) if (!isKnownResource) { - // the cacheResourceHash method overwrites the datastore entry and we don't want that if we haven't finished - // initializing - if (resourceDataStoreManager.isReady()) { - resourceDataStoreManager.cacheResourceHash(resourceHash) - } - val enrichedResource = EnrichedResource( resource = item.resourceData, filename = resourceHash, mimeType = item.mimeType ) - resourcesWriter.write(enrichedResource) + // FLASHCAT FORK - remembered as sent only once it is written: a resource held for a + // session whose replay is withheld, and thrown away with it, was never sent. + resourcesWriter.write(enrichedResource, item.recordedQueuedItemContext.newRumContext.sessionId) { + // the cacheResourceHash method overwrites the datastore entry and we don't want that if we haven't + // finished initializing + if (resourceDataStoreManager.isReady()) { + resourceDataStoreManager.cacheResourceHash(resourceHash) + } + } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt index 0e38f0ba20..d795b72fe9 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Recorder.kt @@ -19,4 +19,10 @@ internal interface Recorder { fun resumeRecorders() fun stopRecorders() + + /** + * FLASHCAT FORK - the resources were dropped unsent from the store that held them for a replay + * kept on error; they are captured again the next time they are shown. + */ + fun forgetResources(resourceIds: Collection) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index b50b0c6197..c2653bd819 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt @@ -276,6 +276,10 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { } } + override fun forgetResources(resourceIds: Collection) { + resourceResolver.forgetResources(resourceIds) + } + @MainThread override fun onWindowsAdded(windows: List) { if (shouldRecord) { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceItemCreationHandler.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceItemCreationHandler.kt index 2b213f0412..4485163937 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceItemCreationHandler.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceItemCreationHandler.kt @@ -19,9 +19,15 @@ internal class ResourceItemCreationHandler( @VisibleForTesting internal val resourceIdsSeen: MutableSet = Collections.synchronizedSet(HashSet()) + // FLASHCAT FORK - the resources dropped unsent from the store that held them for a replay kept + // on error: they are queued again the next time they are shown, as if never seen. + @VisibleForTesting internal val forgottenResourceIds: MutableSet = + Collections.synchronizedSet(LinkedHashSet()) + internal fun queueItem(resourceId: String, resourceData: ByteArray) { if (!resourceIdsSeen.contains(resourceId)) { resourceIdsSeen.add(resourceId) + forgottenResourceIds.remove(resourceId) recordedDataQueueHandler.addResourceItem( identifier = resourceId, @@ -29,4 +35,25 @@ internal class ResourceItemCreationHandler( ) } } + + /** FLASHCAT FORK - whether a resource once queued was dropped unsent since. */ + internal fun isForgotten(resourceId: String): Boolean = forgottenResourceIds.contains(resourceId) + + /** FLASHCAT FORK - see [forgottenResourceIds]. */ + internal fun forget(resourceIds: Collection) { + resourceIdsSeen.removeAll(resourceIds.toSet()) + synchronized(forgottenResourceIds) { + forgottenResourceIds.addAll(resourceIds) + // Bounded: an image never shown again is one nobody misses. + val iterator = forgottenResourceIds.iterator() + while (forgottenResourceIds.size > FORGOTTEN_RESOURCE_IDS_LIMIT && iterator.hasNext()) { + iterator.next() + iterator.remove() + } + } + } + + companion object { + internal const val FORGOTTEN_RESOURCE_IDS_LIMIT = 1024 + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt index 256b5cc2fb..bd5cb9edfe 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt @@ -54,6 +54,14 @@ internal class ResourceResolver( applicationContext.unregisterComponentCallbacks(alpha8ResourceCache) } + /** + * FLASHCAT FORK - the resources were dropped unsent from the store that held them for a replay + * kept on error: they are captured and queued again the next time they are shown. + */ + internal fun forgetResources(resourceIds: Collection) { + resourceItemCreationHandler.forget(resourceIds) + } + // region internal @MainThread @@ -88,7 +96,7 @@ internal class ResourceResolver( customResourceIdCacheKey = key ) - if (resourceId != null) { + if (resourceId != null && !resourceItemCreationHandler.isForgotten(resourceId)) { // if we got here it means we saw the bitmap before, // so we don't need to send the resource again resourceResolverCallback.onSuccess(resourceId) @@ -143,7 +151,9 @@ internal class ResourceResolver( val resourceId = tryToGetResourceFromCache(drawable = originalDrawable, customResourceIdCacheKey = customResourceIdCacheKey) - if (resourceId != null) { + // FLASHCAT FORK - a resource dropped unsent from the store holding it for a replay kept on + // error is resolved again from the drawable, so it can be queued again. + if (resourceId != null && !resourceItemCreationHandler.isForgotten(resourceId)) { // if we got here it means we saw the bitmap before, // so we don't need to send the resource again resourceResolverCallback.onSuccess(resourceId) @@ -386,7 +396,7 @@ internal class ResourceResolver( if (cacheKey != null) { val cachedResourceId = alpha8ResourceCache.get(cacheKey) - if (cachedResourceId != null) { + if (cachedResourceId != null && !resourceItemCreationHandler.isForgotten(cachedResourceId)) { resourceResolverCallback.onSuccess(cachedResourceId) return } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpRecordWriter.kt index 3ac971a74d..8e35af1972 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpRecordWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpRecordWriter.kt @@ -12,4 +12,32 @@ internal class NoOpRecordWriter : RecordWriter { override fun write(record: EnrichedRecord) { // no-op } + + override fun withhold(sessionId: String) { + // no-op + } + + override fun stopWithholding(sessionId: String) { + // no-op + } + + override fun release(sessionId: String) { + // no-op + } + + override fun discard(sessionId: String) { + // no-op + } + + override fun dropForConsent() { + // no-op + } + + override fun expectRelease(sessionId: String) { + // no-op + } + + override fun stop(onDone: () -> Unit) { + onDone() + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpResourcesWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpResourcesWriter.kt index 9614d456ec..ca9a1ae74b 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpResourcesWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpResourcesWriter.kt @@ -9,7 +9,7 @@ package com.datadog.android.sessionreplay.internal.storage import com.datadog.android.sessionreplay.internal.processor.EnrichedResource internal class NoOpResourcesWriter : ResourcesWriter { - override fun write(enrichedResource: EnrichedResource) { + override fun write(enrichedResource: EnrichedResource, sessionId: String, onWritten: () -> Unit) { // no-op } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt index 3495ee6196..1540f79229 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt @@ -9,12 +9,60 @@ package com.datadog.android.sessionreplay.internal.storage import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord /** - * Will persists the serialized EnrichedRecord in the allocated Session Replay caching location. + * Writes the records into the storage. */ internal interface RecordWriter { /** - * Writes the record to disk. + * Writes the record into the storage. * @param record to write */ fun write(record: EnrichedRecord) + + /** + * FLASHCAT FORK - holds the records of this session in memory instead of writing them, until + * RUM says with [release] or [discard] what became of the session. What another session still + * holds is kept aside for that word. + * @param sessionId the RUM session whose replay is kept only if it reports an error + */ + fun withhold(sessionId: String) + + /** + * FLASHCAT FORK - the session now current is not held. If it is the session whose records are + * held, they are written now: it has been released. Otherwise what another session still holds + * is kept aside for RUM's word. + * @param sessionId the RUM session now current + */ + fun stopWithholding(sessionId: String) + + /** + * FLASHCAT FORK - writes what is held for this session, whether it is still current or has + * ended since. Nothing happens if nothing is held for it. + * @param sessionId the RUM session whose events were released + */ + fun release(sessionId: String) + + /** + * FLASHCAT FORK - throws away what is held for this session, and whatever of it arrives later. + * @param sessionId the RUM session that ended without reporting an error + */ + fun discard(sessionId: String) + + /** + * FLASHCAT FORK - tracking consent was withdrawn: whatever is held, of any session, is dropped. + */ + fun dropForConsent() + + /** + * FLASHCAT FORK - the session reported its error: its replay will be released once its events + * are. Noted now so that a stop in between still writes it. + * @param sessionId the RUM session that reported an error + */ + fun expectRelease(sessionId: String) + + /** + * FLASHCAT FORK - the SDK is stopping: what is held for a session known to be released is + * written now, whatever is held for any other session is thrown away. + * @param onDone called once that is done, on the storage thread - or at once if there is none + */ + fun stop(onDone: () -> Unit) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/ResourcesWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/ResourcesWriter.kt index 09bab2f5ab..a31cc76538 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/ResourcesWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/ResourcesWriter.kt @@ -12,6 +12,9 @@ internal interface ResourcesWriter { /** * Writes the resource to disk. * @param enrichedResource to write + * @param sessionId the RUM session the resource was captured in (FLASHCAT FORK - so a resource + * of a session whose replay is withheld is held with its records) + * @param onWritten called once the resource is handed to storage, which is when it counts as sent */ - fun write(enrichedResource: EnrichedResource) + fun write(enrichedResource: EnrichedResource, sessionId: String, onWritten: () -> Unit) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt index 83c22e6734..809a0d505f 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt @@ -6,32 +6,366 @@ package com.datadog.android.sessionreplay.internal.storage +import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType import com.datadog.android.api.storage.RawBatchEvent +import com.datadog.android.privacy.TrackingConsent import com.datadog.android.sessionreplay.internal.RecordCallback import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord +import com.datadog.android.sessionreplay.internal.processor.EnrichedResource +import com.datadog.android.sessionreplay.model.MobileSegment +import java.util.concurrent.TimeUnit +/** + * Writes the records - and, through [ResourcesWriter], the images they refer to - unless their + * session is one whose replay is kept only in case it reports an error (FLASHCAT FORK). Those are + * held in memory until the session is released, and thrown away if it never is: an image is part of + * what the screen showed, and must no more reach the intake than the records do. + */ internal class SessionReplayRecordWriter( private val sdkCore: FeatureSdkCore, - private val recordCallback: RecordCallback -) : RecordWriter { + private val recordCallback: RecordCallback, + private val resourcesWriter: ResourcesWriter, + /** + * Tells the recorder the images were dropped unsent, so it captures them again when shown: + * it captures each image once, and would otherwise never send one dropped here. + */ + private val forgetResources: (Collection) -> Unit +) : RecordWriter, ResourcesWriter { + + private class HeldRecord(val record: EnrichedRecord, val data: ByteArray, val resourceIds: Set) + + private class HeldResource(val resource: EnrichedResource, val onWritten: () -> Unit) + + /** What one session holds while its replay waits for an error. */ + private class Buffer(val sessionId: String) { + val records = ArrayList() + var bytes = 0L + var droppedCount = 0 + + /** + * The latest meta and focus records of each view held. A view's first segment needs them to + * be playable, and cutting the buffer at a periodic full snapshot leaves them behind. + */ + val viewStartRecords = HashMap>() + } + + // Everything below is touched on the storage thread only, in submission order, which keeps the + // control calls in step with the records they concern. + + /** The session whose records are held rather than written. */ + private var current: Buffer? = null + + /** + * The sessions that ended while still held. RUM decides on the storage thread whether such a + * session is released or thrown away, and its word reaches here one hop later than the next + * session's announcement: until it does, what the session held is kept aside. Oldest first; + * more than a couple only when storage lags several sessions behind, and then the oldest go. + */ + private val parked = ArrayList() + + /** + * The images captured while a replay was withheld, kept across sessions. The recorder captures + * an image once per process, so one thrown away with a session that never errored would be + * missing from every later replay that shows it. An image leaves only with records that show + * it, so nothing of a session that never errored is ever sent. + */ + private val heldResources = LinkedHashMap() + private var heldResourceBytes = 0L + + /** + * The sessions whose held records were thrown away: records still queued for them when they + * ended must not reach the intake on their own. + */ + private val discardedSessionIds = ArrayDeque() + + /** + * RUM's word on the sessions it ended, true for released, noted the moment it is given rather + * than when the storage thread gets to act on it: a session already released is never the one + * to go when too many ended sessions wait, its records are about to be written. + */ + private val fates = LinkedHashMap() + override fun write(record: EnrichedRecord) { + onStorageThread { writer, consent -> + if (record.sessionId in discardedSessionIds) return@onStorageThread + // A record of a session kept aside is held with it: it shares that session's fate. + val buffer = bufferOf(record.sessionId) + when { + buffer == null -> { + writeNow(writer, record, serialize(record)) + // The recorder captures an image once per process: one it captured while a + // replay was withheld leaves with the first sent record that shows it. + if (heldResources.isNotEmpty()) sendResources(record.resourceIds(), record.sessionId) + } + // Nothing may be held while consent is withdrawn, and what was held under the + // consent now withdrawn goes too. + consent == TrackingConsent.NOT_GRANTED -> dropAllForConsent() + else -> hold(buffer, record) + } + } + } + + override fun write(enrichedResource: EnrichedResource, sessionId: String, onWritten: () -> Unit) { + onStorageThread { _, consent -> + when { + consent == TrackingConsent.NOT_GRANTED -> { + clearResources() + // Captured once, like any image: told to the recorder so it is captured again. + forgetResources(listOf(enrichedResource.filename)) + } + bufferOf(sessionId) != null || sessionId in discardedSessionIds -> + holdResource(enrichedResource, onWritten) + else -> resourcesWriter.write(enrichedResource, sessionId, onWritten) + } + } + } + + override fun withhold(sessionId: String) { + onStorageThread { _, _ -> + // A session already thrown away is over: a word about it that arrives late changes nothing. + if (sessionId in discardedSessionIds || current?.sessionId == sessionId) return@onStorageThread + current?.let(::park) + current = Buffer(sessionId) + } + } + + override fun stopWithholding(sessionId: String) { + onStorageThread { writer, _ -> + val buffer = current ?: return@onStorageThread + if (buffer.sessionId == sessionId) release(writer, buffer) else park(buffer) + } + } + + override fun release(sessionId: String) { + noteFate(sessionId, released = true) + onStorageThread { writer, _ -> + bufferOf(sessionId)?.let { release(writer, it) } + } + } + + override fun discard(sessionId: String) { + noteFate(sessionId, released = false) + onStorageThread { _, _ -> + bufferOf(sessionId)?.let(::discard) + } + } + + private fun noteFate(sessionId: String, released: Boolean) { + synchronized(this) { + fates.remove(sessionId) + fates[sessionId] = released + while (fates.size > FATES_REMEMBERED) fates.remove(fates.keys.first()) + } + } + + override fun dropForConsent() { + onStorageThread { _, _ -> dropAllForConsent() } + } + + override fun expectRelease(sessionId: String) { + noteFate(sessionId, released = true) + } + + override fun stop(onDone: () -> Unit) { + val feature = sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + if (feature == null) { + onDone() + return + } + feature.withWriteContext { _, writeScope -> + writeScope { writer -> + synchronized(this@SessionReplayRecordWriter) { + (listOfNotNull(current) + parked).forEach { buffer -> + if (fates[buffer.sessionId] == true) release(writer, buffer) else discard(buffer) + } + } + onDone() + } + } + } + + private fun bufferOf(sessionId: String): Buffer? = + current?.takeIf { it.sessionId == sessionId } ?: parked.firstOrNull { it.sessionId == sessionId } + + /** The session is no longer current: kept aside for RUM's word, unless that word was already given. */ + private fun park(buffer: Buffer) { + if (current === buffer) current = null + if (fates[buffer.sessionId] == false) { + discard(buffer) + return + } + parked.add(buffer) + val waiting = parked.filter { fates[it.sessionId] != true } + if (waiting.size > PARKED_LIMIT) discard(waiting.first()) + } + + private fun hold(buffer: Buffer, record: EnrichedRecord) { + record.records.filter { it.isViewStart() }.takeIf { it.isNotEmpty() }?.let { + buffer.viewStartRecords[record.viewId] = it + } + val fullSnapshotAt = record.fullSnapshotTimestamp() + // History can only be replayed from a full snapshot, so holding starts at one: a touch + // before the first, or a mutation captured after consent was withdrawn and granted back, + // would open the release with nothing to apply to. + if (buffer.records.isEmpty() && fullSnapshotAt == null) { + buffer.droppedCount++ + return + } + val data = serialize(record) + buffer.records.add(HeldRecord(record, data, record.resourceIds())) + buffer.bytes += data.size + recordCallback.onRecordForViewWithheld(record) + // Only the last minute is kept, and history can only be cut where it can be replayed from: + // at a full snapshot. Kept from the newest full snapshot that is at least a minute old, so + // what is released always spans the whole minute and starts playable. + if (fullSnapshotAt != null) { + val firstInWindow = buffer.records.indexOfLast { + (it.record.fullSnapshotTimestamp() ?: Long.MAX_VALUE) <= fullSnapshotAt - WINDOW_MS + } + if (firstInWindow > 0) dropOldest(buffer, firstInWindow) + } + // Memory bound: the oldest span goes first. A single span over the whole budget goes too - + // holding starts over at the next full snapshot - since the bound is a promise to the app. + while (buffer.bytes > BYTES_LIMIT) { + val nextFullSnapshot = buffer.records.indexOfFirstFrom(1) { it.record.fullSnapshotTimestamp() != null } + dropOldest(buffer, if (nextFullSnapshot == -1) buffer.records.size else nextFullSnapshot) + } + } + + private fun holdResource(resource: EnrichedResource, onWritten: () -> Unit) { + val size = resource.resource.size + // One image the size of the whole budget would only ever evict every other. + if (size > BYTES_LIMIT || heldResources.containsKey(resource.filename)) return + heldResources[resource.filename] = HeldResource(resource, onWritten) + heldResourceBytes += size + // Images are bounded apart from the records; the oldest go first, and the recorder is told + // so that it captures them again when they are shown again. + val evicted = ArrayList() + while (heldResourceBytes > BYTES_LIMIT) { + val oldest = heldResources.keys.first() + heldResourceBytes -= heldResources.remove(oldest)?.resource?.resource?.size ?: 0 + evicted.add(oldest) + } + if (evicted.isNotEmpty()) forgetResources(evicted) + } + + private fun dropOldest(buffer: Buffer, count: Int) { + val dropped = buffer.records.subList(0, count) + buffer.bytes -= dropped.sumOf { it.data.size.toLong() } + buffer.droppedCount += dropped.size + recordCallback.onWithheldRecordsCleared(dropped.map { it.record }) + val lastDroppedViewId = dropped.last().record.viewId + dropped.clear() + // A view's start is only ever needed by records still held of that view - or by the records + // to come of the view in progress, whose span may just have been dropped whole. + val viewsHeld = buffer.records.mapTo(HashSet()) { it.record.viewId } + buffer.viewStartRecords.keys.retainAll(viewsHeld + lastDroppedViewId) + } + + private fun release(writer: EventBatchWriter, buffer: Buffer) { + val first = buffer.records.firstOrNull()?.record + val startRecords = first?.let { buffer.viewStartRecords[it.viewId] } + buffer.records.forEachIndexed { index, heldRecord -> + if (index == 0 && first != null && startRecords != null && first.records.none { it.isViewStart() }) { + // Cut at a periodic full snapshot: the view's meta and focus were left behind, and a + // segment cannot be played without them. + val timestamp = first.fullSnapshotTimestamp() ?: startRecords.first().timestamp() + val record = first.copy(records = startRecords.map { it.at(timestamp) } + first.records) + writeNow(writer, record, serialize(record)) + } else { + writeNow(writer, heldRecord.record, heldRecord.data) + } + } + // Only the images these records show: any other held image belongs to what was never sent. + sendResources(buffer.records.flatMapTo(HashSet()) { it.resourceIds }, buffer.sessionId) + recordCallback.onWithheldRecordsCleared(buffer.records.map { it.record }) + // Without this the replay's promise of a minute before the error could not be checked. + sdkCore.internalLogger.log( + level = InternalLogger.Level.INFO, + target = InternalLogger.Target.TELEMETRY, + messageBuilder = { RELEASED_MESSAGE }, + throwable = null, + onlyOnce = false, + additionalProperties = mapOf( + "buffer.records_count" to buffer.records.sumOf { it.record.records.size }, + "buffer.duration_ms" to buffer.records.durationMs(), + "buffer.dropped_count" to buffer.droppedCount + ) + ) + forget(buffer) + } + + private fun sendResources(resourceIds: Set, sessionId: String) { + resourceIds.forEach { id -> + heldResources.remove(id)?.let { held -> + heldResourceBytes -= held.resource.resource.size + resourcesWriter.write(held.resource, sessionId, held.onWritten) + } + } + } + + private fun discard(buffer: Buffer) { + discardedSessionIds.addLast(buffer.sessionId) + if (discardedSessionIds.size > DISCARDED_SESSIONS_REMEMBERED) { + discardedSessionIds.removeFirstOrNull() + } + recordCallback.onWithheldRecordsCleared(buffer.records.map { it.record }) + forget(buffer) + } + + private fun forget(buffer: Buffer) { + if (current === buffer) current = null + parked.remove(buffer) + } + + /** + * Consent was withdrawn: what was held under the consent now withdrawn is dropped, and the + * sessions hold again from scratch should consent be granted back. The views' meta and focus + * stay: they say how big the screen is, not what it showed, and the view in progress will not + * record them again. + */ + private fun dropAllForConsent() { + (listOfNotNull(current) + parked).forEach { buffer -> + recordCallback.onWithheldRecordsCleared(buffer.records.map { it.record }) + buffer.droppedCount += buffer.records.size + // Only the view in progress will hold records again without recording its start anew. + val viewInProgress = buffer.records.lastOrNull()?.record?.viewId + buffer.records.clear() + buffer.bytes = 0L + buffer.viewStartRecords.keys.retainAll(listOfNotNull(viewInProgress)) + } + clearResources() + } + + private fun clearResources() { + if (heldResources.isNotEmpty()) forgetResources(heldResources.keys.toList()) + heldResources.clear() + heldResourceBytes = 0L + } + + private fun writeNow(writer: EventBatchWriter, record: EnrichedRecord, data: ByteArray) { + val success = writer.write( + event = RawBatchEvent(data = data), + batchMetadata = null, + eventType = EventType.DEFAULT + ) + if (success) { + // Counted only once written: a view claims a replay for records actually sent, so + // held records that are thrown away never count. + updateViewSent(record) + } + } + + private fun onStorageThread(block: (EventBatchWriter, TrackingConsent) -> Unit) { sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) - ?.withWriteContext { _, writeScope -> + ?.withWriteContext { datadogContext, writeScope -> writeScope { - val serializedRecord = record.toJson().toByteArray(Charsets.UTF_8) - val rawBatchEvent = RawBatchEvent(data = serializedRecord) synchronized(this@SessionReplayRecordWriter) { - val success = it.write( - event = rawBatchEvent, - batchMetadata = null, - eventType = EventType.DEFAULT - ) - if (success) { - updateViewSent(record) - } + block(it, datadogContext.trackingConsent) } } } @@ -46,4 +380,81 @@ internal class SessionReplayRecordWriter( */ recordCallback.onRecordForViewSent(record) } + + internal companion object { + /** How much replay a withheld session keeps: the minute leading up to its error. */ + internal val WINDOW_MS = TimeUnit.SECONDS.toMillis(60) + + /** + * Memory bound on the serialized records a session holds, and the same again on the images. + * The records are held as objects too, so a buffer weighs up to about twice this. + */ + internal const val BYTES_LIMIT = 4L * 1024 * 1024 + + /** How many ended sessions wait for a word RUM has not given yet; beyond that the oldest goes. */ + internal const val PARKED_LIMIT = 2 + + /** More than a browser tab would need: a stopped session keeps draining while others come and go. */ + private const val DISCARDED_SESSIONS_REMEMBERED = 64 + + private const val FATES_REMEMBERED = 16 + + internal const val RELEASED_MESSAGE = "Error session replay buffer released" + + private fun serialize(record: EnrichedRecord) = record.toJson().toByteArray(Charsets.UTF_8) + + private fun EnrichedRecord.fullSnapshotTimestamp(): Long? = + records.firstNotNullOfOrNull { (it as? MobileSegment.MobileRecord.MobileFullSnapshotRecord)?.timestamp } + + /** The images these records show. */ + private fun EnrichedRecord.resourceIds(): Set { + val ids = HashSet() + records.forEach { record -> + when (record) { + is MobileSegment.MobileRecord.MobileFullSnapshotRecord -> + record.data.wireframes.forEach { it.resourceId()?.let(ids::add) } + is MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord -> + (record.data as? MobileSegment.MobileIncrementalData.MobileMutationData)?.let { mutation -> + mutation.adds.forEach { it.wireframe.resourceId()?.let(ids::add) } + mutation.updates.forEach { + (it as? MobileSegment.WireframeUpdateMutation.ImageWireframeUpdate) + ?.resourceId?.let(ids::add) + } + } + else -> Unit + } + } + return ids + } + + private fun MobileSegment.Wireframe.resourceId(): String? = + (this as? MobileSegment.Wireframe.ImageWireframe)?.resourceId + + private fun MobileSegment.MobileRecord.isViewStart() = + this is MobileSegment.MobileRecord.MetaRecord || this is MobileSegment.MobileRecord.FocusRecord + + private fun MobileSegment.MobileRecord.timestamp(): Long = when (this) { + is MobileSegment.MobileRecord.MetaRecord -> timestamp + is MobileSegment.MobileRecord.FocusRecord -> timestamp + else -> 0L + } + + private fun MobileSegment.MobileRecord.at(timestamp: Long): MobileSegment.MobileRecord = when (this) { + is MobileSegment.MobileRecord.MetaRecord -> copy(timestamp = timestamp) + is MobileSegment.MobileRecord.FocusRecord -> copy(timestamp = timestamp) + else -> this + } + + private inline fun List.indexOfFirstFrom(from: Int, predicate: (T) -> Boolean): Int { + for (index in from until size) { + if (predicate(this[index])) return index + } + return -1 + } + + private fun List.durationMs(): Long { + val stamps = mapNotNull { it.record.fullSnapshotTimestamp() } + return (stamps.maxOrNull() ?: 0L) - (stamps.minOrNull() ?: 0L) + } + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayResourcesWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayResourcesWriter.kt index 9dd8e26d39..be6fc3a467 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayResourcesWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayResourcesWriter.kt @@ -17,7 +17,7 @@ import com.datadog.android.sessionreplay.internal.processor.asBinaryMetadata internal class SessionReplayResourcesWriter( private val sdkCore: FeatureSdkCore ) : ResourcesWriter { - override fun write(enrichedResource: EnrichedResource) { + override fun write(enrichedResource: EnrichedResource, sessionId: String, onWritten: () -> Unit) { sdkCore.getFeature(Feature.SESSION_REPLAY_RESOURCES_FEATURE_NAME) ?.withWriteContext( withFeatureContexts = setOf(Feature.RUM_FEATURE_NAME) @@ -25,7 +25,7 @@ internal class SessionReplayResourcesWriter( writeScope { synchronized(this@SessionReplayResourcesWriter) { val serializedMetadata = enrichedResource.asBinaryMetadata(datadogContext.rumApplicationId) - it.write( + val written = it.write( event = RawBatchEvent( data = enrichedResource.resource, metadata = serializedMetadata @@ -33,6 +33,8 @@ internal class SessionReplayResourcesWriter( batchMetadata = null, eventType = EventType.DEFAULT ) + // FLASHCAT FORK - a resource counts as sent only once it is in storage. + if (written) onWritten() } } } 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 3ac4e7a8a2..721b73b67e 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 @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureContextUpdateReceiver import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.sampling.Sampler +import com.datadog.android.privacy.TrackingConsent import com.datadog.android.sessionreplay.NoOpSessionReplayInternalCallback import com.datadog.android.sessionreplay.SessionReplayConfiguration import com.datadog.android.sessionreplay.forge.ForgeConfigurator @@ -20,6 +21,7 @@ import com.datadog.android.sessionreplay.internal.recorder.NoOpRecorder import com.datadog.android.sessionreplay.internal.recorder.Recorder import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder import com.datadog.android.sessionreplay.internal.storage.NoOpRecordWriter +import com.datadog.android.sessionreplay.internal.storage.RecordWriter import com.datadog.android.sessionreplay.internal.storage.SessionReplayRecordWriter import com.datadog.android.sessionreplay.utils.config.ApplicationContextTestConfiguration import com.datadog.android.sessionreplay.utils.verifyLog @@ -41,10 +43,12 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock +import org.mockito.Mockito.mockingDetails 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.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder @@ -535,6 +539,287 @@ internal class SessionReplayFeatureTest { verifyNoMoreInteractions(mockRecorder) } + // region Replay on error + + private fun onErrorMessage( + keep: Boolean, + eventsOnError: Boolean, + replayOnError: Boolean, + released: Boolean = false, + forced: Boolean = false + ) = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to keep, + SessionReplayFeature.RUM_SESSION_FORCED_BUS_MESSAGE_KEY to forced, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId, + SessionReplayFeature.RUM_SESSION_ON_ERROR_BUS_MESSAGE_KEY to eventsOnError, + SessionReplayFeature.RUM_REPLAY_ON_ERROR_BUS_MESSAGE_KEY to replayOnError, + SessionReplayFeature.RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to released + ) + + private fun initializeWithWriter(sampledIn: Boolean): RecordWriter { + whenever(mockSampler.sample(any())).thenReturn(sampledIn) + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.stopRecording() + val mockWriter = mock() + testedFeature.dataWriter = mockWriter + return mockWriter + } + + @Test + fun `M record withheld W rum session updated { kept, replay sampled out, replay switch on }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + + // When + testedFeature.onReceive(onErrorMessage(keep = true, eventsOnError = false, replayOnError = true)) + + // Then + verify(mockRecorder).resumeRecorders() + verify(mockWriter).withhold(fakeSessionId) + } + + @Test + fun `M not record W rum session updated { kept, replay sampled out, replay switch off }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + + // When + testedFeature.onReceive(onErrorMessage(keep = true, eventsOnError = false, replayOnError = false)) + + // Then + verify(mockRecorder, never()).resumeRecorders() + verify(mockWriter, never()).withhold(any()) + } + + @Test + fun `M record normally W rum session updated { kept, replay sampled in, replay switch on }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = true) + + // When + testedFeature.onReceive(onErrorMessage(keep = true, eventsOnError = false, replayOnError = true)) + + // Then + verify(mockRecorder).resumeRecorders() + verify(mockWriter, never()).withhold(any()) + verify(mockWriter).stopWithholding(fakeSessionId) + } + + @Test + fun `M hold the sampled replay with the events W rum session updated { events withheld }`() { + // Given - the replay draw kept it, but the events are held: so is the replay + val mockWriter = initializeWithWriter(sampledIn = true) + + // When + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = false)) + + // Then + verify(mockRecorder).resumeRecorders() + verify(mockWriter).withhold(fakeSessionId) + } + + @Test + fun `M record withheld W rum session updated { events withheld, replay switch on }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + + // When + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = true)) + + // Then + verify(mockRecorder).resumeRecorders() + verify(mockWriter).withhold(fakeSessionId) + } + + @Test + fun `M not record W rum session updated { events withheld, replay sampled out, switch off }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + + // When + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = false)) + + // Then + verify(mockRecorder, never()).resumeRecorders() + verify(mockWriter, never()).withhold(any()) + } + + @Test + fun `M release and keep recording W rum session updated { withheld session released }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = true)) + + // When + testedFeature.onReceive( + onErrorMessage(keep = true, eventsOnError = true, replayOnError = true, released = true) + ) + + // Then + inOrder(mockWriter) { + verify(mockWriter).withhold(fakeSessionId) + verify(mockWriter).stopWithholding(fakeSessionId) + } + verify(mockRecorder).resumeRecorders() + verify(mockRecorder, never()).stopRecorders() + } + + @Test + fun `M release W rum session released { events released }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = true)) + + // When + testedFeature.onReceive( + mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RELEASED_BUS_MESSAGE, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + ) + + // Then + inOrder(mockWriter) { + verify(mockWriter).withhold(fakeSessionId) + verify(mockWriter).stopWithholding(fakeSessionId) + } + verify(mockRecorder, never()).stopRecorders() + } + + @Test + fun `M release what a past session held W rum session released { another session is current }`() { + // Given - the past session's word lands after the next one announced itself + val mockWriter = initializeWithWriter(sampledIn = false) + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = true)) + + // When + testedFeature.onReceive( + mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RELEASED_BUS_MESSAGE, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to "past-session" + ) + ) + + // Then + verify(mockWriter).release("past-session") + verify(mockWriter, never()).stopWithholding(any()) + verify(mockRecorder, never()).stopRecorders() + } + + @Test + fun `M drop what is held W onConsentUpdated { consent not granted }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = true)) + + // When + testedFeature.onConsentUpdated(TrackingConsent.PENDING, TrackingConsent.NOT_GRANTED) + testedFeature.onConsentUpdated(TrackingConsent.NOT_GRANTED, TrackingConsent.GRANTED) + + // Then + verify(mockWriter).dropForConsent() + } + + @Test + fun `M expect the release W rum session errored`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = true)) + + // When + testedFeature.onReceive( + mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_ERRORED_BUS_MESSAGE, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + ) + + // Then + verify(mockWriter).expectRelease(fakeSessionId) + } + + @Test + fun `M settle what the writer holds and wait for it W onStop()`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + whenever(mockWriter.stop(any())) doAnswer { it.getArgument<() -> Unit>(0).invoke() } + + // When + testedFeature.onStop() + + // Then + verify(mockWriter).stop(any()) + assertThat(testedFeature.dataWriter).isInstanceOf(NoOpRecordWriter::class.java) + } + + @Test + fun `M throw away what a session held W rum session discarded`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = true)) + + // When + testedFeature.onReceive( + mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_DISCARDED_BUS_MESSAGE, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + ) + + // Then + verify(mockWriter).discard(fakeSessionId) + } + + @Test + fun `M not release W rum session updated { error seen, events not released yet }`() { + // Given - the message keeps saying the replay may not go out until the events have + val mockWriter = initializeWithWriter(sampledIn = false) + testedFeature.onReceive(onErrorMessage(keep = false, eventsOnError = true, replayOnError = true)) + + // When + testedFeature.onReceive(onErrorMessage(keep = true, eventsOnError = true, replayOnError = true)) + + // Then + verify(mockWriter, never()).stopWithholding(any()) + } + + @Test + fun `M leave the feature context alone W rum session updated { no on-error mode }`() { + // Given + whenever(mockSampler.sample(any())).thenReturn(true) + testedFeature.onInitialize(appContext.mockInstance) + val updatesBefore = mockingDetails(mockSdkCore).invocations.count { it.method.name == "updateFeatureContext" } + + // When + testedFeature.onReceive(onErrorMessage(keep = true, eventsOnError = false, replayOnError = false)) + + // Then - only the recording flag, as before this existed + val updates = mockingDetails(mockSdkCore).invocations.count { it.method.name == "updateFeatureContext" } + assertThat(updates - updatesBefore).isEqualTo(1) + } + + @Test + fun `M release W rum session updated { withheld replay forced }`() { + // Given + val mockWriter = initializeWithWriter(sampledIn = false) + testedFeature.onReceive(onErrorMessage(keep = true, eventsOnError = false, replayOnError = true)) + + // When + testedFeature.onReceive(onErrorMessage(keep = true, eventsOnError = false, replayOnError = true, forced = true)) + + // Then + verify(mockWriter).stopWithholding(fakeSessionId) + verify(mockRecorder, never()).stopRecorders() + } + + // endregion + @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 diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt index 0118daa9a4..dc74e56734 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallbackTest.kt @@ -21,11 +21,14 @@ import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock 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.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -165,4 +168,34 @@ internal class SessionReplayRecordCallbackTest { private fun Forge.forgeEmptyValidEnrichedRecord(): EnrichedRecord { return getForgery().copy(records = emptyList()) } + + @Suppress("UNCHECKED_CAST") + @Test + fun `M count held records apart from sent ones W onRecordForViewWithheld then cleared`() { + // Given + val featureContext = mutableMapOf() + whenever(mockDatadogCore.updateFeatureContext(eq(Feature.SESSION_REPLAY_FEATURE_NAME), eq(false), any())) + .doAnswer { it.getArgument<(MutableMap) -> Unit>(2).invoke(featureContext) } + val size = fakeEnrichedRecord.records.size.toLong() + + // When + testedRecordCallback.onRecordForViewWithheld(fakeEnrichedRecord) + testedRecordCallback.onRecordForViewWithheld(fakeEnrichedRecord) + val viewMetadata = featureContext[fakeEnrichedRecord.viewId] as Map + val heldCount = viewMetadata[SessionReplayRecordCallback.VIEW_WITHHELD_RECORDS_COUNT_KEY] + testedRecordCallback.onWithheldRecordsCleared(listOf(fakeEnrichedRecord)) + val afterOneCleared = viewMetadata[SessionReplayRecordCallback.VIEW_WITHHELD_RECORDS_COUNT_KEY] + testedRecordCallback.onWithheldRecordsCleared(listOf(fakeEnrichedRecord)) + + // Then - nothing claimed as sent, and the held count goes back to nothing + assertThat(heldCount).isEqualTo(2 * size) + assertThat(afterOneCleared).isEqualTo(size) + assertThat(viewMetadata).doesNotContainKeys( + SessionReplayRecordCallback.VIEW_WITHHELD_RECORDS_COUNT_KEY, + SessionReplayRecordCallback.HAS_REPLAY_KEY, + SessionReplayRecordCallback.VIEW_RECORDS_COUNT_KEY + ) + // and the entry left with nothing in it goes too + assertThat(featureContext).doesNotContainKey(fakeEnrichedRecord.viewId) + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt index b7706b109b..b59fca8b4a 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt @@ -40,6 +40,8 @@ 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.eq import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -1244,7 +1246,7 @@ internal class RecordedDataProcessorTest { // Then val captor = argumentCaptor() - verify(mockResourcesWriter, times(1)).write(captor.capture()) + verify(mockResourcesWriter, times(1)).write(captor.capture(), eq(fakeRumContext.sessionId), any()) val capturedResource = captor.allValues[0] assertThat(capturedResource.resource).isEqualTo(fakeByteArray) @@ -1264,6 +1266,7 @@ internal class RecordedDataProcessorTest { val fakeByteArray = forge.anAlphaNumericalString().toByteArray() val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) whenever(mockResourceDataStoreManager.isReady()).thenReturn(false) + whenever(mockResourcesWriter.write(any(), any(), any())) doAnswer { it.getArgument<() -> Unit>(2).invoke() } // When testedProcessor.processResources(fakeResourceItem) @@ -1280,6 +1283,7 @@ internal class RecordedDataProcessorTest { val fakeByteArray = forge.anAlphaNumericalString().toByteArray() val fakeResourceItem = createResourceItem(fakeByteArray, usedContext = initialRecordedQueuedItemContext) whenever(mockResourceDataStoreManager.isReady()).thenReturn(true) + whenever(mockResourcesWriter.write(any(), any(), any())) doAnswer { it.getArgument<() -> Unit>(2).invoke() } // When testedProcessor.processResources(fakeResourceItem) @@ -1288,6 +1292,22 @@ internal class RecordedDataProcessorTest { verify(mockResourceDataStoreManager, times(1)).cacheResourceHash(fakeIdentifier) } + @Test + fun `M not store resource in datastore W processResources { resource not written yet }`(forge: Forge) { + // Given - held with a withheld replay: not sent, so not remembered as sent + val fakeResourceItem = createResourceItem( + forge.anAlphaNumericalString().toByteArray(), + usedContext = initialRecordedQueuedItemContext + ) + whenever(mockResourceDataStoreManager.isReady()).thenReturn(true) + + // When + testedProcessor.processResources(fakeResourceItem) + + // Then + verify(mockResourceDataStoreManager, never()).cacheResourceHash(fakeIdentifier) + } + @Test fun `M not write resource data W processResources { resource was previously seen }`( @StringForgery fakeString: String diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceItemCreationHandlerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceItemCreationHandlerTest.kt index e1fdf004b3..d1818ced92 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceItemCreationHandlerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceItemCreationHandlerTest.kt @@ -19,6 +19,7 @@ import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.quality.Strictness @@ -59,6 +60,40 @@ internal class ResourceItemCreationHandlerTest { ) } + @Test + fun `M queue item again W queueItem() { forgotten since }`() { + // Given + val fakeByteArray = fakeResourceId.toByteArray() + testedHandler.queueItem(fakeResourceId, fakeByteArray) + + // When + testedHandler.forget(listOf(fakeResourceId)) + val forgotten = testedHandler.isForgotten(fakeResourceId) + testedHandler.queueItem(fakeResourceId, fakeByteArray) + + // Then + assertThat(forgotten).isTrue + assertThat(testedHandler.isForgotten(fakeResourceId)).isFalse + verify(mockDataQueueHandler, times(2)).addResourceItem( + identifier = fakeResourceId, + resourceData = fakeByteArray + ) + } + + @Test + fun `M forget the oldest forgotten ids W forget() { over the limit }`() { + // Given + val ids = List(ResourceItemCreationHandler.FORGOTTEN_RESOURCE_IDS_LIMIT + 1) { "r$it" } + + // When + testedHandler.forget(ids) + + // Then + assertThat(testedHandler.isForgotten("r0")).isFalse + assertThat(testedHandler.isForgotten(ids.last())).isTrue + assertThat(testedHandler.forgottenResourceIds).hasSize(ResourceItemCreationHandler.FORGOTTEN_RESOURCE_IDS_LIMIT) + } + @Test fun `M not queue item W queueItem() { previously seen }`() { // Given diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolverTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolverTest.kt index f274a4c9f0..e4e2ba52b9 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolverTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolverTest.kt @@ -326,6 +326,37 @@ internal class ResourceResolverTest { verify(mockSerializerCallback).onFailure() } + @Test + fun `M resolve the drawable again W resolveResourceIdFromDrawable() { cache hit, resource forgotten }`() { + // Given + whenever(mockBitmapCachesManager.getFromResourceCache(fakeResourceKey)).thenReturn(fakeResourceId) + testedResourceResolver.forgetResources(listOf(fakeResourceId)) + + // When + testedResourceResolver.resolveResourceIdFromDrawable( + resources = mockResources, + applicationContext = mockApplicationContext, + displayMetrics = mockDisplayMetrics, + originalDrawable = mockDrawable, + drawableCopier = mockDrawableCopier, + drawableWidth = mockDrawable.intrinsicWidth, + drawableHeight = mockDrawable.intrinsicHeight, + customResourceIdCacheKey = null, + resourceResolverCallback = mockSerializerCallback + ) + + // Then + verify(mockDrawableUtils).createBitmapOfApproxSizeFromDrawable( + drawable = any(), + drawableWidth = any(), + drawableHeight = any(), + displayMetrics = any(), + requestedSizeInBytes = anyOrNull(), + config = anyOrNull(), + bitmapCreationCallback = any() + ) + } + @Test fun `M calculate resourceId W resolveResourceIdFromDrawable() { cache miss }`() { // Given diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt index 6fef625248..f5d4b07044 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.storage +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 @@ -14,24 +15,34 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType import com.datadog.android.api.storage.RawBatchEvent +import com.datadog.android.privacy.TrackingConsent import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.RecordCallback import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord +import com.datadog.android.sessionreplay.internal.processor.EnrichedResource +import com.datadog.android.sessionreplay.model.MobileSegment import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +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.junit.jupiter.api.extension.Extensions import org.mockito.Mock +import org.mockito.Mockito.mockingDetails import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever @@ -73,7 +84,9 @@ internal class SessionReplayRecordWriterTest { whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) .thenReturn(mockSessionReplayFeature) - testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback) + testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback, mockResourcesWriter) { + forgottenResources.addAll(it) + } } @Test @@ -149,6 +162,734 @@ internal class SessionReplayRecordWriterTest { verifyNoMoreInteractions(mockRecordCallback) } + // region Withholding + + private val written = mutableListOf() + + private fun recordWrites(consent: TrackingConsent = TrackingConsent.GRANTED) { + fakeDatadogContext = fakeDatadogContext.copy(trackingConsent = consent) + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + whenever(mockEventBatchWriter.write(anyOrNull(), anyOrNull(), any())) doAnswer { + written.add(EnrichedRecordTag.of(it.getArgument(0))) + true + } + whenever(mockEventWriteScope.invoke(any())) doAnswer { + it.getArgument<(EventBatchWriter) -> Unit>(0).invoke(mockEventBatchWriter) + } + whenever(mockSessionReplayFeature.withWriteContext(any(), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) + callback.invoke(fakeDatadogContext, mockEventWriteScope) + } + } + + /** A record tagged through its view id, with a full snapshot at [fullSnapshotAt] if given. */ + private fun record(tag: String, sessionId: String, fullSnapshotAt: Long? = null): EnrichedRecord { + val records = listOfNotNull( + fullSnapshotAt?.let { + MobileSegment.MobileRecord.MobileFullSnapshotRecord( + it, + MobileSegment.Data(emptyList()) + ) + } + ) + return EnrichedRecord("app", sessionId, tag, records) + } + + private object EnrichedRecordTag { + fun of(event: RawBatchEvent): String = + com.google.gson.JsonParser.parseString(String(event.data)).asJsonObject.get("view_id").asString + } + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Mock + lateinit var mockResourcesWriter: ResourcesWriter + + private fun resource(hash: String, size: Int = 10) = EnrichedResource(ByteArray(size), hash) + + private val forgottenResources = mutableListOf() + + /** Storage work queued rather than run, as the real write scope does; [runQueued] runs it in order. */ + private val queued = mutableListOf<() -> Unit>() + + private fun deferWrites() { + recordWrites() + whenever(mockSessionReplayFeature.withWriteContext(any(), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventWriteScope) -> Unit>(it.arguments.lastIndex) + queued.add { callback.invoke(fakeDatadogContext, mockEventWriteScope) } + Unit + } + } + + private fun runQueued() { + while (queued.isNotEmpty()) queued.removeAt(0).invoke() + } + + @Test + fun `M keep a released session aside until its write W withhold { its word given, storage behind }`() { + // Given - RUM's word for s0 is given while the next sessions' announcements are still queued + deferWrites() + testedWriter.withhold("s0") + testedWriter.write(record("r-s0", "s0", fullSnapshotAt = 1_000)) + runQueued() + val later = List(SessionReplayRecordWriter.PARKED_LIMIT + 2) { "s${it + 1}" } + later.forEach { testedWriter.withhold(it) } + testedWriter.release("s0") + + // When + runQueued() + + // Then - s0 was parked behind every later session yet never evicted, and went out + assertThat(written).containsExactly("r-s0") + } + + @Test + fun `M tell the recorder to forget an image W write(resource) { evicted over the image budget }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + val half = SessionReplayRecordWriter.BYTES_LIMIT.toInt() / 2 + testedWriter.write(resource("first", size = half), "s1") {} + testedWriter.write(resource("second", size = half), "s1") {} + + // When + testedWriter.write(resource("third", size = half), "s1") {} + + // Then + assertThat(forgottenResources).containsExactly("first") + } + + @Test + fun `M tell the recorder to forget an image W write(resource) { consent not granted }`() { + // Given - the recorder captured it once, under consent now withdrawn + recordWrites(consent = TrackingConsent.NOT_GRANTED) + testedWriter.withhold("s1") + + // When + testedWriter.write(resource("new"), "s1") {} + + // Then + assertThat(forgottenResources).containsExactly("new") + verify(mockResourcesWriter, never()).write(any(), any(), any()) + } + + @Test + fun `M tell the recorder to forget every image W dropForConsent`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(resource("a"), "s1") {} + testedWriter.write(resource("b"), "s1") {} + + // When + testedWriter.dropForConsent() + + // Then + assertThat(forgottenResources).containsExactlyInAnyOrder("a", "b") + } + + @Test + fun `M write a resource through W write(resource) { session not withheld }`() { + // Given + recordWrites() + val onWritten = {} + + // When + testedWriter.write(resource("img"), "s1", onWritten) + + // Then + verify(mockResourcesWriter).write(any(), eq("s1"), eq(onWritten)) + } + + @Test + fun `M hold resources and write them on release W write(resource) { session withheld }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(imageRecord("r1", "s1", 1_000, "img")) + testedWriter.write(resource("img"), "s1") {} + testedWriter.write(resource("img"), "s1") {} + + // When + val beforeRelease = mockingDetails(mockResourcesWriter).invocations.size + testedWriter.stopWithholding("s1") + + // Then + assertThat(beforeRelease).isZero() + verify(mockResourcesWriter, times(1)).write(any(), eq("s1"), any()) + } + + @Test + fun `M drop held resources W stopWithholding { another session }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(resource("img"), "s1") {} + + // When + testedWriter.stopWithholding("s2") + testedWriter.write(resource("late"), "s1") {} + + // Then + verify(mockResourcesWriter, never()).write(any(), any(), any()) + } + + @Test + fun `M count held records apart and clear them W hold then release`() { + // Given + recordWrites() + testedWriter.withhold("s1") + val held = record("r1", "s1", fullSnapshotAt = 1_000) + + // When + testedWriter.write(held) + testedWriter.stopWithholding("s1") + + // Then + inOrder(mockRecordCallback) { + verify(mockRecordCallback).onRecordForViewWithheld(held) + verify(mockRecordCallback).onRecordForViewSent(held) + verify(mockRecordCallback).onWithheldRecordsCleared(listOf(held)) + } + } + + @Test + fun `M start the release with the view's meta and focus W release { cut at a periodic full snapshot }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + val meta = MobileSegment.MobileRecord.MetaRecord(0, data = MobileSegment.Data1(100, 200)) + val focus = MobileSegment.MobileRecord.FocusRecord(0, data = MobileSegment.Data2(true)) + val full0 = MobileSegment.MobileRecord.MobileFullSnapshotRecord(0, MobileSegment.Data(emptyList())) + testedWriter.write(EnrichedRecord("app", "s1", "view", listOf(meta, focus, full0))) + testedWriter.write(record("view", "s1", fullSnapshotAt = 30_000)) + testedWriter.write(record("view", "s1", fullSnapshotAt = 95_000)) + val released = mutableListOf() + whenever(mockEventBatchWriter.write(anyOrNull(), anyOrNull(), any())) doAnswer { + released.add(String(it.getArgument(0).data)) + true + } + + // When + testedWriter.stopWithholding("s1") + + // Then - cut at the full snapshot of 30s, which now carries the meta and focus first + assertThat(released).hasSize(2) + val first = com.google.gson.JsonParser.parseString(released[0]).asJsonObject.getAsJsonArray("records") + assertThat(first.map { it.asJsonObject.get("type").asInt }).containsExactly(4, 6, 10) + assertThat(first.map { it.asJsonObject.get("timestamp").asLong }).containsOnly(30_000L) + } + + @Test + fun `M drop the oldest span but keep a full snapshot W hold { over the byte limit }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + val big = SessionReplayRecordWriter.BYTES_LIMIT.toInt() / 3 + testedWriter.write(bigRecord("old", "s1", 1_000, big)) + testedWriter.write(bigRecord("mid", "s1", 2_000, big)) + testedWriter.write(bigRecord("new", "s1", 3_000, big)) + + // When + testedWriter.stopWithholding("s1") + + // Then + assertThat(written).containsExactly("mid", "new") + } + + @Test + fun `M drop a single span over the byte limit and start over W hold`() { + // Given - the bound is a promise to the app, so even a span that cannot be cut goes + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(bigRecord("huge", "s1", 1_000, SessionReplayRecordWriter.BYTES_LIMIT.toInt() + 1)) + testedWriter.write(record("inc", "s1")) + testedWriter.write(record("next", "s1", fullSnapshotAt = 4_000)) + + // When + testedWriter.stopWithholding("s1") + + // Then - holding started over at the next full snapshot + assertThat(written).containsExactly("next") + } + + private fun bigRecord(tag: String, sessionId: String, timestamp: Long, size: Int): EnrichedRecord { + val text = MobileSegment.Wireframe.TextWireframe( + id = 1, + x = 0, + y = 0, + width = 1, + height = 1, + text = "x".repeat(size), + textStyle = MobileSegment.TextStyle("f", 1, "#000000") + ) + val full = MobileSegment.MobileRecord.MobileFullSnapshotRecord(timestamp, MobileSegment.Data(listOf(text))) + return EnrichedRecord("app", sessionId, tag, listOf(full)) + } + + @Test + fun `M hold the records W write { session withheld }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + + // When + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + testedWriter.write(record("other", "s2")) + + // Then + assertThat(written).containsExactly("other") + // records not written are not counted for their view + verify(mockRecordCallback).onRecordForViewSent(any()) + } + + @Test + fun `M write what was held in order W stopWithholding { session released }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + testedWriter.write(record("r2", "s1")) + + // When + testedWriter.stopWithholding("s1") + testedWriter.write(record("r3", "s1")) + + // Then + assertThat(written).containsExactly("r1", "r2", "r3") + verify(mockRecordCallback, times(3)).onRecordForViewSent(any()) + verify(mockInternalLogger).log( + eq(InternalLogger.Level.INFO), + eq(InternalLogger.Target.TELEMETRY), + any(), + anyOrNull(), + eq(false), + any() + ) + } + + @Test + fun `M throw away what was held and its stragglers W stopWithholding { another session }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + + // When + testedWriter.stopWithholding("s2") + testedWriter.write(record("late", "s1")) + testedWriter.write(record("next", "s2")) + + // Then + assertThat(written).containsExactly("next") + verify(mockRecordCallback).onRecordForViewSent(any()) + } + + @Test + fun `M throw away the previous session W withhold { new session }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + + // When + testedWriter.withhold("s2") + testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) + testedWriter.stopWithholding("s2") + + // Then + assertThat(written).containsExactly("r2") + } + + @Test + fun `M keep a playable minute W write { more than a minute held }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("full0", "s1", fullSnapshotAt = 0)) + testedWriter.write(record("inc0", "s1")) + testedWriter.write(record("full30", "s1", fullSnapshotAt = 30_000)) + testedWriter.write(record("inc30", "s1")) + testedWriter.write(record("full61", "s1", fullSnapshotAt = 61_000)) + testedWriter.write(record("full95", "s1", fullSnapshotAt = 95_000)) + + // When + testedWriter.stopWithholding("s1") + + // Then - cut at the newest full snapshot at least a minute older than the latest one + assertThat(written).containsExactly("full30", "inc30", "full61", "full95") + } + + @Test + fun `M keep a session aside W withhold { new session }`() { + // Given - RUM has yet to say whether the previous session is released or thrown away + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + + // When + testedWriter.withhold("s2") + testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) + testedWriter.write(record("r1b", "s1")) + + // Then - a late record of the session kept aside is kept with it + assertThat(written).isEmpty() + + // When - the previous session's word arrives + testedWriter.release("s1") + + // Then - what it held goes, what the current one holds stays + assertThat(written).containsExactly("r1", "r1b") + + // When + testedWriter.release("s2") + + // Then + assertThat(written).containsExactly("r1", "r1b", "r2") + } + + @Test + fun `M keep every session aside until its word W withhold { storage lags two sessions behind }`() { + // Given - RUM's word for s1 arrives only after s2 and s3 announced themselves + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + testedWriter.withhold("s2") + testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) + testedWriter.withhold("s3") + testedWriter.write(record("r3", "s3", fullSnapshotAt = 3_000)) + + // When + testedWriter.release("s1") + testedWriter.discard("s2") + testedWriter.release("s3") + + // Then + assertThat(written).containsExactly("r1", "r3") + } + + @Test + fun `M never throw away a released session kept aside W withhold { too many wait for their word }`() { + // Given - RUM's word for s0 is given before the storage thread gets to it + recordWrites() + testedWriter.withhold("s0") + testedWriter.write(record("r-s0", "s0", fullSnapshotAt = 1_000)) + testedWriter.release("s0") + val later = List(SessionReplayRecordWriter.PARKED_LIMIT + 2) { "s${it + 1}" } + later.forEach { + testedWriter.withhold(it) + testedWriter.write(record("r-$it", it, fullSnapshotAt = 1_000)) + } + + // Then + assertThat(written).containsExactly("r-s0") + } + + @Test + fun `M not keep a session aside W withhold { RUM already threw it away }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + testedWriter.discard("s1") + + // When + testedWriter.withhold("s2") + testedWriter.write(record("late", "s1")) + testedWriter.release("s1") + + // Then + assertThat(written).isEmpty() + } + + @Test + fun `M keep the view's meta and focus W hold { its whole span dropped over the byte limit }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + val meta = MobileSegment.MobileRecord.MetaRecord(0, data = MobileSegment.Data1(100, 200)) + val focus = MobileSegment.MobileRecord.FocusRecord(0, data = MobileSegment.Data2(true)) + val full0 = MobileSegment.MobileRecord.MobileFullSnapshotRecord(0, MobileSegment.Data(emptyList())) + testedWriter.write(EnrichedRecord("app", "s1", "view", listOf(meta, focus, full0))) + testedWriter.write(bigRecord("view", "s1", 1_000, SessionReplayRecordWriter.BYTES_LIMIT.toInt() + 1)) + testedWriter.write(record("view", "s1", fullSnapshotAt = 4_000)) + val released = mutableListOf() + whenever(mockEventBatchWriter.write(anyOrNull(), anyOrNull(), any())) doAnswer { + released.add(String(it.getArgument(0).data)) + true + } + + // When + testedWriter.release("s1") + + // Then + assertThat(released).hasSize(1) + val first = com.google.gson.JsonParser.parseString(released[0]).asJsonObject.getAsJsonArray("records") + assertThat(first.map { it.asJsonObject.get("type").asInt }).containsExactly(4, 6, 10) + } + + @Test + fun `M throw away the oldest session kept aside W withhold { too many wait for their word }`() { + // Given + recordWrites() + val sessions = List(SessionReplayRecordWriter.PARKED_LIMIT + 2) { "s$it" } + sessions.forEach { + testedWriter.withhold(it) + testedWriter.write(record("r-$it", it, fullSnapshotAt = 1_000)) + } + + // When - every session is released, oldest first + sessions.forEach { testedWriter.release(it) } + + // Then - only the last PARKED_LIMIT ended sessions and the current one were still held + assertThat(written).containsExactlyElementsOf(sessions.drop(1).map { "r-$it" }) + } + + @Test + fun `M drop what every session holds W dropForConsent`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(imageRecord("r1", "s1", 1_000, "img")) + testedWriter.write(resource("img"), "s1") {} + testedWriter.withhold("s2") + testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) + + // When + testedWriter.dropForConsent() + testedWriter.write(record("r2b", "s2", fullSnapshotAt = 5_000)) + testedWriter.release("s1") + testedWriter.release("s2") + + // Then - only what was held again afterwards goes + assertThat(written).containsExactly("r2b") + verify(mockResourcesWriter, never()).write(any(), any(), any()) + } + + @Test + fun `M write what a session expected to be released holds and throw the rest away W stop`() { + // Given - s1 reported its error, s2 did not + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + testedWriter.expectRelease("s1") + testedWriter.withhold("s2") + testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) + var done = false + + // When + testedWriter.stop { done = true } + + // Then + assertThat(written).containsExactly("r1") + assertThat(done).isTrue + } + + @Test + fun `M call back at once W stop { no feature to write with }`() { + // Given + whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)).thenReturn(null) + var done = false + + // When + testedWriter.stop { done = true } + + // Then + assertThat(done).isTrue + } + + @Test + fun `M throw away a session kept aside W discard`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + testedWriter.stopWithholding("s2") + + // When + testedWriter.discard("s1") + testedWriter.write(record("late", "s1")) + testedWriter.write(record("next", "s2")) + + // Then + assertThat(written).containsExactly("next") + verify(mockRecordCallback).onWithheldRecordsCleared(any()) + } + + @Test + fun `M ignore a word about a session already thrown away W withhold`() { + // Given - a stopped session may announce itself once more after the next one has + recordWrites() + testedWriter.withhold("s1") + testedWriter.discard("s1") + testedWriter.withhold("s2") + testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) + + // When + testedWriter.withhold("s1") + testedWriter.write(record("r2b", "s2")) + testedWriter.release("s2") + + // Then + assertThat(written).containsExactly("r2", "r2b") + } + + @Test + fun `M hold nothing and drop what was held W write { consent not granted }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + testedWriter.write(resource("img"), "s1") {} + + // When + recordWrites(consent = TrackingConsent.NOT_GRANTED) + testedWriter.write(record("r2", "s1", fullSnapshotAt = 2_000)) + testedWriter.write(resource("img2"), "s1") {} + recordWrites(consent = TrackingConsent.GRANTED) + testedWriter.write(record("inc", "s1")) + testedWriter.write(record("r3", "s1", fullSnapshotAt = 3_000)) + testedWriter.release("s1") + + // Then - holding starts over at the first full snapshot under consent + assertThat(written).containsExactly("r3") + verify(mockResourcesWriter, never()).write(any(), any(), any()) + } + + @Test + fun `M drop the oldest span W hold { an incremental record takes the buffer over the byte limit }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + val half = SessionReplayRecordWriter.BYTES_LIMIT.toInt() / 2 + testedWriter.write(bigRecord("old", "s1", 1_000, half)) + testedWriter.write(bigRecord("new", "s1", 2_000, half / 2)) + + // When + val inc = bigRecord("inc", "s1", 2_500, half) + testedWriter.write(inc.copy(records = inc.records.map { incrementalOf(it) })) + testedWriter.release("s1") + + // Then + assertThat(written).containsExactly("new", "inc") + } + + @Test + fun `M send only the images the released records show W release`() { + // Given - an image of a session that never errored, and one of the session released + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(resource("unseen"), "s1") {} + testedWriter.write(imageRecord("r1", "s1", 1_000, "shared")) + testedWriter.write(resource("shared"), "s1") {} + testedWriter.withhold("s2") + testedWriter.write(imageRecord("r2", "s2", 2_000, "shared")) + testedWriter.write(resource("own"), "s2") {} + testedWriter.write(imageRecord("r2b", "s2", 3_000, "own")) + + // When + testedWriter.release("s2") + + // Then - the image captured under the discarded session goes with the records that show it + val sent = argumentCaptor() + verify(mockResourcesWriter, times(2)).write(sent.capture(), eq("s2"), any()) + assertThat(sent.allValues.map { it.filename }).containsExactlyInAnyOrder("shared", "own") + } + + @Test + fun `M send a held image with a collected session's record that shows it W write`() { + // Given - the recorder captured the image once, under a session that never errored + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(resource("icon"), "s1") {} + testedWriter.stopWithholding("s2") + + // When + testedWriter.write(imageRecord("r2", "s2", 2_000, "icon")) + + // Then + assertThat(written).containsExactly("r2") + val sent = argumentCaptor() + verify(mockResourcesWriter).write(sent.capture(), eq("s2"), any()) + assertThat(sent.firstValue.filename).isEqualTo("icon") + } + + @Test + fun `M start the release with the view's meta and focus W release { held again after consent }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + val meta = MobileSegment.MobileRecord.MetaRecord(0, data = MobileSegment.Data1(100, 200)) + val focus = MobileSegment.MobileRecord.FocusRecord(0, data = MobileSegment.Data2(true)) + val full0 = MobileSegment.MobileRecord.MobileFullSnapshotRecord(0, MobileSegment.Data(emptyList())) + testedWriter.write(EnrichedRecord("app", "s1", "view", listOf(meta, focus, full0))) + recordWrites(consent = TrackingConsent.NOT_GRANTED) + testedWriter.write(record("view", "s1", fullSnapshotAt = 3_000)) + recordWrites(consent = TrackingConsent.GRANTED) + testedWriter.write(record("view", "s1", fullSnapshotAt = 6_000)) + val released = mutableListOf() + whenever(mockEventBatchWriter.write(anyOrNull(), anyOrNull(), any())) doAnswer { + released.add(String(it.getArgument(0).data)) + true + } + + // When + testedWriter.release("s1") + + // Then + assertThat(released).hasSize(1) + val first = com.google.gson.JsonParser.parseString(released[0]).asJsonObject.getAsJsonArray("records") + assertThat(first.map { it.asJsonObject.get("type").asInt }).containsExactly(4, 6, 10) + assertThat(first.map { it.asJsonObject.get("timestamp").asLong }).containsOnly(6_000L) + } + + @Test + fun `M not hold an image over the budget W write(resource)`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(imageRecord("r1", "s1", 1_000, "huge")) + + // When + testedWriter.write(resource("huge", size = SessionReplayRecordWriter.BYTES_LIMIT.toInt() + 1), "s1") {} + testedWriter.release("s1") + + // Then + verify(mockResourcesWriter, never()).write(any(), any(), any()) + } + + private fun imageRecord(tag: String, sessionId: String, fullSnapshotAt: Long, resourceId: String): EnrichedRecord { + val image = MobileSegment.Wireframe.ImageWireframe( + id = 1, + x = 0, + y = 0, + width = 1, + height = 1, + resourceId = resourceId + ) + val full = MobileSegment.MobileRecord.MobileFullSnapshotRecord( + fullSnapshotAt, + MobileSegment.Data(listOf(image)) + ) + return EnrichedRecord("app", sessionId, tag, listOf(full)) + } + + /** The same wireframes as an incremental record: an addition of each. */ + private fun incrementalOf(record: MobileSegment.MobileRecord): MobileSegment.MobileRecord { + val full = record as MobileSegment.MobileRecord.MobileFullSnapshotRecord + return MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord( + full.timestamp, + MobileSegment.MobileIncrementalData.MobileMutationData( + adds = full.data.wireframes.map { MobileSegment.Add(wireframe = it) }, + removes = emptyList(), + updates = emptyList() + ) + ) + } + + // endregion + private fun Forge.forgeEnrichedRecord(): EnrichedRecord { // We don't want to create a forgery for this as this lives in the session-replay module // and we will need to copy all the records forgeries. Instead we just forge this record diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayResourcesWriterTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayResourcesWriterTest.kt index 6632bfe2bb..f325e0241b 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayResourcesWriterTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayResourcesWriterTest.kt @@ -20,6 +20,7 @@ import com.datadog.android.sessionreplay.internal.processor.asBinaryMetadata import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +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 @@ -30,6 +31,7 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -97,8 +99,11 @@ internal class SessionReplayResourcesWriterTest { } ) + whenever(mockEventBatchWriter.write(any(), anyOrNull(), any())) doReturn true + var written = 0 + // When - testedWriter.write(fakeEnrichedResource) + testedWriter.write(fakeEnrichedResource, "session") { written++ } // Then val metadataBytearray = fakeEnrichedResource.asBinaryMetadata(fakeRumApplicationId.toString()) @@ -107,5 +112,6 @@ internal class SessionReplayResourcesWriterTest { batchMetadata = null, eventType = EventType.DEFAULT ) + assertThat(written).isEqualTo(1) } } diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumer.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumer.kt index 2d89dd14e8..f42e82f653 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumer.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumer.kt @@ -42,9 +42,16 @@ internal class WebViewReplayEventConsumer( val sessionReplayEnabled = sessionReplayFeatureContext?.get( SESSION_REPLAY_ENABLED_KEY ) as? Boolean ?: false + // FLASHCAT FORK - the native replay of this session is held in case it reports an + // error: nothing of the replay may reach the intake before it does, the web + // view's part included. + val sessionReplayWithheld = sessionReplayFeatureContext?.get( + SESSION_REPLAY_WITHHELD_KEY + ) as? Boolean ?: false if (rumContext != null && rumContext.sessionState == "TRACKED" && - sessionReplayEnabled + sessionReplayEnabled && + !sessionReplayWithheld ) { writeScope { map(event, datadogContext, rumContext)?.let { mappedEvent -> @@ -102,5 +109,8 @@ internal class WebViewReplayEventConsumer( "The bundled web Replay event could not be deserialized" internal const val SESSION_REPLAY_ENABLED_KEY = "session_replay_is_enabled" + + // FLASHCAT FORK - published by Session Replay while it holds the session's replay on error. + internal const val SESSION_REPLAY_WITHHELD_KEY = "session_replay_withheld" } } diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumer.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumer.kt index 14f7f86ea2..b61de80f34 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumer.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumer.kt @@ -45,9 +45,14 @@ internal class WebViewRumEventConsumer( val sessionReplayFeatureContext = datadogContext.featuresContext[ Feature.SESSION_REPLAY_FEATURE_NAME ] - val sessionReplayEnabled = sessionReplayFeatureContext?.get( - WebViewReplayEventConsumer.SESSION_REPLAY_ENABLED_KEY - ) as? Boolean ?: false + // FLASHCAT FORK - a replay still held on error is not one these events may claim. + val sessionReplayEnabled = ( + sessionReplayFeatureContext?.get( + WebViewReplayEventConsumer.SESSION_REPLAY_ENABLED_KEY + ) as? Boolean ?: false + ) && sessionReplayFeatureContext?.get( + WebViewReplayEventConsumer.SESSION_REPLAY_WITHHELD_KEY + ) != true writeScope { val mappedEvent = map(event, datadogContext, rumContext, sessionReplayEnabled) dataWriter.write(it, mappedEvent, EventType.DEFAULT) diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumerTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumerTest.kt index fb94a063e4..f9150752fb 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumerTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumerTest.kt @@ -190,6 +190,25 @@ internal class WebViewReplayEventConsumerTest { verifyNoInteractions(mockDataWriter) } + @Test + fun `M do nothing W consume() { native replay withheld on error }`(forge: Forge) { + // Given - the session is collected, but its replay waits for an error + fakeDatadogContext = fakeDatadogContext.copy( + featuresContext = forge.aMap { + Feature.SESSION_REPLAY_FEATURE_NAME to forge.aMap { + WebViewReplayEventConsumer.SESSION_REPLAY_ENABLED_KEY to true + WebViewReplayEventConsumer.SESSION_REPLAY_WITHHELD_KEY to true + } + } + ) + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verifyNoInteractions(mockDataWriter) + } + @Test fun `M do nothing W consume() { sr feature context does not exist }`() { // Given