diff --git a/CHANGELOG.md b/CHANGELOG.md index ec63c894fc8..7df4c039d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Deprecate `sendDefaultPii` in favor of `dataCollection` ahead of its removal in 9.0 ([#6158](https://github.com/getsentry/sentry-java/pull/6158)) - Make the tombstone merge time threshold configurable via `SentryAndroidOptions.setTombstoneMergeTimeThresholdMillis` and the `io.sentry.tombstone.merge-time-threshold-millis` manifest option ([#6154](https://github.com/getsentry/sentry-java/pull/6154)) +### Improvements + +- Recover Android 17 `MemoryLimiter` app exits recorded as `ApplicationExitInfo.REASON_MEMORY_LIMITER` ([#6174](https://github.com/getsentry/sentry-java/pull/6174)) + ## 8.58.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java index 648d8a4adac..f259250a733 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java @@ -190,24 +190,30 @@ private MemoryLimiterPolicy(final @NotNull SentryAndroidOptions options) { /** * Returns true if the provided {@code exitInfo} looks like it came from a MemoryLimiter-induced * process death. - * - *

Criteria taken from here. */ @Override @RequiresApi(api = Build.VERSION_CODES.R) public boolean matches(final @NotNull ApplicationExitInfo exitInfo) { - if (exitInfo.getReason() != ApplicationExitInfo.REASON_OTHER) { - return false; + final int reason = exitInfo.getReason(); + + // 1. Return true for explicit matches. + if (reason == ApplicationExitInfo.REASON_MEMORY_LIMITER) { + return true; } - final String description = exitInfo.getDescription(); - // We match on the "MemoryLimiter:" prefix rather than the full "MemoryLimiter:AnonSwap" - // string mentioned in the Android 17 release notes because we want to capture any future - // MemoryLimiter kill reason without a code change. (MemoryLimiter source already tracks - // MemoryLimiter:Memory and MemoryLimiter:Swap, but for now doesn't kill the process because - // of them.) - return description != null && description.contains(MEMORY_LIMITER_DESCRIPTION_PREFIX); + // 2. Return true for heuristic matches based on criteria discussed here: + // https://developer.android.com/about/versions/17/behavior-changes-all#app-memory-limits + if (reason == ApplicationExitInfo.REASON_OTHER) { + final String description = exitInfo.getDescription(); + // We match on the "MemoryLimiter:" prefix rather than the full "MemoryLimiter:AnonSwap" + // string mentioned in the Android 17 release notes because we want to capture any future + // MemoryLimiter kill reason without a code change. (MemoryLimiter source already tracks + // MemoryLimiter:Memory and MemoryLimiter:Swap, but for now doesn't kill the process because + // of them.) + return description != null && description.contains(MEMORY_LIMITER_DESCRIPTION_PREFIX); + } + + return false; } @Override diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt index 183ca3dfbdd..0b72fb6e894 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt @@ -214,8 +214,30 @@ class MemoryLimiterIntegrationTest { verify(fixture.options.executorService).submit(any()) } + // "Explicit records" are ApplicationExitInfo instances that use + // ApplicationExitInfo.REASON_MEMORY_LIMITER to indicate a MemoryLimiter kill. Contrast "heuristic + // records" below. @Test - fun `captures exit when reason and description match exits produced by MemoryLimiter`() { + fun `captures exit for explicit MemoryLimiter record`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_MEMORY_LIMITER, + description = null, + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + + // "Heuristic records" are ApplicationExitInfo instances that use something other than + // ApplicationExitInfo.REASON_MEMORY_LIMITER to indicate a MemoryLimiter kill. + // (REASON_MEMORY_LIMITER wasn't introduced until 37.2.) They consist of + // ApplicationExitInfo.REASON_OTHER + an ApplicationExitInfo.description that contains the prefix + // "MemoryLimiter:". + @Test + fun `captures exit for heuristic MemoryLimiter record`() { val integration = fixture.getSut( memoryLimiterEnabled = true, @@ -285,25 +307,34 @@ class MemoryLimiterIntegrationTest { } @Test - fun `ignores exit when reason does not match exits produced by MemoryLimiter`() { + fun `captures exit for heuristic record even if description suffix is not AnonSwap`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) fixture.addAppExitInfo( - reason = ApplicationExitInfo.REASON_ANR, - description = MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, + reason = ApplicationExitInfo.REASON_OTHER, + // Description suffix is "Memory" rather than "AnonSwap". + description = "MemoryLimiter:Memory", timestamp = newTimestamp, ) integration.register(fixture.scopes, fixture.options) - verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + verify(fixture.scopes) + .captureEvent( + check { event -> + assertEquals("MemoryLimiter:Memory", event.exceptions!!.single().mechanism!!.description) + }, + anyOrNull(), + ) } + // "Heuristic candidates" are ApplicationExitInfo instances that may or may not be heuristic + // records. @Test - fun `ignores exit when description does not match exits produced by MemoryLimiter`() { + fun `ignores exit for heuristic candidate when reason does not match`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) fixture.addAppExitInfo( - reason = ApplicationExitInfo.REASON_OTHER, - description = "LowSwapKiller", + reason = ApplicationExitInfo.REASON_ANR, + description = MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, timestamp = newTimestamp, ) @@ -313,29 +344,21 @@ class MemoryLimiterIntegrationTest { } @Test - fun `captures exit for any MemoryLimiter sub-reason, not just AnonSwap`() { + fun `ignores exit for heuristic candidate when description does not match`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) - // A future MemoryLimiter kill sub-reason (e.g. the memory or swap limits) still lives in the - // "MemoryLimiter:" namespace and must be captured, with its raw sub-reason preserved. fixture.addAppExitInfo( reason = ApplicationExitInfo.REASON_OTHER, - description = "MemoryLimiter:Memory", + description = "LowSwapKiller", timestamp = newTimestamp, ) integration.register(fixture.scopes, fixture.options) - verify(fixture.scopes) - .captureEvent( - check { event -> - assertEquals("MemoryLimiter:Memory", event.exceptions!!.single().mechanism!!.description) - }, - anyOrNull(), - ) + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) } @Test - fun `ignores exit when description mentions MemoryLimiter without the namespace delimiter`() { + fun `ignores exit for heuristic candidate when description mentions MemoryLimiter without the namespace delimiter`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) // "MemoryLimiter" without the ":" delimiter is not a MemoryLimiter kill; matching requires the // namespace prefix so we don't over-capture unrelated REASON_OTHER exits. @@ -351,7 +374,7 @@ class MemoryLimiterIntegrationTest { } @Test - fun `ignores exit when description is null`() { + fun `ignores exit for heuristic candidate when description is null`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) fixture.addAppExitInfo( reason = ApplicationExitInfo.REASON_OTHER, @@ -481,7 +504,7 @@ class MemoryLimiterIntegrationTest { } @Test - fun `dedupes MemoryLimiter exists independently of ANR exits`() { + fun `dedupes MemoryLimiter exits independently of ANR exits`() { val integration = fixture.getSut( tmpDir, diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index c923282527e..31bf86c7db2 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -992,7 +993,7 @@ fun IntegrationsScreen() { tagSampleAction("memory_limiter") activity.startActivity(Intent(activity, MemoryLimiterActivity::class.java)) }, - modifier = Modifier, + modifier = Modifier.testTag("open_memory_limiter_demo"), ) { Text("MemoryLimiter Demo", maxLines = 2, overflow = TextOverflow.Ellipsis) } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/memory/MemoryLimiterActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/memory/MemoryLimiterActivity.kt index 6f9c650590b..087cfdbb8a1 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/memory/MemoryLimiterActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/memory/MemoryLimiterActivity.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -307,13 +308,25 @@ private fun MemoryLimiterScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Button(onClick = onStartAllocating, enabled = isSupported && !isAllocating) { + Button( + onClick = onStartAllocating, + enabled = isSupported && !isAllocating, + modifier = Modifier.testTag("memory_limiter_start_pressure"), + ) { Text("Start memory pressure") } - OutlinedButton(onClick = onReleaseBuffers, enabled = allocatedMb > 0 || isAllocating) { + OutlinedButton( + onClick = onReleaseBuffers, + enabled = allocatedMb > 0 || isAllocating, + modifier = Modifier.testTag("memory_limiter_reset_run"), + ) { Text("Reset run") } - OutlinedButton(onClick = onMoveToBackground, enabled = isSupported) { + OutlinedButton( + onClick = onMoveToBackground, + enabled = isSupported, + modifier = Modifier.testTag("memory_limiter_move_background"), + ) { Text("Move app to background") } } @@ -431,7 +444,7 @@ private fun RuntimeCard( isAllocating: Boolean, statusLine: String, ) { - ElevatedCard { + ElevatedCard(modifier = Modifier.testTag("memory_limiter_runtime_card")) { Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = "Runtime", @@ -445,6 +458,7 @@ private fun RuntimeCard( RuntimeRow(label = "Retained", value = "$allocatedMb MiB") HorizontalDivider() Text( + modifier = Modifier.testTag("memory_limiter_status_line"), text = statusLine, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,