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