-
-
Notifications
You must be signed in to change notification settings - Fork 465
[SR] Capture Replays for ANRs and crashes #3565
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 1 commit
dd0e9a4
d34ddee
0cca47c
0a26e8d
5ebdfed
04f43ed
b461847
f8419d1
a63cac1
fa72057
6cfb511
0d031d7
07e6b26
18af924
64cedfa
b8cb924
28d341f
1e76fc7
13c1971
a14e090
86baf7f
f1ca9f6
fbbe0d9
688233f
fd63960
93785cc
4db19e0
4e55ec0
9603672
1ce57cb
62477b4
af42fb3
1951891
cd09739
4e54c77
b2940c4
002a0f3
64f70c5
fb14ecb
79151e9
9e87fe8
2e954f7
8bc6219
a5aa4be
f72e45f
545712c
2df34a3
c02f1db
bee240b
e2a821c
d6bb9ab
7854e4f
2cddcc4
ef3d62c
f7ac74f
5faeb4e
65d35ec
0f4e718
fd6e633
915bd29
1a5c4da
fd92dc7
da37c89
c53a975
18eb67e
a7ae2b7
bf14d83
82fe21a
de56e35
fa8c527
dfbb992
4ada96f
c6a993f
ad7d78d
ab00547
7f78fee
9f252bc
27b15d7
5278c86
27674b1
957f0cf
da3560d
b04aaf2
023cb5f
1a77d17
b88b1b9
4d533fb
4276264
3fe5e0f
ca9f9d4
7079d7a
ea417e4
f994ac9
0c5e4b0
3e0894d
1fc9aa2
15e61dd
ad98acd
5b8ed7c
227c22a
0fca8ad
5e37622
d0b4d5c
f0fcf5d
d7a7123
236ee2c
e9bf0b3
bf8f49a
71837c1
1430e7e
a0c2678
3ef9f06
59b63e0
a9e3405
21239ca
b27a905
b83a894
9cafe43
6c9baea
62a0984
2057e22
7b24e7c
5e33e95
f8f5698
5c59bf7
247f1c9
c2be16f
06d4b6d
59056ef
06495d8
0e951eb
9686a74
e33b29c
27e17c1
51cc432
82680fb
4c7d1a0
32fb5f0
c6b16ed
6f3dc0a
4c562fb
cd4fd9e
e836f49
4f240d4
12c0eb7
db66737
33cd776
bdd9db5
9295f0f
f6b464b
2d508c9
c0158eb
234b789
a68e88e
b3ee659
e89a3ef
2c0977b
d4ac484
5c05b6f
0476132
7823d87
9596bb9
733b490
1217bb1
647822c
6f83386
69e5144
a3d581c
d93e609
0af0984
5e119af
b9b78df
50443a4
f37c593
730dc66
69b23cc
c2dcad5
d8fda33
26df8c6
9c874d7
db4ed1b
7b01de3
aa2a5a9
4aa50c2
11437d4
e3623e5
5b412ae
522b586
263e147
a83b5d9
9444da9
54056cb
587b6d0
df7270e
53d5fbc
a783326
01b7d67
c832416
cfc52d9
eef3c11
97d530c
5557450
213ff9e
fc7138b
d655d52
a154cd0
2f727bc
17a4d22
402587d
71bdb5a
a54f92f
bc18c8b
8f9a6d3
da59406
67efa10
89f5186
befe3fe
4866d54
615bb0e
837e911
50c1d50
361f73a
061ac4b
092f714
c9b0804
641a434
d5b213f
8cf2d1c
ff1e3fe
544bd00
c0ebbed
7fd180a
eced6ae
09fb0e4
78b08b1
d7dd9c4
b303380
dffa4a9
8d86dab
2267b1d
4e4a437
a45ade2
c503b1d
2df4894
d43e70d
aadb364
5c2f8fc
b984455
67fede6
dc8b49a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,15 +3,25 @@ package io.sentry.android.replay | |
| import android.graphics.Bitmap | ||
| import android.graphics.Bitmap.CompressFormat.JPEG | ||
| import android.graphics.BitmapFactory | ||
| import io.sentry.DateUtils | ||
| import io.sentry.ReplayRecording | ||
| import io.sentry.SentryLevel.DEBUG | ||
| import io.sentry.SentryLevel.ERROR | ||
| import io.sentry.SentryLevel.WARNING | ||
| import io.sentry.SentryOptions | ||
| import io.sentry.SentryReplayEvent.ReplayType | ||
| import io.sentry.SentryReplayEvent.ReplayType.SESSION | ||
| import io.sentry.android.replay.video.MuxerConfig | ||
| import io.sentry.android.replay.video.SimpleVideoEncoder | ||
| import io.sentry.protocol.SentryId | ||
| import io.sentry.rrweb.RRWebEvent | ||
| import io.sentry.util.FileUtils | ||
| import java.io.Closeable | ||
| import java.io.File | ||
| import java.io.StringReader | ||
| import java.util.Date | ||
| import java.util.LinkedList | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
|
|
||
| /** | ||
| * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the | ||
|
|
@@ -50,19 +60,12 @@ public class ReplayCache internal constructor( | |
| ).also { it.start() } | ||
| }) | ||
|
|
||
| private val isClosed = AtomicBoolean(false) | ||
| private val encoderLock = Any() | ||
| private var encoder: SimpleVideoEncoder? = null | ||
|
|
||
| internal val replayCacheDir: File? by lazy { | ||
| if (options.cacheDirPath.isNullOrEmpty()) { | ||
| options.logger.log( | ||
| WARNING, | ||
| "SentryOptions.cacheDirPath is not set, session replay is no-op" | ||
| ) | ||
| null | ||
| } else { | ||
| File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } | ||
| } | ||
| makeReplayCacheDir(options, replayId) | ||
| } | ||
|
|
||
| // TODO: maybe account for multi-threaded access | ||
|
|
@@ -71,7 +74,7 @@ public class ReplayCache internal constructor( | |
| /** | ||
| * Stores the current frame screenshot to in-memory cache as well as disk with [frameTimestamp] | ||
| * as filename. Uses [Bitmap.CompressFormat.JPEG] format with quality 80. The frames are stored | ||
| * under [replayCacheDir]. | ||
| * under [makeReplayCacheDir]. | ||
| * | ||
| * This method is not thread-safe. | ||
| * | ||
|
|
@@ -111,14 +114,14 @@ public class ReplayCache internal constructor( | |
| /** | ||
| * Creates a video out of currently stored [frames] given the start time and duration using the | ||
| * on-device codecs [android.media.MediaCodec]. The generated video will be stored in | ||
| * [videoFile] location, which defaults to "[replayCacheDir]/[segmentId].mp4". | ||
| * [videoFile] location, which defaults to "[makeReplayCacheDir]/[segmentId].mp4". | ||
| * | ||
| * This method is not thread-safe. | ||
| * | ||
| * @param duration desired video duration in milliseconds | ||
| * @param from desired start of the video represented as unix timestamp in milliseconds | ||
| * @param segmentId current segment id, used for inferring the filename to store the | ||
| * result video under [replayCacheDir], e.g. "replay_<uuid>/0.mp4", where segmentId=0 | ||
| * result video under [makeReplayCacheDir], e.g. "replay_<uuid>/0.mp4", where segmentId=0 | ||
| * @param height desired height of the video in pixels (e.g. it can change from the initial one | ||
| * in case of window resize or orientation change) | ||
| * @param width desired width of the video in pixels (e.g. it can change from the initial one | ||
|
|
@@ -237,9 +240,169 @@ public class ReplayCache internal constructor( | |
| encoder?.release() | ||
| encoder = null | ||
| } | ||
| isClosed.set(true) | ||
| } | ||
|
|
||
| // TODO: it's awful, choose a better serialization format | ||
|
Member
Author
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. it's pretty bad right now, cause we load the entire thing into memory, then modify, then serialize it back, and this is called quite often (especially when there's touch events going on). If you have better ideas here, please throw them in. My one idea so far was to use something like
Member
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. Would appending only the new items to the file work better?
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. how big is the full map?
Member
Author
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. it's actually small 5-10 entries. I'll measure the perf currently, maybe it's not that bad
Unfortunately, no, because we don't know when the process is terminated (and close() is not called in that case), so we have to continuously persist the values to have the latest state on restart
Member
Author
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. okie, I've made a small improvement for now where we:
I'm still going to rework it and use RandomAccessFile most likely (and probably flush periodically, let's see), but a bit later, for now I've measured the time and it's taking 0.5ms on average to write the entire map (also done on a background thread), so I think it's ok for the first iteration |
||
| @Synchronized | ||
| fun persistSegmentValues(key: String, value: String?) { | ||
| if (isClosed.get()) { | ||
| return | ||
| } | ||
| val file = File(replayCacheDir, ONGOING_SEGMENT) | ||
| if (!file.exists()) { | ||
| file.createNewFile() | ||
| } | ||
| val map = LinkedHashMap<String, String>() | ||
| file.useLines { lines -> | ||
| lines.associateTo(map) { | ||
| val (k, v) = it.split("=", limit = 2) | ||
| k to v | ||
| } | ||
| if (value == null) { | ||
| map.remove(key) | ||
| } else { | ||
| map[key] = value | ||
| } | ||
| } | ||
| file.writeText(map.entries.joinToString("\n") { (k, v) -> "$k=$v" }) | ||
| } | ||
|
|
||
| companion object { | ||
| private const val ONGOING_SEGMENT = ".ongoing_segment" | ||
|
|
||
| internal const val SEGMENT_KEY_HEIGHT = "config.height" | ||
| internal const val SEGMENT_KEY_WIDTH = "config.width" | ||
| internal const val SEGMENT_KEY_FRAME_RATE = "config.frame-rate" | ||
| internal const val SEGMENT_KEY_BIT_RATE = "config.bit-rate" | ||
| internal const val SEGMENT_KEY_TIMESTAMP = "segment.timestamp" | ||
| internal const val SEGMENT_KEY_REPLAY_ID = "replay.id" | ||
| internal const val SEGMENT_KEY_REPLAY_TYPE = "replay.type" | ||
| internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" | ||
| internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" | ||
| internal const val SEGMENT_KEY_ID = "segment.id" | ||
|
|
||
| fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? { | ||
| return if (options.cacheDirPath.isNullOrEmpty()) { | ||
| options.logger.log( | ||
| WARNING, | ||
| "SentryOptions.cacheDirPath is not set, session replay is no-op" | ||
| ) | ||
| null | ||
| } else { | ||
| File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } | ||
| } | ||
| } | ||
|
|
||
| internal fun fromDisk(options: SentryOptions, replayId: SentryId): LastSegmentData? { | ||
| val replayCacheDir = makeReplayCacheDir(options, replayId) | ||
| val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT) | ||
| if (!lastSegmentFile.exists()) { | ||
| options.logger.log(DEBUG, "No ongoing segment found for replay: %s", replayId) | ||
| FileUtils.deleteRecursively(replayCacheDir) | ||
| return null | ||
| } | ||
|
|
||
| val lastSegment = LinkedHashMap<String, String>() | ||
| lastSegmentFile.useLines { lines -> | ||
| lines.associateTo(lastSegment) { | ||
| val (k, v) = it.split("=", limit = 2) | ||
| k to v | ||
| } | ||
| } | ||
|
|
||
| val height = lastSegment[SEGMENT_KEY_HEIGHT]?.toIntOrNull() | ||
| val width = lastSegment[SEGMENT_KEY_WIDTH]?.toIntOrNull() | ||
| val frameRate = lastSegment[SEGMENT_KEY_FRAME_RATE]?.toIntOrNull() | ||
| val bitRate = lastSegment[SEGMENT_KEY_BIT_RATE]?.toIntOrNull() | ||
| val segmentId = lastSegment[SEGMENT_KEY_ID]?.toIntOrNull() | ||
| val segmentTimestamp = try { | ||
| DateUtils.getDateTime(lastSegment[SEGMENT_KEY_TIMESTAMP].orEmpty()) | ||
| } catch (e: Throwable) { | ||
| null | ||
| } | ||
| val replayType = try { | ||
| ReplayType.valueOf(lastSegment[SEGMENT_KEY_REPLAY_TYPE].orEmpty()) | ||
| } catch (e: Throwable) { | ||
| null | ||
| } | ||
| if (height == null || width == null || frameRate == null || bitRate == null || | ||
| segmentId == null || segmentTimestamp == null || replayType == null | ||
| ) { | ||
| options.logger.log( | ||
| DEBUG, | ||
| "Incorrect segment values found for replay: %s, deleting the replay", | ||
| replayId | ||
| ) | ||
| FileUtils.deleteRecursively(replayCacheDir) | ||
| return null | ||
| } | ||
|
|
||
| val recorderConfig = ScreenshotRecorderConfig( | ||
| recordingHeight = height, | ||
| recordingWidth = width, | ||
| frameRate = frameRate, | ||
| bitRate = bitRate, | ||
| // these are not used for already captured frames, so we just hardcode them | ||
| scaleFactorX = 1.0f, | ||
| scaleFactorY = 1.0f | ||
| ) | ||
|
|
||
| val cache = ReplayCache(options, replayId, recorderConfig) | ||
| cache.replayCacheDir?.listFiles { dir, name -> | ||
| if (name.endsWith(".jpg")) { | ||
| val file = File(dir, name) | ||
| val timestamp = file.nameWithoutExtension.toLongOrNull() | ||
| if (timestamp != null) { | ||
| cache.addFrame(file, timestamp) | ||
| } | ||
| } | ||
| false | ||
| } | ||
|
|
||
| cache.frames.sortBy { it.timestamp } | ||
|
|
||
| val duration = if (replayType == SESSION) { | ||
| options.experimental.sessionReplay.sessionSegmentDuration | ||
| } else { | ||
| options.experimental.sessionReplay.errorReplayDuration | ||
| } | ||
|
|
||
| val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let { | ||
| val reader = StringReader(it) | ||
| val recording = options.serializer.deserialize(reader, ReplayRecording::class.java) | ||
| if (recording?.payload != null) { | ||
| LinkedList(recording.payload!!) | ||
| } else { | ||
| null | ||
| } | ||
| } ?: emptyList() | ||
|
|
||
| return LastSegmentData( | ||
| recorderConfig = recorderConfig, | ||
| cache = cache, | ||
| timestamp = segmentTimestamp, | ||
| id = segmentId, | ||
| duration = duration, | ||
| replayType = replayType, | ||
| screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], | ||
| events = events.sortedBy { it.timestamp } | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| internal data class LastSegmentData( | ||
| val recorderConfig: ScreenshotRecorderConfig, | ||
| val cache: ReplayCache, | ||
| val timestamp: Date, | ||
| val id: Int, | ||
| val duration: Long, | ||
| val replayType: ReplayType, | ||
| val screenAtStart: String?, | ||
| val events: List<RRWebEvent> | ||
| ) | ||
|
|
||
| internal data class ReplayFrame( | ||
| val screenshot: File, | ||
| val timestamp: Long | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.