diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bbaabd38e..aaa860a8f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add Android SDK support for reporting `MemoryLimiter` app exits recovered from `ApplicationExitInfo` ([#6111](https://github.com/getsentry/sentry-java/pull/6111)). - Sentry can now configure Log4j2 automatically for Spring Boot 4 when `sentry-log4j2` is on the classpath and Log4j2 Core is the active logging backend ([#5403](https://github.com/getsentry/sentry-java/pull/5403)) - Enable automatic appender registration with: ```properties diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0e33dfb1381..ca5a8af2ce4 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -334,6 +334,20 @@ public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass { public fun loadClass (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/lang/Class; } +public final class io/sentry/android/core/MemoryLimiterIntegration : io/sentry/Integration, java/io/Closeable { + public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V + public fun close ()V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + +public final class io/sentry/android/core/MemoryLimiterIntegration$MemoryLimiterHint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/Backfillable { + public fun (JLio/sentry/ILogger;JZ)V + public fun isFlushable (Lio/sentry/protocol/SentryId;)Z + public fun setFlushable (Lio/sentry/protocol/SentryId;)V + public fun shouldEnrich ()Z + public fun timestamp ()J +} + public final class io/sentry/android/core/NativeEventCollector { public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun collect ()V @@ -446,7 +460,9 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableStandaloneAppStartTracing ()Z public fun isEnableSystemEventBreadcrumbs ()Z public fun isEnableSystemEventBreadcrumbsExtras ()Z + public fun isMemoryLimiterEnabled ()Z public fun isReportHistoricalAnrs ()Z + public fun isReportHistoricalMemoryLimiterExits ()Z public fun isReportHistoricalTombstones ()Z public fun isTombstoneEnabled ()Z public fun setAnrEnabled (Z)V @@ -480,10 +496,12 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableSystemEventBreadcrumbs (Z)V public fun setEnableSystemEventBreadcrumbsExtras (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V + public fun setMemoryLimiterEnabled (Z)V public fun setNativeHandlerStrategy (Lio/sentry/android/core/NdkHandlerStrategy;)V public fun setNativeSdkName (Ljava/lang/String;)V public fun setNdkAppHangTimeoutIntervalMillis (J)V public fun setReportHistoricalAnrs (Z)V + public fun setReportHistoricalMemoryLimiterExits (Z)V public fun setReportHistoricalTombstones (Z)V public fun setTombstoneEnabled (Z)V } @@ -641,8 +659,8 @@ public class io/sentry/android/core/TombstoneIntegration$TombstonePolicy : io/se public fun buildReport (Landroid/app/ApplicationExitInfo;Z)Lio/sentry/android/core/ApplicationExitInfoHistoryDispatcher$Report; public fun getLabel ()Ljava/lang/String; public fun getLastReportedTimestamp ()Ljava/lang/Long; - public fun getTargetReason ()I public fun markReported (J)V + public fun matches (Landroid/app/ApplicationExitInfo;)Z public fun shouldReportHistorical ()Z } @@ -747,14 +765,18 @@ public final class io/sentry/android/core/anr/StackTraceConverter { public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { public static final field LAST_ANR_MARKER_LABEL Ljava/lang/String; public static final field LAST_ANR_REPORT Ljava/lang/String; + public static final field LAST_MEMORY_LIMITER_MARKER_LABEL Ljava/lang/String; + public static final field LAST_MEMORY_LIMITER_REPORT Ljava/lang/String; public static final field LAST_TOMBSTONE_MARKER_LABEL Ljava/lang/String; public static final field LAST_TOMBSTONE_REPORT Ljava/lang/String; public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getDirectory ()Ljava/io/File; public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z public static fun lastReportedAnr (Lio/sentry/SentryOptions;)Ljava/lang/Long; + public static fun lastReportedMemoryLimiter (Lio/sentry/SentryOptions;)Ljava/lang/Long; public static fun lastReportedTombstone (Lio/sentry/SentryOptions;)Ljava/lang/Long; public static fun markAnrReported (Lio/sentry/SentryOptions;J)V + public static fun markMemoryLimiterReported (Lio/sentry/SentryOptions;J)V public static fun markTombstoneReported (Lio/sentry/SentryOptions;J)V public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 85bfd8b5ac4..12ffb90356c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -434,6 +434,10 @@ static void installDefaultIntegrations( options.addIntegration(new TombstoneIntegration(context)); } + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.CINNAMON_BUN) { + options.addIntegration(new MemoryLimiterIntegration(context, buildInfoProvider)); + } + // this integration uses android.os.FileObserver, we can't move to sentry // before creating a pure java impl. options.addIntegration(EnvelopeFileObserverIntegration.getOutboxFileObserver()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index e28a08f7e8d..5e5500297f8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -118,8 +118,8 @@ private static final class AnrV2Policy } @Override - public int getTargetReason() { - return ApplicationExitInfo.REASON_ANR; + public boolean matches(final @NotNull ApplicationExitInfo exitInfo) { + return exitInfo.getReason() == ApplicationExitInfo.REASON_ANR; } @Override 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 31fe2442bf4..ca55f9cb994 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 @@ -587,6 +587,8 @@ private void setDist( timestamp = ((AbnormalExit) hint).timestamp(); } else if (hint instanceof NativeCrashExit) { timestamp = ((NativeCrashExit) hint).timestamp(); + } else if (hint instanceof MemoryLimiterIntegration.MemoryLimiterHint) { + timestamp = ((MemoryLimiterIntegration.MemoryLimiterHint) hint).timestamp(); } else { timestamp = null; } @@ -800,9 +802,7 @@ private final class AnrHintEnricher implements HintEnricher { @Override public boolean supports(@NotNull Object hint) { - // While this is specifically an ANR enricher we discriminate enrichment application - // on the broader AbnormalExit hints for now. - return hint instanceof AbnormalExit; + return hint instanceof AnrV2Integration.AnrV2Hint; } // by default we assume that the ANR is foreground, unless abnormalMechanism is "anr_background" diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java index 155c9fab27e..0be3bf539a7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java @@ -24,6 +24,23 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +/** + * Shared startup-time pipeline for reporting process deaths recovered from Android's {@link + * ApplicationExitInfo} history. + * + *

This class provides the generic flow used by integrations like ANR, tombstone, and + * MemoryLimiter recovery. That flow involves: + * + *

    + *
  • reading the system's historical exit list; + *
  • finding matching records through an {@link ApplicationExitInfoPolicy}; + *
  • skipping exits that are too old or have already been reported; and + *
  • capturing synthetic Sentry events for the remaining matches. + *
+ * + *

The {@code ApplicationExitInfoPolicy} provides the exit-specific rules, while the dispatcher + * owns the lifecycle and bookkeeping around them. + */ @ApiStatus.Internal final class ApplicationExitInfoHistoryDispatcher implements Runnable { @@ -135,7 +152,7 @@ private void waitPreviousSessionFlush() { final @NotNull List exitInfos) { for (Iterator it = exitInfos.iterator(); it.hasNext(); ) { ApplicationExitInfo applicationExitInfo = it.next(); - if (applicationExitInfo.getReason() == policy.getTargetReason()) { + if (policy.matches(applicationExitInfo)) { it.remove(); return applicationExitInfo; } @@ -149,7 +166,7 @@ private void reportHistorical( final @Nullable Long lastReportedTimestamp) { Collections.reverse(exitInfos); for (ApplicationExitInfo applicationExitInfo : exitInfos) { - if (applicationExitInfo.getReason() == policy.getTargetReason()) { + if (policy.matches(applicationExitInfo)) { if (applicationExitInfo.getTimestamp() < threshold) { options .getLogger() @@ -223,20 +240,43 @@ private void report(final @NotNull ApplicationExitInfo exitInfo, final boolean e } } + /** + * Exit-specific contract for the shared {@link ApplicationExitInfo} recovery pipeline. + * + *

{@link ApplicationExitInfoHistoryDispatcher} owns the generic startup flow for recovering + * past process deaths from Android's historical exit list. An {@code ApplicationExitInfoPolicy} + * provides the rules for a given exit family, including how to recognize matching records, + * whether older matches should also be reported, how deduplication is tracked, and how matching + * record is turned into a synthetic Sentry event. + */ interface ApplicationExitInfoPolicy { + + /** Returns the human-readable label used in dispatcher logs for this exit family. */ @NotNull String getLabel(); - int getTargetReason(); + /** Returns {@code true} when the given {@link ApplicationExitInfo} belongs to this policy. */ + boolean matches(@NotNull ApplicationExitInfo exitInfo); + /** Returns whether older matching exits should be reported in addition to the latest one. */ boolean shouldReportHistorical(); + /** Returns the timestamp of the most recently reported matching exit, if one was recorded. */ @Nullable Long getLastReportedTimestamp(); - /** Records {@code timestamp} as the last reported exit, so it is not reported again. */ + /** + * Records {@code timestamp} as the last reported matching exit, so it is not reported again. + */ void markReported(long timestamp); + /** + * Builds the synthetic report for a matching exit. + * + *

{@code enrich} indicates whether the dispatcher is reporting the latest recovered exit, + * which may be backfilled with persisted launch state, or an older historical one, which is + * typically kept leaner. + */ @Nullable Report buildReport(@NotNull ApplicationExitInfo exitInfo, boolean enrich); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 1ca91bbada3..9f31c6751b0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -47,6 +47,9 @@ final class ManifestMetadataReader { static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; static final String TOMBSTONE_ATTACH_RAW = "io.sentry.tombstone.attach-raw"; static final String TOMBSTONE_REPORT_HISTORICAL = "io.sentry.tombstone.report-historical"; + static final String MEMORY_LIMITER_ENABLE = "io.sentry.memory-limiter.enable"; + static final String MEMORY_LIMITER_REPORT_HISTORICAL = + "io.sentry.memory-limiter.report-historical"; static final String AUTO_INIT = "io.sentry.auto-init"; static final String NDK_ENABLE = "io.sentry.ndk.enable"; @@ -253,6 +256,14 @@ static void applyMetadata( logger, TOMBSTONE_REPORT_HISTORICAL, options.isReportHistoricalTombstones())); + options.setMemoryLimiterEnabled( + readBool(metadata, logger, MEMORY_LIMITER_ENABLE, options.isMemoryLimiterEnabled())); + options.setReportHistoricalMemoryLimiterExits( + readBool( + metadata, + logger, + MEMORY_LIMITER_REPORT_HISTORICAL, + options.isReportHistoricalMemoryLimiterExits())); // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( 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 new file mode 100644 index 00000000000..145d61b35a9 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java @@ -0,0 +1,353 @@ +package io.sentry.android.core; + +import static io.sentry.SentryLevel.DEBUG; +import static io.sentry.SentryLevel.INFO; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.app.ActivityManager.RunningAppProcessInfo; +import android.app.ApplicationExitInfo; +import android.content.Context; +import android.os.Build; +import androidx.annotation.RequiresApi; +import io.sentry.DateUtils; +import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.Integration; +import io.sentry.SentryBaseEvent; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy; +import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.hints.Backfillable; +import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.Message; +import io.sentry.protocol.SentryException; +import io.sentry.protocol.SentryId; +import io.sentry.transport.CurrentDateProvider; +import io.sentry.transport.ICurrentDateProvider; +import io.sentry.util.ExceptionUtils; +import io.sentry.util.HintUtils; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * Reports Android process deaths that the OS records as MemoryLimiter kills. + * + *

Checks Android's historical {@link ApplicationExitInfo} records on app start, finds exits that + * match the MemoryLimiter signature, and turns them into Sentry events. + * + *

Data generated + * + *

Each matching exit is reported as a synthetic fatal event with a {@code MemoryLimitExceeded} + * exception. The original Android exit description is stored together with MemoryLimiter-specific + * context in {@code mechanism.data}, including the raw {@link ApplicationExitInfo#getImportance()} + * value and a derived {@code process_visibility} classification ({@code visible}, {@code + * not_visible}, or {@code cached}). + * + *

The process-visibility value is a best-effort mapping of Android's MemoryLimiter + * process-monitoring states. {@link ApplicationExitInfo} exposes process importance but not the + * exact {@code PROCESS_STATE_*} value used by that table, so some importance bands remain + * inherently lossy when translated back into MemoryLimiter's visibility categories. + * + *

Events may also be backfilled with persisted launch state from the crashed app generation, + * including release info, environment, and other scope data. + * + *

Limitations + * + *

Only available on Android API ≥ 37. + */ +@ApiStatus.Internal +public final class MemoryLimiterIntegration implements Integration, Closeable { + + static final @NotNull String MEMORY_LIMITER_DESCRIPTION_PREFIX = "MemoryLimiter:"; + static final @NotNull String MEMORY_LIMITER_DESCRIPTION = + MEMORY_LIMITER_DESCRIPTION_PREFIX + "AnonSwap"; + + static final @NotNull String MEMORY_LIMITER_MESSAGE = "Android process killed by MemoryLimiter"; + + static final @NotNull String IMPORTANCE_DATA_KEY = "importance"; + static final @NotNull String PROCESS_VISIBILITY_DATA_KEY = "process_visibility"; + static final @NotNull String PROCESS_VISIBILITY_CACHED = "cached"; + static final @NotNull String PROCESS_VISIBILITY_NOT_VISIBLE = "not_visible"; + static final @NotNull String PROCESS_VISIBILITY_VISIBLE = "visible"; + + private final @NotNull Context context; + private final @NotNull ICurrentDateProvider dateProvider; + private final @NotNull BuildInfoProvider buildInfoProvider; + private @Nullable SentryAndroidOptions androidOptions; + + public MemoryLimiterIntegration( + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + // Use CurrentDateProvider instead of AndroidCurrentDateProvider as ApplicationExitInfo uses + // epochal System.currentTimeMillis and not time since boot. + this(context, CurrentDateProvider.getInstance(), buildInfoProvider); + } + + @TestOnly + MemoryLimiterIntegration( + final @NotNull Context context, + final @NotNull ICurrentDateProvider dateProvider, + final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = ContextUtils.getApplicationContext(context); + this.dateProvider = dateProvider; + this.buildInfoProvider = buildInfoProvider; + } + + @Override + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { + androidOptions = + Objects.requireNonNull( + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, + "SentryAndroidOptions is required"); + + androidOptions + .getLogger() + .log( + DEBUG, "MemoryLimiterIntegration enabled: %s", androidOptions.isMemoryLimiterEnabled()); + + if (!androidOptions.isMemoryLimiterEnabled()) { + return; + } + + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.CINNAMON_BUN) { + androidOptions + .getLogger() + .log( + INFO, + "MemoryLimiter is only supported on Android API 37 and above. Skipping registration."); + return; + } + + if (this.androidOptions.getCacheDirPath() == null) { + this.androidOptions + .getLogger() + .log( + INFO, + "Cache dir is not set, unable to process MemoryLimiter exits. Skipping registration."); + return; + } + + try { + options + .getExecutorService() + .submit( + new ApplicationExitInfoHistoryDispatcher( + context, + scopes, + androidOptions, + dateProvider, + new MemoryLimiterPolicy(androidOptions))); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + options.getLogger().log(DEBUG, "Failed to start MemoryLimiter processor.", e); + } + + options.getLogger().log(DEBUG, "MemoryLimiterIntegration installed."); + addIntegrationToSdkVersion("MemoryLimiter"); + } + + @Override + public void close() throws IOException { + if (androidOptions != null) { + androidOptions.getLogger().log(DEBUG, "MemoryLimiterIntegration removed."); + } + } + + /** + * Defines how MemoryLimiter exits are recognized and reported within the shared {@link + * ApplicationExitInfoHistoryDispatcher} pipeline. + * + *

This policy provides the MemoryLimiter-specific rules for that pipeline, including which + * exit records match, whether older matching exits should also be reported, how deduplication is + * tracked on disk, and what synthetic event and hint should be created for a matching exit. + */ + private static final class MemoryLimiterPolicy implements ApplicationExitInfoPolicy { + + private final @NotNull SentryAndroidOptions options; + + private MemoryLimiterPolicy(final @NotNull SentryAndroidOptions options) { + this.options = options; + } + + @Override + public @NotNull String getLabel() { + return "MemoryLimiter process death"; + } + + /** + * Returns true if the provided {@code exitInfo} looks like it comes 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 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 reasons, but for now doesn't kill the process + // because of them.) + return description != null && description.contains(MEMORY_LIMITER_DESCRIPTION_PREFIX); + } + + @Override + public boolean shouldReportHistorical() { + return options.isReportHistoricalMemoryLimiterExits(); + } + + @Override + public @Nullable Long getLastReportedTimestamp() { + return AndroidEnvelopeCache.lastReportedMemoryLimiter(options); + } + + @Override + public void markReported(final long timestamp) { + AndroidEnvelopeCache.markMemoryLimiterReported(options, timestamp); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public @NotNull ApplicationExitInfoHistoryDispatcher.Report buildReport( + final @NotNull ApplicationExitInfo exitInfo, final boolean shouldEnrich) { + final long timestamp = exitInfo.getTimestamp(); + + final MemoryLimiterHint memoryLimiterHint = + new MemoryLimiterHint( + options.getFlushTimeoutMillis(), options.getLogger(), timestamp, shouldEnrich); + final Hint hint = HintUtils.createWithTypeCheckHint(memoryLimiterHint); + + final Message message = new Message(); + message.setFormatted(MEMORY_LIMITER_MESSAGE); + + final SentryEvent event = new SentryEvent(); + event.setMessage(message); + event.setLevel(SentryLevel.FATAL); + event.setPlatform(SentryBaseEvent.DEFAULT_PLATFORM); + event.setTimestamp(DateUtils.getDateTime(timestamp)); + event.setExceptions(Collections.singletonList(buildException(exitInfo, shouldEnrich))); + + return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, memoryLimiterHint); + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private @NotNull SentryException buildException( + final @NotNull ApplicationExitInfo exitInfo, final boolean shouldEnrich) { + final Mechanism mechanism = new Mechanism(); + mechanism.setType(shouldEnrich ? "AppExitInfo" : "HistoricalAppExitInfo"); + mechanism.setDescription(exitInfo.getDescription()); + mechanism.setHandled(false); + mechanism.setSynthetic(true); + mechanism.setData(buildMechanismData(exitInfo)); + + final SentryException sentryException = new SentryException(); + sentryException.setType("MemoryLimitExceeded"); + sentryException.setValue(MEMORY_LIMITER_MESSAGE); + sentryException.setModule("io.sentry.android.core"); + sentryException.setMechanism(mechanism); + return sentryException; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private @NotNull Map buildMechanismData( + final @NotNull ApplicationExitInfo exitInfo) { + final int importance = exitInfo.getImportance(); + final Map data = new HashMap<>(); + data.put(IMPORTANCE_DATA_KEY, importance); + data.put(PROCESS_VISIBILITY_DATA_KEY, getProcessVisibility(importance)); + return data; + } + + /** + * Best-effort mapping from {@link ApplicationExitInfo#getImportance()} to MemoryLimiter's + * visible / not-visible / cached categories. + * + *

The MemoryLimiter + * docs classify exact {@code PROCESS_STATE_*} values, but {@link ApplicationExitInfo} only + * exposes the coarser {@link RunningAppProcessInfo} importance bucket. + */ + private @NotNull String getProcessVisibility(final int importance) { + switch (importance) { + case RunningAppProcessInfo.IMPORTANCE_FOREGROUND: + case RunningAppProcessInfo.IMPORTANCE_VISIBLE: + case RunningAppProcessInfo.IMPORTANCE_TOP_SLEEPING: + return PROCESS_VISIBILITY_VISIBLE; + + case RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE: + case RunningAppProcessInfo.IMPORTANCE_PERCEPTIBLE: + case RunningAppProcessInfo.IMPORTANCE_SERVICE: + case RunningAppProcessInfo.IMPORTANCE_CANT_SAVE_STATE: + case RunningAppProcessInfo.IMPORTANCE_GONE: + return PROCESS_VISIBILITY_NOT_VISIBLE; + + case RunningAppProcessInfo.IMPORTANCE_CACHED: + default: + // Fall back to the least specific bucket. + return PROCESS_VISIBILITY_CACHED; + } + } + } + + /** + * Hint associated with a recovered MemoryLimiter event. + * + *

This hint serves two purposes in the {@link ApplicationExitInfo} recovery pipeline: it lets + * the dispatcher wait for the event to flush to disk before considering the exit reported, and it + * tells {@link ApplicationExitInfoEventProcessor} whether the recovered event should be + * backfilled with persisted launch state or kept as a lighter historical record. + */ + @ApiStatus.Internal + public static final class MemoryLimiterHint extends BlockingFlushHint implements Backfillable { + + private final long epochTimestampMs; + private final boolean shouldEnrich; + + public MemoryLimiterHint( + final long flushTimeoutMillis, + final @NotNull ILogger logger, + final long epochTimestampMs, + final boolean shouldEnrich) { + super(flushTimeoutMillis, logger); + this.epochTimestampMs = epochTimestampMs; + this.shouldEnrich = shouldEnrich; + } + + /** Returns epoch wall-clock time, in milliseconds. */ + public long timestamp() { + return epochTimestampMs; + } + + @Override + public boolean shouldEnrich() { + return shouldEnrich; + } + + @Override + public boolean isFlushable(@Nullable SentryId eventId) { + return true; + } + + @Override + public void setFlushable(@NotNull SentryId eventId) {} + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 202d779d61d..d3e24766cf1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -250,6 +250,25 @@ public interface BeforeCaptureCallback { */ private boolean reportHistoricalTombstones = false; + /** + * Controls whether to report MemoryLimiter exits from + * the {@link ApplicationExitInfo} system API. + * + *

If this flag is true and {@link #reportHistoricalMemoryLimiterExits} is false, then only the + * latest MemoryLimiter exit is reported. If both are true, then all available exits are reported. + */ + private boolean memoryLimiterEnabled = false; + + /** + * Controls whether to report historical MemoryLimiter exits from + * the {@link ApplicationExitInfo} system API (where "historical" means "older than the latest"). + * + *

No-ops if {@link #memoryLimiterEnabled} is false. + */ + private boolean reportHistoricalMemoryLimiterExits = false; + /** * Controls whether to send ANR (v2) thread dump as an attachment with plain text. The thread dump * is being attached from {@link ApplicationExitInfo#getTraceInputStream()}, if available. @@ -725,6 +744,57 @@ public void setAttachRawTombstone(final boolean attachRawTombstone) { this.attachRawTombstone = attachRawTombstone; } + @ApiStatus.Experimental + public boolean isMemoryLimiterEnabled() { + return memoryLimiterEnabled; + } + + /** + * Enables or disables reporting of Android MemoryLimiter exits. + * Disabled by default. + * + *

Enabling this flag allows the SDK to inspect retained {@link ApplicationExitInfo} records on + * the next app start and report the latest retained matching MemoryLimiter exit as a Sentry + * event. + * + *

See {@link #setReportHistoricalMemoryLimiterExits(boolean)} if you also want to create + * Sentry events from MemoryLimiter exits before the latest. + * + *

Available on Android API ≥ 37. No-ops if the Android API is below 37 or if the {@link + * #setCacheDirPath cache dir path} hasn't been set. + */ + @ApiStatus.Experimental + public void setMemoryLimiterEnabled(final boolean memoryLimiterEnabled) { + this.memoryLimiterEnabled = memoryLimiterEnabled; + } + + @ApiStatus.Experimental + public boolean isReportHistoricalMemoryLimiterExits() { + return reportHistoricalMemoryLimiterExits; + } + + /** + * Enables or disables reporting of historical retained MemoryLimiter exits on + * startup (where "historical" means "older than the latest"). Disabled by default. + * + *

Use this together with {@link #setMemoryLimiterEnabled(boolean)} when you want the + * platform's complete retained exit history. + * + *

Note: unlike the latest exit, historical exits are not enriched with contextual + * Sentry data. + * + *

Available on Android API ≥ 37. No-ops if the Android API is below 37, if the {@link + * #setCacheDirPath cache dir path} hasn't been set, or if {@link #setMemoryLimiterEnabled the + * MemoryLimiter integration} hasn't been enabled. + */ + @ApiStatus.Experimental + public void setReportHistoricalMemoryLimiterExits( + final boolean reportHistoricalMemoryLimiterExits) { + this.reportHistoricalMemoryLimiterExits = reportHistoricalMemoryLimiterExits; + } + /** * @return true if performance-v2 is enabled. See {@link #setEnablePerformanceV2(boolean)} for * more details. diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java index d6d6a7767c8..48832ca8dce 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -133,8 +133,8 @@ public TombstonePolicy(final @NotNull SentryAndroidOptions options, @NotNull Con @RequiresApi(api = Build.VERSION_CODES.R) @Override - public int getTargetReason() { - return ApplicationExitInfo.REASON_CRASH_NATIVE; + public boolean matches(final @NotNull ApplicationExitInfo exitInfo) { + return exitInfo.getReason() == ApplicationExitInfo.REASON_CRASH_NATIVE; } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 0373c39deea..8beff7d6e97 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -9,6 +9,7 @@ import io.sentry.SentryOptions; import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AnrV2Integration; +import io.sentry.android.core.MemoryLimiterIntegration; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.TombstoneIntegration; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; @@ -35,6 +36,7 @@ public final class AndroidEnvelopeCache extends EnvelopeCache { public static final String LAST_ANR_REPORT = "last_anr_report"; public static final String LAST_TOMBSTONE_REPORT = "last_tombstone_report"; + public static final String LAST_MEMORY_LIMITER_REPORT = "last_memory_limiter_report"; private final @NotNull ICurrentDateProvider currentDateProvider; @@ -216,6 +218,11 @@ private static void writeLastReportedMarker( return lastReportedMarker(options, LAST_TOMBSTONE_REPORT, LAST_TOMBSTONE_MARKER_LABEL); } + public static @Nullable Long lastReportedMemoryLimiter(final @NotNull SentryOptions options) { + return lastReportedMarker( + options, LAST_MEMORY_LIMITER_REPORT, LAST_MEMORY_LIMITER_MARKER_LABEL); + } + public static void markAnrReported(final @NotNull SentryOptions options, final long timestamp) { writeLastReportedMarker(options, timestamp, LAST_ANR_REPORT, LAST_ANR_MARKER_LABEL); } @@ -225,6 +232,12 @@ public static void markTombstoneReported( writeLastReportedMarker(options, timestamp, LAST_TOMBSTONE_REPORT, LAST_TOMBSTONE_MARKER_LABEL); } + public static void markMemoryLimiterReported( + final @NotNull SentryOptions options, final long timestamp) { + writeLastReportedMarker( + options, timestamp, LAST_MEMORY_LIMITER_REPORT, LAST_MEMORY_LIMITER_MARKER_LABEL); + } + private static final class TimestampMarkerHandler { interface TimestampExtractor { @NotNull @@ -267,6 +280,7 @@ void handle(final @NotNull Hint hint, final @NotNull SentryAndroidOptions option public static final String LAST_TOMBSTONE_MARKER_LABEL = "Tombstone"; public static final String LAST_ANR_MARKER_LABEL = "ANR"; + public static final String LAST_MEMORY_LIMITER_MARKER_LABEL = "MemoryLimiter"; private static final List> TIMESTAMP_MARKER_HANDLERS = Arrays.asList( new TimestampMarkerHandler<>( @@ -278,5 +292,10 @@ void handle(final @NotNull Hint hint, final @NotNull SentryAndroidOptions option TombstoneIntegration.TombstoneHint.class, LAST_TOMBSTONE_MARKER_LABEL, LAST_TOMBSTONE_REPORT, - tombstoneHint -> tombstoneHint.timestamp())); + tombstoneHint -> tombstoneHint.timestamp()), + new TimestampMarkerHandler<>( + MemoryLimiterIntegration.MemoryLimiterHint.class, + LAST_MEMORY_LIMITER_MARKER_LABEL, + LAST_MEMORY_LIMITER_REPORT, + memoryLimiterHint -> memoryLimiterHint.timestamp())); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 85fba36f7d0..4610e4bbcb8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -989,6 +989,56 @@ class AndroidOptionsInitializerTest { assertNull(anrv1Integration) } + @Test + fun `MemoryLimiterIntegration added to integrations list for API 37 and above`() { + val options = SentryAndroidOptions() + val buildInfo = mock() + whenever(buildInfo.sdkInfoVersion).thenReturn(37) + val loadClass = LoadClass() + val activityFramesTracker = ActivityFramesTracker(loadClass, options) + + AndroidOptionsInitializer.installDefaultIntegrations( + fixture.context, + options, + buildInfo, + loadClass, + activityFramesTracker, + false, + false, + false, + false, + ) + + val integration = options.integrations.firstOrNull { it is MemoryLimiterIntegration } + + assertNotNull(integration) + } + + @Test + fun `MemoryLimiterIntegration not added to integrations list below API 37`() { + val options = SentryAndroidOptions() + val buildInfo = mock() + whenever(buildInfo.sdkInfoVersion).thenReturn(36) + val loadClass = LoadClass() + val activityFramesTracker = ActivityFramesTracker(loadClass, options) + + AndroidOptionsInitializer.installDefaultIntegrations( + fixture.context, + options, + buildInfo, + loadClass, + activityFramesTracker, + false, + false, + false, + false, + ) + + val integration = options.integrations.firstOrNull { it is MemoryLimiterIntegration } + + assertNull(integration) + } + @Test fun `AndroidUserFeedbackFormHandler is set as feedback form handler`() { fixture.initSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index d9fd9c1889e..89fc9e65cb0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -221,6 +221,18 @@ class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { verify(fixture.scopes).captureEvent(any(), check { assertNotNull(it.threadDump) }) } + @Test + fun `MemoryLimiter marker does not suppress matching ANR exit`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + File(fixture.options.cacheDirPath!!, AndroidEnvelopeCache.LAST_MEMORY_LIMITER_REPORT) + .writeText(newTimestamp.toString()) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + @Test fun `when traceInputStream is null, does not report ANR`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoCrossIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoCrossIntegrationTest.kt new file mode 100644 index 00000000000..f362fed252d --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoCrossIntegrationTest.kt @@ -0,0 +1,268 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryEvent +import io.sentry.cache.EnvelopeCache +import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService +import java.io.File +import java.util.zip.GZIPInputStream +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder + +// Our current Robolectric version (4.15) caps at API 35. All ApplicationExitInfo code paths we +// exercise are at API 31 or below, so we use the latter. +@RunWith(AndroidJUnit4::class) +@Config(sdk = [31]) +class ApplicationExitInfoCrossIntegrationTest { + + @get:Rule val tmpDir = TemporaryFolder() + + private class Fixture { + lateinit var context: Context + lateinit var shadowActivityManager: ShadowActivityManager + + fun init(appContext: Context) { + context = appContext + context.applicationInfo.nativeLibraryDir = + "/data/app/~~gu-2hA9_Zg6tfIuDAbLpKA==/io.sentry.samples.android-MFqmKAMnl9AjNlHcO3mejA==/lib/arm64" + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + shadowActivityManager = Shadow.extract(activityManager) + } + + fun newOptions( + dir: File, + configure: SentryAndroidOptions.() -> Unit, + ): SentryAndroidOptions { + return SentryAndroidOptions().apply { + val logger = mock() + whenever(logger.isEnabled(any())).thenReturn(true) + setLogger(logger) + isDebug = true + cacheDirPath = dir.absolutePath + executorService = ImmediateExecutorService() + setEnvelopeDiskCache(EnvelopeCache.create(this)) + addInAppInclude("io.sentry.samples") + configure() + } + } + + fun addMemoryLimiterExit(timestamp: Long) { + addExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + timestamp = timestamp, + description = MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, + importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ) + } + + fun addAnrExit(timestamp: Long) { + val builder = ApplicationExitInfoBuilder.newBuilder() + builder.setReason(ApplicationExitInfo.REASON_ANR) + builder.setTimestamp(timestamp) + builder.setImportance(ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val exitInfo = + spy(builder.build()) { + whenever(mock.traceInputStream) + .thenReturn( + """ + Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) + Here are no Binder-related exception messages available. + Pid(12233) have D state thread(tid:12236 name:Signal Catcher) + + + ----- dumping pid: 12233 at 313446151 ----- + "main" prio=5 tid=1 Native + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72c4c9a0 self=0xb40000779f142000 + | sysTid=12233 nice=-10 cgrp=top-app sched=0/0 handle=0x7d8e4c44f8 + at io.sentry.samples.MainActivity.blocked(MainActivity.java:42) + at android.os.Looper.loopOnce(Looper.java:226) + at android.os.Looper.loop(Looper.java:313) + """ + .trimIndent() + .byteInputStream() + ) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + + fun addTombstoneExit(timestamp: Long) { + val builder = ApplicationExitInfoBuilder.newBuilder() + builder.setReason(ApplicationExitInfo.REASON_CRASH_NATIVE) + builder.setTimestamp(timestamp) + builder.setImportance(ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val exitInfo = + spy(builder.build()) { + whenever(mock.traceInputStream) + .thenReturn( + GZIPInputStream( + TombstoneIntegrationTest::class.java.getResourceAsStream("/tombstone.pb.gz") + ) + ) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + + private fun addExitInfo( + reason: Int, + timestamp: Long, + description: String?, + importance: Int, + ) { + val builder = ApplicationExitInfoBuilder.newBuilder() + builder.setReason(reason) + builder.setTimestamp(timestamp) + builder.setImportance(importance) + val exitInfo = + spy(builder.build()) { + whenever(mock.description).thenReturn(description) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun `set up`() { + fixture.init(ApplicationProvider.getApplicationContext()) + } + + @AfterTest + fun `tear down`() { + tmpDir.root.deleteRecursively() + } + + @Test + fun `memory limiter and anr integrations each capture only their own exits from shared history`() { + val memoryLimiterTimestamp = System.currentTimeMillis() - 1_000 + val anrTimestamp = memoryLimiterTimestamp + 500 + fixture.addMemoryLimiterExit(memoryLimiterTimestamp) + fixture.addAnrExit(anrTimestamp) + + val memoryLimiterScopes = mock() + whenever(memoryLimiterScopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val memoryLimiterOptions = + fixture.newOptions(tmpDir.newFolder("memory-limiter")) { + isMemoryLimiterEnabled = true + isReportHistoricalMemoryLimiterExits = true + } + val buildInfoProvider = mock() + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(37) + MemoryLimiterIntegration( + fixture.context, + io.sentry.transport.CurrentDateProvider.getInstance(), + buildInfoProvider, + ) + .register(memoryLimiterScopes, memoryLimiterOptions) + + verify(memoryLimiterScopes) + .captureEvent( + argThat { timestamp!!.time == memoryLimiterTimestamp }, + anyOrNull(), + ) + verify(memoryLimiterScopes, never()) + .captureEvent( + argThat { timestamp!!.time == anrTimestamp }, + anyOrNull(), + ) + + val anrScopes = mock() + whenever(anrScopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val anrOptions = + fixture.newOptions(tmpDir.newFolder("anr")) { + isAnrEnabled = true + isReportHistoricalAnrs = true + } + AnrV2Integration(fixture.context).register(anrScopes, anrOptions) + + verify(anrScopes) + .captureEvent( + argThat { timestamp!!.time == anrTimestamp }, + anyOrNull(), + ) + verify(anrScopes, never()) + .captureEvent( + argThat { timestamp!!.time == memoryLimiterTimestamp }, + anyOrNull(), + ) + } + + @Test + fun `memory limiter and tombstone integrations each capture only their own exits from shared history`() { + val memoryLimiterTimestamp = System.currentTimeMillis() - 1_000 + val tombstoneTimestamp = memoryLimiterTimestamp + 500 + fixture.addMemoryLimiterExit(memoryLimiterTimestamp) + fixture.addTombstoneExit(tombstoneTimestamp) + + val memoryLimiterScopes = mock() + whenever(memoryLimiterScopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val memoryLimiterOptions = + fixture.newOptions(tmpDir.newFolder("memory-limiter")) { + isMemoryLimiterEnabled = true + isReportHistoricalMemoryLimiterExits = true + } + val buildInfoProvider = mock() + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(37) + MemoryLimiterIntegration( + fixture.context, + io.sentry.transport.CurrentDateProvider.getInstance(), + buildInfoProvider, + ) + .register(memoryLimiterScopes, memoryLimiterOptions) + + verify(memoryLimiterScopes) + .captureEvent( + argThat { timestamp!!.time == memoryLimiterTimestamp }, + anyOrNull(), + ) + verify(memoryLimiterScopes, never()) + .captureEvent( + argThat { timestamp!!.time == tombstoneTimestamp }, + anyOrNull(), + ) + + val tombstoneScopes = mock() + whenever(tombstoneScopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val tombstoneOptions = + fixture.newOptions(tmpDir.newFolder("tombstone")) { + isTombstoneEnabled = true + isReportHistoricalTombstones = true + } + TombstoneIntegration(fixture.context).register(tombstoneScopes, tombstoneOptions) + + verify(tombstoneScopes) + .captureEvent( + argThat { timestamp!!.time == tombstoneTimestamp }, + anyOrNull(), + ) + verify(tombstoneScopes, never()) + .captureEvent( + argThat { timestamp!!.time == memoryLimiterTimestamp }, + anyOrNull(), + ) + } +} 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 f484f994dbb..fa269aea717 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 @@ -50,6 +50,8 @@ import io.sentry.protocol.Contexts import io.sentry.protocol.DebugImage import io.sentry.protocol.DebugMeta import io.sentry.protocol.Device +import io.sentry.protocol.Mechanism +import io.sentry.protocol.Message import io.sentry.protocol.OperatingSystem import io.sentry.protocol.Request import io.sentry.protocol.Response @@ -217,7 +219,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `when backfillable event is not enrichable, sets different mechanism`() { - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false)) + val hint = anrHint(shouldEnrich = false) val processed = processEvent(hint) @@ -226,7 +228,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `when backfillable event is not enrichable, sets platform`() { - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(shouldEnrich = false)) + val hint = anrHint(shouldEnrich = false) val processed = processEvent(hint) @@ -290,7 +292,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `when backfillable event is enrichable, still sets static data`() { - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + val hint = anrHint() val processed = processEvent(hint) @@ -387,7 +389,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `when ANR event is enrichable, sets foreground flag`() { - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + val hint = anrHint() val processed = processEvent(hint, populateOptionsCache = true) @@ -603,6 +605,81 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.contexts.app) } + @Test + fun `memory limiter event uses current options when cache generation is stale`() { + val hint = memoryLimiterHint(timestamp = 3_000) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@2.0.0+300" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.options.proguardUuid = "current-uuid" + fixture.options.sdkVersion = SdkVersion("current-sdk", "2.0.0") + fixture.options.setTag("account", "current-tag") + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "previous-user") + fixture.persistOptions(DIST_FILENAME, "previous-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("previous-sdk", "1.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "previous-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(2_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@2.0.0+300", processed.release) + assertEquals("current-user", processed.environment) + assertEquals("current-dist", processed.dist) + assertEquals("current-uuid", processed.debugMeta!!.images!![0].uuid) + assertEquals("current-sdk", processed.sdk!!.name) + assertEquals("current-tag", processed.tags!!["account"]) + } + + @Test + fun `memory limiter event uses persisted options when cache generation matches crashed app`() { + val hint = memoryLimiterHint(timestamp = 2_000) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.0.0+100" + fixture.options.environment = "current-user" + fixture.options.dist = "current-dist" + fixture.options.setTag("account", "current-tag") + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100") + fixture.persistOptions(ENVIRONMENT_FILENAME, "crashed-user") + fixture.persistOptions(DIST_FILENAME, "crashed-dist") + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "crashed-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null) + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.0.0+100", processed.release) + assertEquals("crashed-user", processed.environment) + assertEquals("crashed-dist", processed.dist) + assertEquals("crashed-tag", processed.tags!!["account"]) + } + + @Test + fun `memory limiter event ignores persisted options when cache generation is newer than exit`() { + val hint = memoryLimiterHint(timestamp = 2_000) + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@2.0.0+200") + fixture.persistOptions(ENVIRONMENT_FILENAME, "newer-user") + fixture.persistOptions(DIST_FILENAME, "newer-dist") + fixture.persistOptions(PROGUARD_UUID_FILENAME, "newer-uuid") + fixture.persistOptions(SDK_VERSION_FILENAME, SdkVersion("newer-sdk", "2.0.0")) + fixture.persistOptions(OPTIONS_TAGS_FILENAME, mapOf("account" to "newer-tag")) + PersistingOptionsCacheGenerationObserver(fixture.options, 2_500L).setRelease(null) + setLastUpdateTime(3_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.release) + assertNull(processed.environment) + assertNull(processed.dist) + assertTrue(processed.debugMeta!!.images!!.isEmpty()) + assertNull(processed.sdk) + assertNull(processed.tags?.get("account")) + } + @Test fun `if dist is not persisted, backfills it from release`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -715,7 +792,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `populates exception from main thread`() { - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + val hint = anrHint() val stacktrace = SentryStackTrace().apply { frames = @@ -755,7 +832,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `populates exception without stacktrace when there is no main thread in threads`() { - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + val hint = anrHint() val processed = processEvent(hint) { threads = listOf(SentryThread()) } @@ -769,7 +846,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `adds Background to the message when mechanism is anr_background`() { - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_background")) + val hint = anrHint(mechanism = "anr_background") val processed = processEvent(hint) { @@ -788,7 +865,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `does not add Background to the message when mechanism is anr_foreground`() { - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val hint = anrHint(mechanism = "anr_foreground") val processed = processEvent(hint) { @@ -807,8 +884,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `sets default fingerprint to distinguish between background and foreground ANRs`() { - val backgroundHint = - HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_background")) + val backgroundHint = anrHint(mechanism = "anr_background") val processedBackground = processEvent(backgroundHint, populateScopeCache = false) { exceptions = @@ -828,8 +904,7 @@ class ApplicationExitInfoEventProcessorTest { } assertEquals(listOf("{{ default }}", "background-anr"), processedBackground.fingerprints) - val foregroundHint = - HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val foregroundHint = anrHint(mechanism = "anr_foreground") val processedForeground = processEvent(foregroundHint, populateScopeCache = false) { exceptions = @@ -873,10 +948,49 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.fingerprints) } + @Test + fun `memory limiter hint does not apply ANR-specific enrichment`() { + val hint = + HintUtils.createWithTypeCheckHint( + MemoryLimiterIntegration.MemoryLimiterHint( + fixture.options.flushTimeoutMillis, + NoOpLogger.getInstance(), + 1_000, + true, + ) + ) + + val processed = + processEvent(hint, populateScopeCache = false, populateOptionsCache = false) { + level = SentryLevel.FATAL + platform = SentryBaseEvent.DEFAULT_PLATFORM + message = Message().apply { formatted = MemoryLimiterIntegration.MEMORY_LIMITER_MESSAGE } + exceptions = + listOf( + SentryException().apply { + type = "MemoryLimitExceeded" + value = MemoryLimiterIntegration.MEMORY_LIMITER_MESSAGE + mechanism = Mechanism().apply { type = "AppExitInfo" } + } + ) + } + + assertEquals(SentryBaseEvent.DEFAULT_PLATFORM, processed.platform) + assertEquals( + MemoryLimiterIntegration.MEMORY_LIMITER_MESSAGE, + processed.message!!.formatted, + ) + assertEquals("MemoryLimitExceeded", processed.exceptions!!.first().type) + assertEquals("AppExitInfo", processed.exceptions!!.first().mechanism!!.type) + assertNull(processed.fingerprints) + assertNull(processed.contexts.app?.inForeground) + assertNull(processed.contexts.profile) + } + @Test fun `sets system-frames-only fingerprint when ANR fingerprinting enabled and no app frames`() { fixture.options.isEnableAnrFingerprinting = true - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val hint = anrHint(mechanism = "anr_foreground") val processed = processEvent(hint, populateScopeCache = false) { @@ -905,7 +1019,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `does not set system-frames-only fingerprint when ANR fingerprinting is disabled and no app frames are present`() { fixture.options.isEnableAnrFingerprinting = false - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val hint = anrHint(mechanism = "anr_foreground") val processed = processEvent(hint, populateScopeCache = false) { @@ -934,7 +1048,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `sets default fingerprint when ANR fingerprinting enabled and app frames are present`() { fixture.options.isEnableAnrFingerprinting = true - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val hint = anrHint(mechanism = "anr_foreground") val processed = processEvent(hint, populateScopeCache = false) { @@ -962,7 +1076,7 @@ class ApplicationExitInfoEventProcessorTest { @Test fun `does not set profile context when ANR profiling is disabled`() { fixture.options.anrProfilingSampleRate = null - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val hint = anrHint(mechanism = "anr_foreground") val processed = processEvent(hint, populateScopeCache = false) { threads = @@ -996,7 +1110,8 @@ class ApplicationExitInfoEventProcessorTest { isSendDefaultPii = false, ) - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val hintTimestamp = System.currentTimeMillis() + val hint = anrHint(mechanism = "anr_foreground", timestamp = hintTimestamp) AnrProfileManager( fixture.options, @@ -1005,7 +1120,7 @@ class ApplicationExitInfoEventProcessorTest { .apply { add( AnrStackTrace( - System.currentTimeMillis(), + hintTimestamp, arrayOf( StackTraceElement( "android.view.Choreographer", @@ -1054,10 +1169,7 @@ class ApplicationExitInfoEventProcessorTest { fixture.persistOptions(PROGUARD_UUID_FILENAME, "previous-uuid") setLastUpdateTime(2_000) - val hint = - HintUtils.createWithTypeCheckHint( - AbnormalExitHint(mechanism = "anr_foreground", timestamp = 1_000) - ) + val hint = anrHint(mechanism = "anr_foreground", timestamp = 1_000) AnrProfileManager( fixture.options, @@ -1098,7 +1210,7 @@ class ApplicationExitInfoEventProcessorTest { fun `does not crash when ANR profiling is enabled but cache dir is null`() { fixture.options.anrProfilingSampleRate = 1.0 fixture.options.cacheDirPath = null - val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val hint = anrHint(mechanism = "anr_foreground") val original = SentryEvent() val processor = fixture.getSut(tmpDir) @@ -1242,6 +1354,36 @@ class ApplicationExitInfoEventProcessorTest { lastUpdateTime } + private fun anrHint( + mechanism: String = "anr_foreground", + shouldEnrich: Boolean = true, + timestamp: Long = 0, + ): Hint { + return HintUtils.createWithTypeCheckHint( + AnrV2Integration.AnrV2Hint( + fixture.options.flushTimeoutMillis, + NoOpLogger.getInstance(), + timestamp, + shouldEnrich, + mechanism == "anr_background", + ) + ) + } + + private fun memoryLimiterHint( + shouldEnrich: Boolean = true, + timestamp: Long, + ): Hint { + return HintUtils.createWithTypeCheckHint( + MemoryLimiterIntegration.MemoryLimiterHint( + fixture.options.flushTimeoutMillis, + NoOpLogger.getInstance(), + timestamp, + shouldEnrich, + ) + ) + } + internal class AbnormalExitHint( val mechanism: String? = null, private val shouldEnrich: Boolean = true, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index c387cc8794b..189d6bbc79e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -487,6 +487,26 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isReportHistoricalAnrs) } + @Test + fun `applyMetadata reads memory limiter enable to options`() { + val bundle = bundleOf(ManifestMetadataReader.MEMORY_LIMITER_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertEquals(true, fixture.options.isMemoryLimiterEnabled) + } + + @Test + fun `applyMetadata reads memory limiter historical reporting to options`() { + val bundle = bundleOf(ManifestMetadataReader.MEMORY_LIMITER_REPORT_HISTORICAL to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertEquals(true, fixture.options.isReportHistoricalMemoryLimiterExits) + } + @Test fun `applyMetadata reads activity breadcrumbs to options`() { // Arrange 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 new file mode 100644 index 00000000000..fd67229bfdb --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt @@ -0,0 +1,565 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.android.core.MemoryLimiterIntegration.MemoryLimiterHint +import io.sentry.android.core.cache.AndroidEnvelopeCache +import io.sentry.cache.EnvelopeCache +import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService +import io.sentry.transport.CurrentDateProvider +import io.sentry.util.HintUtils +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atMost +import org.mockito.kotlin.check +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class MemoryLimiterIntegrationTest { + + @get:Rule val tmpDir = TemporaryFolder() + + private class Fixture { + lateinit var context: Context + lateinit var shadowActivityManager: ShadowActivityManager + lateinit var lastReportedMemoryLimiterFile: File + lateinit var lastReportedAnrFile: File + lateinit var lastReportedTombstoneFile: File + + val options = SentryAndroidOptions() + val scopes = mock() + val logger = mock() + + fun init(appContext: Context) { + context = appContext + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + shadowActivityManager = Shadow.extract(activityManager) + } + + fun getSut( + dir: TemporaryFolder?, + memoryLimiterEnabled: Boolean = true, + reportHistoricalMemoryLimiterExits: Boolean = true, + lastReportedTimestamp: Long? = null, + anrReportedTimestamp: Long? = null, + tombstoneReportedTimestamp: Long? = null, + useImmediateExecutorService: Boolean = true, + sdkVersion: Int = 37, + ): MemoryLimiterIntegration { + options.run { + setLogger(this@Fixture.logger) + isDebug = true + cacheDirPath = dir?.newFolder()?.absolutePath + executorService = if (useImmediateExecutorService) ImmediateExecutorService() else mock() + isMemoryLimiterEnabled = memoryLimiterEnabled + isReportHistoricalMemoryLimiterExits = reportHistoricalMemoryLimiterExits + setEnvelopeDiskCache(EnvelopeCache.create(this)) + } + + options.cacheDirPath?.let { cacheDirPath -> + val cacheDir = File(cacheDirPath).also { it.mkdirs() } + lastReportedMemoryLimiterFile = + File(cacheDir, AndroidEnvelopeCache.LAST_MEMORY_LIMITER_REPORT).apply { + writeText(lastReportedTimestamp.toString()) + } + lastReportedAnrFile = + File(cacheDir, AndroidEnvelopeCache.LAST_ANR_REPORT).apply { + writeText(anrReportedTimestamp.toString()) + } + lastReportedTombstoneFile = + File(cacheDir, AndroidEnvelopeCache.LAST_TOMBSTONE_REPORT).apply { + writeText(tombstoneReportedTimestamp.toString()) + } + } + + whenever(scopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val buildInfoProvider = mock() + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(sdkVersion) + return MemoryLimiterIntegration(context, CurrentDateProvider.getInstance(), buildInfoProvider) + } + + fun addAppExitInfo( + reason: Int = ApplicationExitInfo.REASON_OTHER, + timestamp: Long, + description: String? = MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, + importance: Int = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ) { + val builder = ApplicationExitInfoBuilder.newBuilder() + builder.setReason(reason) + builder.setTimestamp(timestamp) + builder.setImportance(importance) + val exitInfo = + org.mockito.kotlin.spy(builder.build()) { + whenever(mock.description).thenReturn(description) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + } + + private val fixture = Fixture() + private val oldTimestamp = System.currentTimeMillis() - 10_000 + private val newTimestamp = oldTimestamp + 5_000 + + @BeforeTest + fun `set up`() { + val context = ApplicationProvider.getApplicationContext() + fixture.init(context) + } + + @AfterTest + fun `tear down`() { + fixture.options.cacheDirPath?.let { File(it).deleteRecursively() } + } + + @Test + fun `does not process MemoryLimiter exits when integration is disabled`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = false, + dir = tmpDir, + sdkVersion = 37, + useImmediateExecutorService = false, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `does not process MemoryLimiter exits when cache dir is not set`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + dir = null, + sdkVersion = 37, + useImmediateExecutorService = false, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `does not process MemoryLimiter exits when Android is below API 37`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + sdkVersion = 36, + dir = tmpDir, + useImmediateExecutorService = false, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `processes MemoryLimiter exits when integration is enabled, cache dir is set, and Android is API 37`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + dir = tmpDir, + sdkVersion = 37, + useImmediateExecutorService = false, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService).submit(any()) + } + + @Test + fun `processes MemoryLimiter exits when integration is enabled, cache dir is set, and Android is above API 37`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + dir = tmpDir, + sdkVersion = 38, + useImmediateExecutorService = false, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService).submit(any()) + } + + @Test + fun `captures exit when reason and description match exits produced by MemoryLimiter`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + reportHistoricalMemoryLimiterExits = true, + dir = tmpDir, + sdkVersion = 37, + lastReportedTimestamp = oldTimestamp, + ) + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + description = MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + assertEquals(SentryLevel.FATAL, event.level) + assertEquals(newTimestamp, event.timestamp!!.time) + assertEquals(MemoryLimiterIntegration.MEMORY_LIMITER_MESSAGE, event.message!!.formatted) + assertEquals("java", event.platform) + + val exception = event.exceptions!!.single() + assertEquals("MemoryLimitExceeded", exception.type) + assertEquals(MemoryLimiterIntegration.MEMORY_LIMITER_MESSAGE, exception.value) + assertEquals("io.sentry.android.core", exception.module) + + val mechanism = exception.mechanism!! + assertEquals("AppExitInfo", mechanism.type) + assertEquals( + MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, + mechanism.description, + ) + assertEquals(false, mechanism.isHandled) + assertEquals(true, mechanism.synthetic) + assertEquals( + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + mechanism.data!![MemoryLimiterIntegration.IMPORTANCE_DATA_KEY], + ) + assertEquals( + MemoryLimiterIntegration.PROCESS_VISIBILITY_VISIBLE, + mechanism.data!![MemoryLimiterIntegration.PROCESS_VISIBILITY_DATA_KEY], + ) + }, + argThat { + val hint = HintUtils.getSentrySdkHint(this) as MemoryLimiterHint + hint.shouldEnrich() && hint.timestamp() == newTimestamp + }, + ) + } + + @Test + fun `maps ApplicationExitInfo importance to process visibility`() { + // (importance, expected visibility) covering every branch of getProcessVisibility, including + // the default fallback for an unknown/future importance value. + val cases = + listOf( + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND to + MemoryLimiterIntegration.PROCESS_VISIBILITY_VISIBLE, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE to + MemoryLimiterIntegration.PROCESS_VISIBILITY_VISIBLE, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_TOP_SLEEPING to + MemoryLimiterIntegration.PROCESS_VISIBILITY_VISIBLE, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE to + MemoryLimiterIntegration.PROCESS_VISIBILITY_NOT_VISIBLE, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_PERCEPTIBLE to + MemoryLimiterIntegration.PROCESS_VISIBILITY_NOT_VISIBLE, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_SERVICE to + MemoryLimiterIntegration.PROCESS_VISIBILITY_NOT_VISIBLE, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_CANT_SAVE_STATE to + MemoryLimiterIntegration.PROCESS_VISIBILITY_NOT_VISIBLE, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE to + MemoryLimiterIntegration.PROCESS_VISIBILITY_NOT_VISIBLE, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_CACHED to + MemoryLimiterIntegration.PROCESS_VISIBILITY_CACHED, + // An importance value outside the known bands must fall back to the least specific bucket. + Int.MAX_VALUE to MemoryLimiterIntegration.PROCESS_VISIBILITY_CACHED, + ) + + val baseTimestamp = System.currentTimeMillis() - 60_000 + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + reportHistoricalMemoryLimiterExits = true, + dir = tmpDir, + sdkVersion = 37, + lastReportedTimestamp = baseTimestamp - 10_000, + ) + // The captured events are never really flushed by the mocked scopes, so don't block on the + // per-event flush latch (its default timeout would make this test take minutes). + fixture.options.flushTimeoutMillis = 0 + + // Give each importance a distinct timestamp so captured events can be matched back to it. + cases.forEachIndexed { index, (importance, _) -> + fixture.addAppExitInfo(timestamp = baseTimestamp + index * 1_000L, importance = importance) + } + + integration.register(fixture.scopes, fixture.options) + + val eventCaptor = argumentCaptor() + verify(fixture.scopes, times(cases.size)).captureEvent(eventCaptor.capture(), anyOrNull()) + val visibilityByTimestamp = + eventCaptor.allValues.associate { event -> + event.timestamp!!.time to + event.exceptions!!.single().mechanism!!.data!![ + MemoryLimiterIntegration.PROCESS_VISIBILITY_DATA_KEY] + } + + cases.forEachIndexed { index, (importance, expectedVisibility) -> + assertEquals( + expectedVisibility, + visibilityByTimestamp[baseTimestamp + index * 1_000L], + "importance $importance should map to $expectedVisibility", + ) + } + } + + @Test + fun `ignores exit when reason does not match exits produced by MemoryLimiter`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_ANR, + description = MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `ignores exit when description does not match exits produced by MemoryLimiter`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + description = "LowSwapKiller", + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `captures exit for any MemoryLimiter sub-reason, not just AnonSwap`() { + 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", + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + assertEquals("MemoryLimiter:Memory", event.exceptions!!.single().mechanism!!.description) + }, + anyOrNull(), + ) + } + + @Test + fun `ignores exit 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. + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + description = "NotAMemoryLimiterKill", + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `ignores exit when description is null`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + description = null, + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `historical MemoryLimiter exits are reported oldest to newest`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + reportHistoricalMemoryLimiterExits = true, + dir = tmpDir, + sdkVersion = 37, + lastReportedTimestamp = oldTimestamp, + ) + fixture.addAppExitInfo(timestamp = newTimestamp - 2_000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1_000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + inOrder(fixture.scopes) { + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == newTimestamp - 2_000 }, + argThat { !(HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == newTimestamp - 1_000 }, + argThat { !(HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == newTimestamp }, + argThat { (HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + } + } + + @Test + fun `skips historical MemoryLimiter exits at or before the last reported timestamp`() { + val skippedHistoricalTimestamp = newTimestamp - 2_000 + val reportedHistoricalTimestamp = newTimestamp - 1_000 + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + reportHistoricalMemoryLimiterExits = true, + dir = tmpDir, + sdkVersion = 37, + lastReportedTimestamp = newTimestamp - 1_500, + ) + fixture.addAppExitInfo(timestamp = skippedHistoricalTimestamp) + fixture.addAppExitInfo(timestamp = reportedHistoricalTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()) + .captureEvent(argThat { timestamp!!.time == skippedHistoricalTimestamp }, anyOrNull()) + inOrder(fixture.scopes) { + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == reportedHistoricalTimestamp }, + argThat { !(HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == newTimestamp }, + argThat { (HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + } + } + + @Test + fun `does not report historical MemoryLimiter exits if historical exits are disabled`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + reportHistoricalMemoryLimiterExits = false, + dir = tmpDir, + sdkVersion = 37, + lastReportedTimestamp = oldTimestamp, + ) + fixture.addAppExitInfo(timestamp = newTimestamp - 2_000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1_000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + // Verify we report only the latest MemoryLimiter exit, and none before it. + verify(fixture.scopes, atMost(1)).captureEvent(any(), anyOrNull()) + assertTrue(fixture.lastReportedMemoryLimiterFile.exists()) + } + + @Test + fun `does not report historical MemoryLimiter exits if integration is disabled`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = false, + reportHistoricalMemoryLimiterExits = true, + dir = tmpDir, + sdkVersion = 37, + useImmediateExecutorService = false, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `skips MemoryLimiter exits that were already reported`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = newTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `dedupes MemoryLimiter exists independently of ANR exits`() { + val integration = + fixture.getSut( + tmpDir, + lastReportedTimestamp = oldTimestamp, + anrReportedTimestamp = newTimestamp, + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + + @Test + fun `dedupes MemoryLimiter exits independently of Tombstone exits`() { + val integration = + fixture.getSut( + tmpDir, + lastReportedTimestamp = oldTimestamp, + tombstoneReportedTimestamp = newTimestamp, + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index e3e88d04f7a..bc05f3b23fd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -16,6 +16,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.check import org.mockito.kotlin.spy @@ -136,6 +137,18 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase { this.tombstone == null }) } + @Test + fun `memory limiter marker does not suppress matching tombstone exit`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + File(fixture.options.cacheDirPath!!, AndroidEnvelopeCache.LAST_MEMORY_LIMITER_REPORT) + .writeText(newTimestamp.toString()) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + @Test fun `when matching native event has attachments, they are added to the hint`() { val integration = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index a4063ccb148..e70abf36103 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -6,6 +6,7 @@ import io.sentry.SentryEnvelope import io.sentry.SentryOptions import io.sentry.UncaughtExceptionHandlerIntegration.UncaughtExceptionHint import io.sentry.android.core.AnrV2Integration.AnrV2Hint +import io.sentry.android.core.MemoryLimiterIntegration.MemoryLimiterHint import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.cache.EnvelopeCache @@ -34,6 +35,7 @@ class AndroidEnvelopeCacheTest { val dateProvider = mock() lateinit var startupCrashMarkerFile: File lateinit var lastReportedAnrFile: File + lateinit var lastReportedMemoryLimiterFile: File fun getSut( dir: TemporaryFolder, @@ -48,6 +50,8 @@ class AndroidEnvelopeCacheTest { startupCrashMarkerFile = File(outboxDir, EnvelopeCache.STARTUP_CRASH_MARKER_FILE) lastReportedAnrFile = File(options.cacheDirPath!!, AndroidEnvelopeCache.LAST_ANR_REPORT) + lastReportedMemoryLimiterFile = + File(options.cacheDirPath!!, AndroidEnvelopeCache.LAST_MEMORY_LIMITER_REPORT) if (appStartMillis != null) { AppStartMetrics.getInstance().apply { @@ -209,6 +213,41 @@ class AndroidEnvelopeCacheTest { assertEquals(87654321L, lastReportedAnr) } + @Test + fun `when memory limiter hint exists, writes last memory limiter report timestamp into file`() { + val cache = fixture.getSut(tmpDir) + + val hints = + HintUtils.createWithTypeCheckHint( + MemoryLimiterHint(0, NoOpLogger.getInstance(), 23456789L, false) + ) + cache.storeEnvelope(fixture.envelope, hints) + + assertTrue(fixture.lastReportedMemoryLimiterFile.exists()) + assertEquals("23456789", fixture.lastReportedMemoryLimiterFile.readText()) + } + + @Test + fun `memory limiter and anr markers are stored independently`() { + val cache = fixture.getSut(tmpDir) + + cache.storeEnvelope( + fixture.envelope, + HintUtils.createWithTypeCheckHint( + AnrV2Hint(0, NoOpLogger.getInstance(), 12345678L, false, false) + ), + ) + cache.storeEnvelope( + fixture.envelope, + HintUtils.createWithTypeCheckHint( + MemoryLimiterHint(0, NoOpLogger.getInstance(), 23456789L, false) + ), + ) + + assertEquals("12345678", fixture.lastReportedAnrFile.readText()) + assertEquals("23456789", fixture.lastReportedMemoryLimiterFile.readText()) + } + @Test fun `returns false if storing fails`() { val serializer = mock()