Skip to content

Commit 6952963

Browse files
authored
Merge pull request #2402 from bugsnag/PLAT-15656/near-hang-breadcrumbs
AppHang: Near hang breadcrumbs
2 parents 7137f5c + 0ce2a03 commit 6952963

10 files changed

Lines changed: 137 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
* Build UUIDs derived from dex file signatures no longer block NDK startup, reducing the overall startup time.
88
[#2401](https://github.com/bugsnag/bugsnag-android/pull/2401)
9+
* The `AppHangPlugin` can now be configured to log breadcrumbs for "near hang" situations where the app pauses for long enough to be noticeable but not long enough to warrant a full AppHang report
10+
[#2402](https://github.com/bugsnag/bugsnag-android/pull/2402)
911

1012
## 6.25.0 (2026-03-02)
1113

bugsnag-plugin-android-apphang/api/bugsnag-plugin-android-apphang.api

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
public final class com/bugsnag/android/AppHangConfiguration {
22
public fun <init> ()V
3-
public fun <init> (JLandroid/os/Looper;Ljava/lang/Long;JJ)V
4-
public synthetic fun <init> (JLandroid/os/Looper;Ljava/lang/Long;JJILkotlin/jvm/internal/DefaultConstructorMarker;)V
3+
public fun <init> (JLandroid/os/Looper;Ljava/lang/Long;JJJ)V
4+
public synthetic fun <init> (JLandroid/os/Looper;Ljava/lang/Long;JJJILkotlin/jvm/internal/DefaultConstructorMarker;)V
55
public final fun getAppHangCooldownMillis ()J
66
public final fun getAppHangThresholdMillis ()J
7+
public final fun getNearHangThresholdMillis ()J
78
public final fun getStackSamplingIntervalMillis ()J
89
public final fun getStackSamplingThresholdMillis ()Ljava/lang/Long;
910
public final fun getWatchedLooper ()Landroid/os/Looper;
1011
public final fun setAppHangCooldownMillis (J)V
1112
public final fun setAppHangThresholdMillis (J)V
13+
public final fun setNearHangThresholdMillis (J)V
1214
public final fun setStackSamplingIntervalMillis (J)V
1315
public final fun setStackSamplingThresholdMillis (Ljava/lang/Long;)V
1416
public final fun setWatchedLooper (Landroid/os/Looper;)V

bugsnag-plugin-android-apphang/detekt-baseline.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<SmellBaseline>
33
<ManuallySuppressedIssues/>
44
<CurrentIssues>
5+
<ID>LongParameterList:LooperMonitorThread.kt$LooperMonitorThread$( watchedLooper: Looper, private val appHangThresholdMillis: Long, private val appHangCooldownMillis: Long, private val samplingThresholdMillis: Long, private val samplingRateMillis: Long, private val nearHangThresholdMillis: Long, private val onAppHangDetected: (timeSinceLastHeartbeat: Long, ThreadSampler?) -> Unit, private val onNearHangDetected: (pauseTime: Long, ThreadSampler?) -> Unit )</ID>
56
<ID>LoopWithTooManyJumpStatements:LooperMonitorThread.kt$LooperMonitorThread$while</ID>
67
</CurrentIssues>
78
</SmellBaseline>

bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@ import org.junit.Test
1010
import java.util.concurrent.CountDownLatch
1111
import java.lang.Thread as JThread
1212

13-
private const val APP_HANG_THRESHOLD = 200L
14-
private const val COOLDOWN_TIME = 800L
13+
private const val NEAR_HANG_THRESHOLD = 250L
14+
private const val APP_HANG_THRESHOLD = NEAR_HANG_THRESHOLD * 2
15+
private const val COOLDOWN_TIME = 1000L
1516

1617
class LooperMonitorThreadTest {
1718
private lateinit var handlerThread: HandlerThread
1819
private lateinit var monitorThread: LooperMonitorThread
1920
private lateinit var handler: Handler
2021

2122
private var appHangCount = 0
23+
private var nearHangCount = 0
2224

2325
@Before
2426
fun setup() {
@@ -34,7 +36,9 @@ class LooperMonitorThreadTest {
3436
appHangCooldownMillis = COOLDOWN_TIME,
3537
samplingThresholdMillis = 0,
3638
samplingRateMillis = 0,
37-
onAppHangDetected = { _, _ -> appHangCount++ }
39+
nearHangThresholdMillis = NEAR_HANG_THRESHOLD,
40+
onAppHangDetected = { _, _ -> appHangCount++ },
41+
onNearHangDetected = { _, _ -> nearHangCount++ }
3842
)
3943

4044
monitorThread.startMonitoring()
@@ -52,6 +56,30 @@ class LooperMonitorThreadTest {
5256
assertEquals("no AppHangs expected", 0, appHangCount)
5357
}
5458

59+
@Test
60+
fun testBelowThresholdEvents() {
61+
val nearHangTotal = 10
62+
val countDownLatch = CountDownLatch(nearHangTotal)
63+
val task = object : Runnable {
64+
override fun run() {
65+
JThread.sleep(NEAR_HANG_THRESHOLD)
66+
countDownLatch.countDown()
67+
68+
if (countDownLatch.count > 0) {
69+
handler.postDelayed(this, 1L)
70+
}
71+
}
72+
}
73+
74+
handler.postDelayed(task, 1)
75+
76+
countDownLatch.await()
77+
// Allow the monitor thread to wake up and detect the final near-hang
78+
JThread.sleep(APP_HANG_THRESHOLD)
79+
assertEquals("no AppHangs expected", 0, appHangCount)
80+
assertEquals("$nearHangTotal NearHangs expected", nearHangTotal, nearHangCount)
81+
}
82+
5583
@Test
5684
fun appHang() {
5785
val countDownLatch = CountDownLatch(1)

bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ class SequentialAppHangsTest {
3737
appHangCooldownMillis = 0L, // No cooldown
3838
samplingThresholdMillis = 0,
3939
samplingRateMillis = 0,
40-
onAppHangDetected = { _, _ -> appHangCount++ }
40+
nearHangThresholdMillis = 0L, // No near hangs
41+
onAppHangDetected = { _, _ -> appHangCount++ },
42+
onNearHangDetected = { _, _ -> }
4143
)
4244

4345
monitorThread.startMonitoring()

bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/AppHangConfiguration.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ class AppHangConfiguration(
6161
* Set to 0 (default) to disable the cooldown period and report all detected AppHangs.
6262
*/
6363
var appHangCooldownMillis: Long = 0L,
64+
/**
65+
* The minimum pause duration in milliseconds that will leave a "near hang" breadcrumb.
66+
* When the monitored thread is unresponsive for at least this long (but less than
67+
* [appHangThresholdMillis]), a breadcrumb will be recorded.
68+
*
69+
* Set to 0 (default) to disable near-hang breadcrumbs.
70+
*/
71+
var nearHangThresholdMillis: Long = 0L
6472
) {
6573
constructor() : this(DEFAULT_APP_HANG_THRESHOLD)
6674

bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/BugsnagAppHangPlugin.kt

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ class BugsnagAppHangPlugin @JvmOverloads constructor(
1111
configuration: AppHangConfiguration = AppHangConfiguration()
1212
) : Plugin {
1313
private val appHangThresholdMillis = configuration.appHangThresholdMillis
14+
private val appHangCooldownMillis = configuration.appHangCooldownMillis
15+
16+
private val nearHangThresholdMillis = configuration.nearHangThresholdMillis
17+
1418
private val samplingThresholdMillis = configuration.stackSamplingThresholdMillis ?: 0
1519
private val samplingRateMillis = configuration.stackSamplingIntervalMillis
16-
private val appHangCooldownMillis = configuration.appHangCooldownMillis
20+
1721
private val watchedLooper = configuration.watchedLooper
1822

1923
private var client: Client? = null
@@ -61,19 +65,43 @@ class BugsnagAppHangPlugin @JvmOverloads constructor(
6165
}
6266
}
6367

68+
private fun reportNearHang(pauseTime: Long) {
69+
if (nearHangThresholdMillis <= 0) {
70+
return
71+
}
72+
73+
client?.leaveAutoBreadcrumb(
74+
"Near Hang Detected",
75+
BreadcrumbType.STATE,
76+
mapOf("pauseTimeMs" to "${pauseTime}ms")
77+
)
78+
}
79+
6480
@VisibleForTesting
6581
internal fun startMonitoring() {
6682
if (monitorThread != null) {
6783
return
6884
}
6985

86+
val safeSamplingThresholdMillis =
87+
if (samplingThresholdMillis in 1..appHangThresholdMillis) samplingThresholdMillis
88+
else 0
89+
90+
val safeSamplingRateMillis =
91+
if (samplingRateMillis in 1..appHangThresholdMillis) samplingRateMillis
92+
else 0
93+
7094
monitorThread = LooperMonitorThread(
71-
watchedLooper,
72-
appHangThresholdMillis,
73-
appHangCooldownMillis,
74-
if (samplingThresholdMillis in 1..appHangThresholdMillis) samplingThresholdMillis else 0,
75-
if (samplingRateMillis in 1..appHangThresholdMillis) samplingRateMillis else 0,
76-
this::reportAppHang
95+
watchedLooper = watchedLooper,
96+
appHangThresholdMillis = appHangThresholdMillis,
97+
appHangCooldownMillis = appHangCooldownMillis,
98+
samplingThresholdMillis = safeSamplingThresholdMillis,
99+
samplingRateMillis = safeSamplingRateMillis,
100+
nearHangThresholdMillis = nearHangThresholdMillis,
101+
onAppHangDetected = this::reportAppHang,
102+
onNearHangDetected = { timeSinceLastHeartbeat, _ ->
103+
reportNearHang(timeSinceLastHeartbeat)
104+
}
77105
)
78106

79107
monitorThread?.startMonitoring()

bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,22 @@ internal class LooperMonitorThread(
1111
private val appHangCooldownMillis: Long,
1212
private val samplingThresholdMillis: Long,
1313
private val samplingRateMillis: Long,
14-
private val onAppHangDetected: (timeSinceLastHeartbeat: Long, ThreadSampler?) -> Unit
14+
private val nearHangThresholdMillis: Long,
15+
private val onAppHangDetected: (timeSinceLastHeartbeat: Long, ThreadSampler?) -> Unit,
16+
private val onNearHangDetected: (pauseTime: Long, ThreadSampler?) -> Unit
1517
) : Thread("Bugsnag AppHang Monitor: ${watchedLooper.thread.name}") {
1618
private val handler: Handler = Handler(watchedLooper)
1719

1820
private val threadSampler: ThreadSampler? =
1921
if (isSamplingEnabled) ThreadSampler(watchedLooper.thread)
2022
else null
2123

22-
private val heartbeatInterval =
23-
if (isSamplingEnabled) samplingThresholdMillis / 2
24-
else appHangThresholdMillis / 2
24+
private val heartbeatInterval: Long = run {
25+
var min = appHangThresholdMillis
26+
if (samplingThresholdMillis in 1 until min) min = samplingThresholdMillis
27+
if (nearHangThresholdMillis in 1 until min) min = nearHangThresholdMillis
28+
min / 2
29+
}
2530

2631
@Volatile
2732
private var lastStackSampleTimestamp = 0L
@@ -35,6 +40,9 @@ internal class LooperMonitorThread(
3540
@Volatile
3641
private var isAppHangDetected = false
3742

43+
@Volatile
44+
private var lastPauseDuration = 0L
45+
3846
private var lastHeartbeatPostTimestamp = 0L
3947

4048
private val isRunning = AtomicBoolean(false)
@@ -78,7 +86,6 @@ internal class LooperMonitorThread(
7886
}
7987

8088
isAppHangDetected = true
81-
lastReportedHangTimestamp = currentTime
8289
onAppHangDetected(timeSinceLastHeartbeat, threadSampler)
8390
return true
8491
}
@@ -135,6 +142,19 @@ internal class LooperMonitorThread(
135142
reportAppHang(now, timeSinceHeartbeat)
136143
}
137144
} else {
145+
// range is not always strictly 1>lastPauseDuration so we use <=
146+
@Suppress("ConvertTwoComparisonsToRangeCheck")
147+
// recovered — use the pause duration captured by the heartbeat callback
148+
if (nearHangThresholdMillis > 1 &&
149+
nearHangThresholdMillis <= lastPauseDuration &&
150+
lastPauseDuration < appHangThresholdMillis
151+
) {
152+
onNearHangDetected(lastPauseDuration, threadSampler)
153+
}
154+
155+
if (isAppHangDetected) {
156+
lastReportedHangTimestamp = currentTime()
157+
}
138158
isAppHangDetected = false
139159
threadSampler?.resetSampling()
140160
lastStackSampleTimestamp = 0L
@@ -203,7 +223,15 @@ internal class LooperMonitorThread(
203223

204224
private inner class Heartbeat : Runnable {
205225
override fun run() {
206-
lastHeartbeatTimestamp = currentTime()
226+
val now = currentTime()
227+
val previousHeartbeatTimestamp = lastHeartbeatTimestamp
228+
lastHeartbeatTimestamp = now
229+
lastPauseDuration =
230+
if (previousHeartbeatTimestamp > 0L) {
231+
now - previousHeartbeatTimestamp
232+
} else {
233+
0L
234+
}
207235
}
208236

209237
override fun toString(): String {

features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/AppHangPluginScenario.kt

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import com.bugsnag.android.BugsnagAppHangPlugin
88
import com.bugsnag.android.Configuration
99
import kotlin.system.exitProcess
1010

11+
private const val HEADROOM = 10L
12+
private const val NEAR_HANG_THRESHOLD = 800L
1113
private const val APP_HANG_THRESHOLD = 1000L
1214
private const val APP_HANG_THRESHOLD3 = APP_HANG_THRESHOLD * 3L
1315

@@ -20,20 +22,31 @@ class AppHangPluginScenario(
2022
config.enabledErrorTypes.anrs = false
2123
config.addPlugin(
2224
BugsnagAppHangPlugin(
23-
AppHangConfiguration(appHangThresholdMillis = APP_HANG_THRESHOLD)
25+
AppHangConfiguration(
26+
appHangThresholdMillis = APP_HANG_THRESHOLD,
27+
nearHangThresholdMillis = NEAR_HANG_THRESHOLD
28+
)
2429
)
2530
)
2631
}
2732

2833
override fun startScenario() {
2934
super.startScenario()
3035

31-
Handler(Looper.getMainLooper()).postDelayed(
36+
val handler = Handler(Looper.getMainLooper())
37+
handler.postDelayed(
3238
Runnable {
33-
Thread.sleep(APP_HANG_THRESHOLD3)
34-
exitProcess(0)
39+
Thread.sleep(NEAR_HANG_THRESHOLD + HEADROOM)
40+
41+
handler.postDelayed(
42+
Runnable {
43+
Thread.sleep(APP_HANG_THRESHOLD3)
44+
exitProcess(0)
45+
},
46+
HEADROOM
47+
)
3548
},
36-
1
49+
HEADROOM
3750
)
3851
}
3952
}

features/full_tests/apphang_plugin.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Feature: AppHang Plugin
88
Then I wait to receive an error
99
And the error is valid for the error reporting API version "4.0" for the "Android Bugsnag Notifier" notifier
1010
And the exception "errorClass" equals "AppHang"
11+
And the event has a "state" breadcrumb named "Near Hang Detected"
1112

1213
Scenario: StackSampling reports cause of AppHang
1314
When I run "SampledAppHangScenario"

0 commit comments

Comments
 (0)