From 5efb2d7f9d66bcad642f9ce7117f4d47648e4000 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 16 Sep 2026 13:01:31 +0200 Subject: [PATCH 1/9] ref(android): Mark AppStartMetrics.setAppStartType as @TestOnly (#6121) No production code calls this setter; the cold/warm classification assigns the field directly. Its only callers are tests, so annotate it the way the neighboring test seams (setFirstIdle, getFirstIdle, clear) already are. It has to stay public: most of those call sites live in io.sentry.android.core, a different package from AppStartMetrics. Co-authored-by: Claude Opus 5 (1M context) --- .../java/io/sentry/android/core/performance/AppStartMetrics.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index d39b3ff5ac..82376a75d8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -156,6 +156,7 @@ public AppStartMetrics() { return applicationOnCreate; } + @TestOnly public void setAppStartType(final @NotNull AppStartType appStartType) { this.appStartType = appStartType; } From bc5c4c9fd35c31cf91e5345e98270d7880b97fc2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 16 Sep 2026 14:46:02 +0200 Subject: [PATCH 2/9] feat(android): Recover MemoryLimiter app exits on startup (JAVA-687) (#6111) Introduce a new MemoryLimiterIntegration that captures process deaths attributable to Android 17's new [MemoryLimiter](https://source.android.com/docs/core/perf/memory-limiter#process-monitoring) system service (see also [here](https://android-developers.googleblog.com/2026/06/prioritizing-memory-efficiency-steps-for-android-17.html)). Process death info is extracted from ApplicationExitInfo on the next app launch. We then enrich it with persisted SDK state and send it to Relay as a fatal Sentry event. Integration is experimental; is only available for Android API >= 37; and is disabled by default. --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 24 +- .../core/AndroidOptionsInitializer.java | 4 + .../sentry/android/core/AnrV2Integration.java | 4 +- .../ApplicationExitInfoEventProcessor.java | 6 +- .../ApplicationExitInfoHistoryDispatcher.java | 56 +- .../android/core/ManifestMetadataReader.java | 11 + .../core/MemoryLimiterIntegration.java | 404 ++++++++++++++ .../android/core/SentryAndroidOptions.java | 70 +++ .../android/core/TombstoneIntegration.java | 4 +- .../core/cache/AndroidEnvelopeCache.java | 21 +- .../core/AndroidOptionsInitializerTest.kt | 50 ++ .../android/core/AnrV2IntegrationTest.kt | 12 + ...ApplicationExitInfoCrossIntegrationTest.kt | 268 +++++++++ .../ApplicationExitInfoEventProcessorTest.kt | 203 ++++++- .../core/ManifestMetadataReaderTest.kt | 20 + .../core/MemoryLimiterIntegrationTest.kt | 509 ++++++++++++++++++ .../android/core/TombstoneIntegrationTest.kt | 13 + .../core/cache/AndroidEnvelopeCacheTest.kt | 39 ++ 19 files changed, 1682 insertions(+), 37 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoCrossIntegrationTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a8eea2d9c..d280020f36 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 0e33dfb138..ca5a8af2ce 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 85bfd8b5ac..12ffb90356 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 e28a08f7e8..5e5500297f 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 31fe2442bf..ca55f9cb99 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 155c9fab27..b60cd7e691 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 { @@ -70,7 +87,13 @@ public void run() { waitPreviousSessionFlush(); - final List exitInfos = new ArrayList<>(applicationExitInfoList); + final List exitInfos = new ArrayList<>(applicationExitInfoList.size()); + for (final ApplicationExitInfo exitInfo : applicationExitInfoList) { + if (exitInfo != null) { + exitInfos.add(exitInfo); + } + } + final @Nullable Long lastReportedTimestamp = policy.getLastReportedTimestamp(); final ApplicationExitInfo latest = removeLatest(exitInfos); @@ -135,7 +158,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 +172,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 +246,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 1ca91bbada..9f31c6751b 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 0000000000..40bf4f9e51 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java @@ -0,0 +1,404 @@ +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.Arrays; +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 {@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 a {@link ApplicationExitInfo#getImportance() + * process_importance} value and a derived {@link MemoryLimiterPolicy#toMemoryLimitClass + * memory_limit_class}. + * + *

Events may also be backfilled with state persisted from the crashed app process, 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_FINGERPRINT = "memory-limiter"; + static final @NotNull String MEMORY_LIMITER_MESSAGE_PREFIX = + "Android process killed by MemoryLimiter"; + + static final @NotNull String MEMORY_LIMIT_CLASS_DATA_KEY = "memory_limit_class"; + static final @NotNull String MEMORY_LIMIT_CLASS_CACHED = "cached"; + static final @NotNull String MEMORY_LIMIT_CLASS_NOT_VISIBLE = "not_visible"; + static final @NotNull String MEMORY_LIMIT_CLASS_VISIBLE = "visible"; + + static final @NotNull String PROCESS_IMPORTANCE_DATA_KEY = "process_importance"; + static final @NotNull String PROCESS_IMPORTANCE_FINGERPRINT_PREFIX = "process_importance:"; + + 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 came from a MemoryLimiter-induced + * process death. + * + *

Criteria taken from here. + */ + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public boolean matches(final @NotNull ApplicationExitInfo exitInfo) { + if (exitInfo.getReason() != ApplicationExitInfo.REASON_OTHER) { + return false; + } + + final String description = exitInfo.getDescription(); + // We match on the "MemoryLimiter:" prefix rather than the full "MemoryLimiter:AnonSwap" + // string mentioned in the Android 17 release notes because we want to capture any future + // MemoryLimiter kill reason without a code change. (MemoryLimiter source already tracks + // MemoryLimiter:Memory and MemoryLimiter:Swap, but for now doesn't kill the process because + // of them.) + return description != null && description.contains(MEMORY_LIMITER_DESCRIPTION_PREFIX); + } + + @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 int processImportance = exitInfo.getImportance(); + final String memoryLimit = toMemoryLimitClass(processImportance); + + final Message message = new Message(); + final String messageText = toMessage(processImportance); + message.setFormatted(messageText); + + final SentryEvent event = new SentryEvent(); + event.setLevel(SentryLevel.FATAL); + event.setPlatform(SentryBaseEvent.DEFAULT_PLATFORM); + event.setTimestamp(DateUtils.getDateTime(timestamp)); + event.setMessage(message); + event.setExceptions( + Collections.singletonList( + buildException(exitInfo, shouldEnrich, processImportance, memoryLimit, messageText))); + event.setFingerprints( + Arrays.asList( + MEMORY_LIMITER_FINGERPRINT, + PROCESS_IMPORTANCE_FINGERPRINT_PREFIX + processImportance)); + + 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 int processImportance, + final @NotNull String memoryLimit, + final @NotNull String messageText) { + final Mechanism mechanism = new Mechanism(); + mechanism.setType(shouldEnrich ? "AppExitInfo" : "HistoricalAppExitInfo"); + mechanism.setDescription(exitInfo.getDescription()); + mechanism.setHandled(false); + mechanism.setSynthetic(true); + mechanism.setData(buildMechanismData(processImportance, memoryLimit)); + + final SentryException sentryException = new SentryException(); + sentryException.setType("MemoryLimitExceeded"); + sentryException.setValue(messageText); + sentryException.setModule("io.sentry.android.core"); + sentryException.setMechanism(mechanism); + return sentryException; + } + + private @NotNull Map buildMechanismData( + final int processImportance, final @NotNull String memoryLimit) { + final Map data = new HashMap<>(); + data.put(PROCESS_IMPORTANCE_DATA_KEY, toImportanceDescription(processImportance)); + data.put(MEMORY_LIMIT_CLASS_DATA_KEY, memoryLimit); + return data; + } + + /** + * Best-effort mapping from {@link ApplicationExitInfo#getImportance()} to MemoryLimiter's visible, + * not-visible, or cached classifications. + * + *

Mappings are inexact because MemoryLimiter determines category membership from {@code + * PROCESS_STATE_*} values, but {@link ApplicationExitInfo} only exposes a coarser {@link + * RunningAppProcessInfo} importance bucket. + */ + private @NotNull String toMemoryLimitClass(final int processImportance) { + switch (processImportance) { + case RunningAppProcessInfo.IMPORTANCE_FOREGROUND: + // Docs say IMPORTANCE_TOP_SLEEPING isn't visible to users but MemoryLimiter treats is as a + // "visible" class (cf. docs linked in this method's Javadoc). + case RunningAppProcessInfo.IMPORTANCE_TOP_SLEEPING: + // Over-classifies some exits as visible b/c IMPORTANCE_VISIBLE corresponds to + // PROCESS_STATE_IMPORTANT_FOREGROUND (visible) and PROCESS_STATE_IMPORTANT_BACKGROUND (not + // visible). + case RunningAppProcessInfo.IMPORTANCE_VISIBLE: + return MEMORY_LIMIT_CLASS_VISIBLE; + + case RunningAppProcessInfo.IMPORTANCE_CANT_SAVE_STATE: + case RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE: + case RunningAppProcessInfo.IMPORTANCE_GONE: + case RunningAppProcessInfo.IMPORTANCE_PERCEPTIBLE: + case RunningAppProcessInfo.IMPORTANCE_SERVICE: + return MEMORY_LIMIT_CLASS_NOT_VISIBLE; + + case RunningAppProcessInfo.IMPORTANCE_CACHED: + default: + // Fall back to the least specific bucket. + return MEMORY_LIMIT_CLASS_CACHED; + } + } + + private @NotNull String toMessage(final int processImportance) { + return MEMORY_LIMITER_MESSAGE_PREFIX + + " (importance: " + + toImportanceLabel(processImportance) + + ")"; + } + + private @NotNull String toImportanceDescription(final int processImportance) { + return processImportance + " (" + toImportanceLabel(processImportance) + ")"; + } + + private @NotNull String toImportanceLabel(final int processImportance) { + switch (processImportance) { + case RunningAppProcessInfo.IMPORTANCE_CACHED: + return "cached"; + case RunningAppProcessInfo.IMPORTANCE_CANT_SAVE_STATE: + return "cant_save_state"; + case RunningAppProcessInfo.IMPORTANCE_FOREGROUND: + return "foreground"; + case RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE: + return "foreground_service"; + case RunningAppProcessInfo.IMPORTANCE_GONE: + return "gone"; + case RunningAppProcessInfo.IMPORTANCE_PERCEPTIBLE: + return "perceptible"; + case RunningAppProcessInfo.IMPORTANCE_SERVICE: + return "service"; + case RunningAppProcessInfo.IMPORTANCE_TOP_SLEEPING: + return "top_sleeping"; + case RunningAppProcessInfo.IMPORTANCE_VISIBLE: + return "visible"; + default: + return "unknown"; + } + } + } + + /** + * 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 202d779d61..d3e24766cf 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 d6d6a7767c..48832ca8dc 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 f001c4947d..1314809728 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; @@ -218,6 +220,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); } @@ -227,6 +234,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 @@ -269,6 +282,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<>( @@ -280,5 +294,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 85fba36f7d..4610e4bbcb 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 d9fd9c1889..89fc9e65cb 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 0000000000..f362fed252 --- /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 f484f994db..2efdf8e31c 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,64 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.fingerprints) } + @Test + fun `memory limiter hint does not apply ANR-specific enrichment`() { + val expectedMessage = + MemoryLimiterIntegration.MEMORY_LIMITER_MESSAGE_PREFIX + + " (importance_foreground: " + + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + + ")" + 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 = expectedMessage } + fingerprints = + listOf( + MemoryLimiterIntegration.MEMORY_LIMITER_FINGERPRINT, + MemoryLimiterIntegration.PROCESS_IMPORTANCE_FINGERPRINT_PREFIX + + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ) + exceptions = + listOf( + SentryException().apply { + type = "MemoryLimitExceeded" + value = expectedMessage + mechanism = Mechanism().apply { type = "AppExitInfo" } + } + ) + } + + assertEquals(SentryBaseEvent.DEFAULT_PLATFORM, processed.platform) + assertEquals(expectedMessage, processed.message!!.formatted) + assertEquals("MemoryLimitExceeded", processed.exceptions!!.first().type) + assertEquals("AppExitInfo", processed.exceptions!!.first().mechanism!!.type) + assertEquals( + listOf( + MemoryLimiterIntegration.MEMORY_LIMITER_FINGERPRINT, + MemoryLimiterIntegration.PROCESS_IMPORTANCE_FINGERPRINT_PREFIX + + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ), + 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 +1034,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 +1063,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 +1091,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 +1125,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 +1135,7 @@ class ApplicationExitInfoEventProcessorTest { .apply { add( AnrStackTrace( - System.currentTimeMillis(), + hintTimestamp, arrayOf( StackTraceElement( "android.view.Choreographer", @@ -1054,10 +1184,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 +1225,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 +1369,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 c387cc8794..189d6bbc79 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 0000000000..a30dfbaf01 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt @@ -0,0 +1,509 @@ +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.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.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) + + val expectedMessage = + MemoryLimiterIntegration.MEMORY_LIMITER_MESSAGE_PREFIX + " (importance: foreground)" + + verify(fixture.scopes) + .captureEvent( + check { event -> + assertEquals(SentryLevel.FATAL, event.level) + assertEquals(newTimestamp, event.timestamp!!.time) + assertEquals(expectedMessage, event.message!!.formatted) + assertEquals("java", event.platform) + assertEquals( + listOf( + MemoryLimiterIntegration.MEMORY_LIMITER_FINGERPRINT, + MemoryLimiterIntegration.PROCESS_IMPORTANCE_FINGERPRINT_PREFIX + + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ), + event.fingerprints, + ) + + val exception = event.exceptions!!.single() + assertEquals("MemoryLimitExceeded", exception.type) + assertEquals(expectedMessage, 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.toString() + + " (foreground)", + mechanism.data!![MemoryLimiterIntegration.PROCESS_IMPORTANCE_DATA_KEY], + ) + assertEquals( + MemoryLimiterIntegration.MEMORY_LIMIT_CLASS_VISIBLE, + mechanism.data!![MemoryLimiterIntegration.MEMORY_LIMIT_CLASS_DATA_KEY], + ) + }, + argThat { + val hint = HintUtils.getSentrySdkHint(this) as MemoryLimiterHint + hint.shouldEnrich() && hint.timestamp() == newTimestamp + }, + ) + } + + @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 e3e88d04f7..bc05f3b23f 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 a4063ccb14..e70abf3610 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() From 56fb8ca86a2ec6ff96fb8f4a7450edb8d8cf9f4c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 16 Sep 2026 15:16:36 +0200 Subject: [PATCH 3/9] fix(core): Keep resolving the hostname after Sentry.close() (#6119) * fix(core): Keep resolving the hostname after Sentry.close() MainEventProcessor was Closeable, so Scopes.close() closed it, and it shut down the process-wide HostnameCache singleton. Nothing ever replaced that singleton: INSTANCE is assigned once and never cleared, so a re-init handed the same shut-down cache to the new MainEventProcessor, and to MetricsApi and LoggerApi, which read it directly. The damage was silent and permanent. While the cache was still fresh, getHostname() kept returning the value it already had. On the first expiry after the close, getHostname() flipped updateRunning to true and then submit() threw RejectedExecutionException on the terminated executor. That is a RuntimeException, so it was swallowed into handleCacheUpdateFailure(), but the updateRunning reset lives in the submitted callable's finally block, which never ran. updateRunning stayed true, so the compareAndSet guard failed from then on and no refresh was ever attempted again. server_name froze at its last resolved value for the life of the process, with no exception and no log line. Nothing needs to close this cache. Its executor is a single daemon thread with allowCoreThreadTimeOut(true) and a 30 second keep-alive, so the worker exits on its own once idle and never holds up process exit; the thread exists for about 30 seconds out of every 5 hour refresh interval. Scopes.close() already leaves the timer executor running for exactly this reason. The one test that covered this path, SentryClientTest's `when client is closed, hostname cache is closed`, asserted isClosed() on a processor that had never resolved a hostname, where isClosed() returned true because the cache was still null. It never exercised the behavior it named. Replaced with an assertion that MainEventProcessor is not Closeable, which fails if the wiring comes back. Co-Authored-By: Claude Opus 5 (1M context) * changelog * fix(core): Clear updateRunning when a refresh cannot be queued updateRunning is cleared in exactly one place, the submitted callable's finally block, so it is cleared if and only if the callable runs. Every failure from Future.get() leaves the callable running, so it still clears the flag itself. A failure from submit() does not: the callable was never queued, nothing clears the flag, and the compareAndSet guard in getHostname() then fails forever, so no refresh is ever attempted again. Removing MainEventProcessor's close() took away the only reachable way to make submit() throw, but the invariant was still wrong: a bounded queue, a shutdown added later, or a failure to start a thread would silently resurrect the same permanent freeze. Splitting submit() out of the try means the two cases can be told apart. Clearing the flag on a timeout or an interrupt as well would be wrong, since the callable is still running there and refreshes would pile up behind a slow lookup; MainEventProcessorTest's `sets servername to null if retrieving takes longer time` covers that path. Co-Authored-By: Claude Opus 5 (1M context) * test(core): Drop the not-Closeable assertion It asserted a type relationship rather than behavior, which says nothing about whether the hostname keeps resolving. The behavior that matters is covered by HostnameCacheTest: `worker thread times out while idle instead of staying alive` guards the self-terminating executor that makes closing unnecessary, and `a refresh that cannot be queued does not stop later refreshes` guards the latch that turned a one-off failure into a permanent one. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +++ sentry/api/sentry.api | 3 +-- .../main/java/io/sentry/HostnameCache.java | 26 ++++++++++++------- .../java/io/sentry/MainEventProcessor.java | 26 +------------------ .../test/java/io/sentry/HostnameCacheTest.kt | 24 ++++++++++++----- .../java/io/sentry/MainEventProcessorTest.kt | 10 ------- .../test/java/io/sentry/SentryClientTest.kt | 10 ------- 7 files changed, 39 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d280020f36..55ef1a8f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ - Sentry can now configure Log4j2 automatically for Spring Boot 3 when `sentry-log4j2` is on the classpath and Log4j2 Core is the active logging backend ([#6072](https://github.com/getsentry/sentry-java/pull/6072)) - Disabled by default for now; enable it and configure levels the same way as described in the Spring Boot 4 entry above (`sentry.logging.enabled=true`) +### Fixes + +- Keep resolving the server name after `Sentry.close()` or a re-init. Closing the SDK shut down the shared hostname cache for the life of the process, so `server_name` silently froze at the value it had last resolved ([#6119](https://github.com/getsentry/sentry-java/pull/6119)) + ### Internal - Deprecate `AndroidCurrentDateProvider.getInstance()` in favor of `MonotonicTicker`, which counts time spent in deep sleep and cannot be confused with the epoch-based `CurrentDateProvider` ([#6103](https://github.com/getsentry/sentry-java/pull/6103)) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 24635fc5dd..f28fffd6b8 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1392,9 +1392,8 @@ public abstract interface class io/sentry/JsonUnknown { public abstract fun setUnknown (Ljava/util/Map;)V } -public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java/io/Closeable { +public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/SentryOptions;)V - public fun close ()V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/SentryLogEvent;)Lio/sentry/SentryLogEvent; diff --git a/sentry/src/main/java/io/sentry/HostnameCache.java b/sentry/src/main/java/io/sentry/HostnameCache.java index 56cc0c2e84..4e9e2bc4c0 100644 --- a/sentry/src/main/java/io/sentry/HostnameCache.java +++ b/sentry/src/main/java/io/sentry/HostnameCache.java @@ -8,6 +8,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -91,7 +92,7 @@ private HostnameCache() { this.cacheDuration = cacheDuration; this.getLocalhost = Objects.requireNonNull(getLocalhost, "getLocalhost is required"); // A single thread executor whose worker thread times out while idle, so no thread is kept - // alive between the infrequent cache refreshes. + // alive between the infrequent cache refreshes and nothing has to shut it down. final @NotNull ThreadPoolExecutor executor = new ThreadPoolExecutor( 1, @@ -105,14 +106,6 @@ private HostnameCache() { updateCache(); } - void close() { - this.executorService.shutdown(); - } - - boolean isClosed() { - return this.executorService.isShutdown(); - } - /** * Gets the hostname of the current machine. * @@ -144,8 +137,21 @@ private void updateCache() { return null; }; + final Future futureTask; + try { + futureTask = executorService.submit(hostRetriever); + } catch (RejectedExecutionException e) { + // updateRunning is cleared by the callable's finally block, which never runs if the callable + // was never queued. Clearing it here keeps a failure to queue from latching the flag on and + // silencing every later refresh. + updateRunning.set(false); + handleCacheUpdateFailure(); + return; + } + + // A timeout or interrupt below leaves the callable running, so it still clears updateRunning + // itself; doing it here as well would let refreshes pile up behind a slow lookup. try { - final Future futureTask = executorService.submit(hostRetriever); futureTask.get(GET_HOSTNAME_TIMEOUT, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index d84c9e47be..bde148134a 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -8,18 +8,15 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.util.HintUtils; -import java.io.Closeable; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.VisibleForTesting; @ApiStatus.Internal -public final class MainEventProcessor implements EventProcessor, Closeable { +public final class MainEventProcessor implements EventProcessor { private final @NotNull SentryOptions options; private final @NotNull SentryThreadFactory sentryThreadFactory; @@ -271,27 +268,6 @@ private boolean isCachedHint(final @NotNull Hint hint) { return HintUtils.hasType(hint, Cached.class); } - @Override - public void close() throws IOException { - if (hostnameCache != null) { - hostnameCache.close(); - } - } - - boolean isClosed() { - if (hostnameCache != null) { - return hostnameCache.isClosed(); - } else { - return true; - } - } - - @VisibleForTesting - @Nullable - HostnameCache getHostnameCache() { - return hostnameCache; - } - @Override public @Nullable Long getOrder() { return 0L; diff --git a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt index 3cc3a52aa2..6d0398858a 100644 --- a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt +++ b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt @@ -2,9 +2,11 @@ package io.sentry import com.google.common.truth.Truth.assertThat import io.sentry.test.getProperty +import io.sentry.test.injectForField import java.net.InetAddress import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -23,6 +25,21 @@ class HostnameCacheTest { assertThat(cache.hostname).isEqualTo("myhost") } + @Test + fun `a refresh that cannot be queued does not stop later refreshes`() { + val cache = getSut() + // Reject the next submit the way an executor that could not start a thread would, and mark the + // cache stale so that reading the hostname attempts a refresh. + cache.getProperty("executorService").shutdown() + cache.injectForField("expirationTimestamp", 0L) + + assertThat(cache.hostname).isEqualTo("myhost") + + // The callable never ran, so nothing else clears this flag; left set, it would fail the + // compareAndSet guard in getHostname() and no refresh would ever be attempted again. + assertThat(cache.getProperty("updateRunning").get()).isFalse() + } + @Test fun `worker thread times out while idle instead of staying alive`() { val cache = getSut() @@ -31,11 +48,4 @@ class HostnameCacheTest { assertThat(executorService.corePoolSize).isEqualTo(1) assertThat(executorService.maximumPoolSize).isEqualTo(1) } - - @Test - fun `close shuts the executor down`() { - val cache = getSut() - cache.close() - assertThat(cache.isClosed).isTrue() - } } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index fe5c835c90..a35f0e0e02 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -571,16 +571,6 @@ class MainEventProcessorTest { } } - @Test - fun `when processor is closed, closes hostname cache`() { - val sut = fixture.getSut(serverName = null) - - sut.process(SentryTransaction(fixture.sentryTracer), Hint()) - - sut.close() - assertNotNull(sut.hostnameCache) { assertTrue(it.isClosed) } - } - @Test fun `when event has modules, appends to them`() { val sut = fixture.getSut(modules = mapOf("group1:artifact1" to "2.0.0")) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 3066d2c1b5..61181ee96a 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -214,16 +214,6 @@ class SentryClientTest { assertFalse(sut.isEnabled) } - @Test - fun `when client is closed, hostname cache is closed`() { - val sut = fixture.getSut() - assertTrue(sut.isEnabled) - sut.close() - val mainEventProcessor = - fixture.sentryOptions.eventProcessors.filterIsInstance().first() - assertTrue(mainEventProcessor.isClosed) - } - @Test fun `when beforeSend is set, callback is invoked`() { var invoked = false From bcc2bb9330a1461d40089203f34d8881e7ba2a8d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 16 Sep 2026 15:40:12 +0200 Subject: [PATCH 4/9] fix(core): Order breadcrumbs by their own timestamp (JAVA-579) (#6097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): Order breadcrumbs by their own timestamp (JAVA-579) Breadcrumb.compareTo ordered purely by a System.nanoTime() reading taken in the constructor. A breadcrumb rebuilt from a serialized one — read back from disk, or handed over by a hybrid SDK — got that reading at parse time, so a breadcrumb recorded yesterday sorted as if it had just happened, and the merged order in CombinedScopeView became parse order. The clone constructor had the same problem: copying a breadcrumb moved it to the end of the order. Order by the recorded timestamp instead, and keep the creation tick only as the tie-breaker it was added for in #3355, since timestamps are millisecond-granular. A deserialized breadcrumb carries no tick, and a clone carries the original's, so neither jumps position. Co-Authored-By: Claude Opus 5 (1M context) * changelog * ref(core): Keep the creation tick on deserialized breadcrumbs Always fill the tick and let the timestamp comparison carry the fix, so ordering no longer depends on every caller using a stable sort. Co-Authored-By: Claude Opus 5 (1M context) * ref(core): Sort breadcrumbs by their natural ordering in SentryClient SortBreadcrumbsByDate compared timestamps only, so ties fell through to the sort's stability. Breadcrumb.compareTo now defines that same order with a defined tie-breaker, leaving the comparator a weaker duplicate of it and the codebase with two definitions of breadcrumb order. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/Breadcrumb.java | 10 +++- .../src/main/java/io/sentry/SentryClient.java | 13 +---- .../src/test/java/io/sentry/BreadcrumbTest.kt | 56 +++++++++++++++++++ 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ef1a8f2c..0e0dc71961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ ### Fixes - Keep resolving the server name after `Sentry.close()` or a re-init. Closing the SDK shut down the shared hostname cache for the life of the process, so `server_name` silently froze at the value it had last resolved ([#6119](https://github.com/getsentry/sentry-java/pull/6119)) +- Order breadcrumbs by the timestamp they carry rather than by when they were created in the current process, so breadcrumbs restored from disk or handed over by a hybrid SDK no longer sort as if they had just happened ([#6097](https://github.com/getsentry/sentry-java/pull/6097)) ### Internal diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fff6954ee5..159e8adddf 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -73,7 +73,9 @@ public Breadcrumb(final long timestamp) { } Breadcrumb(final @NotNull Breadcrumb breadcrumb) { - this.nanos = System.nanoTime(); + // A clone stands in for the breadcrumb it was copied from, so it inherits its tie-breaker + // instead of taking a fresh one and sorting after everything recorded since. + this.nanos = breadcrumb.nanos; this.timestamp = breadcrumb.timestamp; this.timestampMs = breadcrumb.timestampMs; this.message = breadcrumb.message; @@ -832,6 +834,12 @@ public void setUnknown(@Nullable Map unknown) { @Override @SuppressWarnings("JavaUtilDate") public int compareTo(@NotNull Breadcrumb o) { + final int byTimestamp = getTimestamp().compareTo(o.getTimestamp()); + if (byTimestamp != 0) { + return byTimestamp; + } + // Timestamps are millisecond-granular, so breadcrumbs recorded in the same millisecond tie. + // nanos is only meaningful within a process run, which is all a tie-breaker has to cover. return nanos.compareTo(o.nanos); } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 0e44812a49..4bba195fee 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -26,7 +26,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.Comparator; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -41,7 +40,6 @@ public final class SentryClient implements ISentryClient { private final @NotNull SentryOptions options; private final @NotNull ITransport transport; - private final @NotNull SortBreadcrumbsByDate sortBreadcrumbsByDate = new SortBreadcrumbsByDate(); private final @NotNull ILoggerBatchProcessor loggerBatchProcessor; private final @NotNull IMetricsBatchProcessor metricsBatchProcessor; @@ -1660,7 +1658,7 @@ private void sortBreadcrumbsByDate( if (sortedBreadcrumbs != null && !breadcrumbs.isEmpty()) { sortedBreadcrumbs.addAll(breadcrumbs); - Collections.sort(sortedBreadcrumbs, sortBreadcrumbsByDate); + Collections.sort(sortedBreadcrumbs); } } @@ -1851,13 +1849,4 @@ private boolean sample() { } return true; } - - private static final class SortBreadcrumbsByDate implements Comparator { - - @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) - @Override - public int compare(final @NotNull Breadcrumb b1, final @NotNull Breadcrumb b2) { - return b1.getTimestamp().compareTo(b2.getTimestamp()); - } - } } diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index f51acca81c..d0341f8e58 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -1,5 +1,7 @@ package io.sentry +import com.google.common.truth.Truth.assertThat +import java.io.StringReader import java.util.Date import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -365,6 +367,60 @@ class BreadcrumbTest { } } + @Test + fun `breadcrumbs sharing a timestamp keep the order they were recorded in`() { + val timestamp = Date(1_600_000_000_000) + val first = Breadcrumb(timestamp).apply { message = "first" } + val second = Breadcrumb(timestamp).apply { message = "second" } + val third = Breadcrumb(timestamp).apply { message = "third" } + + val sorted = listOf(third, first, second).sorted().map { it.message } + + assertThat(sorted).containsExactly("first", "second", "third").inOrder() + } + + @Test + fun `a deserialized breadcrumb is ordered by its own timestamp, not by when it was parsed`() { + val live = Breadcrumb(Date(1_600_000_000_000)).apply { message = "live" } + val restored = + Breadcrumb.fromMap( + mapOf( + Breadcrumb.JsonKeys.TIMESTAMP to DateUtils.getTimestamp(Date(1_500_000_000_000)), + Breadcrumb.JsonKeys.MESSAGE to "restored", + ), + SentryOptions(), + ) + + val sorted = listOf(live, restored).sorted().map { it.message } + + assertThat(sorted).containsExactly("restored", "live").inOrder() + } + + @Test + fun `a breadcrumb read back from JSON is ordered by its own timestamp, not by when it was parsed`() { + val live = Breadcrumb(Date(1_600_000_000_000)).apply { message = "live" } + val json = + """{"timestamp":"${DateUtils.getTimestamp(Date(1_500_000_000_000))}","message":"restored"}""" + val restored = + Breadcrumb.Deserializer() + .deserialize(JsonObjectReader(StringReader(json)), NoOpLogger.getInstance()) + + val sorted = listOf(live, restored).sorted().map { it.message } + + assertThat(sorted).containsExactly("restored", "live").inOrder() + } + + @Test + fun `cloning a breadcrumb keeps its position among breadcrumbs sharing its timestamp`() { + val timestamp = Date(1_600_000_000_000) + val first = Breadcrumb(timestamp).apply { message = "first" } + val second = Breadcrumb(timestamp).apply { message = "second" } + + val sorted = listOf(second, Breadcrumb(first)).sorted().map { it.message } + + assertThat(sorted).containsExactly("first", "second").inOrder() + } + class TestKey(val id: Long) { override fun toString(): String = id.toString() } From 91f22cd2d0601497afb5e1cadf3724d8ab03163d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 16 Sep 2026 16:31:54 +0200 Subject: [PATCH 5/9] chore(android): Mark MemoryLimiter sessions as having exited abnormally (JAVA-687) (#6113) Mark recovered MemoryLimiter exits as abnormal session endings so release health no longer treats the terminated process as a healthy exit. Reuses the existing AbnormalExit cache repair path with a stable memory_limiter mechanism and the OS exit timestamp. --- .../api/sentry-android-core.api | 6 +- .../ApplicationExitInfoEventProcessor.java | 2 - .../core/MemoryLimiterIntegration.java | 19 ++++- .../core/MemoryLimiterIntegrationTest.kt | 5 +- .../core/cache/AndroidEnvelopeCacheTest.kt | 71 +++++++++++++++++++ .../java/io/sentry/hints/AbnormalExit.java | 27 +++++-- 6 files changed, 115 insertions(+), 15 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ca5a8af2ce..4670f9f9a7 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 ca55f9cb99..55392340d1 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 40bf4f9e51..648d8a4ada 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 a30dfbaf01..183ca3dfbd 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 e70abf3610..47163c0e84 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 bf63f98ea6..0ac6be4dd4 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(); } From cfde326f38586faf85fbb16977b6eb5e9cd550b9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 16 Sep 2026 16:32:56 +0200 Subject: [PATCH 6/9] fix(changelog): Relocate misplaced CHANGELOG entries ahead of release (#6123) --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e0dc71961..72d35fc356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Behavioral Changes + +- Measure HTTP rate-limit backoff on a monotonic clock instead of the wall clock, so that a device time change no longer lifts or extends an active rate limit ([#6030](https://github.com/getsentry/sentry-java/pull/6030)) + ### Features - Add Android SDK support for reporting `MemoryLimiter` app exits recovered from `ApplicationExitInfo` ([#6111](https://github.com/getsentry/sentry-java/pull/6111)). @@ -42,14 +46,11 @@ ### Internal +- Deprecate `RateLimiter(ICurrentDateProvider, SentryOptions)` in favor of `RateLimiter(SentryOptions)`, whose backoff is measured on a monotonic ticker ([#6030](https://github.com/getsentry/sentry-java/pull/6030)) - Deprecate `AndroidCurrentDateProvider.getInstance()` in favor of `MonotonicTicker`, which counts time spent in deep sleep and cannot be confused with the epoch-based `CurrentDateProvider` ([#6103](https://github.com/getsentry/sentry-java/pull/6103)) ## 8.56.0 -### Behavioral Changes - -- Measure HTTP rate-limit backoff on a monotonic clock instead of the wall clock, so that a device time change no longer lifts or extends an active rate limit ([#6030](https://github.com/getsentry/sentry-java/pull/6030)) - ### Fixes - Update `SentryTraced` so that it now honors `options.setIgnoredSpanOrigins` ([#6058](https://github.com/getsentry/sentry-java/pull/6058)) @@ -67,7 +68,6 @@ - Add an internal `MonotonicTicker` abstraction with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) - Add internal `Timestamp`, `EpochClock` and `AnchoredClock`, so related instants project from one wall-clock reading instead of each reading the clock ([#6045](https://github.com/getsentry/sentry-java/pull/6045)) -- Deprecate `RateLimiter(ICurrentDateProvider, SentryOptions)` in favor of `RateLimiter(SentryOptions)`, whose backoff is measured on a monotonic ticker ([#6030](https://github.com/getsentry/sentry-java/pull/6030)) ### Dependencies From f7b56efdb7088ad7bcb1ab95bfd22bebab29b3d0 Mon Sep 17 00:00:00 2001 From: 0xadam-brown <281682121+0xadam-brown@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:34:35 +0000 Subject: [PATCH 7/9] release: 8.57.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d35fc356..7160644a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.57.0 ### Behavioral Changes diff --git a/gradle.properties b/gradle.properties index c58781b879..bcd121eb06 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ android.newDsl=false android.experimental.lint.version=9.4.0 # Release information -versionName=8.56.0 +versionName=8.57.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 564f05ea18e5b049d144bd7d12b0b169206b5b65 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 17 Sep 2026 15:50:05 +0200 Subject: [PATCH 8/9] fix(core): Disable manifest URL caching when reading versions (JAVA-730) (#6124) ManifestVersionReader was retaining jar-backed inflater state while scanning META-INF/MANIFEST.MF entries. Disable URL caching for those reads and close the stream after parsing. --- CHANGELOG.md | 6 ++ .../internal/ManifestVersionReader.java | 89 +++++++++++-------- 2 files changed, 56 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7160644a78..67c683f0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) + ## 8.57.0 ### Behavioral Changes diff --git a/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java b/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java index 3a0161b187..af8bd90336 100644 --- a/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java +++ b/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java @@ -4,7 +4,9 @@ import io.sentry.SentryIntegrationPackageStorage; import io.sentry.util.AutoClosableReentrantLock; import java.io.IOException; +import java.io.InputStream; import java.net.URL; +import java.net.URLConnection; import java.util.Enumeration; import java.util.jar.Attributes; import java.util.jar.Manifest; @@ -56,48 +58,57 @@ public void readManifestFiles() { ClassLoader.getSystemClassLoader().getResources("META-INF/MANIFEST.MF"); while (resources.hasMoreElements()) { try { - final @NotNull Manifest manifest = new Manifest(resources.nextElement().openStream()); - final @Nullable Attributes mainAttributes = manifest.getMainAttributes(); - if (mainAttributes != null) { - final @Nullable String name = mainAttributes.getValue("Sentry-Opentelemetry-SDK-Name"); - final @Nullable String version = mainAttributes.getValue("Implementation-Version"); - final @Nullable String sdkName = mainAttributes.getValue("Sentry-SDK-Name"); - final @Nullable String packageName = mainAttributes.getValue("Sentry-SDK-Package-Name"); + final @NotNull URLConnection connection = resources.nextElement().openConnection(); + // Disable caches to avoid leaking jar-backed inflater state. + connection.setUseCaches(false); - if (name != null && version != null) { - versionInfo.sdkName = name; - versionInfo.sdkVersion = version; - final @Nullable String otelVersion = - mainAttributes.getValue("Sentry-Opentelemetry-Version-Name"); - if (otelVersion != null) { - SentryIntegrationPackageStorage.getInstance() - .addPackage("maven:io.opentelemetry:opentelemetry-sdk", otelVersion); - SentryIntegrationPackageStorage.getInstance().addIntegration("OpenTelemetry"); - } - final @Nullable String otelJavaagentVersion = - mainAttributes.getValue("Sentry-Opentelemetry-Javaagent-Version-Name"); - if (otelJavaagentVersion != null) { - SentryIntegrationPackageStorage.getInstance() - .addPackage( - "maven:io.opentelemetry.javaagent:opentelemetry-javaagent", - otelJavaagentVersion); - SentryIntegrationPackageStorage.getInstance().addIntegration("OpenTelemetry-Agent"); - } - if (name.equals("sentry.java.opentelemetry.agentless")) { - SentryIntegrationPackageStorage.getInstance() - .addIntegration("OpenTelemetry-Agentless"); - } - if (name.equals("sentry.java.opentelemetry.agentless-spring")) { - SentryIntegrationPackageStorage.getInstance() - .addIntegration("OpenTelemetry-Agentless-Spring"); + try (final @NotNull InputStream inputStream = connection.getInputStream()) { + final @NotNull Manifest manifest = new Manifest(inputStream); + final @Nullable Attributes mainAttributes = manifest.getMainAttributes(); + if (mainAttributes != null) { + final @Nullable String name = + mainAttributes.getValue("Sentry-Opentelemetry-SDK-Name"); + final @Nullable String version = mainAttributes.getValue("Implementation-Version"); + final @Nullable String sdkName = mainAttributes.getValue("Sentry-SDK-Name"); + final @Nullable String packageName = + mainAttributes.getValue("Sentry-SDK-Package-Name"); + + if (name != null && version != null) { + versionInfo.sdkName = name; + versionInfo.sdkVersion = version; + final @Nullable String otelVersion = + mainAttributes.getValue("Sentry-Opentelemetry-Version-Name"); + if (otelVersion != null) { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.opentelemetry:opentelemetry-sdk", otelVersion); + SentryIntegrationPackageStorage.getInstance().addIntegration("OpenTelemetry"); + } + final @Nullable String otelJavaagentVersion = + mainAttributes.getValue("Sentry-Opentelemetry-Javaagent-Version-Name"); + if (otelJavaagentVersion != null) { + SentryIntegrationPackageStorage.getInstance() + .addPackage( + "maven:io.opentelemetry.javaagent:opentelemetry-javaagent", + otelJavaagentVersion); + SentryIntegrationPackageStorage.getInstance() + .addIntegration("OpenTelemetry-Agent"); + } + if (name.equals("sentry.java.opentelemetry.agentless")) { + SentryIntegrationPackageStorage.getInstance() + .addIntegration("OpenTelemetry-Agentless"); + } + if (name.equals("sentry.java.opentelemetry.agentless-spring")) { + SentryIntegrationPackageStorage.getInstance() + .addIntegration("OpenTelemetry-Agentless-Spring"); + } } - } - if (sdkName != null - && version != null - && packageName != null - && sdkName.startsWith("sentry.java")) { - SentryIntegrationPackageStorage.getInstance().addPackage(packageName, version); + if (sdkName != null + && version != null + && packageName != null + && sdkName.startsWith("sentry.java")) { + SentryIntegrationPackageStorage.getInstance().addPackage(packageName, version); + } } } } catch (Exception e) { From 1511066b8e9e045a6cc739ef4da35a77fc95ef2a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:26:30 +0000 Subject: [PATCH 9/9] chore(deps): bump the github-actions group across 1 directory with 4 updates (#6110) Bumps the github-actions group with 4 updates in the / directory: [actions/setup-java](https://github.com/actions/setup-java), [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [getsentry/craft](https://github.com/getsentry/craft). Updates `actions/setup-java` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/dd06d9cba3e5552c54d9f8ea23572deb30010f7c...de7274f081f381c8f8158605e0321c36c376e2e6) Updates `github/codeql-action/init` from 4.37.9 to 4.38.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63) Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63) Updates `getsentry/craft` from 2.30.1 to 2.31.0 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/cd1e8294061fd970b40d98b77aaa109cb1e00e78...55694f8bf3a0fd051e21a877f57b4c0dd8e946bb) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.31.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/analyze dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: github/codeql-action/init dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-macrobenchmark.yml | 2 +- .github/workflows/integration-tests-size.yml | 2 +- .github/workflows/integration-tests-ui-critical.yml | 2 +- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/spring-boot-2-matrix.yml | 2 +- .github/workflows/spring-boot-3-matrix.yml | 2 +- .github/workflows/spring-boot-4-matrix.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 16 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index d846d0a041..7039f96be4 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -33,7 +33,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b874144249..6d853ddf2f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 43745cf62c..2efc786187 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' @@ -36,7 +36,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # pin@v2 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # pin@v2 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # pin@v2 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index c58e73bbb6..0e0e3fc3c6 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -14,7 +14,7 @@ jobs: uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - name: Set up Java - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 24accb2f74..8ed4f9f91d 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -13,7 +13,7 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 2d8cd743c2..12ddf83e9a 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -14,7 +14,7 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-macrobenchmark.yml b/.github/workflows/integration-tests-macrobenchmark.yml index 6b2aca8936..91ae4dbd5e 100644 --- a/.github/workflows/integration-tests-macrobenchmark.yml +++ b/.github/workflows/integration-tests-macrobenchmark.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: 'Set up Java: 17' - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 8a0f126144..13adef7826 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Java Version - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: "temurin" java-version: "17" diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index dd534708d1..796ed10496 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java 17 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index db1c6e15e9..e8fc82aebc 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -27,7 +27,7 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index d2d1fea979..702b8c8abe 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -20,7 +20,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e58292dc84..2696d4f475 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: fetch-depth: 0 submodules: 'recursive' - name: Prepare release - uses: getsentry/craft@cd1e8294061fd970b40d98b77aaa109cb1e00e78 # v2 + uses: getsentry/craft@55694f8bf3a0fd051e21a877f57b4c0dd8e946bb # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 9a338e71df..806822fdc3 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 74e08f2566..9c9d97ac2b 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 185a25f408..8bf44375dd 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index a067f82ac2..90b1516bab 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -118,7 +118,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' java-version: '17'