Skip to content

Commit 72f2592

Browse files
feat: Limit accumulating canvas buffer (#322)
## Summary RRWeb Canvas is being updated by draw commands and they all kept in the browser memory. Pr makes SR to reset this Canvas cache by pushing FullSnapshot after 10mb of canvas payloads <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Track accumulated canvas draw size with a configurable limit to force full snapshots; serialize exports and update tests. > > - **Replay exporter (`RRwebGraphQLReplayLogExporter`)**: > - Add configurable `canvasBufferLimit` and `canvasDrawEntourage` (defaults ~10MB, 300 bytes) and track `generatingCanvasSize`/`pushedCanvasSize`. > - Trigger full snapshot when accumulated canvas size exceeds limit, in addition to session/size changes. > - Update size accounting on incremental and full events; flush size after `pushPayload`. > - Ensure single-threaded export with `Mutex` around export to protect counters. > - **Tests**: > - Add `test canvas buffer limit` validating full snapshot when limit exceeded. > - Pass buffer config into exporter setup; adjust verifiers to accept expected counts. > - Minor test cleanups/wording tweaks. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2117644. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 3c6a77b commit 72f2592

File tree

2 files changed

+77
-13
lines changed

2 files changed

+77
-13
lines changed

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ import io.opentelemetry.sdk.logs.export.LogRecordExporter
1111
import kotlinx.coroutines.CoroutineScope
1212
import kotlinx.coroutines.SupervisorJob
1313
import kotlinx.coroutines.launch
14+
import kotlinx.coroutines.sync.Mutex
15+
import kotlinx.coroutines.sync.withLock
1416
import kotlinx.serialization.json.Json
1517
import kotlinx.serialization.json.JsonPrimitive
1618
import kotlinx.serialization.json.jsonArray
1719
import kotlinx.serialization.json.jsonObject
1820

19-
private const val REPLAY_EXPORTER_NAME = "RRwebGraphQLReplayLogExporter"
21+
// size limit of accumulated continues canvas operations on the RRWeb player
22+
private const val RRWEB_CANVAS_BUFFER_LIMIT = 10_000_000 // ~10mb
23+
private const val RRWEB_CANVAS_DRAW_ENTOURAGE = 300 // 300 bytes
2024

2125
/**
2226
* An [LogRecordExporter] that can send session replay capture logs to the backend using RRWeb syntax
@@ -33,9 +37,12 @@ class RRwebGraphQLReplayLogExporter(
3337
val backendUrl: String,
3438
val serviceName: String,
3539
val serviceVersion: String,
36-
private val injectedReplayApiService: SessionReplayApiService? = null
40+
private val injectedReplayApiService: SessionReplayApiService? = null,
41+
private val canvasBufferLimit: Int = RRWEB_CANVAS_BUFFER_LIMIT,
42+
private val canvasDrawEntourage: Int = RRWEB_CANVAS_DRAW_ENTOURAGE
3743
) : LogRecordExporter {
3844
private val coroutineScope = CoroutineScope(DispatcherProviderHolder.current.io + SupervisorJob())
45+
private val exportMutex = Mutex()
3946

4047
private var graphqlClient: GraphQLClient = GraphQLClient(backendUrl)
4148
private val replayApiService: SessionReplayApiService =
@@ -56,12 +63,18 @@ class RRwebGraphQLReplayLogExporter(
5663
)
5764

5865
private var lastSeenState = LastSeenState(sessionId = null, height = 0, width = 0)
66+
private var generatingCanvasSize = 0
67+
private var pushedCanvasSize = 0
5968

6069
override fun export(logs: MutableCollection<LogRecordData>): CompletableResultCode {
6170
val resultCode = CompletableResultCode()
6271

6372
coroutineScope.launch {
73+
// payloadIdCounter and pushedCanvasSize require to have a single reentrancy
74+
exportMutex.withLock {
6475
try {
76+
generatingCanvasSize = pushedCanvasSize
77+
6578
// Map to collect events by session ID
6679
val eventsBySession = mutableMapOf<String, MutableList<Event>>()
6780
// Set to track sessions that need initialization
@@ -80,7 +93,8 @@ class RRwebGraphQLReplayLogExporter(
8093

8194
val stateChanged = capture.session != lastSeenState.sessionId ||
8295
capture.origHeight != lastSeenState.height ||
83-
capture.origWidth != lastSeenState.width
96+
capture.origWidth != lastSeenState.width ||
97+
generatingCanvasSize >= canvasBufferLimit
8498

8599
if (stateChanged) {
86100
lastSeenState = LastSeenState(
@@ -126,6 +140,9 @@ class RRwebGraphQLReplayLogExporter(
126140
if (events.isNotEmpty()) {
127141
try {
128142
replayApiService.pushPayload(sessionId, "${nextPayloadId()}", events)
143+
144+
// flushes generating canvas size into pushedCanvasSize
145+
pushedCanvasSize = generatingCanvasSize
129146
} catch (e: Exception) {
130147
// TODO: O11Y-627 - pass in logger to implementation and use here
131148
// Log.e(REPLAY_EXPORTER_NAME, "Error pushing payload for session $sessionId: ${e.message}", e)
@@ -142,6 +159,7 @@ class RRwebGraphQLReplayLogExporter(
142159
// Log.e("RRwebGraphQLReplayLogExporter", "Error during export: ${e.message}", e)
143160
resultCode.fail()
144161
}
162+
}
145163
}
146164

147165
return resultCode
@@ -259,6 +277,7 @@ class RRwebGraphQLReplayLogExporter(
259277
Json.parseToJsonElement("""{"source":9,"id":6,"type":0,"commands":[{"property":"clearRect","args":[0,0,${captureEvent.origWidth},${captureEvent.origHeight}]},{"property":"drawImage","args":[{"rr_type":"ImageBitmap","args":[{"rr_type":"Blob","data":[{"rr_type":"ArrayBuffer","base64":"${captureEvent.imageBase64}"}],"type":"image/jpeg"}]},0,0,${captureEvent.origWidth},${captureEvent.origHeight}]}]}""")
260278
)
261279
)
280+
generatingCanvasSize += captureEvent.imageBase64.length + canvasDrawEntourage
262281
eventsBatch.add(incrementalEvent)
263282

264283
return eventsBatch
@@ -342,6 +361,9 @@ class RRwebGraphQLReplayLogExporter(
342361
)
343362
),
344363
)
364+
365+
// starting again canvas size
366+
generatingCanvasSize = captureEvent.imageBase64.length + canvasDrawEntourage
345367
eventBatch.add(snapShotEvent)
346368

347369
val viewportEvent = Event(

sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/RRwebGraphQLReplayLogExporterTest.kt

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ class RRwebGraphQLReplayLogExporterTest {
2525
backendUrl = "http://test.com",
2626
serviceName = "test-service",
2727
serviceVersion = "1.0.0",
28-
injectedReplayApiService = mockService
28+
injectedReplayApiService = mockService,
29+
canvasBufferLimit = 20,
30+
canvasDrawEntourage = 1
2931
)
3032
}
3133

@@ -62,7 +64,7 @@ class RRwebGraphQLReplayLogExporterTest {
6264
}
6365

6466
@Test
65-
@Disabled // Feature if handling multiples session is not done
67+
@Disabled // Feature of handling multiples session is not done
6668
fun `export should send full capture for first session and incremental for subsequent captures in same session`() = runTest {
6769
// Arrange: Create captures for two different sessions
6870
val sessionACaptureEvents = listOf(
@@ -176,8 +178,48 @@ class RRwebGraphQLReplayLogExporterTest {
176178

177179
// Verify event types: First and third captures should be full, second and fourth should be incremental
178180
val capturedEvents: List<Event> = capturedEventsLists[0]
179-
verifyFullCaptureEvents(capturedEvents)
180-
verifyIncrementalCaptureEvents(capturedEvents)
181+
verifyFullCaptureEvents(capturedEvents) // First capture - full
182+
verifyIncrementalCaptureEvents(capturedEvents) // Second capture - incremental
183+
}
184+
185+
@Test
186+
fun `test canvas buffer limit`() = runTest {
187+
// Arrange: Create captures for same session but with dimension changes
188+
val captureEvents = listOf(
189+
// small canvas
190+
CaptureEvent("base64data1", 800, 600, 1000L, "session-a"), // First capture - full
191+
// large canvases to cause overlimit
192+
CaptureEvent("base64data2222222222222", 800, 600, 2000L, "session-a"), // Same dimensions - incremental
193+
CaptureEvent("base64data3333333333333", 1024, 768, 3000L, "session-a"), // Dimension change - full
194+
CaptureEvent(
195+
"base64data444444444444",
196+
1024,
197+
768,
198+
4000L,
199+
"session-a"
200+
) // Same dimensions - incremental
201+
)
202+
203+
val logRecords = createLogRecordsFromCaptures(captureEvents)
204+
205+
// Capture the events sent to pushPayload
206+
val capturedEventsLists = mutableListOf<List<Event>>()
207+
208+
// Mock the API service methods
209+
coEvery { mockService.initializeReplaySession(any(), any()) } just Runs
210+
coEvery { mockService.identifyReplaySession(any()) } just Runs
211+
coEvery { mockService.pushPayload(any(), any(), capture(capturedEventsLists)) } just Runs
212+
213+
// Act: Export all log records
214+
val result = exporter.export(logRecords.toMutableList())
215+
216+
// Assert: Verify the result completes successfully
217+
assertTrue(result.join(5, TimeUnit.SECONDS).isSuccess)
218+
219+
// Verify event types: First and third captures should be full, second and fourth should be incremental
220+
val capturedEvents: List<Event> = capturedEventsLists[0]
221+
verifyFullCaptureEvents(capturedEvents, count = 3) // First capture - full
222+
verifyIncrementalCaptureEvents(capturedEvents, 1) // Second capture - incremental
181223
}
182224

183225
@Test
@@ -429,15 +471,15 @@ class RRwebGraphQLReplayLogExporterTest {
429471
/**
430472
* Verifies that the events represent a full capture (META, FULL_SNAPSHOT, CUSTOM)
431473
*/
432-
private fun verifyFullCaptureEvents(events: List<Event>) {
474+
private fun verifyFullCaptureEvents(events: List<Event>, count: Int = 2) {
433475
// Verify META event
434476
val metaEvent = events.find { it.type == EventType.META }
435477
assertNotNull(metaEvent, "Full capture should contain a META event")
436478

437479
// Verify FULL_SNAPSHOT event
438-
val fullSnapshotEvent = events.find { it.type == EventType.FULL_SNAPSHOT }
439-
assertNotNull(fullSnapshotEvent, "Full capture should contain a FULL_SNAPSHOT event")
440-
480+
val fullSnapshotEvents = events.filter { it.type == EventType.FULL_SNAPSHOT }
481+
assertEquals(count, fullSnapshotEvents.size, "Full capture should contain $count FULL_SNAPSHOT events")
482+
441483
// Verify CUSTOM event (viewport)
442484
val customEvent = events.find { it.type == EventType.CUSTOM }
443485
assertNotNull(customEvent, "Full capture should contain a CUSTOM event")
@@ -446,9 +488,9 @@ class RRwebGraphQLReplayLogExporterTest {
446488
/**
447489
* Verifies that the events represent an incremental capture (2 INCREMENTAL_SNAPSHOT events)
448490
*/
449-
private fun verifyIncrementalCaptureEvents(events: List<Event>) {
491+
private fun verifyIncrementalCaptureEvents(events: List<Event>, count: Int = 2) {
450492
// Verify both events are INCREMENTAL_SNAPSHOT
451493
val incrementalEvents = events.filter { it.type == EventType.INCREMENTAL_SNAPSHOT }
452-
assertEquals(2, incrementalEvents.size, "Incremental capture should contain 2 INCREMENTAL_SNAPSHOT events")
494+
assertEquals(count, incrementalEvents.size, "Incremental capture should contain $count INCREMENTAL_SNAPSHOT events")
453495
}
454496
}

0 commit comments

Comments
 (0)