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,