Skip to content
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

[SR] Reduce memory and disk consumption #4016

Merged
merged 26 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
07b758b
Get rid of the lock on touch events
romtsn Dec 17, 2024
e3df539
pre-allocate some things for gesture converter
romtsn Dec 17, 2024
fe15278
have one less thread switch for re]play
romtsn Dec 18, 2024
c65f9b6
update
romtsn Dec 18, 2024
14e8155
Merge branch 'main' into rz/fix/session-replay-anr-ontouchevent
romtsn Dec 18, 2024
bc2e9d6
Changelog
romtsn Dec 18, 2024
fb832b2
Add option to disable orientation change tracking for session replay
romtsn Dec 19, 2024
55dbeec
Make RecorderConfig lazier
romtsn Dec 20, 2024
01b5a88
Fix tests
romtsn Dec 20, 2024
e963707
Changelog
romtsn Dec 20, 2024
c48094f
Allow overriding SdkVersion for replay events only
romtsn Dec 30, 2024
94586a7
Send replay recording options
romtsn Dec 30, 2024
26ccb69
Merge branch 'main' into rz/feat/session-replay-override-sdk-version
romtsn Dec 30, 2024
870fdd8
Changelog
romtsn Dec 30, 2024
fdcae96
Merge branch 'rz/feat/session-replay-override-sdk-version' into rz/fe…
romtsn Dec 30, 2024
cba3c72
Changelog
romtsn Dec 30, 2024
4bf8acd
Add a comment
romtsn Dec 30, 2024
8869e77
Merge branch 'rz/feat/session-replay-override-sdk-version' into rz/fe…
romtsn Dec 30, 2024
35313ba
Add a test
romtsn Dec 30, 2024
fa37ca3
Change joinToString to serializable list for options
romtsn Dec 30, 2024
a5f1ba5
Reduce heap allocations in screenshot recorder
romtsn Jan 2, 2025
c709c50
Formatting
romtsn Jan 2, 2025
cd14384
Add test
romtsn Jan 2, 2025
fa3ae2e
Merge branch 'main' into rz/fix/delete-corrupted-frames
romtsn Jan 2, 2025
179088d
Fix
romtsn Jan 2, 2025
036e373
Changelog
romtsn Jan 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Fixes

- Fix warm start detection ([#3937](https://github.com/getsentry/sentry-java/pull/3937))
- Session Replay: Reduce memory allocations, disk space consumption, and payload size ([#4016](https://github.com/getsentry/sentry-java/pull/4016))
- Session Replay: Do not try to encode corrupted frames multiple times ([#4016](https://github.com/getsentry/sentry-java/pull/4016))

### Internal

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public class ReplayCache(
it.createNewFile()
}
screenshot.outputStream().use {
bitmap.compress(JPEG, 80, it)
bitmap.compress(JPEG, options.experimental.sessionReplay.quality.screenshotQuality, it)
it.flush()
}

Expand Down Expand Up @@ -162,7 +162,7 @@ public class ReplayCache(

val step = 1000 / frameRate.toLong()
var frameCount = 0
var lastFrame: ReplayFrame = frames.first()
var lastFrame: ReplayFrame? = frames.first()
for (timestamp in from until (from + (duration)) step step) {
val iter = frames.iterator()
while (iter.hasNext()) {
Expand All @@ -182,6 +182,12 @@ public class ReplayCache(
// to respect the video duration
if (encode(lastFrame)) {
frameCount++
} else if (lastFrame != null) {
// if we failed to encode the frame, we delete the screenshot right away as the
// likelihood of it being able to be encoded later is low
deleteFile(lastFrame.screenshot)
frames.remove(lastFrame)
lastFrame = null
}
}

Expand All @@ -206,7 +212,10 @@ public class ReplayCache(
return GeneratedVideo(videoFile, frameCount, videoDuration)
}

private fun encode(frame: ReplayFrame): Boolean {
private fun encode(frame: ReplayFrame?): Boolean {
if (frame == null) {
return false
}
return try {
val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath)
synchronized(encoderLock) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package io.sentry.android.replay
import android.annotation.TargetApi
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.Config.ARGB_8888
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
Expand Down Expand Up @@ -54,9 +53,14 @@ internal class ScreenshotRecorder(
Bitmap.createBitmap(
1,
1,
Bitmap.Config.ARGB_8888
Bitmap.Config.RGB_565
)
}
private val screenshot = Bitmap.createBitmap(
config.recordingWidth,
config.recordingHeight,
Bitmap.Config.RGB_565
)
private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) }
private val prescaledMatrix by lazy(NONE) {
Matrix().apply {
Expand All @@ -65,22 +69,18 @@ internal class ScreenshotRecorder(
}
private val contentChanged = AtomicBoolean(false)
private val isCapturing = AtomicBoolean(true)
private var lastScreenshot: Bitmap? = null
private val lastCaptureSuccessful = AtomicBoolean(false)

fun capture() {
if (!isCapturing.get()) {
options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot")
return
}

if (!contentChanged.get() && lastScreenshot != null && !lastScreenshot!!.isRecycled) {
if (!contentChanged.get() && lastCaptureSuccessful.get()) {
options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame")

lastScreenshot?.let {
screenshotRecorderCallback?.onScreenshotRecorded(
it.copy(ARGB_8888, false)
)
}
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
return
}

Expand All @@ -96,38 +96,33 @@ internal class ScreenshotRecorder(
return
}

val bitmap = Bitmap.createBitmap(
config.recordingWidth,
config.recordingHeight,
Bitmap.Config.ARGB_8888
)

// postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible
mainLooperHandler.post {
try {
contentChanged.set(false)
PixelCopy.request(
window,
bitmap,
screenshot,
{ copyResult: Int ->
if (copyResult != PixelCopy.SUCCESS) {
options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult)
bitmap.recycle()
lastCaptureSuccessful.set(false)
return@request
}

// TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture)
if (contentChanged.get()) {
options.logger.log(INFO, "Failed to determine view hierarchy, not capturing")
bitmap.recycle()
lastCaptureSuccessful.set(false)
return@request
}

// TODO: disableAllMasking here and dont traverse?
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
root.traverse(viewHierarchy, options)

recorder.submitSafely(options, "screenshot_recorder.mask") {
val canvas = Canvas(bitmap)
val canvas = Canvas(screenshot)
canvas.setMatrix(prescaledMatrix)
viewHierarchy.traverse { node ->
if (node.shouldMask && (node.width > 0 && node.height > 0)) {
Expand All @@ -141,7 +136,7 @@ internal class ScreenshotRecorder(
val (visibleRects, color) = when (node) {
is ImageViewHierarchyNode -> {
listOf(node.visibleRect) to
bitmap.dominantColorForRect(node.visibleRect)
screenshot.dominantColorForRect(node.visibleRect)
}

is TextViewHierarchyNode -> {
Expand All @@ -168,20 +163,16 @@ internal class ScreenshotRecorder(
return@traverse true
}

val screenshot = bitmap.copy(ARGB_8888, false)
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
lastScreenshot?.recycle()
lastScreenshot = screenshot
lastCaptureSuccessful.set(true)
contentChanged.set(false)

bitmap.recycle()
}
},
mainLooperHandler.handler
)
} catch (e: Throwable) {
options.logger.log(WARNING, "Failed to capture replay recording", e)
bitmap.recycle()
lastCaptureSuccessful.set(false)
}
}
}
Expand Down Expand Up @@ -226,7 +217,7 @@ internal class ScreenshotRecorder(
fun close() {
unbind(rootView?.get())
rootView?.clear()
lastScreenshot?.recycle()
screenshot.recycle()
isCapturing.set(false)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowBitmapFactory
import java.io.File
import kotlin.test.BeforeTest
import kotlin.test.Test
Expand Down Expand Up @@ -61,6 +62,7 @@ class ReplayCacheTest {
@BeforeTest
fun `set up`() {
ReplayShadowMediaCodec.framesToEncode = 5
ShadowBitmapFactory.setAllowInvalidImageData(true)
}

@Test
Expand Down Expand Up @@ -500,4 +502,28 @@ class ReplayCacheTest {

assertEquals(0, lastSegment.id)
}

@Test
fun `when screenshot is corrupted, deletes it immediately`() {
ShadowBitmapFactory.setAllowInvalidImageData(false)
ReplayShadowMediaCodec.framesToEncode = 1
val replayCache = fixture.getSut(
tmpDir
)

val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888)
replayCache.addFrame(bitmap, 1)

// corrupt the image
File(replayCache.replayCacheDir, "1.jpg").outputStream().use {
it.write(Int.MIN_VALUE)
it.flush()
}

val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 1, 20_000)
assertNull(segment0)

assertTrue(replayCache.frames.isEmpty())
assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" })
}
}
1 change: 1 addition & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -2759,6 +2759,7 @@ public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang
public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality;
public static final field MEDIUM Lio/sentry/SentryReplayOptions$SentryReplayQuality;
public final field bitRate I
public final field screenshotQuality I
public final field sizeScale F
public fun serializedName ()Ljava/lang/String;
public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayOptions$SentryReplayQuality;
Expand Down
18 changes: 11 additions & 7 deletions sentry/src/main/java/io/sentry/SentryReplayOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ public final class SentryReplayOptions {
"com.google.android.exoplayer2.ui.StyledPlayerView";

public enum SentryReplayQuality {
/** Video Scale: 80% Bit Rate: 50.000 */
LOW(0.8f, 50_000),
/** Video Scale: 80% Bit Rate: 50.000 JPEG Compression: 10 */
LOW(0.8f, 50_000, 10),

/** Video Scale: 100% Bit Rate: 75.000 */
MEDIUM(1.0f, 75_000),
/** Video Scale: 100% Bit Rate: 75.000 JPEG Compression: 30 */
MEDIUM(1.0f, 75_000, 30),

/** Video Scale: 100% Bit Rate: 100.000 */
HIGH(1.0f, 100_000);
/** Video Scale: 100% Bit Rate: 100.000 JPEG Compression: 50 */
HIGH(1.0f, 100_000, 50);

/** The scale related to the window size (in dp) at which the replay will be created. */
public final float sizeScale;
Expand All @@ -39,9 +39,13 @@ public enum SentryReplayQuality {
*/
public final int bitRate;

SentryReplayQuality(final float sizeScale, final int bitRate) {
/** Defines the compression quality with which the screenshots are stored to disk. */
public final int screenshotQuality;

SentryReplayQuality(final float sizeScale, final int bitRate, final int screenshotQuality) {
this.sizeScale = sizeScale;
this.bitRate = bitRate;
this.screenshotQuality = screenshotQuality;
}

public @NotNull String serializedName() {
Expand Down
Loading