diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index d846d0a041b..7039f96be4b 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 b8741442497..6d853ddf2fe 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 43745cf62cd..2efc786187c 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 c58e73bbb69..0e0e3fc3c62 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 24accb2f74e..8ed4f9f91d2 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 2d8cd743c2c..12ddf83e9aa 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 6b2aca8936c..91ae4dbd5e0 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 8a0f1261442..13adef7826b 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 dd534708d11..796ed10496c 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 db1c6e15e9d..e8fc82aebc2 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 d2d1fea9794..702b8c8abed 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 e58292dc849..2696d4f4758 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 9a338e71df2..806822fdc3b 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 74e08f2566b..9c9d97ac2b5 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 185a25f4080..8bf44375dd4 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 a067f82ac29..90b1516bab3 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' diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a8eea2d9cf..67c683f0ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,19 @@ ## 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 + +- 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)). - 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 @@ -34,16 +45,18 @@ - 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)) +- 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 +- 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)) @@ -61,7 +74,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 diff --git a/gradle.properties b/gradle.properties index c58781b8796..bcd121eb063 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 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0e33dfb1381..4670f9f9a75 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -334,6 +334,22 @@ 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/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 ()Ljava/lang/Long; +} + public final class io/sentry/android/core/NativeEventCollector { public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun collect ()V @@ -446,7 +462,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 +498,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 +661,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 +767,18 @@ public final class io/sentry/android/core/anr/StackTraceConverter { public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { public static final field LAST_ANR_MARKER_LABEL Ljava/lang/String; public static final field LAST_ANR_REPORT Ljava/lang/String; + public static final field LAST_MEMORY_LIMITER_MARKER_LABEL Ljava/lang/String; + public static final field LAST_MEMORY_LIMITER_REPORT Ljava/lang/String; public static final field LAST_TOMBSTONE_MARKER_LABEL Ljava/lang/String; public static final field LAST_TOMBSTONE_REPORT Ljava/lang/String; public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getDirectory ()Ljava/io/File; public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z public static fun lastReportedAnr (Lio/sentry/SentryOptions;)Ljava/lang/Long; + public static fun lastReportedMemoryLimiter (Lio/sentry/SentryOptions;)Ljava/lang/Long; public static fun lastReportedTombstone (Lio/sentry/SentryOptions;)Ljava/lang/Long; public static fun markAnrReported (Lio/sentry/SentryOptions;J)V + public static fun markMemoryLimiterReported (Lio/sentry/SentryOptions;J)V public static fun markTombstoneReported (Lio/sentry/SentryOptions;J)V public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 85bfd8b5ac4..12ffb90356c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -434,6 +434,10 @@ static void installDefaultIntegrations( options.addIntegration(new TombstoneIntegration(context)); } + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.CINNAMON_BUN) { + options.addIntegration(new MemoryLimiterIntegration(context, buildInfoProvider)); + } + // this integration uses android.os.FileObserver, we can't move to sentry // before creating a pure java impl. options.addIntegration(EnvelopeFileObserverIntegration.getOutboxFileObserver()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index e28a08f7e8d..5e5500297f8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -118,8 +118,8 @@ private static final class AnrV2Policy } @Override - public int getTargetReason() { - return ApplicationExitInfo.REASON_ANR; + public boolean matches(final @NotNull ApplicationExitInfo exitInfo) { + return exitInfo.getReason() == ApplicationExitInfo.REASON_ANR; } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 31fe2442bf4..55392340d15 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -800,9 +800,7 @@ private final class AnrHintEnricher implements HintEnricher { @Override public boolean supports(@NotNull Object hint) { - // While this is specifically an ANR enricher we discriminate enrichment application - // on the broader AbnormalExit hints for now. - return hint instanceof AbnormalExit; + return hint instanceof AnrV2Integration.AnrV2Hint; } // by default we assume that the ANR is foreground, unless abnormalMechanism is "anr_background" diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java index 155c9fab27e..b60cd7e6914 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 1ca91bbada3..9f31c6751b0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -47,6 +47,9 @@ final class ManifestMetadataReader { static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; static final String TOMBSTONE_ATTACH_RAW = "io.sentry.tombstone.attach-raw"; static final String TOMBSTONE_REPORT_HISTORICAL = "io.sentry.tombstone.report-historical"; + static final String MEMORY_LIMITER_ENABLE = "io.sentry.memory-limiter.enable"; + static final String MEMORY_LIMITER_REPORT_HISTORICAL = + "io.sentry.memory-limiter.report-historical"; static final String AUTO_INIT = "io.sentry.auto-init"; static final String NDK_ENABLE = "io.sentry.ndk.enable"; @@ -253,6 +256,14 @@ static void applyMetadata( logger, TOMBSTONE_REPORT_HISTORICAL, options.isReportHistoricalTombstones())); + options.setMemoryLimiterEnabled( + readBool(metadata, logger, MEMORY_LIMITER_ENABLE, options.isMemoryLimiterEnabled())); + options.setReportHistoricalMemoryLimiterExits( + readBool( + metadata, + logger, + MEMORY_LIMITER_REPORT_HISTORICAL, + options.isReportHistoricalMemoryLimiterExits())); // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java new file mode 100644 index 00000000000..648d8a4adac --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/MemoryLimiterIntegration.java @@ -0,0 +1,417 @@ +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.AbnormalExit; +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_MECHANISM = "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, AbnormalExit { + + 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; + } + + @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; + } + + @Override + public boolean isFlushable(@Nullable SentryId eventId) { + return true; + } + + @Override + public void setFlushable(@NotNull SentryId eventId) {} + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 202d779d61d..d3e24766cf1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -250,6 +250,25 @@ public interface BeforeCaptureCallback { */ private boolean reportHistoricalTombstones = false; + /** + * Controls whether to report MemoryLimiter exits from + * the {@link ApplicationExitInfo} system API. + * + *

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

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

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

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

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

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

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

Available on Android API ≥ 37. No-ops if the Android API is below 37, if the {@link + * #setCacheDirPath cache dir path} hasn't been set, or if {@link #setMemoryLimiterEnabled the + * MemoryLimiter integration} hasn't been enabled. + */ + @ApiStatus.Experimental + public void setReportHistoricalMemoryLimiterExits( + final boolean reportHistoricalMemoryLimiterExits) { + this.reportHistoricalMemoryLimiterExits = reportHistoricalMemoryLimiterExits; + } + /** * @return true if performance-v2 is enabled. See {@link #setEnablePerformanceV2(boolean)} for * more details. diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java index d6d6a7767c8..48832ca8dce 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -133,8 +133,8 @@ public TombstonePolicy(final @NotNull SentryAndroidOptions options, @NotNull Con @RequiresApi(api = Build.VERSION_CODES.R) @Override - public int getTargetReason() { - return ApplicationExitInfo.REASON_CRASH_NATIVE; + public boolean matches(final @NotNull ApplicationExitInfo exitInfo) { + return exitInfo.getReason() == ApplicationExitInfo.REASON_CRASH_NATIVE; } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index f001c4947de..13148097289 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/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index d39b3ff5ac4..82376a75d8b 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; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 85fba36f7d0..4610e4bbcb8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -989,6 +989,56 @@ class AndroidOptionsInitializerTest { assertNull(anrv1Integration) } + @Test + fun `MemoryLimiterIntegration added to integrations list for API 37 and above`() { + val options = SentryAndroidOptions() + val buildInfo = mock() + whenever(buildInfo.sdkInfoVersion).thenReturn(37) + val loadClass = LoadClass() + val activityFramesTracker = ActivityFramesTracker(loadClass, options) + + AndroidOptionsInitializer.installDefaultIntegrations( + fixture.context, + options, + buildInfo, + loadClass, + activityFramesTracker, + false, + false, + false, + false, + ) + + val integration = options.integrations.firstOrNull { it is MemoryLimiterIntegration } + + assertNotNull(integration) + } + + @Test + fun `MemoryLimiterIntegration not added to integrations list below API 37`() { + val options = SentryAndroidOptions() + val buildInfo = mock() + whenever(buildInfo.sdkInfoVersion).thenReturn(36) + val loadClass = LoadClass() + val activityFramesTracker = ActivityFramesTracker(loadClass, options) + + AndroidOptionsInitializer.installDefaultIntegrations( + fixture.context, + options, + buildInfo, + loadClass, + activityFramesTracker, + false, + false, + false, + false, + ) + + val integration = options.integrations.firstOrNull { it is MemoryLimiterIntegration } + + assertNull(integration) + } + @Test fun `AndroidUserFeedbackFormHandler is set as feedback form handler`() { fixture.initSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index d9fd9c1889e..89fc9e65cb0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -221,6 +221,18 @@ class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { verify(fixture.scopes).captureEvent(any(), check { assertNotNull(it.threadDump) }) } + @Test + fun `MemoryLimiter marker does not suppress matching ANR exit`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + File(fixture.options.cacheDirPath!!, AndroidEnvelopeCache.LAST_MEMORY_LIMITER_REPORT) + .writeText(newTimestamp.toString()) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + @Test fun `when traceInputStream is null, does not report ANR`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoCrossIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoCrossIntegrationTest.kt new file mode 100644 index 00000000000..f362fed252d --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoCrossIntegrationTest.kt @@ -0,0 +1,268 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryEvent +import io.sentry.cache.EnvelopeCache +import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService +import java.io.File +import java.util.zip.GZIPInputStream +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder + +// Our current Robolectric version (4.15) caps at API 35. All ApplicationExitInfo code paths we +// exercise are at API 31 or below, so we use the latter. +@RunWith(AndroidJUnit4::class) +@Config(sdk = [31]) +class ApplicationExitInfoCrossIntegrationTest { + + @get:Rule val tmpDir = TemporaryFolder() + + private class Fixture { + lateinit var context: Context + lateinit var shadowActivityManager: ShadowActivityManager + + fun init(appContext: Context) { + context = appContext + context.applicationInfo.nativeLibraryDir = + "/data/app/~~gu-2hA9_Zg6tfIuDAbLpKA==/io.sentry.samples.android-MFqmKAMnl9AjNlHcO3mejA==/lib/arm64" + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + shadowActivityManager = Shadow.extract(activityManager) + } + + fun newOptions( + dir: File, + configure: SentryAndroidOptions.() -> Unit, + ): SentryAndroidOptions { + return SentryAndroidOptions().apply { + val logger = mock() + whenever(logger.isEnabled(any())).thenReturn(true) + setLogger(logger) + isDebug = true + cacheDirPath = dir.absolutePath + executorService = ImmediateExecutorService() + setEnvelopeDiskCache(EnvelopeCache.create(this)) + addInAppInclude("io.sentry.samples") + configure() + } + } + + fun addMemoryLimiterExit(timestamp: Long) { + addExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + timestamp = timestamp, + description = MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, + importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ) + } + + fun addAnrExit(timestamp: Long) { + val builder = ApplicationExitInfoBuilder.newBuilder() + builder.setReason(ApplicationExitInfo.REASON_ANR) + builder.setTimestamp(timestamp) + builder.setImportance(ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val exitInfo = + spy(builder.build()) { + whenever(mock.traceInputStream) + .thenReturn( + """ + Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) + Here are no Binder-related exception messages available. + Pid(12233) have D state thread(tid:12236 name:Signal Catcher) + + + ----- dumping pid: 12233 at 313446151 ----- + "main" prio=5 tid=1 Native + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72c4c9a0 self=0xb40000779f142000 + | sysTid=12233 nice=-10 cgrp=top-app sched=0/0 handle=0x7d8e4c44f8 + at io.sentry.samples.MainActivity.blocked(MainActivity.java:42) + at android.os.Looper.loopOnce(Looper.java:226) + at android.os.Looper.loop(Looper.java:313) + """ + .trimIndent() + .byteInputStream() + ) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + + fun addTombstoneExit(timestamp: Long) { + val builder = ApplicationExitInfoBuilder.newBuilder() + builder.setReason(ApplicationExitInfo.REASON_CRASH_NATIVE) + builder.setTimestamp(timestamp) + builder.setImportance(ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val exitInfo = + spy(builder.build()) { + whenever(mock.traceInputStream) + .thenReturn( + GZIPInputStream( + TombstoneIntegrationTest::class.java.getResourceAsStream("/tombstone.pb.gz") + ) + ) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + + private fun addExitInfo( + reason: Int, + timestamp: Long, + description: String?, + importance: Int, + ) { + val builder = ApplicationExitInfoBuilder.newBuilder() + builder.setReason(reason) + builder.setTimestamp(timestamp) + builder.setImportance(importance) + val exitInfo = + spy(builder.build()) { + whenever(mock.description).thenReturn(description) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun `set up`() { + fixture.init(ApplicationProvider.getApplicationContext()) + } + + @AfterTest + fun `tear down`() { + tmpDir.root.deleteRecursively() + } + + @Test + fun `memory limiter and anr integrations each capture only their own exits from shared history`() { + val memoryLimiterTimestamp = System.currentTimeMillis() - 1_000 + val anrTimestamp = memoryLimiterTimestamp + 500 + fixture.addMemoryLimiterExit(memoryLimiterTimestamp) + fixture.addAnrExit(anrTimestamp) + + val memoryLimiterScopes = mock() + whenever(memoryLimiterScopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val memoryLimiterOptions = + fixture.newOptions(tmpDir.newFolder("memory-limiter")) { + isMemoryLimiterEnabled = true + isReportHistoricalMemoryLimiterExits = true + } + val buildInfoProvider = mock() + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(37) + MemoryLimiterIntegration( + fixture.context, + io.sentry.transport.CurrentDateProvider.getInstance(), + buildInfoProvider, + ) + .register(memoryLimiterScopes, memoryLimiterOptions) + + verify(memoryLimiterScopes) + .captureEvent( + argThat { timestamp!!.time == memoryLimiterTimestamp }, + anyOrNull(), + ) + verify(memoryLimiterScopes, never()) + .captureEvent( + argThat { timestamp!!.time == anrTimestamp }, + anyOrNull(), + ) + + val anrScopes = mock() + whenever(anrScopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val anrOptions = + fixture.newOptions(tmpDir.newFolder("anr")) { + isAnrEnabled = true + isReportHistoricalAnrs = true + } + AnrV2Integration(fixture.context).register(anrScopes, anrOptions) + + verify(anrScopes) + .captureEvent( + argThat { timestamp!!.time == anrTimestamp }, + anyOrNull(), + ) + verify(anrScopes, never()) + .captureEvent( + argThat { timestamp!!.time == memoryLimiterTimestamp }, + anyOrNull(), + ) + } + + @Test + fun `memory limiter and tombstone integrations each capture only their own exits from shared history`() { + val memoryLimiterTimestamp = System.currentTimeMillis() - 1_000 + val tombstoneTimestamp = memoryLimiterTimestamp + 500 + fixture.addMemoryLimiterExit(memoryLimiterTimestamp) + fixture.addTombstoneExit(tombstoneTimestamp) + + val memoryLimiterScopes = mock() + whenever(memoryLimiterScopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val memoryLimiterOptions = + fixture.newOptions(tmpDir.newFolder("memory-limiter")) { + isMemoryLimiterEnabled = true + isReportHistoricalMemoryLimiterExits = true + } + val buildInfoProvider = mock() + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(37) + MemoryLimiterIntegration( + fixture.context, + io.sentry.transport.CurrentDateProvider.getInstance(), + buildInfoProvider, + ) + .register(memoryLimiterScopes, memoryLimiterOptions) + + verify(memoryLimiterScopes) + .captureEvent( + argThat { timestamp!!.time == memoryLimiterTimestamp }, + anyOrNull(), + ) + verify(memoryLimiterScopes, never()) + .captureEvent( + argThat { timestamp!!.time == tombstoneTimestamp }, + anyOrNull(), + ) + + val tombstoneScopes = mock() + whenever(tombstoneScopes.captureEvent(any(), anyOrNull())).thenReturn(SentryId()) + val tombstoneOptions = + fixture.newOptions(tmpDir.newFolder("tombstone")) { + isTombstoneEnabled = true + isReportHistoricalTombstones = true + } + TombstoneIntegration(fixture.context).register(tombstoneScopes, tombstoneOptions) + + verify(tombstoneScopes) + .captureEvent( + argThat { timestamp!!.time == tombstoneTimestamp }, + anyOrNull(), + ) + verify(tombstoneScopes, never()) + .captureEvent( + argThat { timestamp!!.time == memoryLimiterTimestamp }, + anyOrNull(), + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index f484f994dbb..2efdf8e31c3 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 c387cc8794b..189d6bbc79e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -487,6 +487,26 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isReportHistoricalAnrs) } + @Test + fun `applyMetadata reads memory limiter enable to options`() { + val bundle = bundleOf(ManifestMetadataReader.MEMORY_LIMITER_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertEquals(true, fixture.options.isMemoryLimiterEnabled) + } + + @Test + fun `applyMetadata reads memory limiter historical reporting to options`() { + val bundle = bundleOf(ManifestMetadataReader.MEMORY_LIMITER_REPORT_HISTORICAL to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertEquals(true, fixture.options.isReportHistoricalMemoryLimiterExits) + } + @Test fun `applyMetadata reads activity breadcrumbs to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt new file mode 100644 index 00000000000..183ca3dfbdd --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/MemoryLimiterIntegrationTest.kt @@ -0,0 +1,512 @@ +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 && + hint.mechanism() == MemoryLimiterIntegration.MEMORY_LIMITER_MECHANISM && + !hint.ignoreCurrentThread() + }, + ) + } + + @Test + fun `ignores exit when reason does not match exits produced by MemoryLimiter`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_ANR, + description = MemoryLimiterIntegration.MEMORY_LIMITER_DESCRIPTION, + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `ignores exit when description does not match exits produced by MemoryLimiter`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + description = "LowSwapKiller", + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `captures exit for any MemoryLimiter sub-reason, not just AnonSwap`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + // A future MemoryLimiter kill sub-reason (e.g. the memory or swap limits) still lives in the + // "MemoryLimiter:" namespace and must be captured, with its raw sub-reason preserved. + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + description = "MemoryLimiter:Memory", + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { event -> + assertEquals("MemoryLimiter:Memory", event.exceptions!!.single().mechanism!!.description) + }, + anyOrNull(), + ) + } + + @Test + fun `ignores exit when description mentions MemoryLimiter without the namespace delimiter`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + // "MemoryLimiter" without the ":" delimiter is not a MemoryLimiter kill; matching requires the + // namespace prefix so we don't over-capture unrelated REASON_OTHER exits. + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + description = "NotAMemoryLimiterKill", + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `ignores exit when description is null`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo( + reason = ApplicationExitInfo.REASON_OTHER, + description = null, + timestamp = newTimestamp, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `historical MemoryLimiter exits are reported oldest to newest`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + reportHistoricalMemoryLimiterExits = true, + dir = tmpDir, + sdkVersion = 37, + lastReportedTimestamp = oldTimestamp, + ) + fixture.addAppExitInfo(timestamp = newTimestamp - 2_000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1_000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + inOrder(fixture.scopes) { + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == newTimestamp - 2_000 }, + argThat { !(HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == newTimestamp - 1_000 }, + argThat { !(HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == newTimestamp }, + argThat { (HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + } + } + + @Test + fun `skips historical MemoryLimiter exits at or before the last reported timestamp`() { + val skippedHistoricalTimestamp = newTimestamp - 2_000 + val reportedHistoricalTimestamp = newTimestamp - 1_000 + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + reportHistoricalMemoryLimiterExits = true, + dir = tmpDir, + sdkVersion = 37, + lastReportedTimestamp = newTimestamp - 1_500, + ) + fixture.addAppExitInfo(timestamp = skippedHistoricalTimestamp) + fixture.addAppExitInfo(timestamp = reportedHistoricalTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()) + .captureEvent(argThat { timestamp!!.time == skippedHistoricalTimestamp }, anyOrNull()) + inOrder(fixture.scopes) { + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == reportedHistoricalTimestamp }, + argThat { !(HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + verify(fixture.scopes) + .captureEvent( + argThat { timestamp!!.time == newTimestamp }, + argThat { (HintUtils.getSentrySdkHint(this) as MemoryLimiterHint).shouldEnrich() }, + ) + } + } + + @Test + fun `does not report historical MemoryLimiter exits if historical exits are disabled`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = true, + reportHistoricalMemoryLimiterExits = false, + dir = tmpDir, + sdkVersion = 37, + lastReportedTimestamp = oldTimestamp, + ) + fixture.addAppExitInfo(timestamp = newTimestamp - 2_000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1_000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + // Verify we report only the latest MemoryLimiter exit, and none before it. + verify(fixture.scopes, atMost(1)).captureEvent(any(), anyOrNull()) + assertTrue(fixture.lastReportedMemoryLimiterFile.exists()) + } + + @Test + fun `does not report historical MemoryLimiter exits if integration is disabled`() { + val integration = + fixture.getSut( + memoryLimiterEnabled = false, + reportHistoricalMemoryLimiterExits = true, + dir = tmpDir, + sdkVersion = 37, + useImmediateExecutorService = false, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `skips MemoryLimiter exits that were already reported`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = newTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `dedupes MemoryLimiter exists independently of ANR exits`() { + val integration = + fixture.getSut( + tmpDir, + lastReportedTimestamp = oldTimestamp, + anrReportedTimestamp = newTimestamp, + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + + @Test + fun `dedupes MemoryLimiter exits independently of Tombstone exits`() { + val integration = + fixture.getSut( + tmpDir, + lastReportedTimestamp = oldTimestamp, + tombstoneReportedTimestamp = newTimestamp, + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt index e3e88d04f7a..bc05f3b23fd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TombstoneIntegrationTest.kt @@ -16,6 +16,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.check import org.mockito.kotlin.spy @@ -136,6 +137,18 @@ class TombstoneIntegrationTest : ApplicationExitIntegrationTestBase { this.tombstone == null }) } + @Test + fun `memory limiter marker does not suppress matching tombstone exit`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + File(fixture.options.cacheDirPath!!, AndroidEnvelopeCache.LAST_MEMORY_LIMITER_REPORT) + .writeText(newTimestamp.toString()) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes).captureEvent(any(), anyOrNull()) + } + @Test fun `when matching native event has attachments, they are added to the hint`() { val integration = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index a4063ccb148..47163c0e841 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -1,11 +1,19 @@ 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 import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.cache.EnvelopeCache @@ -13,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 @@ -34,6 +43,7 @@ class AndroidEnvelopeCacheTest { val dateProvider = mock() lateinit var startupCrashMarkerFile: File lateinit var lastReportedAnrFile: File + lateinit var lastReportedMemoryLimiterFile: File fun getSut( dir: TemporaryFolder, @@ -48,6 +58,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 +221,86 @@ 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 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) + + 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() @@ -221,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/api/sentry.api b/sentry/api/sentry.api index 24635fc5ddd..f28fffd6b80 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/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fff6954ee56..159e8adddf2 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/HostnameCache.java b/sentry/src/main/java/io/sentry/HostnameCache.java index 56cc0c2e845..4e9e2bc4c0d 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 d84c9e47be8..bde148134a5 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/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 0e44812a491..4bba195feea 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/main/java/io/sentry/hints/AbnormalExit.java b/sentry/src/main/java/io/sentry/hints/AbnormalExit.java index bf63f98ea6f..0ac6be4dd4b 100644 --- a/sentry/src/main/java/io/sentry/hints/AbnormalExit.java +++ b/sentry/src/main/java/io/sentry/hints/AbnormalExit.java @@ -5,11 +5,12 @@ /** * Marker interface for Sessions experiencing abnormal status. * - *

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

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

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

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

Epoch time in milliseconds, or null. + */ @Nullable Long timestamp(); } diff --git a/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java b/sentry/src/main/java/io/sentry/internal/ManifestVersionReader.java index 3a0161b1879..af8bd903363 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) { diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index f51acca81cb..d0341f8e58e 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() } diff --git a/sentry/src/test/java/io/sentry/HostnameCacheTest.kt b/sentry/src/test/java/io/sentry/HostnameCacheTest.kt index 3cc3a52aa26..6d0398858a3 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 fe5c835c90f..a35f0e0e026 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 3066d2c1b53..61181ee96a6 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