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] Support replays for crashes in buffer and session modes #3609

Merged
merged 10 commits into from
Jul 30, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME;
import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME;
import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME;
import static io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME;
import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME;
import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME;
Expand Down Expand Up @@ -53,6 +54,8 @@
import io.sentry.protocol.SentryTransaction;
import io.sentry.protocol.User;
import io.sentry.util.HintUtils;
import java.io.File;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
Expand Down Expand Up @@ -80,13 +83,24 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor {

private final @NotNull SentryExceptionFactory sentryExceptionFactory;

private final @Nullable SecureRandom random;

public AnrV2EventProcessor(
final @NotNull Context context,
final @NotNull SentryAndroidOptions options,
final @NotNull BuildInfoProvider buildInfoProvider) {
this(context, options, buildInfoProvider, null);
}

AnrV2EventProcessor(
final @NotNull Context context,
final @NotNull SentryAndroidOptions options,
final @NotNull BuildInfoProvider buildInfoProvider,
final @Nullable SecureRandom random) {
this.context = context;
this.options = options;
this.buildInfoProvider = buildInfoProvider;
this.random = random;

final SentryStackTraceFactory sentryStackTraceFactory =
new SentryStackTraceFactory(this.options);
Expand Down Expand Up @@ -156,14 +170,68 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje
setReplayId(event);
}

private boolean sampleReplay(final @NotNull SentryEvent event) {
final @Nullable String replayErrorSampleRate =
PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class);

if (replayErrorSampleRate == null) {
return false;
}

try {
// we have to sample here with the old sample rate, because it may change between app launches
final @NotNull SecureRandom random = this.random != null ? this.random : new SecureRandom();
final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate);
if (replayErrorSampleRateDouble < random.nextDouble()) {
options
.getLogger()
.log(
SentryLevel.DEBUG,
"Not capturing replay for ANR %s due to not being sampled.",
event.getEventId());
return false;
}
} catch (Throwable e) {
options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e);
return false;
}

return true;
}

private void setReplayId(final @NotNull SentryEvent event) {
final @Nullable String persistedReplayId =
PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class);
@Nullable
String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class);
final @NotNull File replayFolder =
new File(options.getCacheDirPath(), "replay_" + persistedReplayId);
if (!replayFolder.exists()) {
if (!sampleReplay(event)) {
return;
}
// if the replay folder does not exist (e.g. running in buffer mode), we need to find the
// latest replay folder that was modified before the ANR event.
persistedReplayId = null;
long lastModified = Long.MIN_VALUE;
final File[] dirs = new File(options.getCacheDirPath()).listFiles();
if (dirs != null) {
for (File dir : dirs) {
if (dir.isDirectory() && dir.getName().startsWith("replay_")) {
if (dir.lastModified() > lastModified
&& dir.lastModified() <= event.getTimestamp().getTime()) {
lastModified = dir.lastModified();
persistedReplayId = dir.getName().substring("replay_".length());
}
}
}
}
}

if (persistedReplayId == null) {
return;
}

// store the relevant replayId so ReplayIntegration can pick it up and finalize that replay
PersistingScopeObserver.store(options, persistedReplayId, REPLAY_FILENAME);
event.getContexts().put(REPLAY_ID, persistedReplayId);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import io.sentry.SentryEvent
import io.sentry.SentryLevel
import io.sentry.SentryLevel.DEBUG
import io.sentry.SpanContext
import io.sentry.cache.PersistingOptionsObserver
import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME
import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME
import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE
import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME
import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME
import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME
import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME
import io.sentry.cache.PersistingScopeObserver
import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME
import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME
import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME
Expand Down Expand Up @@ -77,7 +78,9 @@ class AnrV2EventProcessorTest {
val tmpDir = TemporaryFolder()

class Fixture {

companion object {
const val REPLAY_ID = "64cf554cc8d74c6eafa3e08b7c984f6d"
}
val buildInfo = mock<BuildInfoProvider>()
lateinit var context: Context
val options = SentryAndroidOptions().apply {
Expand All @@ -89,7 +92,8 @@ class AnrV2EventProcessorTest {
dir: TemporaryFolder,
currentSdk: Int = Build.VERSION_CODES.LOLLIPOP,
populateScopeCache: Boolean = false,
populateOptionsCache: Boolean = false
populateOptionsCache: Boolean = false,
replayErrorSampleRate: Double? = null
): AnrV2EventProcessor {
options.cacheDirPath = dir.newFolder().absolutePath
options.environment = "release"
Expand Down Expand Up @@ -120,7 +124,7 @@ class AnrV2EventProcessorTest {
REQUEST_FILENAME,
Request().apply { url = "google.com"; method = "GET" }
)
persistScope(REPLAY_FILENAME, SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"))
persistScope(REPLAY_FILENAME, SentryId(REPLAY_ID))
}

if (populateOptionsCache) {
Expand All @@ -129,7 +133,10 @@ class AnrV2EventProcessorTest {
persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0"))
persistOptions(DIST_FILENAME, "232")
persistOptions(ENVIRONMENT_FILENAME, "debug")
persistOptions(PersistingOptionsObserver.TAGS_FILENAME, mapOf("option" to "tag"))
persistOptions(TAGS_FILENAME, mapOf("option" to "tag"))
replayErrorSampleRate?.let {
persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString())
}
}

return AnrV2EventProcessor(context, options, buildInfo)
Expand Down Expand Up @@ -295,8 +302,6 @@ 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 Expand Up @@ -549,6 +554,65 @@ class AnrV2EventProcessorTest {
assertEquals(listOf("{{ default }}", "foreground-anr"), processedForeground.fingerprints)
}

@Test
fun `sets replayId when replay folder exists`() {
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
val processor = fixture.getSut(tmpDir, populateScopeCache = true)
val replayFolder = File(fixture.options.cacheDirPath, "replay_${Fixture.REPLAY_ID}").also { it.mkdirs() }

val processed = processor.process(SentryEvent(), hint)!!

assertEquals(Fixture.REPLAY_ID, processed.contexts[Contexts.REPLAY_ID].toString())
}

@Test
fun `does not set replayId when replay folder does not exist and no sample rate persisted`() {
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
val processor = fixture.getSut(tmpDir, populateScopeCache = true)
val replayId1 = SentryId()
val replayId2 = SentryId()

val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() }
val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() }

val processed = processor.process(SentryEvent(), hint)!!

assertNull(processed.contexts[Contexts.REPLAY_ID])
}

@Test
fun `does not set replayId when replay folder does not exist and not sampled`() {
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 0.0)
val replayId1 = SentryId()
val replayId2 = SentryId()

val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() }
val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() }

val processed = processor.process(SentryEvent(), hint)!!

assertNull(processed.contexts[Contexts.REPLAY_ID])
}

@Test
fun `set replayId of the last modified folder`() {
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 1.0)
val replayId1 = SentryId()
val replayId2 = SentryId()

val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() }
val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() }
replayFolder1.setLastModified(1000)
replayFolder2.setLastModified(500)

val processed = processor.process(SentryEvent(), hint)!!

assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString())
assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java))
}

private fun processEvent(
hint: Hint,
populateScopeCache: Boolean = false,
Expand Down
3 changes: 1 addition & 2 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/
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
public synthetic fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun captureReplay (Ljava/lang/Boolean;)V
public fun close ()V
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
public final fun getReplayCacheDir ()Ljava/io/File;
Expand All @@ -65,8 +66,6 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/
public fun pause ()V
public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V
public fun resume ()V
public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V
public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
public fun start ()V
public fun stop ()V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,23 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {

private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent {
val breadcrumb = this
val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP]
val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP]
return RRWebSpanEvent().apply {
timestamp = breadcrumb.timestamp.time
op = "resource.http"
description = breadcrumb.data["url"] as String
startTimestamp =
(breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] as Long) / 1000.0
endTimestamp =
(breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] as Long) / 1000.0
// can be double if it was serialized to disk
startTimestamp = if (httpStartTimestamp is Double) {
httpStartTimestamp / 1000.0
} else {
(httpStartTimestamp as Long) / 1000.0
}
endTimestamp = if (httpEndTimestamp is Double) {
httpEndTimestamp / 1000.0
} else {
(httpEndTimestamp as Long) / 1000.0
}

val breadcrumbData = mutableMapOf<String, Any?>()
for ((key, value) in breadcrumb.data) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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
Expand All @@ -21,7 +22,6 @@ import java.io.StringReader
import java.util.Date
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.ceil

/**
* A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the
Expand Down Expand Up @@ -95,7 +95,7 @@ public class ReplayCache internal constructor(
* @param frameTimestamp the timestamp when the frame screenshot was taken
*/
internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) {
if (replayCacheDir == null) {
if (replayCacheDir == null || bitmap.isRecycled) {
return
}

Expand Down Expand Up @@ -152,6 +152,9 @@ public class ReplayCache internal constructor(
width: Int,
videoFile: File = File(replayCacheDir, "$segmentId.mp4")
): GeneratedVideo? {
if (videoFile.exists() && videoFile.length() > 0) {
videoFile.delete()
}
if (frames.isEmpty()) {
options.logger.log(
DEBUG,
Expand Down Expand Up @@ -381,17 +384,17 @@ public class ReplayCache internal constructor(
}

cache.frames.sortBy { it.timestamp }

fun roundToNearestFrame(duration: Long, frameDuration: Int): Long {
val frames = duration.toDouble() / frameDuration.toDouble()
return ceil(frames).toLong() * frameDuration
// TODO: this should be removed when we start sending buffered segments on next launch
val normalizedSegmentId = if (replayType == SESSION) segmentId else 0
val normalizedTimestamp = if (replayType == SESSION) {
segmentTimestamp
} else {
// in buffer mode we have to set the timestamp of the first frame as the actual start
DateUtils.getDateTime(cache.frames.first().timestamp)
}

// we need to round to the nearest frame to include breadcrumbs/events happened after the frame was captured
val duration = roundToNearestFrame(
duration = (cache.frames.last().timestamp - segmentTimestamp.time),
frameDuration = 1000 / frameRate
) - 1 // we need to subtract 1ms to avoid capturing the next frame which doesn't exist
// add one frame to include breadcrumbs/events happened after the frame was captured
val duration = cache.frames.last().timestamp - normalizedTimestamp.time + (1000 / frameRate)

val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let {
val reader = StringReader(it)
Expand All @@ -406,8 +409,8 @@ public class ReplayCache internal constructor(
return LastSegmentData(
recorderConfig = recorderConfig,
cache = cache,
timestamp = segmentTimestamp,
id = segmentId,
timestamp = normalizedTimestamp,
id = normalizedSegmentId,
duration = duration,
replayType = replayType,
screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START],
Expand Down
Loading
Loading