Skip to content

Commit eb8377e

Browse files
authored
Merge 09fb0e4 into fc84053
2 parents fc84053 + 09fb0e4 commit eb8377e

26 files changed

Lines changed: 1320 additions & 170 deletions

File tree

sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME;
1111
import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME;
1212
import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME;
13+
import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME;
1314
import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME;
1415
import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME;
1516
import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME;
1617
import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME;
18+
import static io.sentry.protocol.Contexts.REPLAY_ID;
1719

1820
import android.annotation.SuppressLint;
1921
import android.app.ActivityManager;
@@ -151,6 +153,18 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje
151153
setFingerprints(event, hint);
152154
setLevel(event);
153155
setTrace(event);
156+
setReplayId(event);
157+
}
158+
159+
private void setReplayId(final @NotNull SentryEvent event) {
160+
final String persistedReplayId =
161+
PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class);
162+
163+
if (persistedReplayId == null) {
164+
return;
165+
}
166+
167+
event.getContexts().put(REPLAY_ID, persistedReplayId);
154168
}
155169

156170
private void setTrace(final @NotNull SentryEvent event) {

sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME
2727
import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME
2828
import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME
2929
import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME
30+
import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME
3031
import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME
3132
import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE
3233
import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME
@@ -44,6 +45,7 @@ import io.sentry.protocol.OperatingSystem
4445
import io.sentry.protocol.Request
4546
import io.sentry.protocol.Response
4647
import io.sentry.protocol.SdkVersion
48+
import io.sentry.protocol.SentryId
4749
import io.sentry.protocol.SentryStackFrame
4850
import io.sentry.protocol.SentryStackTrace
4951
import io.sentry.protocol.SentryThread
@@ -118,6 +120,7 @@ class AnrV2EventProcessorTest {
118120
REQUEST_FILENAME,
119121
Request().apply { url = "google.com"; method = "GET" }
120122
)
123+
persistScope(REPLAY_FILENAME, SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"))
121124
}
122125

123126
if (populateOptionsCache) {
@@ -292,6 +295,8 @@ class AnrV2EventProcessorTest {
292295
// contexts
293296
assertEquals(1024, processed.contexts.response!!.bodySize)
294297
assertEquals("Google Chrome", processed.contexts.browser!!.name)
298+
// replay_id
299+
assertEquals("64cf554cc8d74c6eafa3e08b7c984f6d", processed.contexts[Contexts.REPLAY_ID].toString())
295300
}
296301

297302
@Test

sentry-android-replay/api/sentry-android-replay.api

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,20 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos
3434
}
3535

3636
public final class io/sentry/android/replay/ReplayCache : java/io/Closeable {
37+
public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion;
3738
public fun <init> (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V
3839
public final fun addFrame (Ljava/io/File;J)V
3940
public fun close ()V
4041
public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo;
4142
public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo;
43+
public final fun persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V
4244
public final fun rotate (J)V
4345
}
4446

47+
public final class io/sentry/android/replay/ReplayCache$Companion {
48+
public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File;
49+
}
50+
4551
public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/TouchRecorderCallback, java/io/Closeable {
4652
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V
4753
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt

Lines changed: 172 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,25 @@ package io.sentry.android.replay
33
import android.graphics.Bitmap
44
import android.graphics.Bitmap.CompressFormat.JPEG
55
import android.graphics.BitmapFactory
6+
import io.sentry.DateUtils
7+
import io.sentry.ReplayRecording
68
import io.sentry.SentryLevel.DEBUG
79
import io.sentry.SentryLevel.ERROR
810
import io.sentry.SentryLevel.WARNING
911
import io.sentry.SentryOptions
12+
import io.sentry.SentryReplayEvent.ReplayType
13+
import io.sentry.SentryReplayEvent.ReplayType.SESSION
1014
import io.sentry.android.replay.video.MuxerConfig
1115
import io.sentry.android.replay.video.SimpleVideoEncoder
1216
import io.sentry.protocol.SentryId
17+
import io.sentry.rrweb.RRWebEvent
18+
import io.sentry.util.FileUtils
1319
import java.io.Closeable
1420
import java.io.File
21+
import java.io.StringReader
22+
import java.util.Date
23+
import java.util.LinkedList
24+
import java.util.concurrent.atomic.AtomicBoolean
1525

1626
/**
1727
* 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(
5060
).also { it.start() }
5161
})
5262

63+
private val isClosed = AtomicBoolean(false)
5364
private val encoderLock = Any()
5465
private var encoder: SimpleVideoEncoder? = null
5566

5667
internal val replayCacheDir: File? by lazy {
57-
if (options.cacheDirPath.isNullOrEmpty()) {
58-
options.logger.log(
59-
WARNING,
60-
"SentryOptions.cacheDirPath is not set, session replay is no-op"
61-
)
62-
null
63-
} else {
64-
File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() }
65-
}
68+
makeReplayCacheDir(options, replayId)
6669
}
6770

6871
// TODO: maybe account for multi-threaded access
@@ -237,9 +240,169 @@ public class ReplayCache internal constructor(
237240
encoder?.release()
238241
encoder = null
239242
}
243+
isClosed.set(true)
244+
}
245+
246+
// TODO: it's awful, choose a better serialization format
247+
@Synchronized
248+
fun persistSegmentValues(key: String, value: String?) {
249+
if (isClosed.get()) {
250+
return
251+
}
252+
val file = File(replayCacheDir, ONGOING_SEGMENT)
253+
if (!file.exists()) {
254+
file.createNewFile()
255+
}
256+
val map = LinkedHashMap<String, String>()
257+
file.useLines { lines ->
258+
lines.associateTo(map) {
259+
val (k, v) = it.split("=", limit = 2)
260+
k to v
261+
}
262+
if (value == null) {
263+
map.remove(key)
264+
} else {
265+
map[key] = value
266+
}
267+
}
268+
file.writeText(map.entries.joinToString("\n") { (k, v) -> "$k=$v" })
269+
}
270+
271+
companion object {
272+
internal const val ONGOING_SEGMENT = ".ongoing_segment"
273+
274+
internal const val SEGMENT_KEY_HEIGHT = "config.height"
275+
internal const val SEGMENT_KEY_WIDTH = "config.width"
276+
internal const val SEGMENT_KEY_FRAME_RATE = "config.frame-rate"
277+
internal const val SEGMENT_KEY_BIT_RATE = "config.bit-rate"
278+
internal const val SEGMENT_KEY_TIMESTAMP = "segment.timestamp"
279+
internal const val SEGMENT_KEY_REPLAY_ID = "replay.id"
280+
internal const val SEGMENT_KEY_REPLAY_TYPE = "replay.type"
281+
internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start"
282+
internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording"
283+
internal const val SEGMENT_KEY_ID = "segment.id"
284+
285+
fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? {
286+
return if (options.cacheDirPath.isNullOrEmpty()) {
287+
options.logger.log(
288+
WARNING,
289+
"SentryOptions.cacheDirPath is not set, session replay is no-op"
290+
)
291+
null
292+
} else {
293+
File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() }
294+
}
295+
}
296+
297+
internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null): LastSegmentData? {
298+
val replayCacheDir = makeReplayCacheDir(options, replayId)
299+
val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT)
300+
if (!lastSegmentFile.exists()) {
301+
options.logger.log(DEBUG, "No ongoing segment found for replay: %s", replayId)
302+
FileUtils.deleteRecursively(replayCacheDir)
303+
return null
304+
}
305+
306+
val lastSegment = LinkedHashMap<String, String>()
307+
lastSegmentFile.useLines { lines ->
308+
lines.associateTo(lastSegment) {
309+
val (k, v) = it.split("=", limit = 2)
310+
k to v
311+
}
312+
}
313+
314+
val height = lastSegment[SEGMENT_KEY_HEIGHT]?.toIntOrNull()
315+
val width = lastSegment[SEGMENT_KEY_WIDTH]?.toIntOrNull()
316+
val frameRate = lastSegment[SEGMENT_KEY_FRAME_RATE]?.toIntOrNull()
317+
val bitRate = lastSegment[SEGMENT_KEY_BIT_RATE]?.toIntOrNull()
318+
val segmentId = lastSegment[SEGMENT_KEY_ID]?.toIntOrNull()
319+
val segmentTimestamp = try {
320+
DateUtils.getDateTime(lastSegment[SEGMENT_KEY_TIMESTAMP].orEmpty())
321+
} catch (e: Throwable) {
322+
null
323+
}
324+
val replayType = try {
325+
ReplayType.valueOf(lastSegment[SEGMENT_KEY_REPLAY_TYPE].orEmpty())
326+
} catch (e: Throwable) {
327+
null
328+
}
329+
if (height == null || width == null || frameRate == null || bitRate == null ||
330+
(segmentId == null || segmentId == -1) || segmentTimestamp == null || replayType == null
331+
) {
332+
options.logger.log(
333+
DEBUG,
334+
"Incorrect segment values found for replay: %s, deleting the replay",
335+
replayId
336+
)
337+
FileUtils.deleteRecursively(replayCacheDir)
338+
return null
339+
}
340+
341+
val recorderConfig = ScreenshotRecorderConfig(
342+
recordingHeight = height,
343+
recordingWidth = width,
344+
frameRate = frameRate,
345+
bitRate = bitRate,
346+
// these are not used for already captured frames, so we just hardcode them
347+
scaleFactorX = 1.0f,
348+
scaleFactorY = 1.0f
349+
)
350+
351+
val cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig)
352+
cache.replayCacheDir?.listFiles { dir, name ->
353+
if (name.endsWith(".jpg")) {
354+
val file = File(dir, name)
355+
val timestamp = file.nameWithoutExtension.toLongOrNull()
356+
if (timestamp != null) {
357+
cache.addFrame(file, timestamp)
358+
}
359+
}
360+
false
361+
}
362+
363+
cache.frames.sortBy { it.timestamp }
364+
365+
val duration = if (replayType == SESSION) {
366+
options.experimental.sessionReplay.sessionSegmentDuration
367+
} else {
368+
options.experimental.sessionReplay.errorReplayDuration
369+
}
370+
371+
val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let {
372+
val reader = StringReader(it)
373+
val recording = options.serializer.deserialize(reader, ReplayRecording::class.java)
374+
if (recording?.payload != null) {
375+
LinkedList(recording.payload!!)
376+
} else {
377+
null
378+
}
379+
} ?: emptyList()
380+
381+
return LastSegmentData(
382+
recorderConfig = recorderConfig,
383+
cache = cache,
384+
timestamp = segmentTimestamp,
385+
id = segmentId,
386+
duration = duration,
387+
replayType = replayType,
388+
screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START],
389+
events = events.sortedBy { it.timestamp }
390+
)
391+
}
240392
}
241393
}
242394

395+
internal data class LastSegmentData(
396+
val recorderConfig: ScreenshotRecorderConfig,
397+
val cache: ReplayCache,
398+
val timestamp: Date,
399+
val id: Int,
400+
val duration: Long,
401+
val replayType: ReplayType,
402+
val screenAtStart: String?,
403+
val events: List<RRWebEvent>
404+
)
405+
243406
internal data class ReplayFrame(
244407
val screenshot: File,
245408
val timestamp: Long

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,12 @@ public class ReplayIntegration(
138138

139139
recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay)
140140
captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) {
141-
SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider)
141+
SessionCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider)
142142
} else {
143-
BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider)
143+
BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider)
144144
}
145145

146-
captureStrategy?.start()
146+
captureStrategy?.start(recorderConfig)
147147
recorder?.start(recorderConfig)
148148
}
149149

@@ -174,16 +174,18 @@ public class ReplayIntegration(
174174
return
175175
}
176176

177-
if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId?.get())) {
177+
if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId)) {
178178
options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", eventId)
179179
return
180180
}
181181

182-
captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() })
182+
captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = {
183+
captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1
184+
})
183185
captureStrategy = captureStrategy?.convert()
184186
}
185187

186-
override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID
188+
override fun getReplayId(): SentryId = captureStrategy?.currentReplayId ?: SentryId.EMPTY_ID
187189

188190
override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) {
189191
replayBreadcrumbConverter = converter

0 commit comments

Comments
 (0)