Skip to content

Commit e039872

Browse files
authored
[SR] Capture Replays for ANRs and crashes (#3565)
1 parent 3a89243 commit e039872

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2488
-556
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609))
8+
- Capture remaining replay segment for ANRs on next app launch
9+
- Capture remaining replay segment for unhandled crashes on next app launch
10+
11+
### Fixes
12+
13+
- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609))
14+
- Fix stopping replay in `session` mode at 1 hour deadline
15+
- Never encode full frames for a video segment, only do partial updates. This further reduces size of the replay segment
16+
- Use propagation context when no active transaction for ANRs
17+
518
### Dependencies
619

720
- Bump Spring Boot to 3.3.2 ([#3541](https://github.com/getsentry/sentry-java/pull/3541))

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@
44
import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME;
55
import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME;
66
import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME;
7+
import static io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME;
78
import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME;
89
import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME;
910
import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME;
1011
import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME;
1112
import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME;
1213
import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME;
14+
import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME;
1315
import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME;
1416
import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME;
1517
import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME;
1618
import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME;
19+
import static io.sentry.protocol.Contexts.REPLAY_ID;
1720

1821
import android.annotation.SuppressLint;
1922
import android.app.ActivityManager;
@@ -51,6 +54,8 @@
5154
import io.sentry.protocol.SentryTransaction;
5255
import io.sentry.protocol.User;
5356
import io.sentry.util.HintUtils;
57+
import java.io.File;
58+
import java.security.SecureRandom;
5459
import java.util.ArrayList;
5560
import java.util.Arrays;
5661
import java.util.Collections;
@@ -78,13 +83,24 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor {
7883

7984
private final @NotNull SentryExceptionFactory sentryExceptionFactory;
8085

86+
private final @Nullable SecureRandom random;
87+
8188
public AnrV2EventProcessor(
8289
final @NotNull Context context,
8390
final @NotNull SentryAndroidOptions options,
8491
final @NotNull BuildInfoProvider buildInfoProvider) {
92+
this(context, options, buildInfoProvider, null);
93+
}
94+
95+
AnrV2EventProcessor(
96+
final @NotNull Context context,
97+
final @NotNull SentryAndroidOptions options,
98+
final @NotNull BuildInfoProvider buildInfoProvider,
99+
final @Nullable SecureRandom random) {
85100
this.context = context;
86101
this.options = options;
87102
this.buildInfoProvider = buildInfoProvider;
103+
this.random = random;
88104

89105
final SentryStackTraceFactory sentryStackTraceFactory =
90106
new SentryStackTraceFactory(this.options);
@@ -151,6 +167,72 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje
151167
setFingerprints(event, hint);
152168
setLevel(event);
153169
setTrace(event);
170+
setReplayId(event);
171+
}
172+
173+
private boolean sampleReplay(final @NotNull SentryEvent event) {
174+
final @Nullable String replayErrorSampleRate =
175+
PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class);
176+
177+
if (replayErrorSampleRate == null) {
178+
return false;
179+
}
180+
181+
try {
182+
// we have to sample here with the old sample rate, because it may change between app launches
183+
final @NotNull SecureRandom random = this.random != null ? this.random : new SecureRandom();
184+
final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate);
185+
if (replayErrorSampleRateDouble < random.nextDouble()) {
186+
options
187+
.getLogger()
188+
.log(
189+
SentryLevel.DEBUG,
190+
"Not capturing replay for ANR %s due to not being sampled.",
191+
event.getEventId());
192+
return false;
193+
}
194+
} catch (Throwable e) {
195+
options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e);
196+
return false;
197+
}
198+
199+
return true;
200+
}
201+
202+
private void setReplayId(final @NotNull SentryEvent event) {
203+
@Nullable
204+
String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class);
205+
final @NotNull File replayFolder =
206+
new File(options.getCacheDirPath(), "replay_" + persistedReplayId);
207+
if (!replayFolder.exists()) {
208+
if (!sampleReplay(event)) {
209+
return;
210+
}
211+
// if the replay folder does not exist (e.g. running in buffer mode), we need to find the
212+
// latest replay folder that was modified before the ANR event.
213+
persistedReplayId = null;
214+
long lastModified = Long.MIN_VALUE;
215+
final File[] dirs = new File(options.getCacheDirPath()).listFiles();
216+
if (dirs != null) {
217+
for (File dir : dirs) {
218+
if (dir.isDirectory() && dir.getName().startsWith("replay_")) {
219+
if (dir.lastModified() > lastModified
220+
&& dir.lastModified() <= event.getTimestamp().getTime()) {
221+
lastModified = dir.lastModified();
222+
persistedReplayId = dir.getName().substring("replay_".length());
223+
}
224+
}
225+
}
226+
}
227+
}
228+
229+
if (persistedReplayId == null) {
230+
return;
231+
}
232+
233+
// store the relevant replayId so ReplayIntegration can pick it up and finalize that replay
234+
PersistingScopeObserver.store(options, persistedReplayId, REPLAY_FILENAME);
235+
event.getContexts().put(REPLAY_ID, persistedReplayId);
154236
}
155237

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

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

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,20 @@ import io.sentry.SentryEvent
1515
import io.sentry.SentryLevel
1616
import io.sentry.SentryLevel.DEBUG
1717
import io.sentry.SpanContext
18-
import io.sentry.cache.PersistingOptionsObserver
1918
import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME
2019
import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME
2120
import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE
2221
import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME
2322
import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME
23+
import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME
2424
import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME
25+
import io.sentry.cache.PersistingScopeObserver
2526
import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME
2627
import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME
2728
import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME
2829
import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME
2930
import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME
31+
import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME
3032
import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME
3133
import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE
3234
import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME
@@ -44,6 +46,7 @@ import io.sentry.protocol.OperatingSystem
4446
import io.sentry.protocol.Request
4547
import io.sentry.protocol.Response
4648
import io.sentry.protocol.SdkVersion
49+
import io.sentry.protocol.SentryId
4750
import io.sentry.protocol.SentryStackFrame
4851
import io.sentry.protocol.SentryStackTrace
4952
import io.sentry.protocol.SentryThread
@@ -75,7 +78,9 @@ class AnrV2EventProcessorTest {
7578
val tmpDir = TemporaryFolder()
7679

7780
class Fixture {
78-
81+
companion object {
82+
const val REPLAY_ID = "64cf554cc8d74c6eafa3e08b7c984f6d"
83+
}
7984
val buildInfo = mock<BuildInfoProvider>()
8085
lateinit var context: Context
8186
val options = SentryAndroidOptions().apply {
@@ -87,7 +92,8 @@ class AnrV2EventProcessorTest {
8792
dir: TemporaryFolder,
8893
currentSdk: Int = Build.VERSION_CODES.LOLLIPOP,
8994
populateScopeCache: Boolean = false,
90-
populateOptionsCache: Boolean = false
95+
populateOptionsCache: Boolean = false,
96+
replayErrorSampleRate: Double? = null
9197
): AnrV2EventProcessor {
9298
options.cacheDirPath = dir.newFolder().absolutePath
9399
options.environment = "release"
@@ -118,6 +124,7 @@ class AnrV2EventProcessorTest {
118124
REQUEST_FILENAME,
119125
Request().apply { url = "google.com"; method = "GET" }
120126
)
127+
persistScope(REPLAY_FILENAME, SentryId(REPLAY_ID))
121128
}
122129

123130
if (populateOptionsCache) {
@@ -126,7 +133,10 @@ class AnrV2EventProcessorTest {
126133
persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0"))
127134
persistOptions(DIST_FILENAME, "232")
128135
persistOptions(ENVIRONMENT_FILENAME, "debug")
129-
persistOptions(PersistingOptionsObserver.TAGS_FILENAME, mapOf("option" to "tag"))
136+
persistOptions(TAGS_FILENAME, mapOf("option" to "tag"))
137+
replayErrorSampleRate?.let {
138+
persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString())
139+
}
130140
}
131141

132142
return AnrV2EventProcessor(context, options, buildInfo)
@@ -544,6 +554,65 @@ class AnrV2EventProcessorTest {
544554
assertEquals(listOf("{{ default }}", "foreground-anr"), processedForeground.fingerprints)
545555
}
546556

557+
@Test
558+
fun `sets replayId when replay folder exists`() {
559+
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
560+
val processor = fixture.getSut(tmpDir, populateScopeCache = true)
561+
val replayFolder = File(fixture.options.cacheDirPath, "replay_${Fixture.REPLAY_ID}").also { it.mkdirs() }
562+
563+
val processed = processor.process(SentryEvent(), hint)!!
564+
565+
assertEquals(Fixture.REPLAY_ID, processed.contexts[Contexts.REPLAY_ID].toString())
566+
}
567+
568+
@Test
569+
fun `does not set replayId when replay folder does not exist and no sample rate persisted`() {
570+
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
571+
val processor = fixture.getSut(tmpDir, populateScopeCache = true)
572+
val replayId1 = SentryId()
573+
val replayId2 = SentryId()
574+
575+
val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() }
576+
val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() }
577+
578+
val processed = processor.process(SentryEvent(), hint)!!
579+
580+
assertNull(processed.contexts[Contexts.REPLAY_ID])
581+
}
582+
583+
@Test
584+
fun `does not set replayId when replay folder does not exist and not sampled`() {
585+
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
586+
val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 0.0)
587+
val replayId1 = SentryId()
588+
val replayId2 = SentryId()
589+
590+
val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() }
591+
val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() }
592+
593+
val processed = processor.process(SentryEvent(), hint)!!
594+
595+
assertNull(processed.contexts[Contexts.REPLAY_ID])
596+
}
597+
598+
@Test
599+
fun `set replayId of the last modified folder`() {
600+
val hint = HintUtils.createWithTypeCheckHint(BackfillableHint())
601+
val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 1.0)
602+
val replayId1 = SentryId()
603+
val replayId2 = SentryId()
604+
605+
val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() }
606+
val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() }
607+
replayFolder1.setLastModified(1000)
608+
replayFolder2.setLastModified(500)
609+
610+
val processed = processor.process(SentryEvent(), hint)!!
611+
612+
assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString())
613+
assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java))
614+
}
615+
547616
private fun processEvent(
548617
hint: Hint,
549618
populateScopeCache: Boolean = false,

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,25 @@ 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
4854
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
55+
public fun captureReplay (Ljava/lang/Boolean;)V
4956
public fun close ()V
5057
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
5158
public final fun getReplayCacheDir ()Ljava/io/File;
@@ -59,8 +66,6 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/
5966
public fun pause ()V
6067
public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V
6168
public fun resume ()V
62-
public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V
63-
public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V
6469
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
6570
public fun start ()V
6671
public fun stop ()V

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,23 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter {
131131

132132
private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent {
133133
val breadcrumb = this
134+
val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP]
135+
val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP]
134136
return RRWebSpanEvent().apply {
135137
timestamp = breadcrumb.timestamp.time
136138
op = "resource.http"
137139
description = breadcrumb.data["url"] as String
138-
startTimestamp =
139-
(breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] as Long) / 1000.0
140-
endTimestamp =
141-
(breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] as Long) / 1000.0
140+
// can be double if it was serialized to disk
141+
startTimestamp = if (httpStartTimestamp is Double) {
142+
httpStartTimestamp / 1000.0
143+
} else {
144+
(httpStartTimestamp as Long) / 1000.0
145+
}
146+
endTimestamp = if (httpEndTimestamp is Double) {
147+
httpEndTimestamp / 1000.0
148+
} else {
149+
(httpEndTimestamp as Long) / 1000.0
150+
}
142151

143152
val breadcrumbData = mutableMapOf<String, Any?>()
144153
for ((key, value) in breadcrumb.data) {

0 commit comments

Comments
 (0)