Skip to content

Commit f7f9f2a

Browse files
committed
fix(android): Prevent Relay from rejecting single-sample ANR profiles (JAVA-550)
Relay currently rejects ANR profiles with a single sample, even though they're still useful to customers. (Relay's policy was developed with continuous profiles in mind, before ANR profiles were a thing.) Commit updates our StackTraceConverter to add a second, synthetic sample whenever an ANR profile would otherwise only contain one. The synthetic sample duplicates the original, save for a minor offset to its timestamp (to avoid misleading the user about the ANRs cause or duration). # Conflicts: # CHANGELOG.md
1 parent 337a9f1 commit f7f9f2a

4 files changed

Lines changed: 100 additions & 1 deletion

File tree

CHANGELOG.md

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

33
## Unreleased
44

5+
### Fixes
6+
7+
- Preserve single-sample ANR profile chunks so profiles remain available on ANR events ([#5872](https://github.com/getsentry/sentry-java/pull/5872))
8+
59
### Performance
610

711
- Remove an unused lock from `SentryPerformanceProvider`, which was allocated on every cold start in `ContentProvider.onCreate` without ever being acquired ([#5871](https://github.com/getsentry/sentry-java/pull/5871))

sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ public final class StackTraceConverter {
3333
private static final String MAIN_THREAD_ID = "0";
3434
private static final String MAIN_THREAD_NAME = "main";
3535

36+
/**
37+
* Timestamp offset used with synthetic ANR profile samples. (Currently 33 ms.)
38+
*
39+
* <p>Places the synthetic sample halfway to the next ANR polling tick.
40+
*/
41+
private static final double SYNTHETIC_SAMPLE_OFFSET_SECONDS =
42+
(AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0d) / 1000.0d;
43+
3644
/**
3745
* Converts a list of {@link AnrStackTrace} objects to a {@link SentryProfile}.
3846
*
@@ -80,6 +88,15 @@ public static SentryProfile convert(final @NotNull AnrProfile anrProfile) {
8088
profile.getSamples().add(sample);
8189
}
8290

91+
// Relay will reject ANR profiles with only one sample, even though they're still useful.
92+
// (Relay's policy was defined with continuous profiles in mind, before ANR profiles were a
93+
// thing.) If we only have one sample, synthesize another that only differs in its timestamp.
94+
if (profile.getSamples().size() == 1) {
95+
final @NotNull SentrySample originalSample = profile.getSamples().get(0);
96+
final @NotNull SentrySample syntheticSample = createSyntheticSample(originalSample);
97+
profile.getSamples().add(syntheticSample);
98+
}
99+
83100
profile.setFrames(frames);
84101
profile.setStacks(stacks);
85102

@@ -147,4 +164,21 @@ private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElemen
147164
}
148165
return frame;
149166
}
167+
168+
/**
169+
* Creates a {@link SentrySample} identical to {@code originalSample}, save that its timestamp is
170+
* advanced by {@link #SYNTHETIC_SAMPLE_OFFSET_SECONDS}.
171+
*
172+
* <p>Lets us produce a plausible synthetic sample without misleading the user about the ANR's
173+
* actual duration or cause.
174+
*/
175+
@NotNull
176+
private static SentrySample createSyntheticSample(@NotNull SentrySample originalSample) {
177+
final @NotNull SentrySample syntheticSample = new SentrySample();
178+
syntheticSample.setTimestamp(originalSample.getTimestamp() + SYNTHETIC_SAMPLE_OFFSET_SECONDS);
179+
syntheticSample.setStackId(originalSample.getStackId());
180+
syntheticSample.setThreadId(originalSample.getThreadId());
181+
syntheticSample.setUnknown(originalSample.getUnknown());
182+
return syntheticSample;
183+
}
150184
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,9 +1012,15 @@ class ApplicationExitInfoEventProcessorTest {
10121012
mockedSentry.`when`<Any> { Sentry.getCurrentScopes() }.thenReturn(scopes)
10131013

10141014
val processed = processor.process(SentryEvent(), hint)
1015+
val chunkCaptor = argumentCaptor<ProfileChunk>()
1016+
verify(scopes).captureProfileChunk(chunkCaptor.capture())
1017+
val sentryProfile = chunkCaptor.firstValue.sentryProfile
10151018

10161019
assertNotNull(processed?.contexts?.profile)
10171020
assertNotNull(processed.contexts.profile?.profilerId)
1021+
assertNotNull(sentryProfile)
1022+
// Two samples are present b/c the converter adds a synthetic one to keep Relay happy.
1023+
assertEquals(2, sentryProfile.samples.size)
10181024
}
10191025
}
10201026

sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ class AnrStackTraceConverterTest {
1919
val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))
2020

2121
Assert.assertNotNull(profile)
22-
Assert.assertEquals(1, profile.samples.size)
22+
// Two samples are present b/c the converter adds a synthetic one to keep Relay happy.
23+
Assert.assertEquals(2, profile.samples.size)
2324
Assert.assertEquals(2, profile.frames.size)
2425
Assert.assertEquals(1, profile.stacks.size)
2526

@@ -46,6 +47,60 @@ class AnrStackTraceConverterTest {
4647
Assert.assertEquals(1.0, sample.timestamp, 0.001) // 1000ms = 1s
4748
}
4849

50+
@Test
51+
fun testAddSyntheticSampleIfOnlyOneSamplePresent() {
52+
val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42))
53+
54+
val anrStackTraces: MutableList<AnrStackTrace?> = ArrayList()
55+
anrStackTraces.add(AnrStackTrace(1000, elements))
56+
57+
val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))
58+
59+
val originalSample = profile.samples[0]
60+
val syntheticSample = profile.samples[1]
61+
val expectedOffsetSeconds = AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0 / 1000.0
62+
63+
Assert.assertEquals(originalSample.stackId, syntheticSample.stackId)
64+
Assert.assertEquals(originalSample.threadId, syntheticSample.threadId)
65+
Assert.assertEquals(originalSample.unknown, syntheticSample.unknown)
66+
Assert.assertEquals(
67+
originalSample.timestamp + expectedOffsetSeconds,
68+
syntheticSample.timestamp,
69+
0.001,
70+
)
71+
72+
Assert.assertTrue(profile.stacks[syntheticSample.stackId].isNotEmpty())
73+
Assert.assertEquals(2, profile.samples.size)
74+
Assert.assertEquals(1, profile.stacks.size)
75+
Assert.assertEquals(1, profile.frames.size)
76+
}
77+
78+
@Test
79+
fun testDoNotAddSyntheticSampleIfMultipleSamplesPresent() {
80+
val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42))
81+
82+
val anrStackTraces: MutableList<AnrStackTrace?> = ArrayList()
83+
anrStackTraces.add(AnrStackTrace(1000, elements))
84+
anrStackTraces.add(AnrStackTrace(2000, elements))
85+
86+
val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))
87+
88+
Assert.assertEquals(2, profile.samples.size)
89+
Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001)
90+
Assert.assertEquals(2.0, profile.samples[1].timestamp, 0.001)
91+
Assert.assertEquals(1, profile.stacks.size)
92+
Assert.assertEquals(1, profile.frames.size)
93+
}
94+
95+
@Test
96+
fun testDoNotAddSyntheticSampleIfNoSamplesPresent() {
97+
val profile = StackTraceConverter.convert(AnrProfile(ArrayList()))
98+
99+
Assert.assertEquals(0, profile.samples.size)
100+
Assert.assertEquals(0, profile.stacks.size)
101+
Assert.assertEquals(0, profile.frames.size)
102+
}
103+
49104
@Test
50105
fun testFrameDeduplication() {
51106
// Create two stack traces with duplicate frames

0 commit comments

Comments
 (0)