Skip to content

Commit 1a08a0e

Browse files
committed
PANA-8616: Snapshot state and incremental diffing
1 parent f970870 commit 1a08a0e

11 files changed

Lines changed: 782 additions & 9 deletions

File tree

detekt_custom_safe_calls_third_party.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ datadog:
478478
- "kotlin.collections.Collection.fold(kotlin.collections.MutableSet, kotlin.Function2)"
479479
- "kotlin.collections.Collection.forEach(kotlin.Function1)"
480480
- "kotlin.collections.Collection.groupBy(kotlin.Function1)"
481+
- "kotlin.collections.Collection.map(kotlin.Function1)"
481482
- "kotlin.collections.Collection.mapNotNull(kotlin.Function1)"
482483
- "kotlin.collections.Collection.isNotEmpty()"
483484
- "kotlin.collections.Collection.sumOf(kotlin.Function1)"

features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import com.datadog.android.sessionreplay.internal.composition.CompositionCapture
4141
import com.datadog.android.sessionreplay.internal.composition.CompositionChangeListener
4242
import com.datadog.android.sessionreplay.internal.composition.CompositionChangeset
4343
import com.datadog.android.sessionreplay.internal.composition.CompositionViewOnDrawInterceptor
44+
import com.datadog.android.sessionreplay.internal.composition.DefaultOrientationProvider
4445
import com.datadog.android.sessionreplay.internal.composition.DefaultRumViewScopeProvider
4546
import com.datadog.android.sessionreplay.internal.composition.DefaultSnapshotCompletionProcessor
4647
import com.datadog.android.sessionreplay.internal.composition.HandlerCaptureMainThreadExecutor
@@ -235,6 +236,8 @@ internal class DefaultRecorderProvider(
235236
rumContextProvider = rumContextProvider,
236237
recordWriter = recordWriter,
237238
internalLogger = internalLogger,
239+
timeProvider = sdkCore.timeProvider,
240+
orientationProvider = DefaultOrientationProvider(),
238241
resourceDataQueueHandler = resourceDataQueueHandler
239242
),
240243
internalLogger = internalLogger

features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/AndroidCapturedSnapshotProducer.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,20 @@ import com.datadog.android.internal.sessionreplay.composition.CapturedChild
1313
import com.datadog.android.internal.sessionreplay.composition.CapturedLayer
1414
import com.datadog.android.internal.sessionreplay.composition.CapturedLayerKind
1515
import com.datadog.android.internal.sessionreplay.composition.CapturedWireframe
16+
import com.datadog.android.internal.sessionreplay.composition.RumViewIdentityScope
1617
import com.datadog.android.internal.time.TimeProvider
1718
import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver
1819
import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver
1920

2021
/**
2122
* The real [CapturedSnapshotProducer] for the plain Android View hierarchy - the workstream-3
2223
* implementation of the extension point [SnapshotCaptureOrchestrator] drives every generation.
23-
* Builds a fresh [CapturedIdentityFactory] per call (this workstream only produces full snapshots;
24-
* an identity factory persisting across generations for incremental diffing is a later workstream's
25-
* concern), walks every currently active window via [AndroidWindowTraversal], and assembles them
26-
* under one synthetic screen root in [ActiveWindowSource.currentWindows] order (already z-ordered).
24+
* Retains one [CapturedIdentityFactory] per active [com.datadog.android.internal.sessionreplay.composition.RumViewIdentityScope],
25+
* reusing it across generations so the same View keeps the same identity - required for
26+
* [CapturedSnapshotDiffer] to mean anything - and minting a fresh one, implicitly starting a new
27+
* identity scope, only when the RUM view changes. Walks every currently active window via
28+
* [AndroidWindowTraversal], and assembles them under one synthetic screen root in
29+
* [ActiveWindowSource.currentWindows] order (already z-ordered).
2730
*/
2831
internal class AndroidCapturedSnapshotProducer(
2932
private val windowSource: ActiveWindowSource,
@@ -33,11 +36,13 @@ internal class AndroidCapturedSnapshotProducer(
3336
private val viewIdentifierResolver: ViewIdentifierResolver = DefaultViewIdentifierResolver
3437
) : CapturedSnapshotProducer {
3538

39+
private var retainedIdentityFactory: DefaultCapturedIdentityFactory? = null
40+
3641
@MainThread
3742
@Suppress("ReturnCount")
3843
override fun capture(context: CaptureGenerationContext, changeset: CaptureChangeset): CaptureOutput? {
3944
val rumViewScope = scopeProvider.currentScope() ?: return null
40-
val identityFactory = DefaultCapturedIdentityFactory(rumViewScope.scope)
45+
val identityFactory = identityFactoryFor(rumViewScope.scope)
4146
val walk = walkWindows(windowSource.currentWindows(), identityFactory, context)
4247

4348
return walk?.let {
@@ -61,6 +66,12 @@ internal class AndroidCapturedSnapshotProducer(
6166
}
6267
}
6368

69+
/** Reuses the retained factory while the RUM view scope is unchanged; mints a fresh one otherwise. */
70+
private fun identityFactoryFor(scope: RumViewIdentityScope): DefaultCapturedIdentityFactory {
71+
retainedIdentityFactory?.takeIf { it.scope == scope }?.let { return it }
72+
return DefaultCapturedIdentityFactory(scope).also { retainedIdentityFactory = it }
73+
}
74+
6475
private fun walkWindows(
6576
windows: List<View>,
6677
identityFactory: CapturedIdentityFactory,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.sessionreplay.internal.composition
8+
9+
import com.datadog.android.internal.sessionreplay.composition.CapturedLayer
10+
11+
/**
12+
* Diffs two accepted [CapturedFullSnapshot]s of the same [com.datadog.android.internal.sessionreplay.composition.RumViewIdentityScope]
13+
* into a [CapturedMutationSet], or returns null when the change can't be expressed as a mutation
14+
* under the current wire contract - the caller must fall back to a full snapshot in that case.
15+
*
16+
* The wire mutation model (block 1) can only express layer-level structural changes (a layer's own
17+
* bounds/children/modifiers/composite operation) - it has no operation for a wireframe's own
18+
* content changing (text, its independently-absolute bounds, an image resource, a style), nor for
19+
* delivering a brand-new wireframe's definition out of band. [diff] therefore requires every
20+
* wireframe referenced by [current] to already have existed, unchanged, in [previous] before it
21+
* will attempt a layer diff at all; a wireframe simply disappearing needs no such check, since it
22+
* becomes unreachable exactly when its owning layer is removed or stops referencing it, which the
23+
* layer diff below already produces correctly by construction.
24+
*/
25+
internal object CapturedSnapshotDiffer {
26+
27+
@Suppress("ReturnCount")
28+
fun diff(previous: CapturedFullSnapshot, current: CapturedFullSnapshot): CapturedMutationSet? {
29+
if (previous.scope != current.scope) return null
30+
val currentRoot = current.root ?: return null
31+
if (!isWireframeContentStable(previous, current)) return null
32+
33+
val previousLayers = previous.layers.associateBy { it.identity.wireId }
34+
val currentLayers = current.layers.associateBy { it.identity.wireId }
35+
36+
val adds = currentLayers.filterKeys { it !in previousLayers }.values.toList()
37+
val removes = previousLayers.filterKeys { it !in currentLayers }.values.map { it.identity }
38+
val updates = currentLayers.mapNotNull { (wireId, layer) ->
39+
previousLayers[wireId]?.let { diffLayer(it, layer) }
40+
}
41+
42+
return CapturedMutationSet(
43+
timestamp = current.timestamp,
44+
scope = current.scope,
45+
root = if (previous.root != currentRoot) CapturedChange.Set(currentRoot) else CapturedChange.Unchanged,
46+
adds = adds.toChange(),
47+
removes = removes.toChange(),
48+
updates = updates.toChange()
49+
)
50+
}
51+
52+
/**
53+
* True only if every wireframe [current] references already existed, with identical content,
54+
* in [previous]. A brand-new wireframe id has no delivery mechanism on the wire today, and a
55+
* persisting wireframe with different content has no update mechanism either - both force a
56+
* full snapshot. Wireframes present only in [previous] are deliberately not examined here.
57+
*/
58+
private fun isWireframeContentStable(previous: CapturedFullSnapshot, current: CapturedFullSnapshot): Boolean {
59+
val previousWireframesById = previous.wireframes.associateBy { it.identity.wireId }
60+
return current.wireframes.all { wireframe ->
61+
previousWireframesById[wireframe.identity.wireId] == wireframe
62+
}
63+
}
64+
65+
/** Null if nothing about [current] differs from [previous]; otherwise a sparse per-field update. */
66+
private fun diffLayer(previous: CapturedLayer, current: CapturedLayer): CapturedLayerUpdate? {
67+
val update = CapturedLayerUpdate(
68+
identity = current.identity,
69+
x = changeIfDiffers(previous.bounds.x, current.bounds.x),
70+
y = changeIfDiffers(previous.bounds.y, current.bounds.y),
71+
width = changeIfDiffers(previous.bounds.width, current.bounds.width),
72+
height = changeIfDiffers(previous.bounds.height, current.bounds.height),
73+
children = changeIfDiffers(previous.children, current.children),
74+
modifiers = changeIfDiffers(previous.modifiers, current.modifiers),
75+
compositeOperation = changeIfDiffers(previous.compositeOperation, current.compositeOperation)
76+
)
77+
val isUnchanged = update.x == CapturedChange.Unchanged &&
78+
update.y == CapturedChange.Unchanged &&
79+
update.width == CapturedChange.Unchanged &&
80+
update.height == CapturedChange.Unchanged &&
81+
update.children == CapturedChange.Unchanged &&
82+
update.modifiers == CapturedChange.Unchanged &&
83+
update.compositeOperation == CapturedChange.Unchanged
84+
return update.takeUnless { isUnchanged }
85+
}
86+
87+
private fun <T> changeIfDiffers(previous: T, current: T): CapturedChange<T> =
88+
if (previous == current) CapturedChange.Unchanged else CapturedChange.Set(current)
89+
90+
private fun <T> List<T>.toChange(): CapturedChange<List<T>> =
91+
takeIf { it.isNotEmpty() }?.let { CapturedChange.Set(it) } ?: CapturedChange.Unchanged
92+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.sessionreplay.internal.composition
8+
9+
import android.content.res.Configuration
10+
import android.content.res.Resources
11+
12+
/** Isolates the current device orientation so it can be substituted in tests. */
13+
internal fun interface OrientationProvider {
14+
fun currentOrientation(): Int
15+
}
16+
17+
/**
18+
* Reads [Resources.getSystem] rather than requiring an Activity/Application context - it reflects
19+
* true device rotation without needing any context threaded into the composition pipeline. Falls
20+
* back to a constant sentinel on failure rather than throwing: a session-replay signal must never
21+
* be able to crash the host app, and a constant value simply makes orientation-change gating inert
22+
* (the other full-snapshot triggers - new view, periodic checkpoint - are unaffected) rather than
23+
* unsafe in either direction.
24+
*/
25+
internal class DefaultOrientationProvider : OrientationProvider {
26+
@Suppress("TooGenericExceptionCaught", "UnsafeThirdPartyFunctionCall")
27+
override fun currentOrientation(): Int = try {
28+
Resources.getSystem().configuration.orientation
29+
} catch (@Suppress("SwallowedException") e: Exception) {
30+
Configuration.ORIENTATION_UNDEFINED
31+
}
32+
}

features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/composition/SnapshotCompletionQueue.kt

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal.composition
88

99
import com.datadog.android.api.InternalLogger
1010
import com.datadog.android.core.internal.utils.executeSafe
11+
import com.datadog.android.internal.time.TimeProvider
1112
import com.datadog.android.sessionreplay.internal.async.DataQueueHandler
1213
import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord
1314
import com.datadog.android.sessionreplay.internal.storage.RecordWriter
@@ -16,12 +17,20 @@ import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext
1617
import com.datadog.android.sessionreplay.model.MobileSegment
1718
import java.util.concurrent.ConcurrentLinkedQueue
1819
import java.util.concurrent.ExecutorService
20+
import java.util.concurrent.TimeUnit
1921
import java.util.concurrent.atomic.AtomicBoolean
2022

2123
internal fun interface SnapshotCompletionProcessor {
2224
fun process(capture: CompletedSnapshotCapture)
2325
}
2426

27+
/** The last accepted snapshot a generation can be diffed against, and when it was last a full one. */
28+
private data class RetainedSnapshotState(
29+
val snapshot: CapturedFullSnapshot,
30+
val orientation: Int,
31+
val lastFullSnapshotAtNs: Long
32+
)
33+
2534
/**
2635
* Bundles composition wire-mapping with the view-lifecycle records the player needs around it -
2736
* mirrors legacy `RecordedDataProcessor`'s new-view handling: every genuinely new RUM view opens
@@ -40,18 +49,29 @@ internal fun interface SnapshotCompletionProcessor {
4049
* calls [DataQueueHandler.tryToConsumeItems] - without a caller, those items sat in memory
4150
* forever, so every composition-tree pixel capture's `resourceId` referenced a resource that had
4251
* never actually been persisted anywhere.
52+
*
53+
* Also diffs each completed generation against the last *accepted* one (retained only inside the
54+
* [CaptureGenerationContext.tryAccept]-gated success branch below, so an expired/rejected
55+
* generation never corrupts it) and emits an incremental mutation via [CapturedSnapshotDiffer]
56+
* where possible, falling back to a full snapshot on a new RUM view, a periodic checkpoint, an
57+
* orientation change, or a mutation that unexpectedly fails validation (self-healing rather than
58+
* dropping the generation) - mirroring legacy `RecordedDataProcessor`'s
59+
* `isNewView`/`isTimeForFullSnapshot`/`screenOrientationChanged` gating for this pipeline's state.
4360
*/
4461
internal class DefaultSnapshotCompletionProcessor(
4562
private val rumContextProvider: RumContextProvider,
4663
private val recordWriter: RecordWriter,
4764
private val internalLogger: InternalLogger,
65+
private val timeProvider: TimeProvider,
66+
private val orientationProvider: OrientationProvider = DefaultOrientationProvider(),
4867
private val wireMapper: CapturedTreeWireMapper = DefaultCapturedTreeWireMapper(),
4968
private val resourceDataQueueHandler: DataQueueHandler? = null
5069
) : SnapshotCompletionProcessor {
5170

5271
// Only ever read/written from SnapshotCompletionQueue's single draining thread - same
5372
// single-threaded-processor assumption legacy RecordedDataProcessor's own prevRumContext relies on.
5473
private var lastViewContext: SessionReplayRumContext? = null
74+
private var retained: RetainedSnapshotState? = null
5575

5676
override fun process(capture: CompletedSnapshotCapture) {
5777
// Whatever resources this generation's pixel captures resolved were already queued
@@ -66,7 +86,8 @@ internal class DefaultSnapshotCompletionProcessor(
6686
return
6787
}
6888

69-
when (val mapping = wireMapper.mapFullSnapshot(capture.snapshot)) {
89+
val currentOrientation = orientationProvider.currentOrientation()
90+
when (val mapping = resolveMapping(capture.snapshot, currentOrientation)) {
7091
is CaptureWireMappingResult.Success -> {
7192
if (capture.generation.tryAccept()) {
7293
writeViewEndRecordIfViewChanged(rumContext, capture.snapshot.timestamp)
@@ -84,6 +105,11 @@ internal class DefaultSnapshotCompletionProcessor(
84105
records = records
85106
)
86107
)
108+
retained = RetainedSnapshotState(
109+
snapshot = capture.snapshot,
110+
orientation = currentOrientation,
111+
lastFullSnapshotAtNs = lastFullSnapshotAtNs(mapping.value)
112+
)
87113
}
88114
}
89115

@@ -125,6 +151,48 @@ internal class DefaultSnapshotCompletionProcessor(
125151
)
126152
)
127153
}
154+
155+
/** Only a full snapshot resets the periodic-checkpoint clock; a mutation cycle leaves it running. */
156+
private fun lastFullSnapshotAtNs(record: MobileSegment.MobileRecord): Long =
157+
if (record is MobileSegment.MobileRecord.MobileFullSnapshotRecord) {
158+
timeProvider.getDeviceElapsedTimeNanos()
159+
} else {
160+
retained?.lastFullSnapshotAtNs ?: timeProvider.getDeviceElapsedTimeNanos()
161+
}
162+
163+
@Suppress("ReturnCount")
164+
private fun resolveMapping(
165+
snapshot: CapturedFullSnapshot,
166+
currentOrientation: Int
167+
): CaptureWireMappingResult<MobileSegment.MobileRecord> {
168+
val retainedState = retained ?: return wireMapper.mapFullSnapshot(snapshot)
169+
val fullSnapshotRequired = retainedState.snapshot.scope != snapshot.scope ||
170+
currentOrientation != retainedState.orientation ||
171+
isTimeForFullSnapshot(retainedState)
172+
if (fullSnapshotRequired) return wireMapper.mapFullSnapshot(snapshot)
173+
174+
val mutation = CapturedSnapshotDiffer.diff(retainedState.snapshot, snapshot)
175+
?: return wireMapper.mapFullSnapshot(snapshot)
176+
177+
return when (val mutationMapping = wireMapper.mapMutation(mutation, retainedState.snapshot)) {
178+
is CaptureWireMappingResult.Success -> mutationMapping
179+
is CaptureWireMappingResult.Invalid -> {
180+
internalLogger.log(
181+
InternalLogger.Level.WARN,
182+
InternalLogger.Target.TELEMETRY,
183+
{ "Computed mutation failed validation, retrying as a full snapshot: ${mutationMapping.failures}" }
184+
)
185+
wireMapper.mapFullSnapshot(snapshot)
186+
}
187+
}
188+
}
189+
190+
private fun isTimeForFullSnapshot(retainedState: RetainedSnapshotState): Boolean =
191+
timeProvider.getDeviceElapsedTimeNanos() - retainedState.lastFullSnapshotAtNs >= FULL_SNAPSHOT_INTERVAL_NS
192+
193+
private companion object {
194+
val FULL_SNAPSHOT_INTERVAL_NS = TimeUnit.MILLISECONDS.toNanos(3000)
195+
}
128196
}
129197

130198
/**

0 commit comments

Comments
 (0)