diff --git a/CHANGELOG.md b/CHANGELOG.md
index d826db437c5..40763ca9807 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Fixes
+- Preserve single-sample ANR profile chunks so profiles remain available on ANR events ([#5872](https://github.com/getsentry/sentry-java/pull/5872))
- Prevent inflated cold app start when the OS spawns the process in the background (e.g. FCM push) on API 35+ ([#5841](https://github.com/getsentry/sentry-java/pull/5841))
- Avoid a CPU busy-loop when recording discarded log or metric envelopes under rate limiting ([#5835](https://github.com/getsentry/sentry-java/pull/5835))
- `ClientReportRecorder` now reads the item count from the envelope item header instead of deserializing the payload, which under sustained rate limiting could pin CPU cores while repeatedly throwing exceptions
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java
index f6f29689f72..8f1254e08ca 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java
@@ -33,6 +33,14 @@ public final class StackTraceConverter {
private static final String MAIN_THREAD_ID = "0";
private static final String MAIN_THREAD_NAME = "main";
+ /**
+ * Timestamp offset used with synthetic ANR profile samples. (Currently 33 ms.)
+ *
+ *
Places the synthetic sample halfway to the next ANR polling tick.
+ */
+ private static final double SYNTHETIC_SAMPLE_OFFSET_SECONDS =
+ (AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0d) / 1000.0d;
+
/**
* Converts a list of {@link AnrStackTrace} objects to a {@link SentryProfile}.
*
@@ -80,6 +88,15 @@ public static SentryProfile convert(final @NotNull AnrProfile anrProfile) {
profile.getSamples().add(sample);
}
+ // Relay will reject ANR profiles with only one sample, even though they're still useful.
+ // (Relay's policy was defined with continuous profiles in mind, before ANR profiles were a
+ // thing.) If we only have one sample, synthesize another that only differs in its timestamp.
+ if (profile.getSamples().size() == 1) {
+ final @NotNull SentrySample originalSample = profile.getSamples().get(0);
+ final @NotNull SentrySample syntheticSample = createSyntheticSample(originalSample);
+ profile.getSamples().add(syntheticSample);
+ }
+
profile.setFrames(frames);
profile.setStacks(stacks);
@@ -147,4 +164,18 @@ private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElemen
}
return frame;
}
+
+ /**
+ * Creates a {@link SentrySample} identical to {@code originalSample}, save that its timestamp is
+ * advanced by {@link #SYNTHETIC_SAMPLE_OFFSET_SECONDS}.
+ *
+ *
Lets us produce a plausible synthetic sample without misleading the user about the ANR's
+ * actual duration or cause.
+ */
+ @NotNull
+ private static SentrySample createSyntheticSample(@NotNull SentrySample originalSample) {
+ final @NotNull SentrySample syntheticSample = new SentrySample(originalSample);
+ syntheticSample.setTimestamp(originalSample.getTimestamp() + SYNTHETIC_SAMPLE_OFFSET_SECONDS);
+ return syntheticSample;
+ }
}
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt
index e80c738b5ea..176ca460eb1 100644
--- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt
@@ -1012,9 +1012,15 @@ class ApplicationExitInfoEventProcessorTest {
mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes)
val processed = processor.process(SentryEvent(), hint)
+ val chunkCaptor = argumentCaptor()
+ verify(scopes).captureProfileChunk(chunkCaptor.capture())
+ val sentryProfile = chunkCaptor.firstValue.sentryProfile
assertNotNull(processed?.contexts?.profile)
assertNotNull(processed.contexts.profile?.profilerId)
+ assertNotNull(sentryProfile)
+ // Two samples are present b/c the converter adds a synthetic one to keep Relay happy.
+ assertEquals(2, sentryProfile.samples.size)
}
}
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt
index f48f1674166..9dcde8eb4a6 100644
--- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt
@@ -19,7 +19,8 @@ class AnrStackTraceConverterTest {
val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))
Assert.assertNotNull(profile)
- Assert.assertEquals(1, profile.samples.size)
+ // Two samples are present b/c the converter adds a synthetic one to keep Relay happy.
+ Assert.assertEquals(2, profile.samples.size)
Assert.assertEquals(2, profile.frames.size)
Assert.assertEquals(1, profile.stacks.size)
@@ -46,6 +47,57 @@ class AnrStackTraceConverterTest {
Assert.assertEquals(1.0, sample.timestamp, 0.001) // 1000ms = 1s
}
+ @Test
+ fun testAddSyntheticSampleIfOnlyOneSamplePresent() {
+ val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42))
+
+ val anrStackTraces: MutableList = ArrayList()
+ anrStackTraces.add(AnrStackTrace(1000, elements))
+
+ val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))
+
+ val originalSample = profile.samples[0]
+ val syntheticSample = profile.samples[1]
+ val expectedOffsetSeconds = AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0 / 1000.0
+
+ Assert.assertEquals(
+ originalSample.timestamp + expectedOffsetSeconds,
+ syntheticSample.timestamp,
+ 0.001,
+ )
+
+ Assert.assertTrue(profile.stacks[syntheticSample.stackId].isNotEmpty())
+ Assert.assertEquals(2, profile.samples.size)
+ Assert.assertEquals(1, profile.stacks.size)
+ Assert.assertEquals(1, profile.frames.size)
+ }
+
+ @Test
+ fun testDoNotAddSyntheticSampleIfMultipleSamplesPresent() {
+ val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42))
+
+ val anrStackTraces: MutableList = ArrayList()
+ anrStackTraces.add(AnrStackTrace(1000, elements))
+ anrStackTraces.add(AnrStackTrace(2000, elements))
+
+ val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))
+
+ Assert.assertEquals(2, profile.samples.size)
+ Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001)
+ Assert.assertEquals(2.0, profile.samples[1].timestamp, 0.001)
+ Assert.assertEquals(1, profile.stacks.size)
+ Assert.assertEquals(1, profile.frames.size)
+ }
+
+ @Test
+ fun testDoNotAddSyntheticSampleIfNoSamplesPresent() {
+ val profile = StackTraceConverter.convert(AnrProfile(ArrayList()))
+
+ Assert.assertEquals(0, profile.samples.size)
+ Assert.assertEquals(0, profile.stacks.size)
+ Assert.assertEquals(0, profile.frames.size)
+ }
+
@Test
fun testFrameDeduplication() {
// Create two stack traces with duplicate frames
diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api
index b807f70a88d..51cffae3302 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -7066,6 +7066,7 @@ public final class io/sentry/protocol/profiling/SentryProfile$JsonKeys {
public final class io/sentry/protocol/profiling/SentrySample : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
public fun ()V
+ public fun (Lio/sentry/protocol/profiling/SentrySample;)V
public fun getStackId ()I
public fun getThreadId ()Ljava/lang/String;
public fun getTimestamp ()D
diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java
index af9053742d3..a21b3a4af9d 100644
--- a/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java
+++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java
@@ -8,6 +8,7 @@
import io.sentry.JsonUnknown;
import io.sentry.ObjectReader;
import io.sentry.ObjectWriter;
+import io.sentry.util.CollectionUtils;
import io.sentry.vendor.gson.stream.JsonToken;
import java.io.IOException;
import java.util.HashMap;
@@ -27,6 +28,15 @@ public final class SentrySample implements JsonUnknown, JsonSerializable {
private @Nullable Map unknown;
+ public SentrySample() {}
+
+ public SentrySample(final @NotNull SentrySample sample) {
+ this.timestamp = sample.timestamp;
+ this.stackId = sample.stackId;
+ this.threadId = sample.threadId;
+ this.unknown = CollectionUtils.newConcurrentHashMap(sample.unknown);
+ }
+
public double getTimestamp() {
return timestamp;
}
diff --git a/sentry/src/test/java/io/sentry/protocol/profiling/SentrySampleTest.kt b/sentry/src/test/java/io/sentry/protocol/profiling/SentrySampleTest.kt
new file mode 100644
index 00000000000..49c587b3004
--- /dev/null
+++ b/sentry/src/test/java/io/sentry/protocol/profiling/SentrySampleTest.kt
@@ -0,0 +1,30 @@
+package io.sentry.protocol.profiling
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class SentrySampleTest {
+
+ @Test
+ fun `copying sample keeps values and copies unknown`() {
+ val unknown = mutableMapOf("key" to "value")
+ val original =
+ SentrySample().apply {
+ timestamp = 1.23
+ stackId = 4
+ threadId = "main"
+ setUnknown(unknown)
+ }
+
+ val copy = SentrySample(original)
+
+ assertThat(copy.timestamp).isEqualTo(original.timestamp)
+ assertThat(copy.stackId).isEqualTo(original.stackId)
+ assertThat(copy.threadId).isEqualTo(original.threadId)
+ assertThat(copy.unknown).containsExactlyEntriesIn(original.unknown)
+ assertThat(copy.unknown).isNotSameInstanceAs(original.unknown)
+
+ unknown["key"] = "changed"
+ assertThat(copy.unknown).containsEntry("key", "value")
+ }
+}