Skip to content

Commit

Permalink
Merge 09fb0e4 into fc84053
Browse files Browse the repository at this point in the history
  • Loading branch information
romtsn committed Jul 23, 2024
2 parents fc84053 + 09fb0e4 commit eb8377e
Show file tree
Hide file tree
Showing 26 changed files with 1,320 additions and 170 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME;
import static io.sentry.protocol.Contexts.REPLAY_ID;

import android.annotation.SuppressLint;
import android.app.ActivityManager;
Expand Down Expand Up @@ -151,6 +153,18 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje
setFingerprints(event, hint);
setLevel(event);
setTrace(event);
setReplayId(event);
}

private void setReplayId(final @NotNull SentryEvent event) {
final String persistedReplayId =
PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class);

if (persistedReplayId == null) {
return;
}

event.getContexts().put(REPLAY_ID, persistedReplayId);
}

private void setTrace(final @NotNull SentryEvent event) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME
import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME
import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME
import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME
import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME
import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME
import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE
import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME
Expand All @@ -44,6 +45,7 @@ import io.sentry.protocol.OperatingSystem
import io.sentry.protocol.Request
import io.sentry.protocol.Response
import io.sentry.protocol.SdkVersion
import io.sentry.protocol.SentryId
import io.sentry.protocol.SentryStackFrame
import io.sentry.protocol.SentryStackTrace
import io.sentry.protocol.SentryThread
Expand Down Expand Up @@ -118,6 +120,7 @@ class AnrV2EventProcessorTest {
REQUEST_FILENAME,
Request().apply { url = "google.com"; method = "GET" }
)
persistScope(REPLAY_FILENAME, SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"))
}

if (populateOptionsCache) {
Expand Down Expand Up @@ -292,6 +295,8 @@ class AnrV2EventProcessorTest {
// contexts
assertEquals(1024, processed.contexts.response!!.bodySize)
assertEquals("Google Chrome", processed.contexts.browser!!.name)
// replay_id
assertEquals("64cf554cc8d74c6eafa3e08b7c984f6d", processed.contexts[Contexts.REPLAY_ID].toString())
}

@Test
Expand Down
6 changes: 6 additions & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,20 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos
}

public final class io/sentry/android/replay/ReplayCache : java/io/Closeable {
public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion;
public fun <init> (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V
public final fun addFrame (Ljava/io/File;J)V
public fun close ()V
public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo;
public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo;
public final fun persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V
public final fun rotate (J)V
}

public final class io/sentry/android/replay/ReplayCache$Companion {
public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File;
}

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 {
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
@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 {
internal 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, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null): 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 || segmentId == -1) || 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 = replayCacheProvider?.invoke(replayId, recorderConfig) ?: 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,12 @@ public class ReplayIntegration(

recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay)
captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) {
SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider)
SessionCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider)
} else {
BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider)
BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider)
}

captureStrategy?.start()
captureStrategy?.start(recorderConfig)
recorder?.start(recorderConfig)
}

Expand Down Expand Up @@ -174,16 +174,18 @@ public class ReplayIntegration(
return
}

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

captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() })
captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = {
captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1
})
captureStrategy = captureStrategy?.convert()
}

override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID
override fun getReplayId(): SentryId = captureStrategy?.currentReplayId ?: SentryId.EMPTY_ID

override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) {
replayBreadcrumbConverter = converter
Expand Down
Loading

0 comments on commit eb8377e

Please sign in to comment.