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..9cfdb6f5f31 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; + } + + final @NotNull 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) + } + } + } }