diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2004921a2..161e7853a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Features - Add `trace_metric_byte` data category and record byte-level client reports when trace metrics are discarded ([#5626](https://github.com/getsentry/sentry-java/pull/5626)) +- Expose sentry-native's heartbeat-based app-hang detection through `SentryAndroidOptions` ([#5623](https://github.com/getsentry/sentry-java/pull/5623)) + - Enable via `setEnableNdkAppHangTracking(true)` (disabled by default) and tune the timeout with `setNdkAppHangTimeoutIntervalMillis(...)` (default `5000` ms), or the `io.sentry.ndk.app-hang.enable` / `io.sentry.ndk.app-hang.timeout-interval-millis` manifest entries + - Intended for hybrid SDKs: emit the heartbeat by calling the native `sentry_app_hang_heartbeat()` from the thread you want monitored. Independent of the JVM-based ANR detection (`setAnrEnabled`) ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 58325d08b5b..0e383125156 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -367,6 +367,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; public fun getNativeSdkName ()Ljava/lang/String; + public fun getNdkAppHangTimeoutIntervalMillis ()J public fun getNdkHandlerStrategy ()I public fun getScreenshot ()Lio/sentry/android/core/SentryScreenshotOptions; public fun getStartupCrashDurationThresholdMillis ()J @@ -388,6 +389,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableAutoTraceIdGeneration ()Z public fun isEnableFramesTracking ()Z public fun isEnableNdk ()Z + public fun isEnableNdkAppHangTracking ()Z public fun isEnableNetworkEventBreadcrumbs ()Z public fun isEnablePerformanceV2 ()Z public fun isEnableRootCheck ()Z @@ -420,6 +422,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableAutoTraceIdGeneration (Z)V public fun setEnableFramesTracking (Z)V public fun setEnableNdk (Z)V + public fun setEnableNdkAppHangTracking (Z)V public fun setEnableNetworkEventBreadcrumbs (Z)V public fun setEnablePerformanceV2 (Z)V public fun setEnableRootCheck (Z)V @@ -430,6 +433,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)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 setReportHistoricalTombstones (Z)V public fun setTombstoneEnabled (Z)V 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 c34ee0dbfa9..6700ad81599 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 @@ -35,6 +35,11 @@ final class ManifestMetadataReader { static final String ANR_ATTACH_THREAD_DUMPS = "io.sentry.anr.attach-thread-dumps"; static final String ANR_REPORT_HISTORICAL = "io.sentry.anr.report-historical"; + static final String NDK_APP_HANG_TRACKING_ENABLE = "io.sentry.ndk.app-hang.enable"; + + static final String NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS = + "io.sentry.ndk.app-hang.timeout-interval-millis"; + static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable"; static final String TOMBSTONE_ATTACH_RAW = "io.sentry.tombstone.attach-raw"; @@ -264,6 +269,20 @@ static void applyMetadata( options.setReportHistoricalAnrs( readBool(metadata, logger, ANR_REPORT_HISTORICAL, options.isReportHistoricalAnrs())); + options.setEnableNdkAppHangTracking( + readBool( + metadata, + logger, + NDK_APP_HANG_TRACKING_ENABLE, + options.isEnableNdkAppHangTracking())); + + options.setNdkAppHangTimeoutIntervalMillis( + readLong( + metadata, + logger, + NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS, + options.getNdkAppHangTimeoutIntervalMillis())); + final @Nullable String dsn = readString(metadata, logger, DSN, options.getDsn()); final boolean enabled = readBool(metadata, logger, ENABLE_SENTRY, options.isEnabled()); 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 ed07c4edaaf..615db97a28d 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 @@ -37,6 +37,22 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable ANR on Debug mode Default is disabled Used by AnrIntegration */ private boolean anrReportInDebug = false; + /** + * Enable or disable in-process, heartbeat-based app-hang detection in sentry-native. Default is + * disabled. When enabled, sentry-native's background watchdog captures an app-hang event if no + * heartbeat is received within {@link #ndkAppHangTimeoutIntervalMillis} on the monitored thread. + * + *

This is intended for downstream/hybrid SDKs that emit the heartbeat by calling the native + * {@code sentry_app_hang_heartbeat()} from their main thread. It is independent of the JVM-based + * {@link #anrEnabled} ANR detection. + */ + private boolean enableNdkAppHangTracking = false; + + /** + * The app-hang detection timeout interval in millis used by sentry-native. Default is 5000 = 5s. + */ + private long ndkAppHangTimeoutIntervalMillis = 5000; + /** * Enable or disable automatic breadcrumbs for Activity lifecycle. Using * Application.ActivityLifecycleCallbacks @@ -338,6 +354,50 @@ public void setAnrReportInDebug(boolean anrReportInDebug) { this.anrReportInDebug = anrReportInDebug; } + /** + * Checks if heartbeat-based app-hang detection in sentry-native is enabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableNdkAppHangTracking() { + return enableNdkAppHangTracking; + } + + /** + * Enables or disables heartbeat-based app-hang detection in sentry-native. Default is disabled. + * Requires the NDK integration to be present and emitting heartbeats via the native {@code + * sentry_app_hang_heartbeat()}. + * + * @param enableNdkAppHangTracking true for enabled and false for disabled + */ + @ApiStatus.Experimental + public void setEnableNdkAppHangTracking(boolean enableNdkAppHangTracking) { + this.enableNdkAppHangTracking = enableNdkAppHangTracking; + } + + /** + * Returns the app-hang detection timeout interval in millis used by sentry-native. Default is + * 5000 = 5s. + * + * @return the timeout in millis + */ + @ApiStatus.Experimental + public long getNdkAppHangTimeoutIntervalMillis() { + return ndkAppHangTimeoutIntervalMillis; + } + + /** + * Sets the app-hang detection timeout interval in millis used by sentry-native. Default is 5000 = + * 5s. + * + * @param ndkAppHangTimeoutIntervalMillis the timeout interval in millis + */ + @ApiStatus.Experimental + public void setNdkAppHangTimeoutIntervalMillis(long ndkAppHangTimeoutIntervalMillis) { + this.ndkAppHangTimeoutIntervalMillis = ndkAppHangTimeoutIntervalMillis; + } + /** * Sets Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) to enabled or disabled. * 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 3b12e6489a7..7814362b588 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 @@ -288,6 +288,56 @@ class ManifestMetadataReaderTest { assertEquals(false, fixture.options.isAttachAnrThreadDump) } + @Test + fun `applyMetadata reads app hang tracking enabled to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.NDK_APP_HANG_TRACKING_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isEnableNdkAppHangTracking) + } + + @Test + fun `applyMetadata reads app hang tracking enabled to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isEnableNdkAppHangTracking) + } + + @Test + fun `applyMetadata reads app hang timeout interval to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS to 1000) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(1000.toLong(), fixture.options.ndkAppHangTimeoutIntervalMillis) + } + + @Test + fun `applyMetadata reads app hang timeout interval to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(5000.toLong(), fixture.options.ndkAppHangTimeoutIntervalMillis) + } + @Test fun `applyMetadata reads tombstone attach raw to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 0eec9502702..94857b91058 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -239,6 +239,13 @@ class SentryAndroidOptionsTest { sentryOptions.anrProfilingSampleRate = 2.0 } + @Test + fun `app hang tracking is disabled by default with a 5s timeout`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableNdkAppHangTracking) + assertEquals(5000L, sentryOptions.ndkAppHangTimeoutIntervalMillis) + } + private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java index 9d6d64a1236..fae652f7a64 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -73,6 +73,9 @@ public static void init(@NotNull final SentryAndroidOptions options) { ndkOptions.setTracesSampleRate(tracesSampleRate.floatValue()); } + ndkOptions.setEnableAppHangTracking(options.isEnableNdkAppHangTracking()); + ndkOptions.setAppHangTimeoutMillis(options.getNdkAppHangTimeoutIntervalMillis()); + //noinspection UnstableApiUsage io.sentry.ndk.SentryNdk.init(ndkOptions); diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt index c9f540f0afa..d9b332ac185 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt @@ -3,7 +3,9 @@ package io.sentry.android.ndk import io.sentry.android.core.SentryAndroidOptions import io.sentry.ndk.NdkOptions import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.junit.Test import org.mockito.Mockito import org.mockito.kotlin.any @@ -68,4 +70,30 @@ class SentryNdkTest { assertEquals(0.75f, fixture.capturedOptions!!.tracesSampleRate, 0.0001f) } } + + @Test + fun `SentryNdk does not enable app hang tracking by default`() { + fixture.getSut { + assertNotNull(fixture.capturedOptions) + assertFalse(fixture.capturedOptions!!.isEnableAppHangTracking) + assertEquals(5000L, fixture.capturedOptions!!.appHangTimeoutMillis) + } + } + + @Test + fun `SentryNdk propagates app hang tracking options`() { + fixture.getSut( + options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + isEnableNdkAppHangTracking = true + ndkAppHangTimeoutIntervalMillis = 2000 + } + ) { + assertNotNull(fixture.capturedOptions) + assertTrue(fixture.capturedOptions!!.isEnableAppHangTracking) + assertEquals(2000L, fixture.capturedOptions!!.appHangTimeoutMillis) + } + } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 02934b1ed7d..262b3a93034 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -992,7 +992,7 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint } if (shouldApplyScopeData(transaction, hint)) { - transaction = applyScope(transaction, scope); + transaction = applyScope(transaction, scope, HintUtils.hasType(hint, Cached.class)); if (transaction != null && scope != null) { transaction = processTransaction(transaction, hint, scope.getEventProcessors()); @@ -1407,7 +1407,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri private @Nullable SentryEvent applyScope( @NotNull SentryEvent event, final @Nullable IScope scope, final @NotNull Hint hint) { if (scope != null) { - applyScope(event, scope); + applyScope(event, scope, HintUtils.hasType(hint, Cached.class)); if (event.getTransaction() == null) { event.setTransaction(scope.getTransactionName()); @@ -1539,7 +1539,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri } private @NotNull T applyScope( - final @NotNull T sentryBaseEvent, final @Nullable IScope scope) { + final @NotNull T sentryBaseEvent, final @Nullable IScope scope, final boolean isCached) { if (scope != null) { if (sentryBaseEvent.getRequest() == null) { sentryBaseEvent.setRequest(scope.getRequest()); @@ -1558,7 +1558,9 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri } if (sentryBaseEvent.getBreadcrumbs() == null) { sentryBaseEvent.setBreadcrumbs(new ArrayList<>(scope.getBreadcrumbs())); - } else { + } else if (!isCached) { + // A Cached event comes from the outbox and already carries its own breadcrumbs (e.g. native + // events written by sentry-native). Appending the scope's breadcrumbs would duplicate them. sortBreadcrumbsByDate(sentryBaseEvent, scope.getBreadcrumbs()); } if (sentryBaseEvent.getExtras() == null) { diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 5b407bfef3d..ea64675570d 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -649,6 +649,52 @@ class SentryClientTest { } } + @Test + fun `when event is cached, scope breadcrumbs are not merged into the event breadcrumbs`() { + val scopeCrumb = Breadcrumb(DateUtils.getDateTime("2020-03-27T08:52:58.001Z")) + val scope = Scope(SentryOptions()).apply { addBreadcrumb(scopeCrumb) } + + val sut = fixture.getSut() + + val eventCrumb = Breadcrumb(DateUtils.getDateTime("2020-03-27T08:52:58.003Z")) + val event = SentryEvent().apply { breadcrumbs = mutableListOf(eventCrumb) } + + val hints = HintUtils.createWithTypeCheckHint(CachedHint()) + sut.captureEvent(event, scope, hints) + + assertNotNull(event.breadcrumbs) { + assertEquals(1, it.size) + assertSame(eventCrumb, it[0]) + } + } + + @Test + fun `when transaction is cached, scope breadcrumbs are not merged into the transaction breadcrumbs`() { + val sut = fixture.getSut() + val scope = Scope(fixture.sentryOptions) + scope.addBreadcrumb(Breadcrumb("from scope")) + + val transaction = SentryTransaction(fixture.sentryTracer) + transaction.breadcrumbs = mutableListOf(Breadcrumb("from transaction")) + + val hints = HintUtils.createWithTypeCheckHint(CachedHint()) + sut.captureTransaction(transaction, scope, hints) + + verify(fixture.transport) + .send( + check { envelope -> + val sent = envelope.items.first().getTransaction(fixture.sentryOptions.serializer) + assertNotNull(sent) { + assertNotNull(it.breadcrumbs) { breadcrumbs -> + assertEquals(1, breadcrumbs.size) + assertEquals("from transaction", breadcrumbs.first().message) + } + } + }, + anyOrNull(), + ) + } + @Test fun `when captureEvent with scope, event data has priority over scope but level and it should append extras, tags and breadcrumbs`() { val event = createEvent()