From 75a9e65e4f81b93e57bfb4fa1d13baa7d7f9cabe Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 27 Jul 2026 12:23:02 +0200 Subject: [PATCH 1/2] fix(anr): Use Proguard ID from origination ANR process with ANR profile chunks We report ANRs from the app process launched _after_ the ANR occurs. Usually the same app installation is used to launch both processes and so the Proguard ID is the same for each. But not always! The user could have installed a new version of the app in between, so we need to make sure we grab the Proguard ID associated with the original process. Prior to this commit, we would always use the originating Proguard ID with the ANR event but not with the linked ANR profile chunk. Instead, the profile chunk would always use the current process Proguard ID. This commit fixes that by updating the profile chunk pipeline so it respects any incoming debug images (including Proguard ones) in the DebugMeta bound to the profile chunk passed to IScopes.createProfileChunk(). --- CHANGELOG.md | 1 + .../ApplicationExitInfoEventProcessor.java | 65 ++++++++-- .../ApplicationExitInfoEventProcessorTest.kt | 57 +++++++++ .../java/io/sentry/protocol/DebugMeta.java | 68 ++++++++-- .../test/java/io/sentry/SentryClientTest.kt | 63 +++++++++ .../java/io/sentry/protocol/DebugMetaTest.kt | 120 ++++++++++++++++++ 6 files changed, 353 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aee94a20b4..60cd95952d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Use the original app build's ProGuard UUID for ANR profile chunks ([#5852](https://github.com/getsentry/sentry-java/pull/5852)) - Fix potential ANR/deadlock in Session Replay when `checkCanRecord` runs on the replay executor thread ([#5837](https://github.com/getsentry/sentry-java/pull/5837)) - Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808)) - Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index f175db90488..91e0072cca6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -184,7 +184,7 @@ public ApplicationExitInfoEventProcessor( setStaticValues(event); if (hintEnricher != null) { - hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint); + hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint, optionsSource); } return event; @@ -504,10 +504,7 @@ private void setDebugMeta( PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); if (proguardUuid != null) { - final DebugImage debugImage = new DebugImage(); - debugImage.setType(DebugImage.PROGUARD); - debugImage.setUuid(proguardUuid); - images.add(debugImage); + images.add(createProguardDebugImage(proguardUuid)); } event.setDebugMeta(debugMeta); } @@ -790,7 +787,10 @@ void applyPreEnrichment( @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); void applyPostEnrichment( - @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); + @NotNull SentryEvent event, + @NotNull Backfillable hint, + @NotNull Object rawHint, + @NotNull OptionsSource optionsSource); } private final class AnrHintEnricher implements HintEnricher { @@ -822,11 +822,14 @@ public void applyPreEnrichment( @Override public void applyPostEnrichment( - @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { + @NotNull SentryEvent event, + @NotNull Backfillable hint, + @NotNull Object rawHint, + @NotNull OptionsSource optionsSource) { final boolean isBackgroundAnr = isBackgroundAnr(rawHint); if (options.isAnrProfilingEnabled()) { - applyAnrProfile(event, hint, isBackgroundAnr); + applyAnrProfile(event, hint, isBackgroundAnr, optionsSource); } setDefaultAnrFingerprint(event, isBackgroundAnr); @@ -920,7 +923,10 @@ private void setAnrExceptions( } private void applyAnrProfile( - @NotNull SentryEvent event, @NotNull Backfillable hint, boolean isBackgroundAnr) { + @NotNull SentryEvent event, + @NotNull Backfillable hint, + boolean isBackgroundAnr, + @NotNull OptionsSource optionsSource) { // Skip background ANRs (as profiling only runs in foreground) if (isBackgroundAnr) { @@ -981,7 +987,8 @@ private void applyAnrProfile( } // Capture profile chunk - final @Nullable SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); + final @Nullable SentryId profilerId = + captureAnrProfile(anrTimestamp, anrProfile, optionsSource); final @NotNull StackTraceElement[] stack = culprit.getStack(); if (stack.length > 0) { @@ -1012,7 +1019,10 @@ private void applyAnrProfile( } @Nullable - private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfile anrProfile) { + private SentryId captureAnrProfile( + final long anrTimestampMs, + @NotNull AnrProfile anrProfile, + final @NotNull OptionsSource optionsSource) { final SentryProfile profile = StackTraceConverter.convert(anrProfile); final ProfileChunk chunk = new ProfileChunk( @@ -1024,6 +1034,7 @@ private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfil ProfileChunk.PLATFORM_JAVA, options); chunk.setSentryProfile(profile); + chunk.setDebugMeta(createAnrProfileDebugMeta(optionsSource)); final SentryId profilerId = Sentry.getCurrentScopes().captureProfileChunk(chunk); if (SentryId.EMPTY_ID.equals(profilerId)) { @@ -1058,5 +1069,37 @@ private boolean hasOnlySystemFrames(@NotNull SentryEvent event) { } return true; } + + /** + * Creates debug metadata for an ANR profile chunk using the build metadata selected for the ANR + * event. + * + *

ANR profile chunks are captured after app relaunch. If the app was updated between the ANR + * and the relaunch, the current options may contain the new build's ProGuard UUID. The provided + * {@link OptionsSource} lets us resolve the profile chunk and ANR event to the same originating + * build. + */ + private @Nullable DebugMeta createAnrProfileDebugMeta( + final @NotNull OptionsSource optionsSource) { + final String proguardUuid = + getBuildOption( + PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); + if (proguardUuid == null) { + // If no historical UUID is available, let the generic profile chunk pipeline apply the + // current options UUID as its normal best-effort fallback. + return null; + } + + final DebugMeta debugMeta = new DebugMeta(); + debugMeta.setImages(Collections.singletonList(createProguardDebugImage(proguardUuid))); + return debugMeta; + } + } + + private static @NotNull DebugImage createProguardDebugImage(final @NotNull String proguardUuid) { + final DebugImage debugImage = new DebugImage(); + debugImage.setType(DebugImage.PROGUARD); + debugImage.setUuid(proguardUuid); + return debugImage; } } 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 d5b916d3b44..e80c738b5ea 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 @@ -11,6 +11,7 @@ import io.sentry.Hint import io.sentry.IScopes import io.sentry.IpAddressUtils import io.sentry.NoOpLogger +import io.sentry.ProfileChunk import io.sentry.Sentry import io.sentry.SentryBaseEvent import io.sentry.SentryEvent @@ -75,7 +76,9 @@ import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow @@ -1015,6 +1018,60 @@ class ApplicationExitInfoEventProcessorTest { } } + @Test + fun `uses persisted proguard uuid for ANR profile chunk after app update`() { + fixture.options.anrProfilingSampleRate = 1.0 + fixture.options.proguardUuid = "current-uuid" + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + setLastUpdateTime(2_000) + + val hint = + HintUtils.createWithTypeCheckHint( + AbnormalExitHint(mechanism = "anr_foreground", timestamp = 1_000) + ) + + AnrProfileManager( + fixture.options, + AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + ) + .apply { + add( + AnrStackTrace( + 1_000, + arrayOf( + StackTraceElement("com.example.MyApp", "blocked", "MyApp.java", 42), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), + ) + ) + close() + } + AnrProfileRotationHelper.rotate() + + val scopes = mock() + whenever(scopes.captureProfileChunk(any())).thenReturn(SentryId()) + + mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + processor.process(SentryEvent(), hint) + + val chunkCaptor = argumentCaptor() + verify(scopes).captureProfileChunk(chunkCaptor.capture()) + val images = chunkCaptor.firstValue.debugMeta!!.images!! + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("previous-uuid", images[0].uuid) + } + } + @Test fun `does not crash when ANR profiling is enabled but cache dir is null`() { fixture.options.anrProfilingSampleRate = 1.0 diff --git a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java index 45e5fda0603..1bfca346e5c 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java @@ -57,6 +57,19 @@ public void setSdkInfo(final @Nullable SdkInfo sdkInfo) { @ApiStatus.Internal public static @Nullable DebugMeta buildDebugMeta( final @Nullable DebugMeta eventDebugMeta, final @NotNull SentryOptions options) { + final @NotNull List optionDebugImages = createDebugImagesFromOptions(options); + + if (eventDebugMeta == null && optionDebugImages.isEmpty()) { + return null; + } + + DebugMeta debugMeta = eventDebugMeta != null ? eventDebugMeta : new DebugMeta(); + addMissingDebugImages(debugMeta, optionDebugImages); + return debugMeta; + } + + private static @NotNull List createDebugImagesFromOptions( + final @NotNull SentryOptions options) { final @NotNull List debugImages = new ArrayList<>(); if (options.getProguardUuid() != null) { @@ -73,21 +86,56 @@ public void setSdkInfo(final @Nullable SdkInfo sdkInfo) { debugImages.add(sourceBundleImage); } - if (!debugImages.isEmpty()) { - DebugMeta debugMeta = eventDebugMeta; + return debugImages; + } - if (debugMeta == null) { - debugMeta = new DebugMeta(); + private static void addMissingDebugImages( + final @NotNull DebugMeta debugMeta, final @NotNull List candidates) { + if (candidates.isEmpty()) { + return; + } + + if (debugMeta.getImages() == null) { + debugMeta.setImages(new ArrayList<>()); + } + + final @Nullable List images = debugMeta.getImages(); + if (images == null) { + return; + } + + for (final @NotNull DebugImage candidate : candidates) { + if (isMissingDebugImage(images, candidate)) { + images.add(candidate); } - if (debugMeta.getImages() == null) { - debugMeta.setImages(debugImages); - } else { - debugMeta.getImages().addAll(debugImages); + } + } + + private static boolean isMissingDebugImage( + final @NotNull List images, final @NotNull DebugImage candidate) { + for (final @NotNull DebugImage image : images) { + if (isMatchingDebugImage(image, candidate)) { + return false; } + } + return true; + } - return debugMeta; + private static boolean isMatchingDebugImage( + final @NotNull DebugImage image, final @NotNull DebugImage candidate) { + // There can only be one ProGuard mapping per payload, so an existing ProGuard image takes + // precedence over the option-derived default. + if (DebugImage.PROGUARD.equals(candidate.getType())) { + return DebugImage.PROGUARD.equals(image.getType()); } - return null; + + if (DebugImage.JVM.equals(candidate.getType())) { + return DebugImage.JVM.equals(image.getType()) + && candidate.getDebugId() != null + && candidate.getDebugId().equals(image.getDebugId()); + } + + return false; } // JsonKeys diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index fa37cb0b70b..02623556498 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -18,6 +18,8 @@ import io.sentry.logger.ILoggerBatchProcessorFactory import io.sentry.metrics.IMetricsBatchProcessor import io.sentry.metrics.IMetricsBatchProcessorFactory import io.sentry.protocol.Contexts +import io.sentry.protocol.DebugImage +import io.sentry.protocol.DebugMeta import io.sentry.protocol.Feedback import io.sentry.protocol.Mechanism import io.sentry.protocol.Message @@ -1993,6 +1995,56 @@ class SentryClientTest { verifyProfileChunkInEnvelope(fixture.profileChunk.chunkId) } + @Test + fun `captureProfileChunk adds options proguard debug meta`() { + fixture.sentryOptions.proguardUuid = "current-uuid" + + val client = fixture.getSut() + client.captureProfileChunk(fixture.profileChunk, mock()) + + verify(fixture.transport) + .send( + check { actual -> + val profileChunk = getProfileChunkFromEnvelope(actual) + val images = profileChunk.debugMeta!!.images!! + + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("current-uuid", images[0].uuid) + } + ) + } + + @Test + fun `captureProfileChunk preserves existing proguard debug meta`() { + fixture.sentryOptions.proguardUuid = "current-uuid" + fixture.profileChunk.debugMeta = + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = DebugImage.PROGUARD + uuid = "previous-uuid" + } + ) + } + + val client = fixture.getSut() + client.captureProfileChunk(fixture.profileChunk, mock()) + + verify(fixture.transport) + .send( + check { actual -> + val profileChunk = getProfileChunkFromEnvelope(actual) + val images = profileChunk.debugMeta!!.images!! + + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("previous-uuid", images[0].uuid) + } + ) + } + @Test fun `when captureProfileChunk with empty trace file, profile chunk is not sent`() { val client = fixture.getSut() @@ -4169,6 +4221,17 @@ class SentryClientTest { )!! } + private fun getProfileChunkFromData(data: ByteArray): ProfileChunk { + val inputStream = InputStreamReader(ByteArrayInputStream(data)) + return fixture.sentryOptions.serializer.deserialize(inputStream, ProfileChunk::class.java)!! + } + + private fun getProfileChunkFromEnvelope(envelope: SentryEnvelope): ProfileChunk { + val profileChunkItem = + envelope.items.first { item -> item.header.type == SentryItemType.ProfileChunk } + return getProfileChunkFromData(profileChunkItem.data) + } + private fun getReplayFromData(data: ByteArray): SentryReplayEvent? { val unpacker = MessagePack.newDefaultUnpacker(data) val mapSize = unpacker.unpackMapHeader() diff --git a/sentry/src/test/java/io/sentry/protocol/DebugMetaTest.kt b/sentry/src/test/java/io/sentry/protocol/DebugMetaTest.kt index 9cb8cf40946..a1c2f4edb05 100644 --- a/sentry/src/test/java/io/sentry/protocol/DebugMetaTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/DebugMetaTest.kt @@ -57,6 +57,98 @@ class DebugMetaTest { } } + @Test + fun `when debug meta already has proguard image, does not attach options proguard uuid`() { + val options = SentryOptions().apply { proguardUuid = "current-id" } + val debugMeta = + DebugMeta.buildDebugMeta( + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = DebugImage.PROGUARD + uuid = "existing-id" + } + ) + }, + options, + ) + + assertNotNull(debugMeta) { + assertNotNull(it.images) { images -> + assertEquals(1, images.size) + assertEquals("existing-id", images[0].uuid) + assertEquals(DebugImage.PROGUARD, images[0].type) + } + } + } + + @Test + fun `when debug meta already has proguard image, still attaches missing bundle ids`() { + val options = + SentryOptions().apply { + proguardUuid = "current-id" + bundleIds.add("bundle-id") + } + val debugMeta = + DebugMeta.buildDebugMeta( + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = DebugImage.PROGUARD + uuid = "existing-id" + } + ) + }, + options, + ) + + assertNotNull(debugMeta) { + assertNotNull(it.images) { images -> + assertEquals(2, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("existing-id", images[0].uuid) + assertEquals(DebugImage.JVM, images[1].type) + assertEquals("bundle-id", images[1].debugId) + } + } + } + + @Test + fun `when debug meta has unrelated debug image, attaches option debug information`() { + val options = + SentryOptions().apply { + proguardUuid = "proguard-id" + bundleIds.add("bundle-id") + } + val debugMeta = + DebugMeta.buildDebugMeta( + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = "elf" + debugId = "native-id" + } + ) + }, + options, + ) + + assertNotNull(debugMeta) { + assertNotNull(it.images) { images -> + assertEquals(3, images.size) + assertEquals("elf", images[0].type) + assertEquals("native-id", images[0].debugId) + assertEquals(DebugImage.PROGUARD, images[1].type) + assertEquals("proguard-id", images[1].uuid) + assertEquals(DebugImage.JVM, images[2].type) + assertEquals("bundle-id", images[2].debugId) + } + } + } + @Test fun `when event has debug meta and bundle ids are set, attaches debug information`() { val options = SentryOptions().apply { bundleIds.addAll(listOf("id1", "id2")) } @@ -86,4 +178,32 @@ class DebugMetaTest { } } } + + @Test + fun `when debug meta already has jvm image, only attaches missing bundle ids`() { + val options = SentryOptions().apply { bundleIds.addAll(listOf("id1", "id2")) } + val debugMeta = + DebugMeta.buildDebugMeta( + DebugMeta().apply { + images = + listOf( + DebugImage().apply { + type = DebugImage.JVM + debugId = "id1" + } + ) + }, + options, + ) + + assertNotNull(debugMeta) { + assertNotNull(it.images) { images -> + assertEquals(2, images.size) + assertEquals("id1", images[0].debugId) + assertEquals(DebugImage.JVM, images[0].type) + assertEquals("id2", images[1].debugId) + assertEquals(DebugImage.JVM, images[1].type) + } + } + } } From 058ee84a07a1632a7e1838598ffd705bc686615e Mon Sep 17 00:00:00 2001 From: arb Date: Wed, 29 Jul 2026 16:02:59 +0200 Subject: [PATCH 2/2] Update sentry/src/main/java/io/sentry/protocol/DebugMeta.java Co-authored-by: Markus Hintersteiner --- sentry/src/main/java/io/sentry/protocol/DebugMeta.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java index 1bfca346e5c..9cfdb6f5f31 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java @@ -63,7 +63,7 @@ public void setSdkInfo(final @Nullable SdkInfo sdkInfo) { return null; } - DebugMeta debugMeta = eventDebugMeta != null ? eventDebugMeta : new DebugMeta(); + final @NotNull DebugMeta debugMeta = eventDebugMeta != null ? eventDebugMeta : new DebugMeta(); addMissingDebugImages(debugMeta, optionDebugImages); return debugMeta; }