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") + } +}