From f7f9f2a6c7e7167dc98b4159167fcbc887bfc843 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 29 Jul 2026 16:54:55 +0200 Subject: [PATCH 1/2] 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 --- CHANGELOG.md | 4 ++ .../android/core/anr/StackTraceConverter.java | 34 +++++++++++ .../ApplicationExitInfoEventProcessorTest.kt | 6 ++ .../core/anr/AnrStackTraceConverterTest.kt | 57 ++++++++++++++++++- 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1823146d55a..bc074613447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Preserve single-sample ANR profile chunks so profiles remain available on ANR events ([#5872](https://github.com/getsentry/sentry-java/pull/5872)) + ### Performance - 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)) 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..f86f1ea689c 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,21 @@ 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(); + syntheticSample.setTimestamp(originalSample.getTimestamp() + SYNTHETIC_SAMPLE_OFFSET_SECONDS); + syntheticSample.setStackId(originalSample.getStackId()); + syntheticSample.setThreadId(originalSample.getThreadId()); + syntheticSample.setUnknown(originalSample.getUnknown()); + 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..86ce726ffe9 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,60 @@ 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.stackId, syntheticSample.stackId) + Assert.assertEquals(originalSample.threadId, syntheticSample.threadId) + Assert.assertEquals(originalSample.unknown, syntheticSample.unknown) + 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 From 72480899fb6d233122a321de256e99266c537d1a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 30 Jul 2026 16:48:10 +0200 Subject: [PATCH 2/2] Introduce a SentrySample copy constructor --- .../android/core/anr/StackTraceConverter.java | 5 +--- .../core/anr/AnrStackTraceConverterTest.kt | 3 -- sentry/api/sentry.api | 1 + .../protocol/profiling/SentrySample.java | 10 +++++++ .../protocol/profiling/SentrySampleTest.kt | 30 +++++++++++++++++++ 5 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/protocol/profiling/SentrySampleTest.kt 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 f86f1ea689c..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 @@ -174,11 +174,8 @@ private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElemen */ @NotNull private static SentrySample createSyntheticSample(@NotNull SentrySample originalSample) { - final @NotNull SentrySample syntheticSample = new SentrySample(); + final @NotNull SentrySample syntheticSample = new SentrySample(originalSample); syntheticSample.setTimestamp(originalSample.getTimestamp() + SYNTHETIC_SAMPLE_OFFSET_SECONDS); - syntheticSample.setStackId(originalSample.getStackId()); - syntheticSample.setThreadId(originalSample.getThreadId()); - syntheticSample.setUnknown(originalSample.getUnknown()); return syntheticSample; } } 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 86ce726ffe9..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 @@ -60,9 +60,6 @@ class AnrStackTraceConverterTest { val syntheticSample = profile.samples[1] val expectedOffsetSeconds = AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0 / 1000.0 - Assert.assertEquals(originalSample.stackId, syntheticSample.stackId) - Assert.assertEquals(originalSample.threadId, syntheticSample.threadId) - Assert.assertEquals(originalSample.unknown, syntheticSample.unknown) Assert.assertEquals( originalSample.timestamp + expectedOffsetSeconds, syntheticSample.timestamp, 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") + } +}