Skip to content

Commit 4531005

Browse files
authored
feat: adds click and swipe interactions to session replay (#292)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Adds touch interaction (down/move/up) capture and RRWeb event generation, refactors capture/export pipeline to batch per session and handle full vs incremental snapshots. > > - **Replay capture**: > - Introduce `InteractionEvent`, `Position`, and `InteractionSource` to intercept window touch events and emit interaction logs (ACTION_DOWN/UP/MOVE) with buffering. > - Add `InteractionMoveGrouper` to filter/group MOVE positions by distance/time and periodically emit grouped events. > - Rename `Capture` to `CaptureEvent`; `CaptureSource` now tracks the most recent activity safely and uses `DispatcherProviderHolder`. > - **Exporter (`RRwebGraphQLReplayLogExporter`)**: > - Process logs sorted by time, group events by `session.id`, and initialize sessions once before pushing payloads. > - Replace send methods with generators: `generateCaptureFullEvents`, `generateCaptureIncrementalEvents`, and new `generateInteractionEvents` (RRWeb-compatible). > - Track last-seen state (session/size) to choose full vs incremental snapshots. > - **Instrumentation (`ReplayInstrumentation`)**: > - Wire `InteractionSource` into logging; emit `event.domain=interaction` with serialized coords; use dispatcher provider for coroutines. > - **Tests**: > - Add comprehensive `InteractionMoveGrouperTest` and update `RRwebGraphQLReplayLogExporterTest` for event generation and batching behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6abf164. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent eb07178 commit 4531005

File tree

9 files changed

+1053
-228
lines changed

9 files changed

+1053
-228
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ package com.launchdarkly.observability.replay
1010
* @property session The unique session identifier that this capture belongs to. This links
1111
* the capture to a specific user session.
1212
*/
13-
data class Capture(
13+
data class CaptureEvent(
1414
val imageBase64: String,
1515
val origHeight: Int,
1616
val origWidth: Int,

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/CaptureSource.kt

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import android.view.ViewGroup
1919
import androidx.compose.ui.platform.ComposeView
2020
import androidx.compose.ui.semantics.SemanticsNode
2121
import androidx.compose.ui.semantics.SemanticsOwner
22+
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
2223
import io.opentelemetry.android.session.SessionManager
2324
import kotlinx.coroutines.CoroutineScope
24-
import kotlinx.coroutines.Dispatchers
2525
import kotlinx.coroutines.flow.MutableSharedFlow
2626
import kotlinx.coroutines.flow.SharedFlow
2727
import kotlinx.coroutines.flow.asSharedFlow
@@ -34,10 +34,10 @@ import kotlin.coroutines.resumeWithException
3434
import androidx.compose.ui.geometry.Rect as ComposeRect
3535

3636
/**
37-
* A source of [Capture]s taken from the most recently resumed [Activity]s window. Captures
37+
* A source of [CaptureEvent]s taken from the most recently resumed [Activity]s window. Captures
3838
* are emitted on the [captureFlow] property of this class.
3939
*
40-
* @param sessionManager Used to get current session for tagging [Capture] with session id
40+
* @param sessionManager Used to get current session for tagging [CaptureEvent] with session id
4141
*/
4242
class CaptureSource(
4343
private val sessionManager: SessionManager,
@@ -46,10 +46,10 @@ class CaptureSource(
4646
) :
4747
Application.ActivityLifecycleCallbacks {
4848

49-
private var _activity: Activity? = null
49+
private var _mostRecentActivity: Activity? = null
5050

51-
private val _captureFlow = MutableSharedFlow<Capture>()
52-
val captureFlow: SharedFlow<Capture> = _captureFlow.asSharedFlow()
51+
private val _captureEventFlow = MutableSharedFlow<CaptureEvent>()
52+
val captureFlow: SharedFlow<CaptureEvent> = _captureEventFlow.asSharedFlow()
5353

5454
/**
5555
* Attaches the [CaptureSource] to the [Application] whose [Activity]s will be captured.
@@ -66,12 +66,12 @@ class CaptureSource(
6666
}
6767

6868
/**
69-
* Requests a [Capture] be taken now.
69+
* Requests a [CaptureEvent] be taken now.
7070
*/
7171
suspend fun captureNow() {
7272
val capture = doCapture()
7373
if (capture != null) {
74-
_captureFlow.emit(capture)
74+
_captureEventFlow.emit(capture)
7575
}
7676
}
7777

@@ -84,11 +84,14 @@ class CaptureSource(
8484
}
8585

8686
override fun onActivityResumed(activity: Activity) {
87-
_activity = activity
87+
_mostRecentActivity = activity
8888
}
8989

9090
override fun onActivityPaused(activity: Activity) {
91-
_activity = null
91+
// this if check prevents pausing of a different activity from interfering with tracking of most recent activity.
92+
if (activity == _mostRecentActivity) {
93+
_mostRecentActivity = null
94+
}
9295
}
9396

9497
override fun onActivityStopped(activity: Activity) {
@@ -106,12 +109,12 @@ class CaptureSource(
106109
/**
107110
* Internal capture routine.
108111
*/
109-
private suspend fun doCapture(): Capture? = withContext(Dispatchers.Main) {
110-
val activity = _activity ?: return@withContext null
111-
112+
private suspend fun doCapture(): CaptureEvent? = withContext(DispatcherProviderHolder.current.main) {
112113
try {
113114
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
114-
val window = activity.window
115+
val activity = _mostRecentActivity ?: return@withContext null // return if no activity
116+
val window = activity.window ?: return@withContext null // return if activity has no window
117+
115118
val decorView = window.decorView
116119
val decorViewWidth = decorView.width
117120
val decorViewHeight = decorView.height
@@ -146,7 +149,7 @@ class CaptureSource(
146149
val session = sessionManager.getSessionId()
147150

148151
if (result == PixelCopy.SUCCESS) {
149-
CoroutineScope(Dispatchers.Default).launch {
152+
CoroutineScope(DispatcherProviderHolder.current.default).launch {
150153
try {
151154
val postMask = if (maskMatchers.isNotEmpty()) {
152155
maskSensitiveRects(bitmap, sensitiveComposeRects)
@@ -161,14 +164,14 @@ class CaptureSource(
161164
val byteArray = outputStream.toByteArray()
162165
val compressedImage = Base64.encodeToString(byteArray, Base64.NO_WRAP)
163166

164-
val capture = Capture(
167+
val captureEvent = CaptureEvent(
165168
imageBase64 = compressedImage,
166169
origWidth = decorViewWidth,
167170
origHeight = decorViewHeight,
168171
timestamp = timestamp,
169172
session = session
170173
)
171-
continuation.resume(capture)
174+
continuation.resume(captureEvent)
172175
} catch (e: Exception) {
173176
continuation.resumeWithException(e)
174177
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.launchdarkly.observability.replay
2+
3+
data class Position(
4+
val x: Int,
5+
val y: Int,
6+
val timestamp: Long,
7+
)
8+
9+
data class InteractionEvent(
10+
val action: Int,
11+
val positions: List<Position>,
12+
val session: String,
13+
)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.launchdarkly.observability.replay
2+
3+
import android.view.MotionEvent
4+
import io.opentelemetry.android.session.SessionManager
5+
import kotlinx.coroutines.flow.MutableSharedFlow
6+
7+
private const val FILTER_THRESHOLD_DISTANCE_SQUARED_PIXELS = 100
8+
private const val FILTER_THRESHOLD_TIME_MILLIS = 20
9+
private const val EMIT_PERIOD_MILLIS = 400
10+
11+
/**
12+
* Class for filtering and grouping emissions of movement interactions to reduce data rates.
13+
*
14+
* This class can only be used from a single thread, usually the main thread.
15+
*
16+
* @param _sessionManager used for tagging events with session id
17+
* @param _bufferedFlow a buffered flow that emitted events will be given to, this is buffered to
18+
* avoid delays handing off data to collector of flow
19+
*/
20+
class InteractionMoveGrouper(
21+
private val _sessionManager: SessionManager,
22+
private val _bufferedFlow: MutableSharedFlow<InteractionEvent>,
23+
) {
24+
private val acceptedPositions = mutableListOf<Position>()
25+
private var lastAccepted : Position? = null
26+
private var lastEmitTime = 0L
27+
28+
// Handles another move applying filtering and possibly invoking tryEmit on the buffered flow when necessary.
29+
fun handleMove(x: Int, y: Int, timestamp: Long) {
30+
val current = Position(x, y, timestamp)
31+
32+
val last = lastAccepted
33+
val passedFilter: Boolean
34+
if (last != null) {
35+
// if we have a last position, only use this new position if it passes filter (both thresholds have been exceeded)
36+
val distThresholdExceeded = distanceSq(
37+
current,
38+
last
39+
) > FILTER_THRESHOLD_DISTANCE_SQUARED_PIXELS // note this is square of distance in pixels
40+
val timeThresholdExceeded = (timestamp - last.timestamp) > FILTER_THRESHOLD_TIME_MILLIS
41+
passedFilter = distThresholdExceeded && timeThresholdExceeded
42+
} else {
43+
// if we don't have a last position that got through filtering, this one is the first and should be used
44+
passedFilter = true
45+
}
46+
47+
if (passedFilter) {
48+
// position has passed filtering, add it to list
49+
acceptedPositions.add(current)
50+
lastAccepted = current
51+
}
52+
53+
// if enough time has passed since last emission, tryEmit the group
54+
val currentTime = System.currentTimeMillis()
55+
if (acceptedPositions.isNotEmpty() && (currentTime - lastEmitTime > EMIT_PERIOD_MILLIS)) {
56+
val interaction = InteractionEvent(
57+
action = MotionEvent.ACTION_MOVE,
58+
positions = acceptedPositions.toList(), // toList() makes a copy, which is required
59+
session = _sessionManager.getSessionId(),
60+
)
61+
_bufferedFlow.tryEmit(interaction)
62+
63+
lastEmitTime = currentTime
64+
acceptedPositions.clear()
65+
}
66+
}
67+
68+
// Call this when the last position of a move is known, this will trigger tryEmit on the buffered flow so no
69+
// positions are left behind in the buffer.
70+
fun completeWithLastPosition(x: Int, y: Int, timestamp: Long) {
71+
val current = Position(x, y, timestamp)
72+
73+
// last position that got through filtering
74+
val last = lastAccepted
75+
if (last == null || last != current) {
76+
acceptedPositions.add(current)
77+
lastAccepted = current
78+
}
79+
80+
// since we have distance and time filtering, it is possible that there are no positions that
81+
// make it through the filtering, need to protect against this case
82+
if (acceptedPositions.isNotEmpty()) {
83+
val interaction = InteractionEvent(
84+
action = MotionEvent.ACTION_MOVE,
85+
positions = acceptedPositions.toList(), // toList() makes a copy, which is required
86+
session = _sessionManager.getSessionId(),
87+
)
88+
_bufferedFlow.tryEmit(interaction)
89+
}
90+
91+
// reset state so next move sequence is treated as an independent sequence
92+
lastAccepted = null
93+
lastEmitTime = 0L
94+
acceptedPositions.clear()
95+
}
96+
97+
// Calculates squared distance between positions
98+
private fun distanceSq(a: Position, b: Position): Int {
99+
val dx = a.x - b.x
100+
val dy = a.y - b.y
101+
return dx * dx + dy * dy
102+
}
103+
}

0 commit comments

Comments
 (0)