-
Notifications
You must be signed in to change notification settings - Fork 48
feat(replay): make screenshot optimizations opt-in #761
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| --- | ||
| 'posthog-android': patch | ||
| 'posthog-android': minor | ||
| --- | ||
|
|
||
| Reduce session replay screenshot overhead by capturing directly into a reusable, half-resolution bitmap with a lower-memory pixel format. | ||
| Add an experimental `sessionReplayConfig.optimizeScreenshots` option to reduce screenshot overhead with a reusable bitmap at half the width and height. It defaults to `false`, preserving full-resolution ARGB_8888 capture. When enabled, RGB_565 reduces image detail and removes alpha, making transparent window regions appear black; captures are skipped while a timed-out PixelCopy still owns the reusable bitmap. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1501,23 +1501,27 @@ public class PostHogReplayIntegration( | |
| scaleY: Float, | ||
| ) { | ||
| set( | ||
| floor(rect.left * scaleX).toFloat(), | ||
| floor(rect.top * scaleY).toFloat(), | ||
| ceil(rect.right * scaleX).toFloat(), | ||
| ceil(rect.bottom * scaleY).toFloat(), | ||
| floor(rect.left * scaleX), | ||
| floor(rect.top * scaleY), | ||
| ceil(rect.right * scaleX), | ||
| ceil(rect.bottom * scaleY), | ||
| ) | ||
| } | ||
|
|
||
| private fun Bitmap.paintScreenshotMasks( | ||
| rects: List<Rect>, | ||
| sourceWidth: Int, | ||
| sourceHeight: Int, | ||
| scaleMasks: Boolean, | ||
| canPaintMask: () -> Boolean = { true }, | ||
| ): Boolean { | ||
| if (!isValid()) { | ||
| this@PostHogReplayIntegration.config.logger.log("Session Replay Bitmap is invalid.") | ||
| return false | ||
| } | ||
| if (sourceWidth <= 0 || sourceHeight <= 0) { | ||
| return false | ||
| } | ||
|
|
||
| val canvas = | ||
| try { | ||
|
|
@@ -1527,14 +1531,18 @@ public class PostHogReplayIntegration( | |
| return false | ||
| } | ||
|
|
||
| val scaleX = width.toFloat() / sourceWidth | ||
| val scaleY = height.toFloat() / sourceHeight | ||
| val scaleX = if (scaleMasks) width.toFloat() / sourceWidth else 1f | ||
| val scaleY = if (scaleMasks) height.toFloat() / sourceHeight else 1f | ||
| val maskRect = RectF() | ||
| for (rect in rects) { | ||
| if (!canPaintMask()) { | ||
| return false | ||
| } | ||
| maskRect.setScaledScreenshotMask(rect, scaleX, scaleY) | ||
| if (scaleMasks) { | ||
| maskRect.setScaledScreenshotMask(rect, scaleX, scaleY) | ||
| } else { | ||
| maskRect.set(rect) | ||
| } | ||
| canvas.drawRoundRect(maskRect, 10f * scaleX, 10f * scaleY, paint) | ||
| } | ||
| return true | ||
|
|
@@ -1544,6 +1552,7 @@ public class PostHogReplayIntegration( | |
| bitmap: Bitmap, | ||
| drawState: WindowDrawState, | ||
| armedCapture: ArmedMaskCapture, | ||
| optimizeScreenshots: Boolean, | ||
| ): Boolean { | ||
| val postWalk = MaskWalk() | ||
| // A layout pass or an invalidating draw sample already sealed the verdict as discard, | ||
|
|
@@ -1564,12 +1573,13 @@ public class PostHogReplayIntegration( | |
| config.logger.log("Session Replay screenshot discarded due to screen changes.") | ||
| return false | ||
| } | ||
| return bitmap.paintScreenshotMasks(postWalk.rects, width, height) | ||
| return bitmap.paintScreenshotMasks(postWalk.rects, width, height, optimizeScreenshots) | ||
| } | ||
|
|
||
| private fun View.maskLegacyScreenshot( | ||
| bitmap: Bitmap, | ||
| drawState: WindowDrawState, | ||
| optimizeScreenshots: Boolean, | ||
| ): Boolean { | ||
| val unsafeRedraw = { drawState.isOnDrawnCalled && !drawState.isOnlyAnimationRedraw } | ||
| if (unsafeRedraw()) { | ||
|
|
@@ -1584,7 +1594,7 @@ public class PostHogReplayIntegration( | |
| return false | ||
| } | ||
|
|
||
| return bitmap.paintScreenshotMasks(walk.rects, width, height) { | ||
| return bitmap.paintScreenshotMasks(walk.rects, width, height, optimizeScreenshots) { | ||
| val safe = !unsafeRedraw() | ||
| if (!safe) { | ||
| config.logger.log("Session Replay screenshot discarded due to screen changes.") | ||
|
|
@@ -1682,28 +1692,43 @@ public class PostHogReplayIntegration( | |
| return null | ||
| } | ||
|
|
||
| val bitmapLease: PixelCopyBitmapBuffer.Lease | ||
| val optimizeScreenshots = config.sessionReplayConfig.optimizeScreenshots | ||
| val bitmap: Bitmap | ||
| val bitmapLease: PixelCopyBitmapBuffer.Lease? | ||
| val handler: Handler | ||
| try { | ||
| handler = ensurePixelCopyHandler() | ||
| bitmapLease = | ||
| pixelCopyBitmapBuffer.acquire( | ||
| downscaledScreenshotDimension(view.width), | ||
| downscaledScreenshotDimension(view.height), | ||
| ) ?: run { | ||
| finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) | ||
| config.logger.log("Session Replay screenshot skipped because the previous PixelCopy is still in progress.") | ||
| recordScreenshotDiscarded(drawState) | ||
| return null | ||
| } | ||
| if (optimizeScreenshots) { | ||
| bitmapLease = | ||
| pixelCopyBitmapBuffer.acquire( | ||
| downscaledScreenshotDimension(view.width), | ||
| downscaledScreenshotDimension(view.height), | ||
| ) ?: run { | ||
| finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) | ||
| config.logger.log("Session Replay screenshot skipped because the previous PixelCopy is still in progress.") | ||
| recordScreenshotDiscarded(drawState) | ||
| return null | ||
| } | ||
| bitmap = bitmapLease.bitmap | ||
| } else { | ||
| bitmapLease = null | ||
| bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a default-mode PixelCopy times out, its fresh full-resolution bitmap is left for the callback to recycle. If Knowledge Base Used: Android session replay Prompt To Fix With AIThis is a comment left during a code review.
Path: posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt
Line: 1715
Comment:
**Pending bitmaps can leak**
If a default-mode PixelCopy times out, its fresh full-resolution bitmap is left for the callback to recycle. If `uninstall()` quits the callback thread before that callback is delivered, the bitmap is not recycled and is not tracked by `pixelCopyBitmapBuffer.close()`. Multiple delayed captures can therefore retain large ARGB_8888 bitmaps without deterministic cleanup. Please retain ownership of pending default-mode bitmaps so uninstall can release them explicitly.
**Knowledge Base Used:** [Android session replay](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-android/-/docs/android-session-replay.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| } | ||
| } catch (e: Throwable) { | ||
| finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) | ||
| config.logger.log("Session Replay screenshot setup failed: $e.") | ||
| recordScreenshotDiscarded(drawState) | ||
| return null | ||
| } | ||
|
|
||
| val bitmap = bitmapLease.bitmap | ||
| fun releaseBitmap() { | ||
| if (bitmapLease != null) { | ||
| bitmapLease.release() | ||
| } else { | ||
| bitmap.recycle() | ||
| } | ||
| } | ||
|
|
||
| val latch = CountDownLatch(1) | ||
| val requestState = PixelCopyRequestState() | ||
|
|
||
|
|
@@ -1728,9 +1753,9 @@ public class PostHogReplayIntegration( | |
| } else if (!requestState.isAbandoned()) { | ||
| succeeded = | ||
| if (armedCapture != null) { | ||
| view.maskVerifiedScreenshot(bitmap, drawState, armedCapture) | ||
| view.maskVerifiedScreenshot(bitmap, drawState, armedCapture, optimizeScreenshots) | ||
| } else { | ||
| view.maskLegacyScreenshot(bitmap, drawState) | ||
| view.maskLegacyScreenshot(bitmap, drawState, optimizeScreenshots) | ||
| } | ||
| } | ||
| } catch (e: Throwable) { | ||
|
|
@@ -1739,7 +1764,7 @@ public class PostHogReplayIntegration( | |
| val releaseInCallback = requestState.complete(succeeded) | ||
| try { | ||
| if (releaseInCallback) { | ||
| bitmapLease.release() | ||
| releaseBitmap() | ||
| } | ||
| } finally { | ||
| latch.countDown() | ||
|
|
@@ -1751,7 +1776,7 @@ public class PostHogReplayIntegration( | |
| } catch (e: Throwable) { | ||
| config.logger.log("Session Replay PixelCopy failed: $e.") | ||
| if (requestState.complete(false)) { | ||
| bitmapLease.release() | ||
| releaseBitmap() | ||
| } | ||
| latch.countDown() | ||
| } | ||
|
|
@@ -1792,7 +1817,7 @@ public class PostHogReplayIntegration( | |
| } finally { | ||
| finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) | ||
| if (releaseFromWaiter) { | ||
| bitmapLease.release() | ||
| releaseBitmap() | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Low: Unbounded bitmap retention after PixelCopy timeouts
When optimizations are disabled, every capture allocates a new full-resolution bitmap. A timed-out request retains that bitmap until its callback arrives, but unlike the lease-backed path, it does not prevent subsequent captures from allocating more; repeated UI redraws during stalled callbacks can therefore exhaust the host app's memory. Keep full-resolution ARGB_8888 capture while applying equivalent single-request backpressure or another explicit bound to outstanding bitmaps.