diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ca5a8af2ce4..4670f9f9a75 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -340,12 +340,14 @@ public final class io/sentry/android/core/MemoryLimiterIntegration : io/sentry/I 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 final class io/sentry/android/core/MemoryLimiterIntegration$MemoryLimiterHint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/AbnormalExit, io/sentry/hints/Backfillable { public fun (JLio/sentry/ILogger;JZ)V + public fun ignoreCurrentThread ()Z public fun isFlushable (Lio/sentry/protocol/SentryId;)Z + public fun mechanism ()Ljava/lang/String; public fun setFlushable (Lio/sentry/protocol/SentryId;)V public fun shouldEnrich ()Z - public fun timestamp ()J + public fun timestamp ()Ljava/lang/Long; } public final class io/sentry/android/core/NativeEventCollector { 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 ca55f9cb994..55392340d15 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,8 +587,6 @@ 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; } 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 40bf4f9e51b..648d8a4adac 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 @@ -20,6 +20,7 @@ import io.sentry.SentryOptions; import io.sentry.android.core.ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy; import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; import io.sentry.protocol.Mechanism; @@ -71,6 +72,7 @@ public final class MemoryLimiterIntegration implements Integration, Closeable { static final @NotNull String MEMORY_LIMITER_DESCRIPTION = MEMORY_LIMITER_DESCRIPTION_PREFIX + "AnonSwap"; static final @NotNull String MEMORY_LIMITER_FINGERPRINT = "memory-limiter"; + static final @NotNull String MEMORY_LIMITER_MECHANISM = "memory_limiter"; static final @NotNull String MEMORY_LIMITER_MESSAGE_PREFIX = "Android process killed by MemoryLimiter"; @@ -368,7 +370,8 @@ public void markReported(final long timestamp) { * backfilled with persisted launch state or kept as a lighter historical record. */ @ApiStatus.Internal - public static final class MemoryLimiterHint extends BlockingFlushHint implements Backfillable { + public static final class MemoryLimiterHint extends BlockingFlushHint + implements Backfillable, AbnormalExit { private final long epochTimestampMs; private final boolean shouldEnrich; @@ -383,11 +386,21 @@ public MemoryLimiterHint( this.shouldEnrich = shouldEnrich; } - /** Returns epoch wall-clock time, in milliseconds. */ - public long timestamp() { + @Override + public @NotNull Long timestamp() { return epochTimestampMs; } + @Override + public @NotNull String mechanism() { + return MEMORY_LIMITER_MECHANISM; + } + + @Override + public boolean ignoreCurrentThread() { + return false; + } + @Override public boolean shouldEnrich() { return shouldEnrich; 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 a30dfbaf014..183ca3dfbdd 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 @@ -276,7 +276,10 @@ class MemoryLimiterIntegrationTest { }, argThat { val hint = HintUtils.getSentrySdkHint(this) as MemoryLimiterHint - hint.shouldEnrich() && hint.timestamp() == newTimestamp + hint.shouldEnrich() && + hint.timestamp() == newTimestamp && + hint.mechanism() == MemoryLimiterIntegration.MEMORY_LIMITER_MECHANISM && + !hint.ignoreCurrentThread() }, ) } 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 e70abf36103..47163c0e841 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 @@ -1,9 +1,16 @@ package io.sentry.android.core.cache +import com.google.common.truth.Truth.assertThat +import io.sentry.DateUtils import io.sentry.ISerializer import io.sentry.NoOpLogger import io.sentry.SentryEnvelope +import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.SentryUUID +import io.sentry.Session +import io.sentry.Session.State.Abnormal +import io.sentry.Session.State.Ok import io.sentry.UncaughtExceptionHandlerIntegration.UncaughtExceptionHint import io.sentry.android.core.AnrV2Integration.AnrV2Hint import io.sentry.android.core.MemoryLimiterIntegration.MemoryLimiterHint @@ -14,6 +21,7 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.HintUtils import java.io.File import java.lang.IllegalArgumentException +import java.util.Date import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -227,6 +235,51 @@ class AndroidEnvelopeCacheTest { assertEquals("23456789", fixture.lastReportedMemoryLimiterFile.readText()) } + @Test + fun `memory limiter hint marks previous session abnormal at exit timestamp`() { + val cache = fixture.getSut(tmpDir) + val sessionStart = DateUtils.getCurrentDateTime() + val exitTimestamp = sessionStart.time + 1_000 + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + fixture.options.serializer.serialize(createSession(sessionStart), previousSessionFile.writer()) + val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) + + cache.storeEnvelope( + envelope, + HintUtils.createWithTypeCheckHint( + MemoryLimiterHint(0, NoOpLogger.getInstance(), exitTimestamp, true) + ), + ) + + val updatedSession = + fixture.options.serializer.deserialize(previousSessionFile.reader(), Session::class.java)!! + assertThat(updatedSession.status).isEqualTo(Abnormal) + assertThat(updatedSession.timestamp!!.time).isEqualTo(exitTimestamp) + assertThat(updatedSession.abnormalMechanism).isEqualTo("memory_limiter") + } + + // Protects against misbehaved clocks, stale cached state, or mismatched recovery data. + @Test + fun `memory limiter exit before previous session start does not mark session abnormal`() { + val cache = fixture.getSut(tmpDir) + val sessionStart = DateUtils.getCurrentDateTime() + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + fixture.options.serializer.serialize(createSession(sessionStart), previousSessionFile.writer()) + val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) + + cache.storeEnvelope( + envelope, + HintUtils.createWithTypeCheckHint( + MemoryLimiterHint(0, NoOpLogger.getInstance(), sessionStart.time - 1_000, true) + ), + ) + + val updatedSession = + fixture.options.serializer.deserialize(previousSessionFile.reader(), Session::class.java)!! + assertThat(updatedSession.status).isEqualTo(Ok) + assertThat(updatedSession.abnormalMechanism).isNull() + } + @Test fun `memory limiter and anr markers are stored independently`() { val cache = fixture.getSut(tmpDir) @@ -260,5 +313,23 @@ class AndroidEnvelopeCacheTest { assertFalse(didStore) } + private fun createSession(started: Date): Session = + Session( + Ok, + started, + started, + 0, + "distinct-id", + SentryUUID.generateSentryId(), + true, + null, + null, + null, + null, + "environment", + "release", + null, + ) + internal class UncaughtHint : UncaughtExceptionHint(0, NoOpLogger.getInstance()) } diff --git a/sentry/src/main/java/io/sentry/hints/AbnormalExit.java b/sentry/src/main/java/io/sentry/hints/AbnormalExit.java index bf63f98ea6f..0ac6be4dd4b 100644 --- a/sentry/src/main/java/io/sentry/hints/AbnormalExit.java +++ b/sentry/src/main/java/io/sentry/hints/AbnormalExit.java @@ -5,11 +5,12 @@ /** * Marker interface for Sessions experiencing abnormal status. * - *

Note: While this interface applies to the broad category of abnormal exits (meaning any - * exits that weren't classified as normal terminations or crashes) it currently is exclusively used - * as a hint marker for Android ANRs (both watchdog and ApplicationExitInfo based). If additional - * categories of abnormal exits were introduced, all instances of discriminator code (`instanceof - * AbnormalExit`) should be carefully reviewed for ANR specifics accidentally being applied. + *

Includes exits that were not classified as normal terminations or crashes, such as Android + * ANRs and MemoryLimiter process deaths. + * + *

Note: Some existing discriminator code ({@code instanceof AbnormalExit}) is shaped by + * the historical ANR-only usage of this interface. New implementations should review all of those + * call sites carefully to ensure ANR-specific behavior isn't applied accidentally. */ public interface AbnormalExit { @@ -17,10 +18,22 @@ public interface AbnormalExit { @Nullable String mechanism(); - /** Whether the current thread should be ignored from being marked as crashed, e.g. a watchdog */ + /** + * Whether the current thread (e.g., a watchdog) should be ignored by the {@code + * MainEventProcessor} when deciding which threads from the current process should be bound to the + * Sentry event associated with this {@code AbnormalExit}. + * + *

This method effectively no-ops for types implementing both {@link AbnormalExit} and {@link + * Backfillable}, as implementors of {@code Backfillable} are not sent to the {@code + * MainEventProcessor}. + */ boolean ignoreCurrentThread(); - /** When exactly the abnormal exit happened */ + /** + * When exactly the abnormal exit happened. + * + *

Epoch time in milliseconds, or null. + */ @Nullable Long timestamp(); }