From d30ec7935f1ee8d6f1f3592f1f922248b968df3c Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 3 Oct 2026 19:26:42 -0700 Subject: [PATCH 01/13] feat(rum): keep sessions missed by the sample rate when they report an error Adds RumConfiguration.Builder.setSessionOnError and the remote configuration keys rum.sessionOnError / rum.sessionReplayOnError (absent keeps the init value; the console wins over init). A session the session sample rate does not keep is drawn as WITHHELD when the switch is on: it is assembled as usual but its events go to an in-memory WithheldEventWriter instead of the batch. Events are held after the event mappers have run and serialized, so an error dropped by a mapper releases nothing. The buffer keeps the last 60s, at most 64KiB and 200 non-view events plus 50 views (latest update per view, current view never evicted), evicting successful requests and long tasks first and errors last, newest first. The first error of the session releases the buffer after a per-session jitter of 0-3s (String.hashCode based), with the window frozen when the release is scheduled; views go first oldest to newest, then errors, then the rest. A JVM crash releases synchronously within the crash write, an oversized error is written on its own, and setForcedSession releases immediately. When the session expires, is renewed or stopped, an unreleased buffer is dropped and its id remembered (last four) so late events of that session are dropped too; a session that had errored is released instead. Withdrawn consent drops what is held. Withheld sessions report a session sample rate of 0, view events carry session.sampled_for_error, the session listener sees them as discarded and getCurrentSessionId answers null until release. The last view is still written locally, so a native crash or fatal ANR is reported at the next launch with the marker and rate 0. A console reset no longer ends a session kept on error while the rate is 0 and the switch is on; a switch change at rate 0 now resets the running session, and beforeSampling returning 0 turns the switch off. --- features/dd-sdk-android-rum/api/apiSurface | 3 +- .../api/dd-sdk-android-rum.api | 13 +- .../src/main/json/rum/view-schema.json | 10 + .../kotlin/com/datadog/android/rum/Rum.kt | 30 +- .../datadog/android/rum/RumConfiguration.kt | 25 + .../android/rum/internal/RumFeature.kt | 10 +- .../android/rum/internal/domain/RumContext.kt | 5 +- .../rum/internal/domain/RumDataWriter.kt | 40 +- .../internal/domain/WithheldEventWriter.kt | 440 ++++++++++++ .../domain/scope/RumApplicationScope.kt | 13 +- .../internal/domain/scope/RumSessionScope.kt | 172 ++++- .../rum/internal/domain/scope/RumViewScope.kt | 11 +- .../rum/internal/monitor/DatadogRumMonitor.kt | 14 +- .../remoteconfig/RemoteConfigController.kt | 41 +- .../remoteconfig/RemoteConfigStore.kt | 33 +- .../internal/DatadogLateCrashReporterTest.kt | 40 ++ .../domain/WithheldEventWriterTest.kt | 641 ++++++++++++++++++ .../domain/scope/RumSessionScopeTest.kt | 318 ++++++++- .../internal/domain/scope/RumViewScopeTest.kt | 51 ++ .../RemoteConfigControllerTest.kt | 102 +++ .../remoteconfig/RemoteConfigStoreTest.kt | 17 + 21 files changed, 1967 insertions(+), 62 deletions(-) create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriter.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriterTest.kt diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index a7c9047051..3c91340ac6 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -68,6 +68,7 @@ data class com.datadog.android.rum.RumConfiguration fun setSessionSampleRate(Float): Builder fun setRemoteConfigurationEnabled(Boolean): Builder fun setBeforeSampling(BeforeSamplingCallback): Builder + fun setSessionOnError(Boolean): Builder fun collectAccessibility(Boolean): Builder fun setTelemetrySampleRate(Float): Builder fun trackUserInteractions(Array = emptyArray(), com.datadog.android.rum.tracking.InteractionPredicate = NoOpInteractionPredicate()): Builder @@ -1659,7 +1660,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..9cbcd21b0b 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,7 @@ 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 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 +6038,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..793eacf3eb 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 @@ -18,6 +18,8 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.sampling.RateBasedSampler import com.datadog.android.rum.internal.RumAnonymousIdentifierManager import com.datadog.android.rum.internal.RumFeature +import com.datadog.android.rum.internal.domain.RumDataWriter +import com.datadog.android.rum.internal.domain.WithheldEventWriter import com.datadog.android.rum.internal.domain.scope.RumVitalAppLaunchEventHelper import com.datadog.android.rum.internal.metric.SessionEndedMetricDispatcher import com.datadog.android.rum.internal.monitor.DatadogRumMonitor @@ -127,6 +129,28 @@ object Rum { val rumAppStartupTelemetryReporter = RumAppStartupTelemetryReporter.create(sdkCore = sdkCore) + val handler = Handler(Looper.getMainLooper()) + + // FLASHCAT FORK - every RUM event of a collected session passes through it, see + // `RumConfiguration.Builder.setSessionOnError`. The release timer only hands the release + // back to the storage thread, so the main looper is enough to carry it. + val withheldEvents = (rumFeature.dataWriter as? RumDataWriter)?.let { rumDataWriter -> + WithheldEventWriter( + delegate = rumDataWriter, + internalLogger = sdkCore.internalLogger, + elapsedTimeNs = { sdkCore.timeProvider.getDeviceElapsedTimeNanos() }, + scheduleRelease = { delayMs, release -> + handler.postDelayed( + { + sdkCore.getFeature(Feature.RUM_FEATURE_NAME) + ?.withWriteContext { _, writeScope -> writeScope(release) } + }, + delayMs + ) + } + ) + } + return DatadogRumMonitor( applicationId = rumFeature.applicationId, sdkCore = sdkCore, @@ -137,8 +161,10 @@ 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, + 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..070ddb9670 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,31 @@ 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 + } + /** * Whether to collect accessibility attributes - this is disabled by default. * diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 382c535914..b46215bbe4 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 @@ -809,6 +809,8 @@ internal class RumFeature( ), store = store, initialSessionSampleRate = sampleRate, + initialSessionOnError = configuration.sessionOnError, + initialSessionReplayOnError = configuration.sessionReplayOnError, callFactory = sdkCore.createOkHttpCallFactory(), executor = sdkCore.createScheduledExecutorService("rum-remote-config"), // Looked up when it fires rather than captured now: the monitor is registered after @@ -870,7 +872,13 @@ 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 { 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..f2caeb8487 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,34 @@ internal class RumDataWriter( @WorkerThread override fun write(writer: EventBatchWriter, element: Any, eventType: EventType): Boolean { + val batchEvent = serialize(element) ?: return false + + synchronized(this) { + val result = writer.write(batchEvent, null, 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,20 +72,16 @@ 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 - @WorkerThread internal fun onDataWritten(data: Any, rawData: ByteArray) { when (data) { 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..fc0049b5c1 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriter.kt @@ -0,0 +1,440 @@ +/* + * 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 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 +) : DataWriter { + + private class HeldView(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, + val isError: Boolean + ) + + /** 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 + } + + private var withheldForSessionId: 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 + + /** 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 != withheldForSessionId) { + return delegate.write(writer, element, eventType) + } + val batchEvent = delegate.serialize(element) ?: return false + if (element is ErrorEvent) { + releasedSessionId = 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. + delegate.writeSerialized(writer, batchEvent, eventType) + 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 } + + /** Starts withholding the events of a session just drawn as one kept only on error. */ + @WorkerThread + fun startWithholding(sessionId: String, writer: EventBatchWriter) { + synchronized(this) { + withheldForSessionId?.let { endSession(it, writer) } + withheldForSessionId = sessionId + } + } + + /** + * 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. + */ + @WorkerThread + fun endSession(sessionId: String, writer: EventBatchWriter) { + synchronized(this) { + 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 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 || releasedSessionId == sessionId) return + clear() + } + } + + // 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), + isError = element is ErrorEvent + ) + ) + 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(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 listOf(EvictionTier.FIRST, EvictionTier.LAST)) { + 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) { + prune() + // A detail whose view is gone has nothing to hang from at the other end. One that never had + // a view (an app launch vital measured before the first one) hangs from the session alone. + val releasable = details.filter { it.viewId == null || views.containsKey(it.viewId) } + + // The session is built out of whichever of its views arrives first, so that one has to be + // the earliest. 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 { delegate.writeSerialized(writer, it.event, it.eventType) } + releasable.filter { it.isError }.forEach { delegate.writeSerialized(writer, it.event, it.eventType) } + releasable.filterNot { it.isError }.forEach { delegate.writeSerialized(writer, it.event, it.eventType) } + + // 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 releasable.size, + "buffer.dropped_count" to droppedCount, + "buffer.bytes" to bytes + ) + ) + + clear() + withheldForSessionId = null + } + + 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 + + private const val DISCARDED_SESSIONS_REMEMBERED = 4 + + private const val HTTP_ERROR_STATUS = 400L + + 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 -> 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/scope/RumApplicationScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt index 36a036dc4f..51dead6742 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,11 @@ 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 ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -79,6 +84,8 @@ internal class RumApplicationScope( remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, beforeSampling = beforeSampling, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError, backgroundTrackingEnabled = backgroundTrackingEnabled, trackFrustrations = trackFrustrations, viewChangedListener = this, @@ -235,7 +242,9 @@ internal class RumApplicationScope( remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, beforeSampling = beforeSampling, - forcedSession = forcedSession + forcedSession = forcedSession, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError ) 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..4900ed2fd2 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,12 @@ 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 value of `sessionOnError`; the console's value wins over it. + private val sessionOnError: Boolean = false ) : RumScope { // FLASHCAT FORK - the rate the current session's events report as their configured sample @@ -101,6 +109,12 @@ 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 + private var startReason: StartReason = StartReason.USER_APP_LAUNCH internal var isActive: Boolean = true private val sessionStartNs = AtomicLong(sdkCore.timeProvider.getDeviceElapsedTimeNanos()) @@ -147,6 +161,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 +212,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 +224,52 @@ 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 && isCollected()) { + 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 - nothing may be held while consent is withdrawn, and what was held under + // the consent now withdrawn goes too. + State.WITHHELD -> 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 +324,8 @@ internal class RumSessionScope( sessionId = sessionId, sessionState = sessionState, sessionStartReason = startReason, - isSessionActive = isActive + isSessionActive = isActive, + sampledForError = sampledForError ) } @@ -289,9 +337,38 @@ internal class RumSessionScope( // region Internal - private fun stopSession() { + private fun stopSession(writeScope: EventWriteScope) { isActive = false sessionEndedMetricDispatcher.onSessionStopped(sessionId) + endWithheldSession(writeScope) + } + + // FLASHCAT FORK - TRACKED or WITHHELD: the session's events are assembled, whether or not they + // are uploaded yet. + private fun isCollected(): Boolean = sessionState == State.TRACKED || sessionState == State.WITHHELD + + /** + * 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) return + val endedSessionId = sessionId + writeScope { withheldEvents?.endSession(endedSessionId, it) } + } + + /** + * FLASHCAT FORK - whether the settings now in force keep this session exactly as it is, so a + * reset asked for by the console must not end it: a session kept on error, under a zero rate + * with the switch on. That is the ordinary configuration for "only the sessions that error", + * not an emergency stop - ending the session would throw away what the switch exists to keep, + * on every first fetch and every release of the app. + */ + 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 +376,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() @@ -326,24 +403,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 +431,35 @@ 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() + if (sampledForError) { + val withheldSessionId = sessionId + writeScope { withheldEvents?.startWithholding(withheldSessionId, 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 +484,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 +494,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,7 +534,7 @@ internal class RumSessionScope( null } return if (override == null || override.isNaN() || override < 0f || override > MAX_SAMPLE_RATE) { - rate + null } else { override } 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..e992b13401 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 @@ -1206,7 +1206,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 +1336,10 @@ 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 } ), synthetics = syntheticsAttribute, source = ViewEvent.ViewEventSource.tryFromSource( 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..3b4a7c2f3b 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,11 @@ 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 ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -135,7 +140,9 @@ internal class DatadogRumMonitor( insightsCollector = insightsCollector, remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, - beforeSampling = beforeSampling + beforeSampling = beforeSampling, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError ) internal val keepAliveRunnable = Runnable { @@ -165,7 +172,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/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index b467a52c7a..8fe466cae6 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,10 @@ 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 values of the on-error switches, which a cleared knob hands back to. + private val initialSessionOnError: Boolean = false, + private val initialSessionReplayOnError: Boolean = false ) { @Volatile @@ -340,7 +343,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 +381,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 +428,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 +445,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) + val replaySwitchChanged = (before.sessionReplayOnError ?: initialSessionReplayOnError) != + (after.sessionReplayOnError ?: initialSessionReplayOnError) + if (previousRate == nextRate && !switchChanged && !replaySwitchChanged) 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 +518,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..0b3fb51e16 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,46 @@ internal class DatadogLateCrashReporterTest { } } + @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/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..35a0a79cbf --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriterTest.kt @@ -0,0 +1,641 @@ +/* + * 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 (payloads.containsKey(model)) payloads[model] 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) } + ) + } + + // 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 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 four discarded sessions W endSession()`() { + // Given + val sessions = List(5) { 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 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 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") + assertThat(written).doesNotContain("old0", "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/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index 138c95d08a..31f43d83c7 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++ } } @@ -2071,6 +2077,308 @@ 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 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 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) + } + + // endregion + // region beforeSampling @Test @@ -2172,7 +2480,9 @@ internal class RumSessionScopeTest { backgroundTrackingEnabled: Boolean? = null, remoteConfig: RemoteConfigStore? = null, onSessionDrawn: () -> Unit = {}, - beforeSampling: BeforeSamplingCallback? = null + beforeSampling: BeforeSamplingCallback? = null, + withheldEvents: WithheldEventWriter? = null, + sessionOnError: Boolean = false ) { testedScope = RumSessionScope( parentScope = mockParentScope, @@ -2201,7 +2511,9 @@ internal class RumSessionScopeTest { insightsCollector = mockInsightsCollector, remoteConfig = remoteConfig, onSessionDrawn = onSessionDrawn, - beforeSampling = beforeSampling + beforeSampling = beforeSampling, + withheldEvents = withheldEvents, + sessionOnError = sessionOnError ) 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..f7aa2661f4 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,57 @@ 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 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) + 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.toJson().asJsonObject.getAsJsonObject("session").has("sampled_for_error")).isFalse + } + } + @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/RemoteConfigControllerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigControllerTest.kt index a77058c084..8178f6f79d 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,110 @@ 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 restart the session W apply() { immediate and only the replay switch changed }`() { + whenever(store.sessionSampleRate()).thenReturn(20f) + whenever(store.sessionReplayOnError()).thenReturn(null) + + testedController.apply( + body(activation = "immediate", rum = """"sessionSampleRate":20,"sessionReplayOnError":true""") + ) + + assertThat(restarts).isOne() + } + @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)) From e3e3f6739f882d9ac4ea60d303275b3cfb649a3d Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 3 Oct 2026 20:05:20 -0700 Subject: [PATCH 02/13] feat(session-replay): keep replays missed by the replay rate when the session errors Adds RumConfiguration.Builder.setSessionReplayOnError (remote key rum.sessionReplayOnError, latched per session at the draw). RUM now tells Session Replay, on the session bus message, whether the session's events are kept on error, the replay switch it was drawn under, and whether it has reported its error. Session Replay keeps its own replay draw and holds the records of two kinds of session in memory instead of writing them: a collected session whose replay the rate missed while the switch is on, and a session whose events are withheld, whichever way the replay draw went, since its replay has nothing to attach to until the events are released. The error releasing a replay-only session is judged after the event mappers, like the events' own; forcing the session releases too. Held records are cut only at full snapshots, keeping from the newest full snapshot at least a minute old, so the release spans the last minute across views and starts playable. View record counts and has_replay are only updated for records actually written, so nothing held and thrown away is ever claimed. A session that ends unreleased drops its records, and records of that session still queued are dropped too. View events carry session.sampled_for_error_replay for replays kept on error and session.sampled_for_replay, which counts a held replay whose events are held too. --- features/dd-sdk-android-rum/api/apiSurface | 1 + .../api/dd-sdk-android-rum.api | 1 + .../kotlin/com/datadog/android/rum/Rum.kt | 1 + .../datadog/android/rum/RumConfiguration.kt | 20 +++ .../rum/internal/FeaturesContextResolver.kt | 26 +++ .../internal/domain/WithheldEventWriter.kt | 17 ++ .../domain/scope/RumApplicationScope.kt | 7 +- .../internal/domain/scope/RumSessionScope.kt | 30 +++- .../rum/internal/domain/scope/RumViewScope.kt | 9 +- .../rum/internal/monitor/DatadogRumMonitor.kt | 6 +- .../internal/FeaturesContextResolverTest.kt | 31 ++++ .../domain/WithheldEventWriterTest.kt | 35 ++++ .../domain/scope/RumSessionScopeTest.kt | 152 +++++++++++++++--- .../internal/domain/scope/RumViewScopeTest.kt | 27 ++++ .../internal/SessionReplayFeature.kt | 76 ++++++++- .../internal/storage/NoOpRecordWriter.kt | 8 + .../internal/storage/RecordWriter.kt | 14 ++ .../storage/SessionReplayRecordWriter.kt | 147 +++++++++++++++-- .../internal/SessionReplayFeatureTest.kt | 144 +++++++++++++++++ .../storage/SessionReplayRecordWriterTest.kt | 140 ++++++++++++++++ 20 files changed, 844 insertions(+), 48 deletions(-) diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index 3c91340ac6..43f210afba 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -69,6 +69,7 @@ data class com.datadog.android.rum.RumConfiguration 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 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 9cbcd21b0b..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 @@ -129,6 +129,7 @@ public final class com/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; 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 793eacf3eb..b1ac3bdcdf 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 @@ -164,6 +164,7 @@ object Rum { writer = withheldEvents ?: rumFeature.dataWriter, withheldEvents = withheldEvents, sessionOnError = rumFeature.configuration.sessionOnError, + sessionReplayOnError = rumFeature.configuration.sessionReplayOnError, handler = handler, telemetryEventHandler = TelemetryEventHandler( sdkCore = sdkCore, 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 070ddb9670..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 @@ -129,6 +129,26 @@ data class RumConfiguration internal constructor( 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/FeaturesContextResolver.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/FeaturesContextResolver.kt index 8af287ce33..e770a2405a 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,34 @@ 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, or null when Session Replay is not + * enabled. 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, sampledForError: Boolean): Boolean? { + val sessionReplayContext = + datadogContext.featuresContext[Feature.SESSION_REPLAY_FEATURE_NAME] ?: 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 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/domain/WithheldEventWriter.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriter.kt index fc0049b5c1..7b7a73073d 100644 --- 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 @@ -80,6 +80,12 @@ internal class WithheldEventWriter( 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 @@ -119,6 +125,11 @@ internal class WithheldEventWriter( // collected. return true } + if (sessionId == watchedSessionId && element is ErrorEvent) { + val written = delegate.write(writer, element, eventType) + if (written) releasedSessionId = sessionId + return written + } if (sessionId != withheldForSessionId) { return delegate.write(writer, element, eventType) } @@ -155,6 +166,12 @@ internal class WithheldEventWriter( /** Whether the given session earned its release. Read by the session scope to stop withholding. */ fun isReleased(sessionId: String): Boolean = synchronized(this) { releasedSessionId == sessionId } + /** Watches a collected session whose replay only is kept on error for its first error. */ + @WorkerThread + fun watchForError(sessionId: String) { + synchronized(this) { watchedSessionId = sessionId } + } + /** Starts withholding the events of a session just drawn as one kept only on error. */ @WorkerThread fun startWithholding(sessionId: String, writer: EventBatchWriter) { 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 51dead6742..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 @@ -68,7 +68,8 @@ internal class RumApplicationScope( // 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 sessionOnError: Boolean = false, + private val sessionReplayOnError: Boolean = false ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -86,6 +87,7 @@ internal class RumApplicationScope( beforeSampling = beforeSampling, withheldEvents = withheldEvents, sessionOnError = sessionOnError, + sessionReplayOnError = sessionReplayOnError, backgroundTrackingEnabled = backgroundTrackingEnabled, trackFrustrations = trackFrustrations, viewChangedListener = this, @@ -244,7 +246,8 @@ internal class RumApplicationScope( beforeSampling = beforeSampling, forcedSession = forcedSession, withheldEvents = withheldEvents, - sessionOnError = sessionOnError + 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 4900ed2fd2..cecdf70ac7 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 @@ -88,8 +88,10 @@ internal class RumSessionScope( // 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 value of `sessionOnError`; the console's value wins over it. - private val sessionOnError: Boolean = false + // 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 @@ -115,6 +117,10 @@ internal class RumSessionScope( 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 + private var startReason: StartReason = StartReason.USER_APP_LAUNCH internal var isActive: Boolean = true private val sessionStartNs = AtomicLong(sdkCore.timeProvider.getDeviceElapsedTimeNanos()) @@ -456,9 +462,14 @@ internal class RumSessionScope( else -> State.NOT_TRACKED } sessionId = UUID.randomUUID().toString() + replayOnError = withheldEvents != null && (remoteValues?.sessionReplayOnError ?: sessionReplayOnError) + val drawnSessionId = sessionId if (sampledForError) { - val withheldSessionId = sessionId - writeScope { withheldEvents?.startWithholding(withheldSessionId, it) } + writeScope { withheldEvents?.startWithholding(drawnSessionId, it) } + } else if (keepSession && replayOnError) { + // 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) } } // 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 @@ -549,7 +560,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 it has reported its error. + 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?.isReleased(sessionId) == true) ) ) } @@ -563,6 +580,9 @@ 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" 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 e992b13401..0506d6766c 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 @@ -1339,7 +1339,14 @@ internal open class RumViewScope( 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 } + sampledForError = rumContext.sampledForError.takeIf { it }, + sampledForReplay = featuresContextResolver.resolveSampledForReplay( + datadogContext, + rumContext.sampledForError + ), + sampledForErrorReplay = featuresContextResolver + .resolveSampledForErrorReplay(datadogContext, rumContext.sessionId) + ?.takeIf { it } ), synthetics = syntheticsAttribute, source = ViewEvent.ViewEventSource.tryFromSource( 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 3b4a7c2f3b..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 @@ -114,7 +114,8 @@ internal class DatadogRumMonitor( // 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 sessionOnError: Boolean = false, + private val sessionReplayOnError: Boolean = false ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -142,7 +143,8 @@ internal class DatadogRumMonitor( onSessionDrawn = onSessionDrawn, beforeSampling = beforeSampling, withheldEvents = withheldEvents, - sessionOnError = sessionOnError + sessionOnError = sessionOnError, + sessionReplayOnError = sessionReplayOnError ) internal val keepAliveRunnable = Runnable { 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..c4d15c419f 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,35 @@ 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 recording = mapOf(FeaturesContextResolver.REPLAY_ENABLED_KEY to true) + val withheld = recording + (FeaturesContextResolver.REPLAY_WITHHELD_KEY to true) + val stopped = mapOf(FeaturesContextResolver.REPLAY_ENABLED_KEY to false) + + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, recording), false)).isTrue + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, withheld), false)).isFalse + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, withheld), true)).isTrue + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, stopped), true)).isFalse + assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, null), true)).isNull() + } + + // endregion } 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 index 35a0a79cbf..c67edfc56e 100644 --- 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 @@ -209,6 +209,41 @@ internal class WithheldEventWriterTest { // endregion + // region Replay-only sessions + + @Test + fun `M write through and mark the error W write() {watched session}`() { + // Given + testedWriter.watchForError(sessionId) + + // When + testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) + val before = testedWriter.isReleased(sessionId) + testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) + + // Then + assertThat(before).isFalse + assertThat(testedWriter.isReleased(sessionId)).isTrue + assertThat(written).containsExactly("v1", "e1") + assertThat(scheduled).isEmpty() + } + + @Test + fun `M not mark W write() {watched session, error dropped by a mapper}`() { + // Given + testedWriter.watchForError(sessionId) + val dropped = error("e1", "v1") + payloads[dropped] = null + + // When + testedWriter.write(batchWriter, dropped, EventType.DEFAULT) + + // Then + assertThat(testedWriter.isReleased(sessionId)).isFalse + } + + // endregion + // region Release @Test 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 31f43d83c7..0e0de90bb8 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 @@ -1129,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 ) ) } @@ -1301,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 ) ) } @@ -1548,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( @@ -1560,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 ) ) } @@ -1599,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( @@ -1611,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 ) ) } @@ -1643,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( @@ -1654,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 ) ) } @@ -1687,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( @@ -1699,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 ) ) } @@ -1733,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( @@ -1745,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( @@ -1757,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 ) ) } @@ -1790,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( @@ -1801,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 ) ) @@ -1835,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( @@ -1846,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 ) ) } @@ -1880,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( @@ -1891,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( @@ -1902,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 ) ) } @@ -2377,6 +2440,49 @@ internal class RumSessionScopeTest { 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.isReleased(testedScope.sessionId)) doReturn true + testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + verify(withheldEvents).watchForError(testedScope.sessionId) + 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 @@ -2482,7 +2588,8 @@ internal class RumSessionScopeTest { onSessionDrawn: () -> Unit = {}, beforeSampling: BeforeSamplingCallback? = null, withheldEvents: WithheldEventWriter? = null, - sessionOnError: Boolean = false + sessionOnError: Boolean = false, + sessionReplayOnError: Boolean = false ) { testedScope = RumSessionScope( parentScope = mockParentScope, @@ -2513,7 +2620,8 @@ internal class RumSessionScopeTest { onSessionDrawn = onSessionDrawn, beforeSampling = beforeSampling, withheldEvents = withheldEvents, - sessionOnError = sessionOnError + 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 f7aa2661f4..e0a773e851 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 @@ -700,6 +700,32 @@ internal class RumViewScopeTest { } } + @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())) 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 not mark the session W handleEvent(StartView) { plainly sampled session }`( @Forgery key: RumScopeKey @@ -722,6 +748,7 @@ internal class RumViewScopeTest { argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) assertThat(lastValue.session.sampledForError).isNull() + assertThat(lastValue.session.sampledForErrorReplay).isNull() assertThat(lastValue.toJson().asJsonObject.getAsJsonObject("session").has("sampled_for_error")).isFalse } } 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..8a0c9a3d6f 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 @@ -115,6 +115,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) @@ -232,9 +237,13 @@ 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 by the session's error (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) } } @@ -255,7 +264,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 +281,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 +299,44 @@ 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; throws away what any other + // session still held. + dataWriter.stopWithholding(sessionId) + } + isReplayWithheld.set(withhold) + val onErrorSessionId = onErrorReplaySessionId.get() + 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 { @@ -434,6 +490,14 @@ 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" + + // 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/storage/NoOpRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/NoOpRecordWriter.kt index 3ac971a74d..cc5fd4d879 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,12 @@ internal class NoOpRecordWriter : RecordWriter { override fun write(record: EnrichedRecord) { // no-op } + + override fun withhold(sessionId: String) { + // no-op + } + + override fun stopWithholding(releasedSessionId: String?) { + // 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..b447de885e 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 @@ -17,4 +17,18 @@ internal interface RecordWriter { * @param record to write */ fun write(record: EnrichedRecord) + + /** + * FLASHCAT FORK - holds the records of this session in memory instead of writing them, until + * [stopWithholding] releases them. + * @param sessionId the RUM session whose replay is kept only if it reports an error + */ + fun withhold(sessionId: String) + + /** + * FLASHCAT FORK - stops holding records. + * @param releasedSessionId the session whose held records are written now; held records of + * any other session are thrown away. Null throws away whatever is held. + */ + fun stopWithholding(releasedSessionId: String?) } 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..72fdc986f6 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,142 @@ 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.sessionreplay.internal.RecordCallback import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord +import com.datadog.android.sessionreplay.model.MobileSegment +import java.util.concurrent.TimeUnit internal class SessionReplayRecordWriter( private val sdkCore: FeatureSdkCore, private val recordCallback: RecordCallback ) : RecordWriter { + + // FLASHCAT FORK - the replay of a session kept only in case it reports an error is held here + // until it does. 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 withheldSessionId: String? = null + private val held = ArrayList() + private var droppedCount = 0 + + /** + * 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() + override fun write(record: EnrichedRecord) { + onStorageThread { writer -> + if (record.sessionId in discardedSessionIds) return@onStorageThread + if (record.sessionId == withheldSessionId) { + hold(record) + } else { + writeNow(writer, record) + } + } + } + + /** + * FLASHCAT FORK - holds the records of [sessionId] from now on. Whatever another session still + * held never earned its release, so it is thrown away. + */ + override fun withhold(sessionId: String) { + onStorageThread { + if (withheldSessionId != sessionId) { + discardHeld() + withheldSessionId = sessionId + } + } + } + + /** + * FLASHCAT FORK - stops holding. If [releasedSessionId] is the session held, what it held is + * written now, in order, and the records that follow are written as they come; anything held + * for another session is thrown away. + */ + override fun stopWithholding(releasedSessionId: String?) { + onStorageThread { writer -> + if (withheldSessionId != null && withheldSessionId == releasedSessionId) { + release(writer) + } else { + discardHeld() + } + } + } + + private fun hold(record: EnrichedRecord) { + held.add(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. + val newest = record.fullSnapshotTimestamp() ?: return + val cutoff = newest - WINDOW_MS + val firstKept = held.indexOfLast { (it.fullSnapshotTimestamp() ?: Long.MAX_VALUE) <= cutoff } + if (firstKept > 0) { + droppedCount += firstKept + held.subList(0, firstKept).clear() + } + } + + private fun release(writer: EventBatchWriter) { + held.forEach { writeNow(writer, it) } + // 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 held.sumOf { it.records.size }, + "buffer.duration_ms" to held.durationMs(), + "buffer.dropped_count" to droppedCount + ) + ) + held.clear() + droppedCount = 0 + withheldSessionId = null + } + + private fun discardHeld() { + val sessionId = withheldSessionId ?: return + discardedSessionIds.addLast(sessionId) + if (discardedSessionIds.size > DISCARDED_SESSIONS_REMEMBERED) { + discardedSessionIds.removeFirstOrNull() + } + held.clear() + droppedCount = 0 + withheldSessionId = null + } + + private fun writeNow(writer: EventBatchWriter, record: EnrichedRecord) { + val serializedRecord = record.toJson().toByteArray(Charsets.UTF_8) + val rawBatchEvent = RawBatchEvent(data = serializedRecord) + val success = writer.write( + event = rawBatchEvent, + 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) -> Unit) { sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) ?.withWriteContext { _, 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) } } } @@ -46,4 +156,21 @@ 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) + + private const val DISCARDED_SESSIONS_REMEMBERED = 4 + + internal const val RELEASED_MESSAGE = "Error session replay buffer released" + + private fun EnrichedRecord.fullSnapshotTimestamp(): Long? = + records.firstNotNullOfOrNull { (it as? MobileSegment.MobileRecord.MobileFullSnapshotRecord)?.timestamp } + + private fun List.durationMs(): Long { + val stamps = mapNotNull { it.fullSnapshotTimestamp() } + return (stamps.maxOrNull() ?: 0L) - (stamps.minOrNull() ?: 0L) + } + } } 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..8ee2a418bb 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 @@ -20,6 +20,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 @@ -535,6 +536,149 @@ 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 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/storage/SessionReplayRecordWriterTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt index 6fef625248..2334aa48e6 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 @@ -17,10 +18,12 @@ import com.datadog.android.api.storage.RawBatchEvent 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.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 @@ -31,7 +34,9 @@ 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.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever @@ -149,6 +154,141 @@ internal class SessionReplayRecordWriterTest { verifyNoMoreInteractions(mockRecordCallback) } + // region Withholding + + private val written = mutableListOf() + + private fun recordWrites() { + 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 + + @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") + } + + // 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 From 21dcb04b3aaa382a926610e95e36280b4fd3c34a Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 4 Oct 2026 01:00:34 -0700 Subject: [PATCH 03/13] fix(rum,session-replay): hold replay images, release replay with events Session Replay resources now go through the record writer, which holds them with the records of a withheld session and writes or drops them together; a resource hash is remembered as sent only once it is written. The held replay is released when the session's events actually are - after the jitter, by an explicit bus message from the event buffer - not when the error is first seen. Released events and the releasing error claim session.has_replay when their view has replay records (sent or held), counted by Session Replay apart from records_count. A release waiting for the jitter goes at once when the app leaves the foreground, and an oversized crash releases the history right after it. The release forwards every held detail, views only setting the order, so an error whose view was never held or was evicted still goes out. The held replay gains a 4MiB bound (images bounded apart), cut at full snapshots, and a release cut at a periodic full snapshot starts with the view's meta and focus records. sampled_for_replay is only reported for sessions in an on-error mode, and Session Replay leaves its feature context untouched when nothing is withheld, so views of customers who did not opt in are unchanged. --- .../kotlin/com/datadog/android/rum/Rum.kt | 22 +-- .../rum/internal/FeaturesContextResolver.kt | 11 +- .../android/rum/internal/RumFeature.kt | 71 ++++++- .../rum/internal/domain/RumDataWriter.kt | 11 +- .../internal/domain/WithheldEventWriter.kt | 124 ++++++++++--- .../internal/domain/scope/RumSessionScope.kt | 11 +- .../rum/internal/domain/scope/RumViewScope.kt | 1 + .../remoteconfig/ProcessForegroundCallback.kt | 8 +- .../internal/FeaturesContextResolverTest.kt | 30 ++- .../domain/WithheldEventWriterTest.kt | 175 +++++++++++++++++- .../domain/scope/RumSessionScopeTest.kt | 2 +- .../internal/domain/scope/RumViewScopeTest.kt | 13 +- .../ProcessForegroundCallbackTest.kt | 35 +++- .../sessionreplay/internal/RecordCallback.kt | 13 ++ .../internal/SessionReplayFeature.kt | 27 ++- .../internal/SessionReplayRecordCallback.kt | 34 ++++ .../processor/RecordedDataProcessor.kt | 16 +- .../internal/storage/NoOpResourcesWriter.kt | 2 +- .../internal/storage/ResourcesWriter.kt | 5 +- .../storage/SessionReplayRecordWriter.kt | 164 +++++++++++++--- .../storage/SessionReplayResourcesWriter.kt | 6 +- .../internal/SessionReplayFeatureTest.kt | 52 ++++++ .../SessionReplayRecordCallbackTest.kt | 31 ++++ .../processor/RecordedDataProcessorTest.kt | 22 ++- .../storage/SessionReplayRecordWriterTest.kt | 149 ++++++++++++++- .../SessionReplayResourcesWriterTest.kt | 8 +- 26 files changed, 912 insertions(+), 131 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt index b1ac3bdcdf..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 @@ -18,8 +18,6 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.sampling.RateBasedSampler import com.datadog.android.rum.internal.RumAnonymousIdentifierManager import com.datadog.android.rum.internal.RumFeature -import com.datadog.android.rum.internal.domain.RumDataWriter -import com.datadog.android.rum.internal.domain.WithheldEventWriter import com.datadog.android.rum.internal.domain.scope.RumVitalAppLaunchEventHelper import com.datadog.android.rum.internal.metric.SessionEndedMetricDispatcher import com.datadog.android.rum.internal.monitor.DatadogRumMonitor @@ -131,25 +129,7 @@ object Rum { val handler = Handler(Looper.getMainLooper()) - // FLASHCAT FORK - every RUM event of a collected session passes through it, see - // `RumConfiguration.Builder.setSessionOnError`. The release timer only hands the release - // back to the storage thread, so the main looper is enough to carry it. - val withheldEvents = (rumFeature.dataWriter as? RumDataWriter)?.let { rumDataWriter -> - WithheldEventWriter( - delegate = rumDataWriter, - internalLogger = sdkCore.internalLogger, - elapsedTimeNs = { sdkCore.timeProvider.getDeviceElapsedTimeNanos() }, - scheduleRelease = { delayMs, release -> - handler.postDelayed( - { - sdkCore.getFeature(Feature.RUM_FEATURE_NAME) - ?.withWriteContext { _, writeScope -> writeScope(release) } - }, - delayMs - ) - } - ) - } + val withheldEvents = rumFeature.withheldEvents return DatadogRumMonitor( applicationId = rumFeature.applicationId, 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 e770a2405a..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 @@ -38,13 +38,15 @@ internal class FeaturesContextResolver { } /** - * FLASHCAT FORK - whether this session is sampled for replay, or null when Session Replay is not - * enabled. 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. + * 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, sampledForError: Boolean): Boolean? { + 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) @@ -53,6 +55,7 @@ internal class FeaturesContextResolver { 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 b46215bbe4..c17ead1d33 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 @@ -24,6 +24,7 @@ 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 @@ -47,6 +48,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 +66,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 @@ -179,6 +182,14 @@ 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 internal var initialResourceIdentifier: InitialResourceIdentifier = NoOpInitialResourceIdentifier() internal var lastInteractionIdentifier: LastInteractionIdentifier? = NoOpLastInteractionIdentifier() internal var slowFramesListener: SlowFramesListener? = null @@ -218,10 +229,12 @@ internal class RumFeature( lastInteractionIdentifier = configuration.lastInteractionIdentifier insightsCollector = configuration.insightsCollector - dataWriter = createDataWriter( + val rumDataWriter = createDataWriter( configuration, sdkCore as InternalSdkCore ) + dataWriter = rumDataWriter + withheldEvents = createWithheldEventWriter(rumDataWriter, appContext) sampleRate = if (sdkCore.isDeveloperModeEnabled) { sdkCore.internalLogger.log( @@ -355,6 +368,10 @@ internal class RumFeature( remoteConfigController = null remoteConfigStore = null + withheldEventsBackgroundCallback?.let { (appContext as? Application)?.unregisterActivityLifecycleCallbacks(it) } + withheldEventsBackgroundCallback = null + withheldEvents = null + rumContextUpdateReceivers.forEach { sdkCore.removeContextUpdateReceiver(it) } @@ -402,10 +419,58 @@ 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 withRumWriteScope: ((EventBatchWriter) -> Unit) -> Unit = { block -> + sdkCore.getFeature(Feature.RUM_FEATURE_NAME)?.withWriteContext { _, writeScope -> writeScope(block) } + } + val writer = WithheldEventWriter( + delegate = rumDataWriter, + internalLogger = sdkCore.internalLogger, + elapsedTimeNs = { sdkCore.timeProvider.getDeviceElapsedTimeNanos() }, + scheduleRelease = { delayMs, release -> handler.postDelayed({ withRumWriteScope(release) }, delayMs) }, + replayRecordsCount = ::resolveReplayRecordsCount, + releaseReplay = { sessionId -> + sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.sendEvent( + mapOf( + RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE, + RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to sessionId + ) + ) + } + ) + (appContext as? Application)?.let { application -> + val callback = ProcessForegroundCallback( + onForeground = {}, + onBackground = { withRumWriteScope(writer::flushScheduledRelease) } + ) + application.registerActivityLifecycleCallbacks(callback) + withheldEventsBackgroundCallback = callback + } + return writer + } + + /** 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( @@ -826,7 +891,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 } 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 f2caeb8487..4fb04b615c 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 @@ -28,14 +28,11 @@ internal class RumDataWriter( @WorkerThread override fun write(writer: EventBatchWriter, element: Any, eventType: EventType): Boolean { val batchEvent = serialize(element) ?: return false - - synchronized(this) { - val result = writer.write(batchEvent, null, eventType) - if (result) { - onDataWritten(element, batchEvent.data) - } - return result + val result = writeSerialized(writer, batchEvent, eventType) + if (result) { + onDataWritten(element, batchEvent.data) } + return result } // endregion 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 index 7b7a73073d..fe2115409f 100644 --- 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 @@ -19,6 +19,7 @@ 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 @@ -49,18 +50,27 @@ internal class WithheldEventWriter( * 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 + 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 ) : DataWriter { - private class HeldView(val date: Long, val event: RawBatchEvent, val eventType: EventType) + 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, - val isError: Boolean + val tier: EvictionTier ) /** What goes first when the buffer is over budget. */ @@ -93,6 +103,9 @@ internal class WithheldEventWriter( */ 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() @@ -126,8 +139,17 @@ internal class WithheldEventWriter( return true } if (sessionId == watchedSessionId && element is ErrorEvent) { - val written = delegate.write(writer, element, eventType) - if (written) releasedSessionId = sessionId + // 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) + if (written && releasedSessionId != sessionId) { + releasedSessionId = sessionId + notifyReplayReleased(sessionId) + } return written } if (sessionId != withheldForSessionId) { @@ -139,9 +161,10 @@ internal class WithheldEventWriter( 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. - delegate.writeSerialized(writer, batchEvent, eventType) - scheduleReleaseOnce(sessionId) + // 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 } } @@ -166,6 +189,24 @@ internal class WithheldEventWriter( /** 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) + } + } + /** Watches a collected session whose replay only is kept on error for its first error. */ @WorkerThread fun watchForError(sessionId: String) { @@ -248,8 +289,7 @@ internal class WithheldEventWriter( event = batchEvent, eventType = eventType, heldAtNs = elapsedTimeNs(), - tier = evictionTierOf(element), - isError = element is ErrorEvent + tier = evictionTierOf(element) ) ) bytes += size @@ -265,7 +305,7 @@ internal class WithheldEventWriter( // 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(element.date, batchEvent, eventType) + 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. @@ -304,7 +344,7 @@ internal class WithheldEventWriter( } private fun evictOne(): Boolean { - for (tier in listOf(EvictionTier.FIRST, EvictionTier.LAST)) { + for (tier in EVICTED_BEFORE_ERRORS) { val index = details.indexOfFirst { it.tier == tier } if (index != -1) { evictAt(index) @@ -338,17 +378,18 @@ internal class WithheldEventWriter( @WorkerThread private fun release(writer: EventBatchWriter) { + val sessionId = withheldForSessionId prune() - // A detail whose view is gone has nothing to hang from at the other end. One that never had - // a view (an app launch vital measured before the first one) hangs from the session alone. - val releasable = details.filter { it.viewId == null || views.containsKey(it.viewId) } - - // The session is built out of whichever of its views arrives first, so that one has to be - // the earliest. 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 { delegate.writeSerialized(writer, it.event, it.eventType) } - releasable.filter { it.isError }.forEach { delegate.writeSerialized(writer, it.event, it.eventType) } - releasable.filterNot { it.isError }.forEach { delegate.writeSerialized(writer, it.event, it.eventType) } + // 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( @@ -359,7 +400,7 @@ internal class WithheldEventWriter( onlyOnce = false, additionalProperties = mapOf( "buffer.views_count" to views.size, - "buffer.events_count" to releasable.size, + "buffer.events_count" to details.size, "buffer.dropped_count" to droppedCount, "buffer.bytes" to bytes ) @@ -367,6 +408,36 @@ internal class WithheldEventWriter( 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() { @@ -407,6 +478,11 @@ internal class WithheldEventWriter( 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" /** 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 cecdf70ac7..ae09835749 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 @@ -234,7 +234,7 @@ internal class RumSessionScope( // 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 && isCollected()) { + if (sampledForError && (sessionState == State.TRACKED || sessionState == State.WITHHELD)) { val releasedSessionId = sessionId writeScope { withheldEvents?.forceRelease(releasedSessionId, it) } if (sessionState == State.WITHHELD) sessionState = State.TRACKED @@ -349,10 +349,6 @@ internal class RumSessionScope( endWithheldSession(writeScope) } - // FLASHCAT FORK - TRACKED or WITHHELD: the session's events are assembled, whether or not they - // are uploaded yet. - private fun isCollected(): Boolean = sessionState == State.TRACKED || sessionState == State.WITHHELD - /** * 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 @@ -563,10 +559,10 @@ internal class RumSessionScope( 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 it has reported its error. + // 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?.isReleased(sessionId) == true) + RUM_SESSION_RELEASED_BUS_MESSAGE_KEY to (withheldEvents?.isReplayReleased(sessionId) == true) ) ) } @@ -583,6 +579,7 @@ internal class RumSessionScope( 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" 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 0506d6766c..3e343289c8 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 @@ -1342,6 +1342,7 @@ internal open class RumViewScope( sampledForError = rumContext.sampledForError.takeIf { it }, sampledForReplay = featuresContextResolver.resolveSampledForReplay( datadogContext, + rumContext.sessionId, rumContext.sampledForError ), sampledForErrorReplay = featuresContextResolver 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/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 c4d15c419f..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 @@ -284,15 +284,33 @@ internal class FeaturesContextResolverTest { @Test fun `M count a held replay as sampled only with held events W resolveSampledForReplay()`(forge: Forge) { - val recording = mapOf(FeaturesContextResolver.REPLAY_ENABLED_KEY to true) + 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), false)).isTrue - assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, withheld), false)).isFalse - assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, withheld), true)).isTrue - assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, stopped), true)).isFalse - assertThat(testedFeaturesContextResolver.resolveSampledForReplay(contextWith(forge, null), true)).isNull() + 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/domain/WithheldEventWriterTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriterTest.kt index c67edfc56e..d07b1eefa8 100644 --- 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 @@ -84,8 +84,17 @@ internal class WithheldEventWriterTest { this.forge = forge whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger val serializer = object : Serializer { - override fun serialize(model: Any): String? = - if (payloads.containsKey(model)) payloads[model] else "untracked" + override fun serialize(model: Any): String? { + if (model is ErrorEvent) claimedErrors.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)) + } + } + return if (key != null) payloads[key] else "untracked" + } } val metaSerializer = object : Serializer { override fun serialize(model: RumEventMeta): String = "meta" @@ -94,10 +103,16 @@ internal class WithheldEventWriterTest { delegate = RumDataWriter(serializer, metaSerializer, mockSdkCore), internalLogger = mockInternalLogger, elapsedTimeNs = { nowNs }, - scheduleRelease = { delayMs, release -> scheduled.add(delayMs to release) } + scheduleRelease = { delayMs, release -> scheduled.add(delayMs to release) }, + replayRecordsCount = { viewId -> replayRecords[viewId] ?: 0L }, + releaseReplay = { releasedReplays.add(it) } ) } + private val replayRecords = mutableMapOf() + private val claimedErrors = mutableListOf() + private val releasedReplays = mutableListOf() + // region Withholding @Test @@ -209,6 +224,140 @@ internal class WithheldEventWriterTest { // 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) + 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 @@ -400,6 +549,22 @@ internal class WithheldEventWriterTest { assertThat(written).containsExactly("v1", "e1") } + @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 @@ -580,7 +745,9 @@ internal class WithheldEventWriterTest { assertThat(views).hasSize(WithheldEventWriter.VIEWS_LIMIT) assertThat(views.last()).isEqualTo("current") assertThat(written).contains("e1", "a0") - assertThat(written).doesNotContain("old0", "a1") + // the evicted view is gone; its detail still goes, views only order the release + assertThat(written).doesNotContain("old0") + assertThat(written).contains("a1") } // endregion 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 0e0de90bb8..d735702866 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 @@ -2454,7 +2454,7 @@ internal class RumSessionScopeTest { mockEventWriteScope, mockWriter ) - whenever(withheldEvents.isReleased(testedScope.sessionId)) doReturn true + whenever(withheldEvents.isReplayReleased(testedScope.sessionId)) doReturn true testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) // Then 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 e0a773e851..e194d821e9 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 @@ -706,7 +706,7 @@ internal class RumViewScopeTest { ) { // Given testedScope = newRumViewScope(trackFrustrations = true) - whenever(mockFeaturesContextResolver.resolveSampledForReplay(any(), any())) doReturn true + whenever(mockFeaturesContextResolver.resolveSampledForReplay(any(), any(), any())) doReturn true whenever(mockFeaturesContextResolver.resolveSampledForErrorReplay(any(), eq(fakeParentContext.sessionId))) .doReturn(true) @@ -733,7 +733,12 @@ internal class RumViewScopeTest { // Given fakeParentContext = fakeParentContext.copy(sampledForError = false) whenever(mockParentScope.getRumContext()) doReturn fakeParentContext - testedScope = newRumViewScope(trackFrustrations = true) + 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 @@ -749,7 +754,9 @@ internal class RumViewScopeTest { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) assertThat(lastValue.session.sampledForError).isNull() assertThat(lastValue.session.sampledForErrorReplay).isNull() - assertThat(lastValue.toJson().asJsonObject.getAsJsonObject("session").has("sampled_for_error")).isFalse + // 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") } } 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-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 8a0c9a3d6f..0d4d77c258 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 @@ -33,6 +33,7 @@ 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 @@ -146,12 +147,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 @@ -237,7 +241,8 @@ internal class SessionReplayFeature( ?.let { sessionData -> val alreadySeenSession = currentRumSessionId.get() == sessionData.sessionId val forceSampling = sessionData.forced && !isSessionSampledIn.get() - // FLASHCAT FORK - a held replay is released by the session's error (or by forcing). + // 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) @@ -247,6 +252,13 @@ internal class SessionReplayFeature( 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, and recording carries on as for any collected session. + val sessionId = sessionMetadata[RUM_SESSION_ID_BUS_MESSAGE_KEY] as? String + if (sessionId != null && isReplayWithheld.get() && currentRumSessionId.get() == sessionId) { + updateWithholding(sessionId, withhold = false) + } } else { sdkCore.internalLogger.log( InternalLogger.Level.WARN, @@ -324,8 +336,10 @@ internal class SessionReplayFeature( // session still held. dataWriter.stopWithholding(sessionId) } - isReplayWithheld.set(withhold) + 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 @@ -421,9 +435,9 @@ 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) } /** @@ -493,6 +507,7 @@ internal class SessionReplayFeature( 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" // 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. 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..975d1a3c36 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,39 @@ 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) + } + } + } + } + private fun updateRecordsCount( viewMetadata: MutableMap, recordsCount: Int @@ -42,5 +75,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/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/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 72fdc986f6..592a2cf67a 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 @@ -14,23 +14,43 @@ import com.datadog.android.api.storage.EventType import com.datadog.android.api.storage.RawBatchEvent 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 +) : RecordWriter, ResourcesWriter { + + private class HeldRecord(val record: EnrichedRecord, val data: ByteArray) + + private class HeldResource(val resource: EnrichedResource, val onWritten: () -> Unit) - // FLASHCAT FORK - the replay of a session kept only in case it reports an error is held here - // until it does. Everything below is touched on the storage thread only, in submission order, - // which keeps the control calls in step with the records they concern. + // 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 withheldSessionId: String? = null - private val held = ArrayList() + private val held = ArrayList() + private var heldBytes = 0L + private val heldResources = LinkedHashMap() + private var heldResourceBytes = 0L private 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. + */ + private val viewStartRecords = HashMap>() + /** * The sessions whose held records were thrown away: records still queued for them when they * ended must not reach the intake on their own. @@ -43,14 +63,24 @@ internal class SessionReplayRecordWriter( if (record.sessionId == withheldSessionId) { hold(record) } else { - writeNow(writer, record) + writeNow(writer, record, serialize(record)) + } + } + } + + override fun write(enrichedResource: EnrichedResource, sessionId: String, onWritten: () -> Unit) { + onStorageThread { + when (sessionId) { + in discardedSessionIds -> Unit + withheldSessionId -> holdResource(enrichedResource, onWritten) + else -> resourcesWriter.write(enrichedResource, sessionId, onWritten) } } } /** - * FLASHCAT FORK - holds the records of [sessionId] from now on. Whatever another session still - * held never earned its release, so it is thrown away. + * Holds the records of [sessionId] from now on. Whatever another session still held never + * earned its release, so it is thrown away. */ override fun withhold(sessionId: String) { onStorageThread { @@ -62,9 +92,9 @@ internal class SessionReplayRecordWriter( } /** - * FLASHCAT FORK - stops holding. If [releasedSessionId] is the session held, what it held is - * written now, in order, and the records that follow are written as they come; anything held - * for another session is thrown away. + * Stops holding. If [releasedSessionId] is the session held, what it held is written now, in + * order, and the records that follow are written as they come; anything held for another + * session is thrown away. */ override fun stopWithholding(releasedSessionId: String?) { onStorageThread { writer -> @@ -77,21 +107,65 @@ internal class SessionReplayRecordWriter( } private fun hold(record: EnrichedRecord) { - held.add(record) + record.records.filter { it.isViewStart() }.takeIf { it.isNotEmpty() }?.let { + viewStartRecords[record.viewId] = it + } + val data = serialize(record) + held.add(HeldRecord(record, data)) + heldBytes += 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. val newest = record.fullSnapshotTimestamp() ?: return - val cutoff = newest - WINDOW_MS - val firstKept = held.indexOfLast { (it.fullSnapshotTimestamp() ?: Long.MAX_VALUE) <= cutoff } - if (firstKept > 0) { - droppedCount += firstKept - held.subList(0, firstKept).clear() + val firstInWindow = held.indexOfLast { + (it.record.fullSnapshotTimestamp() ?: Long.MAX_VALUE) <= newest - WINDOW_MS } + if (firstInWindow > 0) dropOldest(firstInWindow) + // Memory bound: the oldest span goes first, down to the latest full snapshot at least. + while (heldBytes > BYTES_LIMIT) { + val nextFullSnapshot = held.indexOfFirstFrom(1) { it.record.fullSnapshotTimestamp() != null } + if (nextFullSnapshot == -1) break + dropOldest(nextFullSnapshot) + } + } + + private fun holdResource(resource: EnrichedResource, onWritten: () -> Unit) { + if (heldResources.containsKey(resource.filename)) return + heldResources[resource.filename] = HeldResource(resource, onWritten) + heldResourceBytes += resource.resource.size + // Images are bounded apart from the records; the oldest go first. A record released without + // its image shows a placeholder, it does not break the replay. + while (heldResourceBytes > BYTES_LIMIT && heldResources.size > 1) { + val oldest = heldResources.keys.first() + heldResourceBytes -= heldResources.remove(oldest)?.resource?.resource?.size ?: 0 + } + } + + private fun dropOldest(count: Int) { + val dropped = held.subList(0, count) + heldBytes -= dropped.sumOf { it.data.size.toLong() } + droppedCount += dropped.size + recordCallback.onWithheldRecordsCleared(dropped.map { it.record }) + dropped.clear() } private fun release(writer: EventBatchWriter) { - held.forEach { writeNow(writer, it) } + val first = held.firstOrNull()?.record + val startRecords = first?.let { viewStartRecords[it.viewId] } + held.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) + } + } + heldResources.values.forEach { resourcesWriter.write(it.resource, withheldSessionId.orEmpty(), it.onWritten) } + recordCallback.onWithheldRecordsCleared(held.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, @@ -100,14 +174,12 @@ internal class SessionReplayRecordWriter( throwable = null, onlyOnce = false, additionalProperties = mapOf( - "buffer.records_count" to held.sumOf { it.records.size }, + "buffer.records_count" to held.sumOf { it.record.records.size }, "buffer.duration_ms" to held.durationMs(), "buffer.dropped_count" to droppedCount ) ) - held.clear() - droppedCount = 0 - withheldSessionId = null + reset() } private fun discardHeld() { @@ -116,16 +188,23 @@ internal class SessionReplayRecordWriter( if (discardedSessionIds.size > DISCARDED_SESSIONS_REMEMBERED) { discardedSessionIds.removeFirstOrNull() } + recordCallback.onWithheldRecordsCleared(held.map { it.record }) + reset() + } + + private fun reset() { held.clear() + heldBytes = 0L + heldResources.clear() + heldResourceBytes = 0L + viewStartRecords.clear() droppedCount = 0 withheldSessionId = null } - private fun writeNow(writer: EventBatchWriter, record: EnrichedRecord) { - val serializedRecord = record.toJson().toByteArray(Charsets.UTF_8) - val rawBatchEvent = RawBatchEvent(data = serializedRecord) + private fun writeNow(writer: EventBatchWriter, record: EnrichedRecord, data: ByteArray) { val success = writer.write( - event = rawBatchEvent, + event = RawBatchEvent(data = data), batchMetadata = null, eventType = EventType.DEFAULT ) @@ -161,15 +240,42 @@ internal class SessionReplayRecordWriter( /** 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 what a withheld session holds. */ + internal const val BYTES_LIMIT = 4L * 1024 * 1024 + private const val DISCARDED_SESSIONS_REMEMBERED = 4 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 } - private fun List.durationMs(): Long { - val stamps = mapNotNull { it.fullSnapshotTimestamp() } + 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 8ee2a418bb..3cf2daf2d7 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 @@ -42,6 +42,7 @@ 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 @@ -663,6 +664,57 @@ internal class SessionReplayFeatureTest { 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 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 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..5121e44a0e 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,32 @@ 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 + ) + } } 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/storage/SessionReplayRecordWriterTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt index 2334aa48e6..99f8f41769 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 @@ -18,6 +18,7 @@ import com.datadog.android.api.storage.RawBatchEvent 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 @@ -29,6 +30,7 @@ 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 @@ -36,6 +38,8 @@ import org.mockito.kotlin.anyOrNull 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 @@ -78,7 +82,7 @@ internal class SessionReplayRecordWriterTest { whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) .thenReturn(mockSessionReplayFeature) - testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback) + testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback, mockResourcesWriter) } @Test @@ -194,6 +198,149 @@ internal class SessionReplayRecordWriterTest { @Mock lateinit var mockInternalLogger: InternalLogger + @Mock + lateinit var mockResourcesWriter: ResourcesWriter + + private fun resource(hash: String, size: Int = 10) = EnrichedResource(ByteArray(size), hash) + + @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(record("r1", "s1", fullSnapshotAt = 1_000)) + 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 keep the only full snapshot W hold { a single span over the byte limit }`() { + // Given + recordWrites() + testedWriter.withhold("s1") + testedWriter.write(bigRecord("only", "s1", 1_000, SessionReplayRecordWriter.BYTES_LIMIT.toInt() + 1)) + testedWriter.write(record("inc", "s1")) + + // When + testedWriter.stopWithholding("s1") + + // Then + assertThat(written).containsExactly("only", "inc") + } + + 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 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) } } From d9332904145e8b8b2ca6f28255678454dae69a12 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 4 Oct 2026 01:58:18 -0700 Subject: [PATCH 04/13] fix(rum): keep the surviving view version at the view's first position in a batch The upload filter keeps only the latest version of each view in a batch. It used to keep that version where it was written, so when a released error session's buffer (views first, oldest first, then errors and details) shared a batch with the live updates written right after the release - always the case for a crash, which updates the view's crash count - the views ended up last and out of start order. The intake builds the session out of the first view it sees. The latest version now takes the place of the view's first occurrence in the batch; which versions survive is unchanged. --- .../domain/event/RumViewEventFilter.kt | 31 ++++++---- .../domain/event/RumViewEventFilterTest.kt | 57 ++++++++++++++++++- 2 files changed, 76 insertions(+), 12 deletions(-) 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/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 + } + } + } } From 359451b5030a53af68b375bbbde9ac78c74f9ebe Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 02:58:48 -0700 Subject: [PATCH 05/13] fix(rum,session-replay): keep a released replay through renewal, honour consent and never evict a crash Session Replay now keeps aside the replay of a session whose events were withheld until RUM says whether that session was released or thrown away, instead of discarding it as soon as the next session announces itself: RUM's word lands on the storage thread and can arrive after the next session has started. RUM sends an explicit discard for such a session, and a stopped session draining beside its successor no longer announces itself as current. Images captured while a replay is withheld are held across sessions and sent only with released records that show them, since the recorder captures an image once per process. Records and images captured while tracking consent is withdrawn are neither held nor sent, and what was held is dropped. The replay byte budget is enforced on every record. A crash is never evicted from the withheld event buffer, even when it does not fit alongside an earlier large error. A session kept on error is only ended by the emergency stop, not by a console rate leaving zero. --- .../android/rum/internal/RumFeature.kt | 25 +- .../internal/domain/WithheldEventWriter.kt | 21 +- .../internal/domain/scope/RumSessionScope.kt | 18 +- .../domain/WithheldEventWriterTest.kt | 52 +++- .../domain/scope/RumSessionScopeTest.kt | 44 +++ .../internal/SessionReplayFeature.kt | 25 +- .../internal/storage/NoOpRecordWriter.kt | 12 +- .../internal/storage/RecordWriter.kt | 32 ++- .../storage/SessionReplayRecordWriter.kt | 265 ++++++++++++------ .../internal/SessionReplayFeatureTest.kt | 56 +++- .../storage/SessionReplayRecordWriterTest.kt | 201 ++++++++++++- 11 files changed, 609 insertions(+), 142 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index c17ead1d33..94b677d57f 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 @@ -437,12 +437,15 @@ internal class RumFeature( scheduleRelease = { delayMs, release -> handler.postDelayed({ withRumWriteScope(release) }, delayMs) }, replayRecordsCount = ::resolveReplayRecordsCount, releaseReplay = { sessionId -> - sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)?.sendEvent( - mapOf( - RumSessionScope.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to - RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE, - RumSessionScope.RUM_SESSION_ID_BUS_MESSAGE_KEY to sessionId - ) + tellSessionReplay( + RumSessionScope.RUM_SESSION_RELEASED_BUS_MESSAGE, + sessionId + ) + }, + discardReplay = { sessionId -> + tellSessionReplay( + RumSessionScope.RUM_SESSION_DISCARDED_BUS_MESSAGE, + sessionId ) } ) @@ -457,6 +460,16 @@ internal class RumFeature( return writer } + /** 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 = 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 index fe2115409f..6626559688 100644 --- 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 @@ -60,7 +60,13 @@ internal class WithheldEventWriter( * 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 + 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 ) : DataWriter { private class HeldView(val viewId: String, val date: Long, val event: RawBatchEvent, val eventType: EventType) @@ -85,7 +91,13 @@ internal class WithheldEventWriter( * 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 + 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 @@ -240,6 +252,7 @@ internal class WithheldEventWriter( } clear() withheldForSessionId = null + discardReplay(sessionId) } } } @@ -387,7 +400,7 @@ internal class WithheldEventWriter( // 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 } + 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) } @@ -515,7 +528,7 @@ internal class WithheldEventWriter( } private fun evictionTierOf(element: Any): EvictionTier = when (element) { - is ErrorEvent -> EvictionTier.LAST_RESORT + 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 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 ae09835749..1c9e52d3e2 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 @@ -361,16 +361,17 @@ internal class RumSessionScope( } /** - * FLASHCAT FORK - whether the settings now in force keep this session exactly as it is, so a - * reset asked for by the console must not end it: a session kept on error, under a zero rate - * with the switch on. That is the ordinary configuration for "only the sessions that error", - * not an emergency stop - ending the session would throw away what the switch exists to keep, - * on every first fetch and every release of the app. + * 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 + return !(sampling.rate == 0f && !sampling.onError) } private fun isSessionComplete(): Boolean { @@ -548,6 +549,10 @@ internal class RumSessionScope( } 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( @@ -580,6 +585,7 @@ internal class RumSessionScope( 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" private const val MAX_SAMPLE_RATE = 100f 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 index d07b1eefa8..0dfafde24c 100644 --- 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 @@ -105,13 +105,15 @@ internal class WithheldEventWriterTest { elapsedTimeNs = { nowNs }, scheduleRelease = { delayMs, release -> scheduled.add(delayMs to release) }, replayRecordsCount = { viewId -> replayRecords[viewId] ?: 0L }, - releaseReplay = { releasedReplays.add(it) } + releaseReplay = { releasedReplays.add(it) }, + discardReplay = { discardedReplays.add(it) } ) } private val replayRecords = mutableMapOf() private val claimedErrors = mutableListOf() private val releasedReplays = mutableListOf() + private val discardedReplays = mutableListOf() // region Withholding @@ -160,6 +162,35 @@ internal class WithheldEventWriterTest { 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 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 @@ -549,6 +580,25 @@ internal class WithheldEventWriterTest { 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 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 d735702866..9afc0271b3 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 @@ -2404,6 +2404,50 @@ internal class RumSessionScopeTest { 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 session W handleEvent(ResetSession) { zero rate with the switch off }`() { // Given 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 0d4d77c258..03b9d36710 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 @@ -248,17 +248,23 @@ internal class SessionReplayFeature( applySampling(alreadySeenSession, sessionData.forced) val withhold = shouldWithhold(sessionData) modifyShouldRecordState(sessionData, withhold) - updateWithholding(sessionData.sessionId, withhold) + updateWithholding(sessionData.sessionId, withhold, sessionData.eventsOnError) 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, and recording carries on as for any collected session. - val sessionId = sessionMetadata[RUM_SESSION_ID_BUS_MESSAGE_KEY] as? String - if (sessionId != null && isReplayWithheld.get() && currentRumSessionId.get() == sessionId) { - updateWithholding(sessionId, withhold = false) + // 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, eventsOnError = false) + } else { + dataWriter.release(sessionId) } + } 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, @@ -327,13 +333,13 @@ internal class SessionReplayFeature( } } - private fun updateWithholding(sessionId: String, withhold: Boolean) { + private fun updateWithholding(sessionId: String, withhold: Boolean, eventsOnError: Boolean) { if (withhold) { onErrorReplaySessionId.set(sessionId) - dataWriter.withhold(sessionId) + dataWriter.withhold(sessionId, eventsWithheld = eventsOnError) } else { - // Releases what this session held, if it held anything; throws away what any other - // session still held. + // 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) @@ -508,6 +514,7 @@ internal class SessionReplayFeature( 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" // 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. 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 cc5fd4d879..dc65e64f7d 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 @@ -13,11 +13,19 @@ internal class NoOpRecordWriter : RecordWriter { // no-op } - override fun withhold(sessionId: String) { + override fun withhold(sessionId: String, eventsWithheld: Boolean) { // no-op } - override fun stopWithholding(releasedSessionId: String?) { + override fun stopWithholding(sessionId: String) { + // no-op + } + + override fun release(sessionId: String) { + // no-op + } + + override fun discard(sessionId: String) { // 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 b447de885e..d2e9777cea 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,26 +9,42 @@ 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 - * [stopWithholding] releases them. + * [release] or [stopWithholding] lets them go. * @param sessionId the RUM session whose replay is kept only if it reports an error + * @param eventsWithheld whether the session's events are withheld too, in which case RUM alone + * decides, with [release] or [discard], what becomes of the replay once the session has ended */ - fun withhold(sessionId: String) + fun withhold(sessionId: String, eventsWithheld: Boolean) /** - * FLASHCAT FORK - stops holding records. - * @param releasedSessionId the session whose held records are written now; held records of - * any other session are thrown away. Null throws away whatever is held. + * 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 if its events were withheld, and thrown away if not. + * @param sessionId the RUM session now current */ - fun stopWithholding(releasedSessionId: String?) + 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) } 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 592a2cf67a..12ed2adabc 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 @@ -12,6 +12,7 @@ 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 @@ -30,26 +31,44 @@ internal class SessionReplayRecordWriter( private val resourcesWriter: ResourcesWriter ) : RecordWriter, ResourcesWriter { - private class HeldRecord(val record: EnrichedRecord, val data: ByteArray) + 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 eventsWithheld: Boolean) { + 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 withheldSessionId: String? = null - private val held = ArrayList() - private var heldBytes = 0L - private val heldResources = LinkedHashMap() - private var heldResourceBytes = 0L - private var droppedCount = 0 + private var current: Buffer? = null + + /** + * A session that ended while its events were still withheld. RUM decides on the storage thread + * whether such a session is released or thrown away, and its word can land after the next + * session has announced itself: until it does, what the session held is kept aside. + */ + private var parked: Buffer? = null /** - * 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. + * 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 refer + * to it, so nothing of a session that never errored is ever sent. */ - private val viewStartRecords = HashMap>() + private val heldResources = LinkedHashMap() + private var heldResourceBytes = 0L /** * The sessions whose held records were thrown away: records still queued for them when they @@ -58,102 +77,138 @@ internal class SessionReplayRecordWriter( private val discardedSessionIds = ArrayDeque() override fun write(record: EnrichedRecord) { - onStorageThread { writer -> + onStorageThread { writer, consent -> if (record.sessionId in discardedSessionIds) return@onStorageThread - if (record.sessionId == withheldSessionId) { - hold(record) - } else { - writeNow(writer, record, serialize(record)) + // 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)) + // Nothing may be held while consent is withdrawn, and what was held under the + // consent now withdrawn goes too. + consent == TrackingConsent.NOT_GRANTED -> dropForConsent(buffer) + else -> hold(buffer, record) } } } override fun write(enrichedResource: EnrichedResource, sessionId: String, onWritten: () -> Unit) { - onStorageThread { - when (sessionId) { - in discardedSessionIds -> Unit - withheldSessionId -> holdResource(enrichedResource, onWritten) + onStorageThread { _, consent -> + when { + consent == TrackingConsent.NOT_GRANTED -> clearResources() + sessionId == current?.sessionId || sessionId == parked?.sessionId || sessionId in discardedSessionIds -> + holdResource(enrichedResource, onWritten) else -> resourcesWriter.write(enrichedResource, sessionId, onWritten) } } } - /** - * Holds the records of [sessionId] from now on. Whatever another session still held never - * earned its release, so it is thrown away. - */ - override fun withhold(sessionId: String) { - onStorageThread { - if (withheldSessionId != sessionId) { - discardHeld() - withheldSessionId = sessionId - } + override fun withhold(sessionId: String, eventsWithheld: Boolean) { + 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(::retire) + current = Buffer(sessionId, eventsWithheld) } } - /** - * Stops holding. If [releasedSessionId] is the session held, what it held is written now, in - * order, and the records that follow are written as they come; anything held for another - * session is thrown away. - */ - override fun stopWithholding(releasedSessionId: String?) { - onStorageThread { writer -> - if (withheldSessionId != null && withheldSessionId == releasedSessionId) { - release(writer) - } else { - discardHeld() - } + override fun stopWithholding(sessionId: String) { + onStorageThread { writer, _ -> + val buffer = current ?: return@onStorageThread + if (buffer.sessionId == sessionId) release(writer, buffer) else retire(buffer) + } + } + + override fun release(sessionId: String) { + onStorageThread { writer, _ -> + bufferOf(sessionId)?.let { release(writer, it) } } } - private fun hold(record: EnrichedRecord) { + override fun discard(sessionId: String) { + onStorageThread { _, _ -> + bufferOf(sessionId)?.let(::discard) + } + } + + private fun bufferOf(sessionId: String): Buffer? = when (sessionId) { + current?.sessionId -> current + parked?.sessionId -> parked + else -> null + } + + /** The session is no longer current: kept aside for RUM's word if RUM has one, thrown away if not. */ + private fun retire(buffer: Buffer) { + if (buffer.eventsWithheld) { + parked?.let(::discard) + parked = buffer + if (current === buffer) current = null + } else { + discard(buffer) + } + } + + private fun hold(buffer: Buffer, record: EnrichedRecord) { record.records.filter { it.isViewStart() }.takeIf { it.isNotEmpty() }?.let { - viewStartRecords[record.viewId] = it + 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) - held.add(HeldRecord(record, data)) - heldBytes += data.size + 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. - val newest = record.fullSnapshotTimestamp() ?: return - val firstInWindow = held.indexOfLast { - (it.record.fullSnapshotTimestamp() ?: Long.MAX_VALUE) <= newest - WINDOW_MS + if (fullSnapshotAt != null) { + val firstInWindow = buffer.records.indexOfLast { + (it.record.fullSnapshotTimestamp() ?: Long.MAX_VALUE) <= fullSnapshotAt - WINDOW_MS + } + if (firstInWindow > 0) dropOldest(buffer, firstInWindow) } - if (firstInWindow > 0) dropOldest(firstInWindow) // Memory bound: the oldest span goes first, down to the latest full snapshot at least. - while (heldBytes > BYTES_LIMIT) { - val nextFullSnapshot = held.indexOfFirstFrom(1) { it.record.fullSnapshotTimestamp() != null } + while (buffer.bytes > BYTES_LIMIT) { + val nextFullSnapshot = buffer.records.indexOfFirstFrom(1) { it.record.fullSnapshotTimestamp() != null } if (nextFullSnapshot == -1) break - dropOldest(nextFullSnapshot) + dropOldest(buffer, nextFullSnapshot) } } private fun holdResource(resource: EnrichedResource, onWritten: () -> Unit) { - if (heldResources.containsKey(resource.filename)) return + 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 += resource.resource.size + heldResourceBytes += size // Images are bounded apart from the records; the oldest go first. A record released without // its image shows a placeholder, it does not break the replay. - while (heldResourceBytes > BYTES_LIMIT && heldResources.size > 1) { + while (heldResourceBytes > BYTES_LIMIT) { val oldest = heldResources.keys.first() heldResourceBytes -= heldResources.remove(oldest)?.resource?.resource?.size ?: 0 } } - private fun dropOldest(count: Int) { - val dropped = held.subList(0, count) - heldBytes -= dropped.sumOf { it.data.size.toLong() } - droppedCount += dropped.size + 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 }) dropped.clear() + // A view's start is only ever needed by records still held of that view. + val viewsHeld = buffer.records.mapTo(HashSet()) { it.record.viewId } + buffer.viewStartRecords.keys.retainAll(viewsHeld) } - private fun release(writer: EventBatchWriter) { - val first = held.firstOrNull()?.record - val startRecords = first?.let { viewStartRecords[it.viewId] } - held.forEachIndexed { index, heldRecord -> + 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. @@ -164,8 +219,14 @@ internal class SessionReplayRecordWriter( writeNow(writer, heldRecord.record, heldRecord.data) } } - heldResources.values.forEach { resourcesWriter.write(it.resource, withheldSessionId.orEmpty(), it.onWritten) } - recordCallback.onWithheldRecordsCleared(held.map { it.record }) + // Only the images these records show: any other held image belongs to what was never sent. + buffer.records.flatMapTo(HashSet()) { it.resourceIds }.forEach { id -> + heldResources.remove(id)?.let { held -> + heldResourceBytes -= held.resource.resource.size + resourcesWriter.write(held.resource, buffer.sessionId, held.onWritten) + } + } + 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, @@ -174,32 +235,44 @@ internal class SessionReplayRecordWriter( throwable = null, onlyOnce = false, additionalProperties = mapOf( - "buffer.records_count" to held.sumOf { it.record.records.size }, - "buffer.duration_ms" to held.durationMs(), - "buffer.dropped_count" to droppedCount + "buffer.records_count" to buffer.records.sumOf { it.record.records.size }, + "buffer.duration_ms" to buffer.records.durationMs(), + "buffer.dropped_count" to buffer.droppedCount ) ) - reset() + forget(buffer) } - private fun discardHeld() { - val sessionId = withheldSessionId ?: return - discardedSessionIds.addLast(sessionId) + private fun discard(buffer: Buffer) { + discardedSessionIds.addLast(buffer.sessionId) if (discardedSessionIds.size > DISCARDED_SESSIONS_REMEMBERED) { discardedSessionIds.removeFirstOrNull() } - recordCallback.onWithheldRecordsCleared(held.map { it.record }) - reset() + recordCallback.onWithheldRecordsCleared(buffer.records.map { it.record }) + forget(buffer) } - private fun reset() { - held.clear() - heldBytes = 0L + private fun forget(buffer: Buffer) { + if (current === buffer) current = null + if (parked === buffer) parked = null + } + + /** + * Consent was withdrawn: what was held under the consent now withdrawn is dropped, and the + * session holds again from scratch should consent be granted back. + */ + private fun dropForConsent(buffer: Buffer) { + recordCallback.onWithheldRecordsCleared(buffer.records.map { it.record }) + buffer.droppedCount += buffer.records.size + buffer.records.clear() + buffer.bytes = 0L + buffer.viewStartRecords.clear() + clearResources() + } + + private fun clearResources() { heldResources.clear() heldResourceBytes = 0L - viewStartRecords.clear() - droppedCount = 0 - withheldSessionId = null } private fun writeNow(writer: EventBatchWriter, record: EnrichedRecord, data: ByteArray) { @@ -215,12 +288,12 @@ internal class SessionReplayRecordWriter( } } - private fun onStorageThread(block: (EventBatchWriter) -> Unit) { + private fun onStorageThread(block: (EventBatchWriter, TrackingConsent) -> Unit) { sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) - ?.withWriteContext { _, writeScope -> + ?.withWriteContext { datadogContext, writeScope -> writeScope { synchronized(this@SessionReplayRecordWriter) { - block(it) + block(it, datadogContext.trackingConsent) } } } @@ -240,7 +313,7 @@ internal class SessionReplayRecordWriter( /** 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 what a withheld session holds. */ + /** Memory bound on what a withheld session holds, and the same again on the images. */ internal const val BYTES_LIMIT = 4L * 1024 * 1024 private const val DISCARDED_SESSIONS_REMEMBERED = 4 @@ -252,6 +325,30 @@ internal class SessionReplayRecordWriter( 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 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 3cf2daf2d7..790541d11d 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 @@ -575,7 +575,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder).resumeRecorders() - verify(mockWriter).withhold(fakeSessionId) + verify(mockWriter).withhold(fakeSessionId, eventsWithheld = false) } @Test @@ -588,7 +588,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder, never()).resumeRecorders() - verify(mockWriter, never()).withhold(any()) + verify(mockWriter, never()).withhold(any(), any()) } @Test @@ -601,7 +601,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder).resumeRecorders() - verify(mockWriter, never()).withhold(any()) + verify(mockWriter, never()).withhold(any(), any()) verify(mockWriter).stopWithholding(fakeSessionId) } @@ -615,7 +615,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder).resumeRecorders() - verify(mockWriter).withhold(fakeSessionId) + verify(mockWriter).withhold(fakeSessionId, eventsWithheld = true) } @Test @@ -628,7 +628,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder).resumeRecorders() - verify(mockWriter).withhold(fakeSessionId) + verify(mockWriter).withhold(fakeSessionId, eventsWithheld = true) } @Test @@ -641,7 +641,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder, never()).resumeRecorders() - verify(mockWriter, never()).withhold(any()) + verify(mockWriter, never()).withhold(any(), any()) } @Test @@ -657,7 +657,7 @@ internal class SessionReplayFeatureTest { // Then inOrder(mockWriter) { - verify(mockWriter).withhold(fakeSessionId) + verify(mockWriter).withhold(fakeSessionId, eventsWithheld = true) verify(mockWriter).stopWithholding(fakeSessionId) } verify(mockRecorder).resumeRecorders() @@ -681,12 +681,52 @@ internal class SessionReplayFeatureTest { // Then inOrder(mockWriter) { - verify(mockWriter).withhold(fakeSessionId) + verify(mockWriter).withhold(fakeSessionId, eventsWithheld = true) 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 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 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 99f8f41769..38e82c97f2 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 @@ -15,6 +15,7 @@ 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 @@ -35,6 +36,7 @@ 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 @@ -162,7 +164,8 @@ internal class SessionReplayRecordWriterTest { private val written = mutableListOf() - private fun recordWrites() { + 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))) @@ -220,8 +223,8 @@ internal class SessionReplayRecordWriterTest { fun `M hold resources and write them on release W write(resource) { session withheld }`() { // Given recordWrites() - testedWriter.withhold("s1") - testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.write(imageRecord("r1", "s1", 1_000, "img")) testedWriter.write(resource("img"), "s1") {} testedWriter.write(resource("img"), "s1") {} @@ -238,7 +241,7 @@ internal class SessionReplayRecordWriterTest { fun `M drop held resources W stopWithholding { another session }`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) testedWriter.write(resource("img"), "s1") {} // When @@ -253,7 +256,7 @@ internal class SessionReplayRecordWriterTest { fun `M count held records apart and clear them W hold then release`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) val held = record("r1", "s1", fullSnapshotAt = 1_000) // When @@ -272,7 +275,7 @@ internal class SessionReplayRecordWriterTest { 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") + testedWriter.withhold("s1", eventsWithheld = false) 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())) @@ -299,7 +302,7 @@ internal class SessionReplayRecordWriterTest { fun `M drop the oldest span but keep a full snapshot W hold { over the byte limit }`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) val big = SessionReplayRecordWriter.BYTES_LIMIT.toInt() / 3 testedWriter.write(bigRecord("old", "s1", 1_000, big)) testedWriter.write(bigRecord("mid", "s1", 2_000, big)) @@ -316,7 +319,7 @@ internal class SessionReplayRecordWriterTest { fun `M keep the only full snapshot W hold { a single span over the byte limit }`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) testedWriter.write(bigRecord("only", "s1", 1_000, SessionReplayRecordWriter.BYTES_LIMIT.toInt() + 1)) testedWriter.write(record("inc", "s1")) @@ -345,7 +348,7 @@ internal class SessionReplayRecordWriterTest { fun `M hold the records W write { session withheld }`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) // When testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) @@ -361,7 +364,7 @@ internal class SessionReplayRecordWriterTest { fun `M write what was held in order W stopWithholding { session released }`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) testedWriter.write(record("r2", "s1")) @@ -386,7 +389,7 @@ internal class SessionReplayRecordWriterTest { fun `M throw away what was held and its stragglers W stopWithholding { another session }`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) // When @@ -403,11 +406,11 @@ internal class SessionReplayRecordWriterTest { fun `M throw away the previous session W withhold { new session }`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) // When - testedWriter.withhold("s2") + testedWriter.withhold("s2", eventsWithheld = false) testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) testedWriter.stopWithholding("s2") @@ -419,7 +422,7 @@ internal class SessionReplayRecordWriterTest { fun `M keep a playable minute W write { more than a minute held }`() { // Given recordWrites() - testedWriter.withhold("s1") + testedWriter.withhold("s1", eventsWithheld = false) testedWriter.write(record("full0", "s1", fullSnapshotAt = 0)) testedWriter.write(record("inc0", "s1")) testedWriter.write(record("full30", "s1", fullSnapshotAt = 30_000)) @@ -434,6 +437,176 @@ internal class SessionReplayRecordWriterTest { assertThat(written).containsExactly("full30", "inc30", "full61", "full95") } + @Test + fun `M keep a session aside W withhold { new session, previous one's events withheld }`() { + // Given - RUM has yet to say whether the previous session is released or thrown away + recordWrites() + testedWriter.withhold("s1", eventsWithheld = true) + testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) + + // When + testedWriter.withhold("s2", eventsWithheld = true) + 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 throw away a session kept aside W discard`() { + // Given + recordWrites() + testedWriter.withhold("s1", eventsWithheld = true) + 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", eventsWithheld = true) + testedWriter.discard("s1") + testedWriter.withhold("s2", eventsWithheld = true) + testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) + + // When + testedWriter.withhold("s1", eventsWithheld = true) + 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", eventsWithheld = true) + 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", eventsWithheld = true) + 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", eventsWithheld = false) + testedWriter.write(resource("unseen"), "s1") {} + testedWriter.write(imageRecord("r1", "s1", 1_000, "shared")) + testedWriter.write(resource("shared"), "s1") {} + testedWriter.withhold("s2", eventsWithheld = true) + 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 not hold an image over the budget W write(resource)`() { + // Given + recordWrites() + testedWriter.withhold("s1", eventsWithheld = true) + 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 { From 329b334ec469986401e1693329168acc3bccf278 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 03:26:32 -0700 Subject: [PATCH 06/13] fix(rum,session-replay): keep every withheld replay for RUM's word, drop buffers on consent withdrawal Session Replay keeps aside every replay whose session ended while held, including a collected session's replay held on error, until RUM says what became of it; RUM now reports the end of such a session too. Several ended sessions can wait at once, since storage may lag more than one session behind. A stopped session draining beside its successor no longer draws a session of its own, which ended the running session's buffer, and a late error of a stopped session no longer unmarks the running session's release. Session Replay handles RUM's announcements and fates under one lock. Both features now observe tracking consent directly: what was held in memory under a consent since withdrawn is dropped even when no event comes by to notice. A replay span over the byte budget is dropped rather than kept, a sent record flushes the held images it shows, and a view's meta and focus survive the consent drop so the resumed replay stays playable. --- .../android/rum/internal/RumFeature.kt | 19 +- .../internal/domain/WithheldEventWriter.kt | 38 +++- .../internal/domain/scope/RumSessionScope.kt | 20 ++- .../domain/WithheldEventWriterTest.kt | 89 +++++++++- .../domain/scope/RumSessionScopeTest.kt | 44 ++++- .../internal/SessionReplayFeature.kt | 25 ++- .../internal/storage/NoOpRecordWriter.kt | 6 +- .../internal/storage/RecordWriter.kt | 14 +- .../storage/SessionReplayRecordWriter.kt | 106 ++++++----- .../internal/SessionReplayFeatureTest.kt | 31 +++- .../storage/SessionReplayRecordWriterTest.kt | 164 ++++++++++++++---- 11 files changed, 444 insertions(+), 112 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 94b677d57f..2759394fd1 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 @@ -37,6 +37,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 @@ -144,7 +146,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) @@ -427,9 +429,6 @@ internal class RumFeature( */ private fun createWithheldEventWriter(rumDataWriter: RumDataWriter, appContext: Context): WithheldEventWriter { val handler = Handler(Looper.getMainLooper()) - val withRumWriteScope: ((EventBatchWriter) -> Unit) -> Unit = { block -> - sdkCore.getFeature(Feature.RUM_FEATURE_NAME)?.withWriteContext { _, writeScope -> writeScope(block) } - } val writer = WithheldEventWriter( delegate = rumDataWriter, internalLogger = sdkCore.internalLogger, @@ -460,6 +459,18 @@ internal class RumFeature( return writer } + /** FLASHCAT FORK - runs the block on the storage thread, after the RUM writes submitted so far. */ + private fun withRumWriteScope(block: (EventBatchWriter) -> Unit) { + sdkCore.getFeature(Feature.RUM_FEATURE_NAME)?.withWriteContext { _, writeScope -> writeScope(block) } + } + + // 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( 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 index 6626559688..1649c6aa44 100644 --- 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 @@ -158,8 +158,9 @@ internal class WithheldEventWriter( element } val written = delegate.write(writer, claimed, eventType) - if (written && releasedSessionId != sessionId) { - releasedSessionId = sessionId + // 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 @@ -221,27 +222,42 @@ internal class WithheldEventWriter( /** Watches a collected session whose replay only is kept on error for its first error. */ @WorkerThread - fun watchForError(sessionId: String) { - synchronized(this) { watchedSessionId = sessionId } + 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) { - withheldForSessionId?.let { endSession(it, writer) } + 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. + * 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) @@ -280,6 +296,16 @@ internal class WithheldEventWriter( } } + /** + * 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) { clear() } + } + // endregion // region Internal 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 1c9e52d3e2..750d00e067 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 @@ -121,6 +121,11 @@ internal class RumSessionScope( // 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()) @@ -355,7 +360,7 @@ internal class RumSessionScope( * pending writes. */ private fun endWithheldSession(writeScope: EventWriteScope) { - if (!sampledForError) return + if (!sampledForError && !replayWatched) return val endedSessionId = sessionId writeScope { withheldEvents?.endSession(endedSessionId, it) } } @@ -397,6 +402,14 @@ 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. + if (!isActive && (isExpired || isTimedOut)) { + if (sessionState != State.EXPIRED) endWithheldSession(writeScope) + sessionState = State.EXPIRED + return + } + if (isInteraction || isSdkInitInForeground) { if (isNewSession || isExpired || isTimedOut) { val reason = if (isNewSession) { @@ -461,12 +474,13 @@ internal class RumSessionScope( 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 (keepSession && replayOnError) { + } 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) } + 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 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 index 0dfafde24c..48b3441701 100644 --- 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 @@ -301,7 +301,7 @@ internal class WithheldEventWriterTest { fun `M claim the replay for the releasing error W write() {watched session, view has records}`() { // Given replayRecords["v1"] = 2L - testedWriter.watchForError(sessionId) + testedWriter.watchForError(sessionId, batchWriter) val releasing = error("e1", "v1") // When @@ -394,24 +394,103 @@ internal class WithheldEventWriterTest { @Test fun `M write through and mark the error W write() {watched session}`() { // Given - testedWriter.watchForError(sessionId) + testedWriter.watchForError(sessionId, batchWriter) // When testedWriter.write(batchWriter, view("v1"), EventType.DEFAULT) - val before = testedWriter.isReleased(sessionId) + val before = testedWriter.isReplayReleased(sessionId) testedWriter.write(batchWriter, error("e1", "v1"), EventType.DEFAULT) // Then assertThat(before).isFalse - assertThat(testedWriter.isReleased(sessionId)).isTrue + 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 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) + scheduled.last().second(batchWriter) + + // Then - the held minute is gone, the session goes on as a released one + assertThat(scheduled).hasSize(2) + assertThat(written).containsExactly("a1") + } + @Test fun `M not mark W write() {watched session, error dropped by a mapper}`() { // Given - testedWriter.watchForError(sessionId) + testedWriter.watchForError(sessionId, batchWriter) val dropped = error("e1", "v1") payloads[dropped] = 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 9afc0271b3..3f62df0f72 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 @@ -2448,6 +2448,48 @@ internal class RumSessionScopeTest { 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 + assertThat(testedScope.sessionId).isEqualTo(sessionId) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.EXPIRED) + 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 @@ -2502,7 +2544,7 @@ internal class RumSessionScopeTest { testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) // Then - verify(withheldEvents).watchForError(testedScope.sessionId) + verify(withheldEvents).watchForError(testedScope.sessionId, mockEventBatchWriter) val captor = argumentCaptor() verify(mockSessionReplayFeatureScope, atLeastOnce()).sendEvent(captor.capture()) val first = captor.firstValue as Map<*, *> 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 03b9d36710..6531af6153 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 @@ -55,7 +57,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() @@ -215,6 +217,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() { @@ -233,6 +245,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 @@ -248,7 +263,7 @@ internal class SessionReplayFeature( applySampling(alreadySeenSession, sessionData.forced) val withhold = shouldWithhold(sessionData) modifyShouldRecordState(sessionData, withhold) - updateWithholding(sessionData.sessionId, withhold, sessionData.eventsOnError) + updateWithholding(sessionData.sessionId, withhold) handleRecording(sessionData) } } @@ -258,7 +273,7 @@ internal class SessionReplayFeature( // 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, eventsOnError = false) + updateWithholding(sessionId, withhold = false) } else { dataWriter.release(sessionId) } @@ -333,10 +348,10 @@ internal class SessionReplayFeature( } } - private fun updateWithholding(sessionId: String, withhold: Boolean, eventsOnError: Boolean) { + private fun updateWithholding(sessionId: String, withhold: Boolean) { if (withhold) { onErrorReplaySessionId.set(sessionId) - dataWriter.withhold(sessionId, eventsWithheld = eventsOnError) + 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. 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 dc65e64f7d..dc453ed431 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 @@ -13,7 +13,7 @@ internal class NoOpRecordWriter : RecordWriter { // no-op } - override fun withhold(sessionId: String, eventsWithheld: Boolean) { + override fun withhold(sessionId: String) { // no-op } @@ -28,4 +28,8 @@ internal class NoOpRecordWriter : RecordWriter { override fun discard(sessionId: String) { // no-op } + + override fun dropForConsent() { + // 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 d2e9777cea..cf97f153a5 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 @@ -20,17 +20,16 @@ internal interface RecordWriter { /** * FLASHCAT FORK - holds the records of this session in memory instead of writing them, until - * [release] or [stopWithholding] lets them go. + * 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 - * @param eventsWithheld whether the session's events are withheld too, in which case RUM alone - * decides, with [release] or [discard], what becomes of the replay once the session has ended */ - fun withhold(sessionId: String, eventsWithheld: Boolean) + 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 if its events were withheld, and thrown away if not. + * is kept aside for RUM's word. * @param sessionId the RUM session now current */ fun stopWithholding(sessionId: String) @@ -47,4 +46,9 @@ internal interface RecordWriter { * @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() } 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 12ed2adabc..d48bfe7e8d 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 @@ -36,7 +36,7 @@ internal class SessionReplayRecordWriter( 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 eventsWithheld: Boolean) { + private class Buffer(val sessionId: String) { val records = ArrayList() var bytes = 0L var droppedCount = 0 @@ -55,17 +55,18 @@ internal class SessionReplayRecordWriter( private var current: Buffer? = null /** - * A session that ended while its events were still withheld. RUM decides on the storage thread - * whether such a session is released or thrown away, and its word can land after the next - * session has announced itself: until it does, what the session held is kept aside. + * 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 var parked: Buffer? = null + 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 refer - * to it, so nothing of a session that never errored is ever sent. + * 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 @@ -82,10 +83,15 @@ internal class SessionReplayRecordWriter( // 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)) + 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 -> dropForConsent(buffer) + consent == TrackingConsent.NOT_GRANTED -> dropAllForConsent() else -> hold(buffer, record) } } @@ -95,26 +101,26 @@ internal class SessionReplayRecordWriter( onStorageThread { _, consent -> when { consent == TrackingConsent.NOT_GRANTED -> clearResources() - sessionId == current?.sessionId || sessionId == parked?.sessionId || sessionId in discardedSessionIds -> + bufferOf(sessionId) != null || sessionId in discardedSessionIds -> holdResource(enrichedResource, onWritten) else -> resourcesWriter.write(enrichedResource, sessionId, onWritten) } } } - override fun withhold(sessionId: String, eventsWithheld: Boolean) { + 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(::retire) - current = Buffer(sessionId, eventsWithheld) + 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 retire(buffer) + if (buffer.sessionId == sessionId) release(writer, buffer) else park(buffer) } } @@ -130,21 +136,18 @@ internal class SessionReplayRecordWriter( } } - private fun bufferOf(sessionId: String): Buffer? = when (sessionId) { - current?.sessionId -> current - parked?.sessionId -> parked - else -> null + override fun dropForConsent() { + onStorageThread { _, _ -> dropAllForConsent() } } - /** The session is no longer current: kept aside for RUM's word if RUM has one, thrown away if not. */ - private fun retire(buffer: Buffer) { - if (buffer.eventsWithheld) { - parked?.let(::discard) - parked = buffer - if (current === buffer) current = null - } else { - discard(buffer) - } + 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. */ + private fun park(buffer: Buffer) { + if (current === buffer) current = null + parked.add(buffer) + while (parked.size > PARKED_LIMIT) discard(parked.first()) } private fun hold(buffer: Buffer, record: EnrichedRecord) { @@ -172,11 +175,11 @@ internal class SessionReplayRecordWriter( } if (firstInWindow > 0) dropOldest(buffer, firstInWindow) } - // Memory bound: the oldest span goes first, down to the latest full snapshot at least. + // 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 } - if (nextFullSnapshot == -1) break - dropOldest(buffer, nextFullSnapshot) + dropOldest(buffer, if (nextFullSnapshot == -1) buffer.records.size else nextFullSnapshot) } } @@ -220,12 +223,7 @@ internal class SessionReplayRecordWriter( } } // Only the images these records show: any other held image belongs to what was never sent. - buffer.records.flatMapTo(HashSet()) { it.resourceIds }.forEach { id -> - heldResources.remove(id)?.let { held -> - heldResourceBytes -= held.resource.resource.size - resourcesWriter.write(held.resource, buffer.sessionId, held.onWritten) - } - } + 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( @@ -243,6 +241,15 @@ internal class SessionReplayRecordWriter( 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) { @@ -254,19 +261,22 @@ internal class SessionReplayRecordWriter( private fun forget(buffer: Buffer) { if (current === buffer) current = null - if (parked === buffer) parked = null + parked.remove(buffer) } /** * Consent was withdrawn: what was held under the consent now withdrawn is dropped, and the - * session holds again from scratch should consent be granted back. + * 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 dropForConsent(buffer: Buffer) { - recordCallback.onWithheldRecordsCleared(buffer.records.map { it.record }) - buffer.droppedCount += buffer.records.size - buffer.records.clear() - buffer.bytes = 0L - buffer.viewStartRecords.clear() + private fun dropAllForConsent() { + (listOfNotNull(current) + parked).forEach { buffer -> + recordCallback.onWithheldRecordsCleared(buffer.records.map { it.record }) + buffer.droppedCount += buffer.records.size + buffer.records.clear() + buffer.bytes = 0L + } clearResources() } @@ -313,9 +323,15 @@ internal class SessionReplayRecordWriter( /** 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 what a withheld session holds, and the same again on the images. */ + /** + * 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 RUM's word at once; beyond that the oldest is thrown away. */ + internal const val PARKED_LIMIT = 2 + private const val DISCARDED_SESSIONS_REMEMBERED = 4 internal const val RELEASED_MESSAGE = "Error session replay buffer released" 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 790541d11d..c10b5c1e24 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 @@ -575,7 +576,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder).resumeRecorders() - verify(mockWriter).withhold(fakeSessionId, eventsWithheld = false) + verify(mockWriter).withhold(fakeSessionId) } @Test @@ -588,7 +589,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder, never()).resumeRecorders() - verify(mockWriter, never()).withhold(any(), any()) + verify(mockWriter, never()).withhold(any()) } @Test @@ -601,7 +602,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder).resumeRecorders() - verify(mockWriter, never()).withhold(any(), any()) + verify(mockWriter, never()).withhold(any()) verify(mockWriter).stopWithholding(fakeSessionId) } @@ -615,7 +616,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder).resumeRecorders() - verify(mockWriter).withhold(fakeSessionId, eventsWithheld = true) + verify(mockWriter).withhold(fakeSessionId) } @Test @@ -628,7 +629,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder).resumeRecorders() - verify(mockWriter).withhold(fakeSessionId, eventsWithheld = true) + verify(mockWriter).withhold(fakeSessionId) } @Test @@ -641,7 +642,7 @@ internal class SessionReplayFeatureTest { // Then verify(mockRecorder, never()).resumeRecorders() - verify(mockWriter, never()).withhold(any(), any()) + verify(mockWriter, never()).withhold(any()) } @Test @@ -657,7 +658,7 @@ internal class SessionReplayFeatureTest { // Then inOrder(mockWriter) { - verify(mockWriter).withhold(fakeSessionId, eventsWithheld = true) + verify(mockWriter).withhold(fakeSessionId) verify(mockWriter).stopWithholding(fakeSessionId) } verify(mockRecorder).resumeRecorders() @@ -681,7 +682,7 @@ internal class SessionReplayFeatureTest { // Then inOrder(mockWriter) { - verify(mockWriter).withhold(fakeSessionId, eventsWithheld = true) + verify(mockWriter).withhold(fakeSessionId) verify(mockWriter).stopWithholding(fakeSessionId) } verify(mockRecorder, never()).stopRecorders() @@ -708,6 +709,20 @@ internal class SessionReplayFeatureTest { 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 throw away what a session held W rum session discarded`() { // 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 38e82c97f2..27fee5732d 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 @@ -223,7 +223,7 @@ internal class SessionReplayRecordWriterTest { fun `M hold resources and write them on release W write(resource) { session withheld }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.withhold("s1") testedWriter.write(imageRecord("r1", "s1", 1_000, "img")) testedWriter.write(resource("img"), "s1") {} testedWriter.write(resource("img"), "s1") {} @@ -241,7 +241,7 @@ internal class SessionReplayRecordWriterTest { fun `M drop held resources W stopWithholding { another session }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.withhold("s1") testedWriter.write(resource("img"), "s1") {} // When @@ -256,7 +256,7 @@ internal class SessionReplayRecordWriterTest { fun `M count held records apart and clear them W hold then release`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.withhold("s1") val held = record("r1", "s1", fullSnapshotAt = 1_000) // When @@ -275,7 +275,7 @@ internal class SessionReplayRecordWriterTest { 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", eventsWithheld = false) + 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())) @@ -302,7 +302,7 @@ internal class SessionReplayRecordWriterTest { fun `M drop the oldest span but keep a full snapshot W hold { over the byte limit }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + 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)) @@ -316,18 +316,19 @@ internal class SessionReplayRecordWriterTest { } @Test - fun `M keep the only full snapshot W hold { a single span over the byte limit }`() { - // Given + 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", eventsWithheld = false) - testedWriter.write(bigRecord("only", "s1", 1_000, SessionReplayRecordWriter.BYTES_LIMIT.toInt() + 1)) + 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 - assertThat(written).containsExactly("only", "inc") + // 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 { @@ -348,7 +349,7 @@ internal class SessionReplayRecordWriterTest { fun `M hold the records W write { session withheld }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.withhold("s1") // When testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) @@ -364,7 +365,7 @@ internal class SessionReplayRecordWriterTest { fun `M write what was held in order W stopWithholding { session released }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.withhold("s1") testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) testedWriter.write(record("r2", "s1")) @@ -389,7 +390,7 @@ internal class SessionReplayRecordWriterTest { fun `M throw away what was held and its stragglers W stopWithholding { another session }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.withhold("s1") testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) // When @@ -406,11 +407,11 @@ internal class SessionReplayRecordWriterTest { fun `M throw away the previous session W withhold { new session }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.withhold("s1") testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) // When - testedWriter.withhold("s2", eventsWithheld = false) + testedWriter.withhold("s2") testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) testedWriter.stopWithholding("s2") @@ -422,7 +423,7 @@ internal class SessionReplayRecordWriterTest { fun `M keep a playable minute W write { more than a minute held }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = false) + testedWriter.withhold("s1") testedWriter.write(record("full0", "s1", fullSnapshotAt = 0)) testedWriter.write(record("inc0", "s1")) testedWriter.write(record("full30", "s1", fullSnapshotAt = 30_000)) @@ -438,14 +439,14 @@ internal class SessionReplayRecordWriterTest { } @Test - fun `M keep a session aside W withhold { new session, previous one's events withheld }`() { + 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", eventsWithheld = true) + testedWriter.withhold("s1") testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) // When - testedWriter.withhold("s2", eventsWithheld = true) + testedWriter.withhold("s2") testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) testedWriter.write(record("r1b", "s1")) @@ -465,11 +466,69 @@ internal class SessionReplayRecordWriterTest { 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 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 throw away a session kept aside W discard`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = true) + testedWriter.withhold("s1") testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) testedWriter.stopWithholding("s2") @@ -487,13 +546,13 @@ internal class SessionReplayRecordWriterTest { 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", eventsWithheld = true) + testedWriter.withhold("s1") testedWriter.discard("s1") - testedWriter.withhold("s2", eventsWithheld = true) + testedWriter.withhold("s2") testedWriter.write(record("r2", "s2", fullSnapshotAt = 2_000)) // When - testedWriter.withhold("s1", eventsWithheld = true) + testedWriter.withhold("s1") testedWriter.write(record("r2b", "s2")) testedWriter.release("s2") @@ -505,7 +564,7 @@ internal class SessionReplayRecordWriterTest { fun `M hold nothing and drop what was held W write { consent not granted }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = true) + testedWriter.withhold("s1") testedWriter.write(record("r1", "s1", fullSnapshotAt = 1_000)) testedWriter.write(resource("img"), "s1") {} @@ -527,7 +586,7 @@ internal class SessionReplayRecordWriterTest { fun `M drop the oldest span W hold { an incremental record takes the buffer over the byte limit }`() { // Given recordWrites() - testedWriter.withhold("s1", eventsWithheld = true) + 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)) @@ -545,11 +604,11 @@ internal class SessionReplayRecordWriterTest { 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", eventsWithheld = false) + testedWriter.withhold("s1") testedWriter.write(resource("unseen"), "s1") {} testedWriter.write(imageRecord("r1", "s1", 1_000, "shared")) testedWriter.write(resource("shared"), "s1") {} - testedWriter.withhold("s2", eventsWithheld = true) + testedWriter.withhold("s2") testedWriter.write(imageRecord("r2", "s2", 2_000, "shared")) testedWriter.write(resource("own"), "s2") {} testedWriter.write(imageRecord("r2b", "s2", 3_000, "own")) @@ -563,11 +622,58 @@ internal class SessionReplayRecordWriterTest { 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", eventsWithheld = true) + testedWriter.withhold("s1") testedWriter.write(imageRecord("r1", "s1", 1_000, "huge")) // When From 5924cd5a75ca0a7c2908342b5f0a4000b9dfe673 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 07:39:57 -0700 Subject: [PATCH 07/13] fix(rum,session-replay,webview): close the paths that lose or leak a withheld session's data A withheld session that ends without an error now also forgets the view it wrote locally for the native crash reporter, so a crash of a later uncollected session is not reported against it. Both writers remember more discarded sessions, since a stopped session keeps draining its requests while the sessions after it come and go. A stopped session keeps its own state for what it still drains instead of expiring. An SDK stop flushes a release still waiting for its jitter. Session Replay notes RUM's word on an ended session the moment it is given, so a released session is never the one thrown away when too many ended sessions wait, and a view's meta and focus survive the drop of a span over the byte budget. A view of a session whose replay was released claims the replay as soon as the records exist, so the final view of a stopped session does not replace the released one without it. The web view consumers leave the replay and the replay claim alone while the native replay of the session is withheld. --- .../android/rum/internal/RumFeature.kt | 7 +++ .../rum/internal/domain/RumDataWriter.kt | 10 ++++ .../internal/domain/WithheldEventWriter.kt | 22 ++++++- .../internal/domain/scope/RumSessionScope.kt | 7 +-- .../android/rum/internal/RumFeatureTest.kt | 21 +++++++ .../domain/WithheldEventWriterTest.kt | 57 +++++++++++++++++- .../domain/scope/RumSessionScopeTest.kt | 4 +- .../storage/SessionReplayRecordWriter.kt | 39 ++++++++++-- .../storage/SessionReplayRecordWriterTest.kt | 60 +++++++++++++++++++ .../replay/WebViewReplayEventConsumer.kt | 12 +++- .../internal/rum/WebViewRumEventConsumer.kt | 11 +++- .../replay/WebViewReplayEventConsumerTest.kt | 19 ++++++ 12 files changed, 248 insertions(+), 21 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 2759394fd1..aab6a2e6ea 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 @@ -372,6 +372,13 @@ internal class RumFeature( withheldEventsBackgroundCallback?.let { (appContext as? Application)?.unregisterActivityLifecycleCallbacks(it) } withheldEventsBackgroundCallback = null + // A release still waiting for its jitter goes now, with the writes the stop drains: the + // timer would find no feature to write with. + withheldEvents?.let { writer -> + sdkCore.getFeature(Feature.RUM_FEATURE_NAME)?.getWriteContextSync()?.let { (_, writeScope) -> + writeScope { writer.flushScheduledRelease(it) } + } + } withheldEvents = null rumContextUpdateReceivers.forEach { 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 4fb04b615c..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 @@ -79,6 +79,16 @@ internal class RumDataWriter( } } + /** + * 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) { when (data) { 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 index 1649c6aa44..554cfab2c6 100644 --- 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 @@ -166,7 +166,18 @@ internal class WithheldEventWriter( return written } if (sessionId != withheldForSessionId) { - return delegate.write(writer, element, eventType) + // 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) { @@ -268,6 +279,9 @@ internal class WithheldEventWriter( } 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) } } @@ -513,7 +527,11 @@ internal class WithheldEventWriter( */ internal const val RELEASE_MAX_DELAY_MS = 3_000L - private const val DISCARDED_SESSIONS_REMEMBERED = 4 + /** + * 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 = 16 private const val HTTP_ERROR_STATUS = 400L 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 750d00e067..5ac911549d 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 @@ -404,11 +404,8 @@ internal class RumSessionScope( // 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. - if (!isActive && (isExpired || isTimedOut)) { - if (sessionState != State.EXPIRED) endWithheldSession(writeScope) - sessionState = State.EXPIRED - return - } + // 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) { 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..9034174c94 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,11 @@ 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.NoOpDataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.feature.event.JvmCrash @@ -840,6 +844,23 @@ internal class RumFeatureTest { assertThat(testedFeature.dataWriter).isInstanceOf(NoOpDataWriter::class.java) } + @Test + fun `M flush a release waiting for its jitter W onStop()`() { + // Given + val mockRumScope = mock() + val mockWriteScope = mock() + whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumScope + whenever(mockRumScope.getWriteContextSync(any())) doReturn (mock() to mockWriteScope) + testedFeature.onInitialize(appContext.mockInstance) + + // When + testedFeature.onStop() + + // Then + verify(mockWriteScope).invoke(any()) + assertThat(testedFeature.withheldEvents).isNull() + } + @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 index 48b3441701..4c53a0e681 100644 --- 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 @@ -86,6 +86,7 @@ internal class WithheldEventWriterTest { 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 -> @@ -93,6 +94,12 @@ internal class WithheldEventWriterTest { 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" } } @@ -112,6 +119,7 @@ internal class WithheldEventWriterTest { private val replayRecords = mutableMapOf() private val claimedErrors = mutableListOf() + private val claimedViews = mutableListOf() private val releasedReplays = mutableListOf() private val discardedReplays = mutableListOf() @@ -176,6 +184,33 @@ internal class WithheldEventWriterTest { 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 tell Session Replay the session is released W endSession() {session had errored}`() { // Given @@ -207,9 +242,9 @@ internal class WithheldEventWriterTest { } @Test - fun `M remember only the last four discarded sessions W endSession()`() { + fun `M remember only the last sixteen discarded sessions W endSession()`() { // Given - val sessions = List(5) { UUID.randomUUID().toString() } + val sessions = List(17) { UUID.randomUUID().toString() } sessions.forEach { testedWriter.startWithholding(it, batchWriter) testedWriter.endSession(it, batchWriter) @@ -487,6 +522,24 @@ internal class WithheldEventWriterTest { 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 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 3f62df0f72..d5b2471cd8 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 @@ -2483,9 +2483,9 @@ internal class RumSessionScopeTest { // When testedScope.handleEvent(forge.startViewEvent(), fakeDatadogContext, mockEventWriteScope, mockWriter) - // Then + // Then - and keeps its own state for what it still drains assertThat(testedScope.sessionId).isEqualTo(sessionId) - assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.EXPIRED) + assertThat(testedScope.sessionState).isEqualTo(RumSessionScope.State.WITHHELD) verify(withheldEvents, never()).startWithholding(any(), any()) verify(mockSessionListener, never()).onSessionStarted(any(), any()) } 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 d48bfe7e8d..6933812fbf 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 @@ -77,6 +77,13 @@ internal class SessionReplayRecordWriter( */ 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 @@ -125,17 +132,27 @@ internal class SessionReplayRecordWriter( } 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() } } @@ -143,11 +160,16 @@ internal class SessionReplayRecordWriter( 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. */ + /** 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) - while (parked.size > PARKED_LIMIT) discard(parked.first()) + val waiting = parked.filter { fates[it.sessionId] != true } + if (waiting.size > PARKED_LIMIT) discard(waiting.first()) } private fun hold(buffer: Buffer, record: EnrichedRecord) { @@ -202,10 +224,12 @@ internal class SessionReplayRecordWriter( 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. + // 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) + buffer.viewStartRecords.keys.retainAll(viewsHeld + lastDroppedViewId) } private fun release(writer: EventBatchWriter, buffer: Buffer) { @@ -329,10 +353,13 @@ internal class SessionReplayRecordWriter( */ internal const val BYTES_LIMIT = 4L * 1024 * 1024 - /** How many ended sessions wait for RUM's word at once; beyond that the oldest is thrown away. */ + /** How many ended sessions wait for a word RUM has not given yet; beyond that the oldest goes. */ internal const val PARKED_LIMIT = 2 - private const val DISCARDED_SESSIONS_REMEMBERED = 4 + /** More than a browser tab would need: a stopped session keeps draining while others come and go. */ + private const val DISCARDED_SESSIONS_REMEMBERED = 16 + + private const val FATES_REMEMBERED = 16 internal const val RELEASED_MESSAGE = "Error session replay buffer released" 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 27fee5732d..cf9cfaa4c4 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 @@ -486,6 +486,66 @@ internal class SessionReplayRecordWriterTest { 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 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 From 298c6026dd7e38808fb935a9a3b42eca5182b4f7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 18:26:34 -0700 Subject: [PATCH 08/13] fix(session-replay,rum): capture again an image dropped unsent, report rate 0 on app-launch vitals The recorder captures each image once per process. An image held for a replay kept on error and then dropped unsent - evicted over the image budget or cleared when consent is withdrawn - was never captured again, so a replay released later showed a placeholder for it. The writer now tells the recorder what it dropped; the recorder forgets those ids and resolves such an image again from its drawable the next time it is shown, instead of answering from its caches. The app-launch vitals of a session kept on error report a session sample rate of 0, like every other event of such a session. --- .../scope/RumVitalAppLaunchEventHelper.kt | 6 +- .../RumSessionScopeStartupManagerTest.kt | 59 +++++++++++++++++++ .../internal/SessionReplayFeature.kt | 4 +- .../internal/recorder/Recorder.kt | 6 ++ .../recorder/SessionReplayRecorder.kt | 4 ++ .../resources/ResourceItemCreationHandler.kt | 15 +++++ .../recorder/resources/ResourceResolver.kt | 16 ++++- .../storage/SessionReplayRecordWriter.kt | 15 ++++- .../ResourceItemCreationHandlerTest.kt | 21 +++++++ .../resources/ResourceResolverTest.kt | 31 ++++++++++ .../storage/SessionReplayRecordWriterTest.kt | 37 +++++++++++- 11 files changed, 205 insertions(+), 9 deletions(-) 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/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-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 6531af6153..6551377150 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 @@ -458,7 +458,9 @@ internal class SessionReplayFeature( private fun createDataWriter(resourcesWriter: ResourcesWriter): SessionReplayRecordWriter { val recordCallback = SessionReplayRecordCallback(sdkCore) - return SessionReplayRecordWriter(sdkCore, recordCallback, resourcesWriter) + return SessionReplayRecordWriter(sdkCore, recordCallback, resourcesWriter) { resourceIds -> + sessionReplayRecorder.forgetResources(resourceIds) + } } /** 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..52e5307793 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(HashSet()) + internal fun queueItem(resourceId: String, resourceData: ByteArray) { if (!resourceIdsSeen.contains(resourceId)) { resourceIdsSeen.add(resourceId) + forgottenResourceIds.remove(resourceId) recordedDataQueueHandler.addResourceItem( identifier = resourceId, @@ -29,4 +35,13 @@ 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()) + forgottenResourceIds.addAll(resourceIds) + } } 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/SessionReplayRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt index 6933812fbf..8c1d3024cd 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 @@ -28,7 +28,12 @@ import java.util.concurrent.TimeUnit internal class SessionReplayRecordWriter( private val sdkCore: FeatureSdkCore, private val recordCallback: RecordCallback, - private val resourcesWriter: ResourcesWriter + 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) @@ -211,12 +216,15 @@ internal class SessionReplayRecordWriter( 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. A record released without - // its image shows a placeholder, it does not break the replay. + // 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) { @@ -305,6 +313,7 @@ internal class SessionReplayRecordWriter( } private fun clearResources() { + if (heldResources.isNotEmpty()) forgetResources(heldResources.keys.toList()) heldResources.clear() heldResourceBytes = 0L } 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..ae80ca49a6 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,26 @@ 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 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 cf9cfaa4c4..edce89fb8c 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 @@ -84,7 +84,9 @@ internal class SessionReplayRecordWriterTest { whenever(mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) .thenReturn(mockSessionReplayFeature) - testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback, mockResourcesWriter) + testedWriter = SessionReplayRecordWriter(mockSdkCore, mockRecordCallback, mockResourcesWriter) { + forgottenResources.addAll(it) + } } @Test @@ -206,6 +208,39 @@ internal class SessionReplayRecordWriterTest { private fun resource(hash: String, size: Int = 10) = EnrichedResource(ByteArray(size), hash) + private val forgottenResources = mutableListOf() + + @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 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 From 07ee425ae2d64e8f153938bcb18b4db0b73f2f1f Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 18:49:16 -0700 Subject: [PATCH 09/13] fix(rum,session-replay): keep the stop-time flush reachable, settle a view's replay markers once The core takes a feature out of its registry before stopping it, so the flush of a release still waiting for its jitter could not look the RUM scope up at stop time: the scope is kept from initialization. A view of a withheld session keeps its Session Replay entry when it completes, so it can still claim the replay held for it when the session is released; Session Replay drops the entry once nothing is sent or held for the view. A view's replay markers stay as resolved once they are true, so a late update written after another session took over the replay context still describes its own session. The replay switch alone no longer restarts a running session on an immediate configuration change: a replay draw is made once per session. --- .../android/rum/internal/RumFeature.kt | 18 +++++-- .../rum/internal/domain/scope/RumViewScope.kt | 44 +++++++++++---- .../remoteconfig/RemoteConfigController.kt | 11 ++-- .../android/rum/internal/RumFeatureTest.kt | 2 + .../internal/domain/scope/RumViewScopeTest.kt | 53 +++++++++++++++++++ .../RemoteConfigControllerTest.kt | 5 +- .../utils/forge/RumContextForgeryFactory.kt | 3 +- .../internal/SessionReplayRecordCallback.kt | 3 ++ .../SessionReplayRecordCallbackTest.kt | 2 + 9 files changed, 119 insertions(+), 22 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index aab6a2e6ea..9f79a5ee8d 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,6 +20,7 @@ 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 @@ -192,6 +193,10 @@ internal class RumFeature( */ 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 @@ -237,6 +242,8 @@ internal class RumFeature( ) 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( @@ -375,11 +382,12 @@ internal class RumFeature( // A release still waiting for its jitter goes now, with the writes the stop drains: the // timer would find no feature to write with. withheldEvents?.let { writer -> - sdkCore.getFeature(Feature.RUM_FEATURE_NAME)?.getWriteContextSync()?.let { (_, writeScope) -> + rumFeatureScope()?.getWriteContextSync()?.let { (_, writeScope) -> writeScope { writer.flushScheduledRelease(it) } } } withheldEvents = null + rumFeatureScope = null rumContextUpdateReceivers.forEach { sdkCore.removeContextUpdateReceiver(it) @@ -468,7 +476,12 @@ internal class RumFeature( /** FLASHCAT FORK - runs the block on the storage thread, after the RUM writes submitted so far. */ private fun withRumWriteScope(block: (EventBatchWriter) -> Unit) { - sdkCore.getFeature(Feature.RUM_FEATURE_NAME)?.withWriteContext { _, writeScope -> writeScope(block) } + rumFeatureScope()?.withWriteContext { _, writeScope -> writeScope(block) } + } + + 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 @@ -906,7 +919,6 @@ internal class RumFeature( store = store, initialSessionSampleRate = sampleRate, initialSessionOnError = configuration.sessionOnError, - initialSessionReplayOnError = configuration.sessionReplayOnError, callFactory = sdkCore.createOkHttpCallFactory(), executor = sdkCore.createScheduledExecutorService("rum-remote-config"), // Looked up when it fires rather than captured now: the monitor is registered after 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 3e343289c8..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 { @@ -1340,14 +1350,8 @@ internal open class RumViewScope( // 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 = featuresContextResolver.resolveSampledForReplay( - datadogContext, - rumContext.sessionId, - rumContext.sampledForError - ), - sampledForErrorReplay = featuresContextResolver - .resolveSampledForErrorReplay(datadogContext, rumContext.sessionId) - ?.takeIf { it } + sampledForReplay = resolveSampledForReplay(datadogContext, rumContext), + sampledForErrorReplay = resolveSampledForErrorReplay(datadogContext, rumContext) ), synthetics = syntheticsAttribute, source = ViewEvent.ViewEventSource.tryFromSource( @@ -1627,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/remoteconfig/RemoteConfigController.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/remoteconfig/RemoteConfigController.kt index 8fe466cae6..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 @@ -48,9 +48,8 @@ internal class RemoteConfigController( private val restartSession: () -> Unit, private val elapsedTimeMs: () -> Long = SystemClock::elapsedRealtime, private val jitter: () -> Double = { Random.nextDouble() }, - // The init values of the on-error switches, which a cleared knob hands back to. - private val initialSessionOnError: Boolean = false, - private val initialSessionReplayOnError: Boolean = false + // The init value of the on-error switch, which a cleared knob hands back to. + private val initialSessionOnError: Boolean = false ) { @Volatile @@ -447,9 +446,9 @@ internal class RemoteConfigController( val nextRate = after.sessionSampleRate ?: initialSessionSampleRate val switchChanged = (before.sessionOnError ?: initialSessionOnError) != (after.sessionOnError ?: initialSessionOnError) - val replaySwitchChanged = (before.sessionReplayOnError ?: initialSessionReplayOnError) != - (after.sessionReplayOnError ?: initialSessionReplayOnError) - if (previousRate == nextRate && !switchChanged && !replaySwitchChanged) return false + // 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) 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 9034174c94..c5bfab683d 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 @@ -852,6 +852,8 @@ internal class RumFeatureTest { whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumScope whenever(mockRumScope.getWriteContextSync(any())) doReturn (mock() to mockWriteScope) 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() 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 e194d821e9..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 @@ -726,6 +726,59 @@ internal class RumViewScopeTest { } } + @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 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 8178f6f79d..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 @@ -247,7 +247,8 @@ internal class RemoteConfigControllerTest { } @Test - fun `M restart the session W apply() { immediate and only the replay switch changed }`() { + 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) @@ -255,7 +256,7 @@ internal class RemoteConfigControllerTest { body(activation = "immediate", rum = """"sessionSampleRate":20,"sessionReplayOnError":true""") ) - assertThat(restarts).isOne() + assertThat(restarts).isZero() } @Test 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/SessionReplayRecordCallback.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayRecordCallback.kt index 975d1a3c36..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 @@ -58,6 +58,9 @@ internal class SessionReplayRecordCallback( 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) } } } 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 5121e44a0e..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 @@ -195,5 +195,7 @@ internal class SessionReplayRecordCallbackTest { SessionReplayRecordCallback.HAS_REPLAY_KEY, SessionReplayRecordCallback.VIEW_RECORDS_COUNT_KEY ) + // and the entry left with nothing in it goes too + assertThat(featureContext).doesNotContainKey(fakeEnrichedRecord.viewId) } } From e8a1ba70b5b47042c015190bb62a9f184b62e220 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 19:06:07 -0700 Subject: [PATCH 10/13] fix(rum,session-replay): let consent decide for a released session, report a withheld session's last view however old A session already released holds nothing more once consent is withdrawn: its events go to the batch, where consent decides, instead of through the buffer, where a release granted later would have carried out what was collected while consent was withdrawn. An image first captured while consent is withdrawn is forgotten by the recorder like one dropped from the held store, so it is captured again; the forgotten ids are bounded, and only the view in progress keeps its start across a consent drop. More discarded sessions are remembered, since a request can outlive many sessions. The native crash reporter writes the last view of a session kept on error with the crash however old the view is: that session uploaded nothing before it crashed, so this view is the only one the intake will get. --- .../rum/internal/DatadogLateCrashReporter.kt | 11 ++++- .../internal/domain/WithheldEventWriter.kt | 18 +++++-- .../internal/DatadogLateCrashReporterTest.kt | 38 +++++++++++++++ .../domain/WithheldEventWriterTest.kt | 28 +++++++++-- .../resources/ResourceItemCreationHandler.kt | 16 ++++++- .../storage/SessionReplayRecordWriter.kt | 11 ++++- .../ResourceItemCreationHandlerTest.kt | 14 ++++++ .../storage/SessionReplayRecordWriterTest.kt | 48 +++++++++++++++++++ 8 files changed, 169 insertions(+), 15 deletions(-) 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/domain/WithheldEventWriter.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriter.kt index 554cfab2c6..75b7d0c75e 100644 --- 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 @@ -305,8 +305,8 @@ internal class WithheldEventWriter( @WorkerThread fun dropHeld(sessionId: String) { synchronized(this) { - if (withheldForSessionId != sessionId || releasedSessionId == sessionId) return - clear() + if (withheldForSessionId != sessionId) return + dropForConsent() } } @@ -317,7 +317,17 @@ internal class WithheldEventWriter( */ @WorkerThread fun dropHeldForConsent() { - synchronized(this) { clear() } + 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 @@ -531,7 +541,7 @@ internal class WithheldEventWriter( * 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 = 16 + private const val DISCARDED_SESSIONS_REMEMBERED = 64 private const val HTTP_ERROR_STATUS = 400L 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 0b3fb51e16..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,44 @@ 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, 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 index 4c53a0e681..9c76b3a8ae 100644 --- 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 @@ -242,9 +242,9 @@ internal class WithheldEventWriterTest { } @Test - fun `M remember only the last sixteen discarded sessions W endSession()`() { + fun `M remember only the last sixty-four discarded sessions W endSession()`() { // Given - val sessions = List(17) { UUID.randomUUID().toString() } + val sessions = List(65) { UUID.randomUUID().toString() } sessions.forEach { testedWriter.startWithholding(it, batchWriter) testedWriter.endSession(it, batchWriter) @@ -504,6 +504,24 @@ internal class WithheldEventWriterTest { 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 @@ -515,10 +533,9 @@ internal class WithheldEventWriterTest { testedWriter.dropHeldForConsent() scheduled.first().second(batchWriter) testedWriter.write(batchWriter, action("a1", "v1"), EventType.DEFAULT) - scheduled.last().second(batchWriter) // Then - the held minute is gone, the session goes on as a released one - assertThat(scheduled).hasSize(2) + assertThat(scheduled).hasSize(1) assertThat(written).containsExactly("a1") } @@ -551,7 +568,8 @@ internal class WithheldEventWriterTest { testedWriter.write(batchWriter, dropped, EventType.DEFAULT) // Then - assertThat(testedWriter.isReleased(sessionId)).isFalse + assertThat(testedWriter.isReplayReleased(sessionId)).isFalse + assertThat(releasedReplays).isEmpty() } // endregion 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 52e5307793..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 @@ -22,7 +22,7 @@ internal class ResourceItemCreationHandler( // 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(HashSet()) + Collections.synchronizedSet(LinkedHashSet()) internal fun queueItem(resourceId: String, resourceData: ByteArray) { if (!resourceIdsSeen.contains(resourceId)) { @@ -42,6 +42,18 @@ internal class ResourceItemCreationHandler( /** FLASHCAT FORK - see [forgottenResourceIds]. */ internal fun forget(resourceIds: Collection) { resourceIdsSeen.removeAll(resourceIds.toSet()) - forgottenResourceIds.addAll(resourceIds) + 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/storage/SessionReplayRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt index 8c1d3024cd..7c7a733688 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 @@ -112,7 +112,11 @@ internal class SessionReplayRecordWriter( override fun write(enrichedResource: EnrichedResource, sessionId: String, onWritten: () -> Unit) { onStorageThread { _, consent -> when { - consent == TrackingConsent.NOT_GRANTED -> clearResources() + 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) @@ -306,8 +310,11 @@ internal class SessionReplayRecordWriter( (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() } @@ -366,7 +373,7 @@ internal class SessionReplayRecordWriter( 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 = 16 + private const val DISCARDED_SESSIONS_REMEMBERED = 64 private const val FATES_REMEMBERED = 16 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 ae80ca49a6..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 @@ -80,6 +80,20 @@ internal class ResourceItemCreationHandlerTest { ) } + @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/storage/SessionReplayRecordWriterTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt index edce89fb8c..1e3f38a895 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 @@ -210,6 +210,40 @@ internal class SessionReplayRecordWriterTest { 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 @@ -226,6 +260,20 @@ internal class SessionReplayRecordWriterTest { 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 From d51c00d88a083b4a89720137a4ac50332558beae Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 19:17:19 -0700 Subject: [PATCH 11/13] fix(rum): settle the withheld session before the SDK stops, write nothing for a stopped session thrown away The SDK stop ends the session kept on error with it: released if it had reported its error, thrown away if not, along with the view it wrote locally - a crash after the stop must not be reported against a session that never errored. The write scope only queues that work, and the core shuts its executor down without draining it once the features are stopped, so the stop waits for the queue to get there, the way a crash does. A stopped session still draining its requests hands its views nothing to write with while it is withheld: it was thrown away at the stop, and remembering its id for a while could not cover a request that outlives dozens of later sessions. A stopped session that had errored is collected by then and keeps writing. --- .../android/rum/internal/RumFeature.kt | 25 +++++++++++++-- .../internal/domain/WithheldEventWriter.kt | 14 ++++++++ .../internal/domain/scope/RumSessionScope.kt | 9 ++++-- .../android/rum/internal/RumFeatureTest.kt | 8 +++-- .../domain/WithheldEventWriterTest.kt | 32 +++++++++++++++++++ .../domain/scope/RumSessionScopeTest.kt | 16 ++++++++++ 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 9f79a5ee8d..90a6ee6adc 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 @@ -379,11 +379,13 @@ internal class RumFeature( withheldEventsBackgroundCallback?.let { (appContext as? Application)?.unregisterActivityLifecycleCallbacks(it) } withheldEventsBackgroundCallback = null - // A release still waiting for its jitter goes now, with the writes the stop drains: the - // timer would find no feature to write with. + // 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 executor down without draining + // it once the features are stopped, so this waits for the queue to get there. withheldEvents?.let { writer -> rumFeatureScope()?.getWriteContextSync()?.let { (_, writeScope) -> - writeScope { writer.flushScheduledRelease(it) } + writeScope { writer.stop(it) } + waitForPersistence() } } withheldEvents = null @@ -479,6 +481,20 @@ internal class RumFeature( rumFeatureScope()?.withWriteContext { _, writeScope -> writeScope(block) } } + private fun waitForPersistence() { + val executor = (sdkCore as? InternalSdkCore)?.getPersistenceExecutorService() ?: return + try { + executor.submit {}.get(STOP_DRAIN_WAIT_MS, TimeUnit.MILLISECONDS) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { STOP_DRAIN_FAILED_MESSAGE }, + e + ) + } + } + private fun rumFeatureScope(): FeatureScope? { rumFeatureScope = rumFeatureScope ?: sdkCore.getFeature(Feature.RUM_FEATURE_NAME) return rumFeatureScope @@ -990,6 +1006,9 @@ internal class RumFeature( ) 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/WithheldEventWriter.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/WithheldEventWriter.kt index 75b7d0c75e..b634a23336 100644 --- 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 @@ -231,6 +231,20 @@ internal class WithheldEventWriter( } } + /** + * 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) { 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 5ac911549d..8be8361cdb 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 @@ -264,9 +264,12 @@ internal class RumSessionScope( val actualWriter = when (sessionState) { State.TRACKED -> writer - // FLASHCAT FORK - nothing may be held while consent is withdrawn, and what was held under - // the consent now withdrawn goes too. - State.WITHHELD -> if (datadogContext.trackingConsent == TrackingConsent.NOT_GRANTED) { + // 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 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 c5bfab683d..2ba8d93740 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 @@ -845,12 +845,14 @@ internal class RumFeatureTest { } @Test - fun `M flush a release waiting for its jitter W onStop()`() { + fun `M settle the withheld session and wait for storage W onStop()`() { // Given val mockRumScope = mock() val mockWriteScope = mock() + val persistence = java.util.concurrent.Executors.newSingleThreadExecutor() whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumScope whenever(mockRumScope.getWriteContextSync(any())) doReturn (mock() to mockWriteScope) + whenever(mockSdkCore.getPersistenceExecutorService()) doReturn persistence 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 @@ -858,9 +860,11 @@ internal class RumFeatureTest { // When testedFeature.onStop() - // Then + // Then - the work was queued, and the stop waited for the queue to get there verify(mockWriteScope).invoke(any()) + assertThat(persistence.submit {}.get(1, java.util.concurrent.TimeUnit.SECONDS)).isNull() assertThat(testedFeature.withheldEvents).isNull() + persistence.shutdown() } @Test 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 index 9c76b3a8ae..79bc231ea7 100644 --- 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 @@ -211,6 +211,36 @@ internal class WithheldEventWriterTest { 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 @@ -243,6 +273,8 @@ internal class WithheldEventWriterTest { @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 { 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 d5b2471cd8..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 @@ -2273,6 +2273,22 @@ internal class RumSessionScopeTest { 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 From 82970d00ff9ffae5716a37c09d665eff1a4df441 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 19:25:49 -0700 Subject: [PATCH 12/13] fix(rum): never block the SDK stop on the context thread Settling the withheld session at the stop looked the write scope up synchronously, which waits on the context thread without a bound; a stop asked for from the RUM thread - a session listener, say - found that thread waiting on the RUM thread in turn, and the stop never returned, holding the SDK registry lock with it. The stop now queues the work through the ordinary write scope and waits, with a bound, for the persistence thread to have run it. A stop asked for from inside the SDK's own pipeline waits the bound out and goes on without the work, which is what it did before, minus the hang. --- .../android/rum/internal/RumFeature.kt | 31 +++++++---- .../android/rum/internal/RumFeatureTest.kt | 55 ++++++++++++++++--- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 90a6ee6adc..ca876b6d9b 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 @@ -129,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 @@ -380,13 +381,17 @@ internal class RumFeature( 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 executor down without draining - // it once the features are stopped, so this waits for the queue to get there. + // 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 -> - rumFeatureScope()?.getWriteContextSync()?.let { (_, writeScope) -> - writeScope { writer.stop(it) } - waitForPersistence() + val settled = CountDownLatch(1) + withRumWriteScope { + writer.stop(it) + settled.countDown() } + waitForStop(settled) } withheldEvents = null rumFeatureScope = null @@ -481,16 +486,18 @@ internal class RumFeature( rumFeatureScope()?.withWriteContext { _, writeScope -> writeScope(block) } } - private fun waitForPersistence() { - val executor = (sdkCore as? InternalSdkCore)?.getPersistenceExecutorService() ?: return - try { - executor.submit {}.get(STOP_DRAIN_WAIT_MS, TimeUnit.MILLISECONDS) - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + 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 }, - e + { STOP_DRAIN_FAILED_MESSAGE } ) } } 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 2ba8d93740..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 @@ -19,6 +19,7 @@ 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 @@ -93,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 @@ -846,13 +848,24 @@ internal class RumFeatureTest { @Test fun `M settle the withheld session and wait for storage W onStop()`() { - // Given + // Given - the work runs on another thread, as the real write scope's does val mockRumScope = mock() - val mockWriteScope = mock() - val persistence = java.util.concurrent.Executors.newSingleThreadExecutor() + 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.getWriteContextSync(any())) doReturn (mock() to mockWriteScope) - whenever(mockSdkCore.getPersistenceExecutorService()) doReturn persistence + 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 @@ -860,11 +873,35 @@ internal class RumFeatureTest { // When testedFeature.onStop() - // Then - the work was queued, and the stop waited for the queue to get there - verify(mockWriteScope).invoke(any()) - assertThat(persistence.submit {}.get(1, java.util.concurrent.TimeUnit.SECONDS)).isNull() + // Then - the stop returned only once the queued work had run + assertThat(ranOn.get()).isNotNull.isNotSameAs(Thread.currentThread()) assertThat(testedFeature.withheldEvents).isNull() - persistence.shutdown() + 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 From c30083dbea33f48a5106b48278fb9b83ee9c3d84 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 19:37:35 -0700 Subject: [PATCH 13/13] fix(rum,session-replay): write a replay its session earned before Session Replay stops The features stop in no particular order. Session Replay only heard that a replay may go out when RUM actually released the events, after the jitter, and a stop in between left it stopping with the replay still held and nobody left to tell it. RUM now says so the moment the releasing error is written, Session Replay notes it, and its own stop writes every replay whose session reported an error, throws the rest away, and waits - with a bound - for that to have run. --- .../android/rum/internal/RumFeature.kt | 6 ++++ .../internal/domain/WithheldEventWriter.kt | 12 +++++-- .../internal/domain/scope/RumSessionScope.kt | 1 + .../domain/WithheldEventWriterTest.kt | 20 ++++++++++- .../internal/SessionReplayFeature.kt | 28 +++++++++++++++ .../internal/storage/NoOpRecordWriter.kt | 8 +++++ .../internal/storage/RecordWriter.kt | 14 ++++++++ .../storage/SessionReplayRecordWriter.kt | 22 ++++++++++++ .../internal/SessionReplayFeatureTest.kt | 34 +++++++++++++++++++ .../storage/SessionReplayRecordWriterTest.kt | 32 +++++++++++++++++ 10 files changed, 174 insertions(+), 3 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index ca876b6d9b..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 @@ -468,6 +468,12 @@ internal class RumFeature( RumSessionScope.RUM_SESSION_DISCARDED_BUS_MESSAGE, sessionId ) + }, + expectReplayRelease = { sessionId -> + tellSessionReplay( + RumSessionScope.RUM_SESSION_ERRORED_BUS_MESSAGE, + sessionId + ) } ) (appContext as? Application)?.let { application -> 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 index b634a23336..34a8f81704 100644 --- 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 @@ -66,7 +66,12 @@ internal class WithheldEventWriter( * 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 + 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) @@ -181,7 +186,10 @@ internal class WithheldEventWriter( } val batchEvent = delegate.serialize(element) ?: return false if (element is ErrorEvent) { - releasedSessionId = sessionId + 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 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 8be8361cdb..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 @@ -600,6 +600,7 @@ internal class RumSessionScope( 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/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 index 79bc231ea7..257f1fe6de 100644 --- 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 @@ -113,10 +113,28 @@ internal class WithheldEventWriterTest { scheduleRelease = { delayMs, release -> scheduled.add(delayMs to release) }, replayRecordsCount = { viewId -> replayRecords[viewId] ?: 0L }, releaseReplay = { releasedReplays.add(it) }, - discardReplay = { discardedReplays.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() 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 6551377150..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 @@ -40,6 +40,8 @@ import com.datadog.android.sessionreplay.internal.storage.SessionReplayRecordWri 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 @@ -189,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) @@ -277,6 +297,10 @@ internal class SessionReplayFeature( } 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) } @@ -532,6 +556,10 @@ internal class SessionReplayFeature( 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. 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 dc453ed431..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 @@ -32,4 +32,12 @@ internal class NoOpRecordWriter : RecordWriter { 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/RecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/RecordWriter.kt index cf97f153a5..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 @@ -51,4 +51,18 @@ internal interface RecordWriter { * 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/SessionReplayRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt index 7c7a733688..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 @@ -166,6 +166,28 @@ internal class SessionReplayRecordWriter( 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 } 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 c10b5c1e24..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 @@ -48,6 +48,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder @@ -723,6 +724,39 @@ internal class SessionReplayFeatureTest { 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 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 1e3f38a895..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 @@ -667,6 +667,38 @@ internal class SessionReplayRecordWriterTest { 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