Skip to content

Commit 20be537

Browse files
authored
feat(android): forward app hang options to NDK (#5623)
1 parent a979f23 commit 20be537

10 files changed

Lines changed: 232 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@
6464
Sentry.finishExtendedAppStart()
6565
```
6666
- 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))
67+
- Expose sentry-native's heartbeat-based app-hang detection through `SentryAndroidOptions` ([#5623](https://github.com/getsentry/sentry-java/pull/5623))
68+
- 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
69+
- 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`)
6770
- Support the `io.sentry.tombstone.report-historical` manifest option to enable historical tombstone reporting via `AndroidManifest.xml` `<meta-data>` ([#5683](https://github.com/getsentry/sentry-java/pull/5683))
6871

6972
### Fixes
@@ -82,6 +85,12 @@
8285
- Remove executor prewarm during SDK init ([#5681](https://github.com/getsentry/sentry-java/pull/5681))
8386
- The single-threaded `SentryExecutorService` queued the prewarm work ahead of the first useful task, so it could only delay init work, never speed it up; the thread and class loading it warmed are paid identically by the first real task submitted right after.
8487

88+
### Dependencies
89+
90+
- Bump Native SDK from v0.15.2 to v0.15.3 ([#5623](https://github.com/getsentry/sentry-java/pull/5623))
91+
- [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0153)
92+
- [diff](https://github.com/getsentry/sentry-native/compare/0.15.2...0.15.3)
93+
8594
## 8.47.0
8695

8796
### Behavioral Changes

sentry-android-core/api/sentry-android-core.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
391391
public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader;
392392
public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;
393393
public fun getNativeSdkName ()Ljava/lang/String;
394+
public fun getNdkAppHangTimeoutIntervalMillis ()J
394395
public fun getNdkHandlerStrategy ()I
395396
public fun getScreenshot ()Lio/sentry/android/core/SentryScreenshotOptions;
396397
public fun getStartupCrashDurationThresholdMillis ()J
@@ -412,6 +413,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
412413
public fun isEnableAutoTraceIdGeneration ()Z
413414
public fun isEnableFramesTracking ()Z
414415
public fun isEnableNdk ()Z
416+
public fun isEnableNdkAppHangTracking ()Z
415417
public fun isEnableNetworkEventBreadcrumbs ()Z
416418
public fun isEnablePerformanceV2 ()Z
417419
public fun isEnableRootCheck ()Z
@@ -444,6 +446,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
444446
public fun setEnableAutoTraceIdGeneration (Z)V
445447
public fun setEnableFramesTracking (Z)V
446448
public fun setEnableNdk (Z)V
449+
public fun setEnableNdkAppHangTracking (Z)V
447450
public fun setEnableNetworkEventBreadcrumbs (Z)V
448451
public fun setEnablePerformanceV2 (Z)V
449452
public fun setEnableRootCheck (Z)V
@@ -454,6 +457,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
454457
public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V
455458
public fun setNativeHandlerStrategy (Lio/sentry/android/core/NdkHandlerStrategy;)V
456459
public fun setNativeSdkName (Ljava/lang/String;)V
460+
public fun setNdkAppHangTimeoutIntervalMillis (J)V
457461
public fun setReportHistoricalAnrs (Z)V
458462
public fun setReportHistoricalTombstones (Z)V
459463
public fun setTombstoneEnabled (Z)V

sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ final class ManifestMetadataReader {
3535
static final String ANR_ATTACH_THREAD_DUMPS = "io.sentry.anr.attach-thread-dumps";
3636
static final String ANR_REPORT_HISTORICAL = "io.sentry.anr.report-historical";
3737

38+
static final String NDK_APP_HANG_TRACKING_ENABLE = "io.sentry.ndk.app-hang.enable";
39+
40+
static final String NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS =
41+
"io.sentry.ndk.app-hang.timeout-interval-millis";
42+
3843
static final String TOMBSTONE_ENABLE = "io.sentry.tombstone.enable";
3944
static final String TOMBSTONE_ATTACH_RAW = "io.sentry.tombstone.attach-raw";
4045
static final String TOMBSTONE_REPORT_HISTORICAL = "io.sentry.tombstone.report-historical";
@@ -271,6 +276,20 @@ static void applyMetadata(
271276
options.setReportHistoricalAnrs(
272277
readBool(metadata, logger, ANR_REPORT_HISTORICAL, options.isReportHistoricalAnrs()));
273278

279+
options.setEnableNdkAppHangTracking(
280+
readBool(
281+
metadata,
282+
logger,
283+
NDK_APP_HANG_TRACKING_ENABLE,
284+
options.isEnableNdkAppHangTracking()));
285+
286+
options.setNdkAppHangTimeoutIntervalMillis(
287+
readLong(
288+
metadata,
289+
logger,
290+
NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS,
291+
options.getNdkAppHangTimeoutIntervalMillis()));
292+
274293
final @Nullable String dsn = readString(metadata, logger, DSN, options.getDsn());
275294
final boolean enabled = readBool(metadata, logger, ENABLE_SENTRY, options.isEnabled());
276295

sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ public final class SentryAndroidOptions extends SentryOptions {
3737
/** Enable or disable ANR on Debug mode Default is disabled Used by AnrIntegration */
3838
private boolean anrReportInDebug = false;
3939

40+
/**
41+
* Enable or disable in-process, heartbeat-based app-hang detection in sentry-native. Default is
42+
* disabled. When enabled, sentry-native's background watchdog captures an app-hang event if no
43+
* heartbeat is received within {@link #ndkAppHangTimeoutIntervalMillis} on the monitored thread.
44+
*
45+
* <p>This is intended for downstream/hybrid SDKs that emit the heartbeat by calling the native
46+
* {@code sentry_app_hang_heartbeat()} from their main thread. It is independent of the JVM-based
47+
* {@link #anrEnabled} ANR detection.
48+
*/
49+
private boolean enableNdkAppHangTracking = false;
50+
51+
/**
52+
* The app-hang detection timeout interval in millis used by sentry-native. Default is 5000 = 5s.
53+
*/
54+
private long ndkAppHangTimeoutIntervalMillis = 5000;
55+
4056
/**
4157
* Enable or disable automatic breadcrumbs for Activity lifecycle. Using
4258
* Application.ActivityLifecycleCallbacks
@@ -338,6 +354,50 @@ public void setAnrReportInDebug(boolean anrReportInDebug) {
338354
this.anrReportInDebug = anrReportInDebug;
339355
}
340356

357+
/**
358+
* Checks if heartbeat-based app-hang detection in sentry-native is enabled. Default is disabled.
359+
*
360+
* @return true if enabled or false otherwise
361+
*/
362+
@ApiStatus.Experimental
363+
public boolean isEnableNdkAppHangTracking() {
364+
return enableNdkAppHangTracking;
365+
}
366+
367+
/**
368+
* Enables or disables heartbeat-based app-hang detection in sentry-native. Default is disabled.
369+
* Requires the NDK integration to be present and emitting heartbeats via the native {@code
370+
* sentry_app_hang_heartbeat()}.
371+
*
372+
* @param enableNdkAppHangTracking true for enabled and false for disabled
373+
*/
374+
@ApiStatus.Experimental
375+
public void setEnableNdkAppHangTracking(boolean enableNdkAppHangTracking) {
376+
this.enableNdkAppHangTracking = enableNdkAppHangTracking;
377+
}
378+
379+
/**
380+
* Returns the app-hang detection timeout interval in millis used by sentry-native. Default is
381+
* 5000 = 5s.
382+
*
383+
* @return the timeout in millis
384+
*/
385+
@ApiStatus.Experimental
386+
public long getNdkAppHangTimeoutIntervalMillis() {
387+
return ndkAppHangTimeoutIntervalMillis;
388+
}
389+
390+
/**
391+
* Sets the app-hang detection timeout interval in millis used by sentry-native. Default is 5000 =
392+
* 5s.
393+
*
394+
* @param ndkAppHangTimeoutIntervalMillis the timeout interval in millis
395+
*/
396+
@ApiStatus.Experimental
397+
public void setNdkAppHangTimeoutIntervalMillis(long ndkAppHangTimeoutIntervalMillis) {
398+
this.ndkAppHangTimeoutIntervalMillis = ndkAppHangTimeoutIntervalMillis;
399+
}
400+
341401
/**
342402
* Sets Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) to enabled or disabled.
343403
*

sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,56 @@ class ManifestMetadataReaderTest {
288288
assertEquals(false, fixture.options.isAttachAnrThreadDump)
289289
}
290290

291+
@Test
292+
fun `applyMetadata reads app hang tracking enabled to options`() {
293+
// Arrange
294+
val bundle = bundleOf(ManifestMetadataReader.NDK_APP_HANG_TRACKING_ENABLE to true)
295+
val context = fixture.getContext(metaData = bundle)
296+
297+
// Act
298+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
299+
300+
// Assert
301+
assertEquals(true, fixture.options.isEnableNdkAppHangTracking)
302+
}
303+
304+
@Test
305+
fun `applyMetadata reads app hang tracking enabled to options and keeps default`() {
306+
// Arrange
307+
val context = fixture.getContext()
308+
309+
// Act
310+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
311+
312+
// Assert
313+
assertEquals(false, fixture.options.isEnableNdkAppHangTracking)
314+
}
315+
316+
@Test
317+
fun `applyMetadata reads app hang timeout interval to options`() {
318+
// Arrange
319+
val bundle = bundleOf(ManifestMetadataReader.NDK_APP_HANG_TIMEOUT_INTERVAL_MILLIS to 1000)
320+
val context = fixture.getContext(metaData = bundle)
321+
322+
// Act
323+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
324+
325+
// Assert
326+
assertEquals(1000.toLong(), fixture.options.ndkAppHangTimeoutIntervalMillis)
327+
}
328+
329+
@Test
330+
fun `applyMetadata reads app hang timeout interval to options and keeps default`() {
331+
// Arrange
332+
val context = fixture.getContext()
333+
334+
// Act
335+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
336+
337+
// Assert
338+
assertEquals(5000.toLong(), fixture.options.ndkAppHangTimeoutIntervalMillis)
339+
}
340+
291341
@Test
292342
fun `applyMetadata reads tombstone attach raw to options`() {
293343
// Arrange

sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ class SentryAndroidOptionsTest {
239239
sentryOptions.anrProfilingSampleRate = 2.0
240240
}
241241

242+
@Test
243+
fun `app hang tracking is disabled by default with a 5s timeout`() {
244+
val sentryOptions = SentryAndroidOptions()
245+
assertFalse(sentryOptions.isEnableNdkAppHangTracking)
246+
assertEquals(5000L, sentryOptions.ndkAppHangTimeoutIntervalMillis)
247+
}
248+
242249
private class CustomDebugImagesLoader : IDebugImagesLoader {
243250
override fun loadDebugImages(): List<DebugImage>? = null
244251

sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public static void init(@NotNull final SentryAndroidOptions options) {
7373
ndkOptions.setTracesSampleRate(tracesSampleRate.floatValue());
7474
}
7575

76+
ndkOptions.setEnableAppHangTracking(options.isEnableNdkAppHangTracking());
77+
ndkOptions.setAppHangTimeoutMillis(options.getNdkAppHangTimeoutIntervalMillis());
78+
7679
//noinspection UnstableApiUsage
7780
io.sentry.ndk.SentryNdk.init(ndkOptions);
7881

sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package io.sentry.android.ndk
33
import io.sentry.android.core.SentryAndroidOptions
44
import io.sentry.ndk.NdkOptions
55
import kotlin.test.assertEquals
6+
import kotlin.test.assertFalse
67
import kotlin.test.assertNotNull
8+
import kotlin.test.assertTrue
79
import org.junit.Test
810
import org.mockito.Mockito
911
import org.mockito.kotlin.any
@@ -68,4 +70,30 @@ class SentryNdkTest {
6870
assertEquals(0.75f, fixture.capturedOptions!!.tracesSampleRate, 0.0001f)
6971
}
7072
}
73+
74+
@Test
75+
fun `SentryNdk does not enable app hang tracking by default`() {
76+
fixture.getSut {
77+
assertNotNull(fixture.capturedOptions)
78+
assertFalse(fixture.capturedOptions!!.isEnableAppHangTracking)
79+
assertEquals(5000L, fixture.capturedOptions!!.appHangTimeoutMillis)
80+
}
81+
}
82+
83+
@Test
84+
fun `SentryNdk propagates app hang tracking options`() {
85+
fixture.getSut(
86+
options =
87+
SentryAndroidOptions().apply {
88+
dsn = "https://key@sentry.io/proj"
89+
cacheDirPath = "/cache"
90+
isEnableNdkAppHangTracking = true
91+
ndkAppHangTimeoutIntervalMillis = 2000
92+
}
93+
) {
94+
assertNotNull(fixture.capturedOptions)
95+
assertTrue(fixture.capturedOptions!!.isEnableAppHangTracking)
96+
assertEquals(2000L, fixture.capturedOptions!!.appHangTimeoutMillis)
97+
}
98+
}
7199
}

sentry/src/main/java/io/sentry/SentryClient.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,7 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint
992992
}
993993

994994
if (shouldApplyScopeData(transaction, hint)) {
995-
transaction = applyScope(transaction, scope);
995+
transaction = applyScope(transaction, scope, HintUtils.hasType(hint, Cached.class));
996996

997997
if (transaction != null && scope != null) {
998998
transaction = processTransaction(transaction, hint, scope.getEventProcessors());
@@ -1407,7 +1407,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri
14071407
private @Nullable SentryEvent applyScope(
14081408
@NotNull SentryEvent event, final @Nullable IScope scope, final @NotNull Hint hint) {
14091409
if (scope != null) {
1410-
applyScope(event, scope);
1410+
applyScope(event, scope, HintUtils.hasType(hint, Cached.class));
14111411

14121412
if (event.getTransaction() == null) {
14131413
event.setTransaction(scope.getTransactionName());
@@ -1539,7 +1539,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri
15391539
}
15401540

15411541
private <T extends SentryBaseEvent> @NotNull T applyScope(
1542-
final @NotNull T sentryBaseEvent, final @Nullable IScope scope) {
1542+
final @NotNull T sentryBaseEvent, final @Nullable IScope scope, final boolean isCached) {
15431543
if (scope != null) {
15441544
if (sentryBaseEvent.getRequest() == null) {
15451545
sentryBaseEvent.setRequest(scope.getRequest());
@@ -1558,7 +1558,9 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri
15581558
}
15591559
if (sentryBaseEvent.getBreadcrumbs() == null) {
15601560
sentryBaseEvent.setBreadcrumbs(new ArrayList<>(scope.getBreadcrumbs()));
1561-
} else {
1561+
} else if (!isCached) {
1562+
// A Cached event comes from the outbox and already carries its own breadcrumbs (e.g. native
1563+
// events written by sentry-native). Appending the scope's breadcrumbs would duplicate them.
15621564
sortBreadcrumbsByDate(sentryBaseEvent, scope.getBreadcrumbs());
15631565
}
15641566
if (sentryBaseEvent.getExtras() == null) {

sentry/src/test/java/io/sentry/SentryClientTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,52 @@ class SentryClientTest {
649649
}
650650
}
651651

652+
@Test
653+
fun `when event is cached, scope breadcrumbs are not merged into the event breadcrumbs`() {
654+
val scopeCrumb = Breadcrumb(DateUtils.getDateTime("2020-03-27T08:52:58.001Z"))
655+
val scope = Scope(SentryOptions()).apply { addBreadcrumb(scopeCrumb) }
656+
657+
val sut = fixture.getSut()
658+
659+
val eventCrumb = Breadcrumb(DateUtils.getDateTime("2020-03-27T08:52:58.003Z"))
660+
val event = SentryEvent().apply { breadcrumbs = mutableListOf(eventCrumb) }
661+
662+
val hints = HintUtils.createWithTypeCheckHint(CachedHint())
663+
sut.captureEvent(event, scope, hints)
664+
665+
assertNotNull(event.breadcrumbs) {
666+
assertEquals(1, it.size)
667+
assertSame(eventCrumb, it[0])
668+
}
669+
}
670+
671+
@Test
672+
fun `when transaction is cached, scope breadcrumbs are not merged into the transaction breadcrumbs`() {
673+
val sut = fixture.getSut()
674+
val scope = Scope(fixture.sentryOptions)
675+
scope.addBreadcrumb(Breadcrumb("from scope"))
676+
677+
val transaction = SentryTransaction(fixture.sentryTracer)
678+
transaction.breadcrumbs = mutableListOf(Breadcrumb("from transaction"))
679+
680+
val hints = HintUtils.createWithTypeCheckHint(CachedHint())
681+
sut.captureTransaction(transaction, scope, hints)
682+
683+
verify(fixture.transport)
684+
.send(
685+
check { envelope ->
686+
val sent = envelope.items.first().getTransaction(fixture.sentryOptions.serializer)
687+
assertNotNull(sent) {
688+
assertNotNull(it.breadcrumbs) { breadcrumbs ->
689+
assertEquals(1, breadcrumbs.size)
690+
assertEquals("from transaction", breadcrumbs.first().message)
691+
}
692+
}
693+
},
694+
anyOrNull(),
695+
)
696+
}
697+
652698
@Test
653699
fun `when captureEvent with scope, event data has priority over scope but level and it should append extras, tags and breadcrumbs`() {
654700
val event = createEvent()

0 commit comments

Comments
 (0)