Skip to content

Commit

Permalink
Merge branch 'anr-detection' into anr-detection-ndk
Browse files Browse the repository at this point in the history
  • Loading branch information
fractalwrench committed Mar 19, 2019
2 parents 60a805d + fdef465 commit 4150181
Show file tree
Hide file tree
Showing 19 changed files with 417 additions and 2 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Enhancements

* Add ANR detection to bugsnag-android
[#442](https://github.com/bugsnag/bugsnag-android/pull/442)

* Add unhandled_events field to native payload
[#445](https://github.com/bugsnag/bugsnag-android/pull/445)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import android.os.Bundle;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.widget.Toast;

Expand Down Expand Up @@ -54,6 +55,17 @@ public void onClick(View view) {
doCrash();
}
});

findViewById(R.id.btn_anr).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
try {
Thread.sleep(10000);
} catch (InterruptedException ignored) {
Log.d("Bugsnag", "Interrupted");
}
}
});
}

private void performAdditionalBugsnagSetup() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class ExampleActivity : AppCompatActivity() {

val nativeBtn: View = findViewById(R.id.btn_native_crash)
nativeBtn.setOnClickListener { doCrash() }

findViewById<View>(R.id.btn_anr).setOnClickListener { Thread.sleep(10000) }
}

private fun performAdditionalBugsnagSetup() {
Expand Down
7 changes: 7 additions & 0 deletions examples/sdk-app-example/src/main/res/layout/main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
android:layout_height="wrap_content"
android:text="@string/trigger_native_crash" />

<Button
android:id="@+id/btn_anr"
style="@style/ButtonTheme"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trigger_anr" />

<View style="@style/separator" />

<!-- Trigger handled exceptions -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<string name="trigger_fatal_crash">FR</string>
<string name="trigger_native_crash">FR</string>
<string name="trigger_native_notify">FR</string>
<string name="trigger_anr">FR</string>

<string name="handled_exceptions">FR</string>
<string name="trigger_nonfatal_crash">FR</string>
Expand Down
1 change: 1 addition & 0 deletions examples/sdk-app-example/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<string name="unhandled_exceptions">Unhandled Exceptions</string>
<string name="trigger_fatal_crash">Trigger a fatal crash</string>
<string name="trigger_native_crash">Trigger a native crash</string>
<string name="trigger_anr">Trigger an ANR</string>

<string name="handled_exceptions">Handled Exceptions</string>
<string name="trigger_nonfatal_crash">Trigger a non-fatal crash</string>
Expand Down
27 changes: 27 additions & 0 deletions features/detect_anr.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Feature: Detects ANR

Scenario: Test ANR detected with default timing
When I run "AppNotRespondingScenario"
Then I should receive a request
And the request is a valid for the error reporting API
And the exception "errorClass" equals "ANR"
And the exception "message" equals "Application did not respond for at least 5000 ms"

Scenario: Test ANR not detected when disabled
When I run "AppNotRespondingDisabledScenario"
Then I should receive 0 requests

Scenario: Test ANR not detected under response time
When I run "AppNotRespondingShortScenario"
Then I should receive 0 requests

Scenario: Test ANR wait time can be set to under default time
When I run "AppNotRespondingShorterThresholdScenario"
Then I should receive a request
And the request is a valid for the error reporting API
And the exception "errorClass" equals "ANR"
And the exception "message" equals "Application did not respond for at least 2000 ms"

Scenario: Test ANR wait time can be set to over default time
When I run "AppNotRespondingLongerThresholdScenario"
Then I should receive 0 requests
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class MainActivity : Activity() {
val config = Configuration(intent.getStringExtra("BUGSNAG_API_KEY"))
val port = intent.getStringExtra("BUGSNAG_PORT")
config.setEndpoints("${findHostname()}:$port", "${findHostname()}:$port")
config.detectAnrs = false
return config
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.bugsnag.android.mazerunner.scenarios

import android.content.Context
import com.bugsnag.android.Bugsnag
import com.bugsnag.android.Configuration

/**
* Stops the app from responding for a time period with ANR detection disabled
*/
internal class AppNotRespondingDisabledScenario(config: Configuration,
context: Context) : Scenario(config, context) {
init {
config.setAutoCaptureSessions(false)
config.detectAnrs = false
}

override fun run() {
super.run()
Thread.sleep(6000)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.bugsnag.android.mazerunner.scenarios

import android.content.Context
import com.bugsnag.android.Bugsnag
import com.bugsnag.android.Configuration

/**
* Stops the app from responding for a time period after changing the threshold to be higher
*/
internal class AppNotRespondingLongerThresholdScenario(config: Configuration,
context: Context) : Scenario(config, context) {
init {
config.setAutoCaptureSessions(false)
config.detectAnrs = true
config.anrThresholdMs = 8000L
}

override fun run() {
super.run()
Thread.sleep(6000)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.bugsnag.android.mazerunner.scenarios

import android.content.Context
import com.bugsnag.android.Bugsnag
import com.bugsnag.android.Configuration

/**
* Stops the app from responding for a time period
*/
internal class AppNotRespondingScenario(config: Configuration,
context: Context) : Scenario(config, context) {
init {
config.setAutoCaptureSessions(false)
config.detectAnrs = true
}

override fun run() {
super.run()
Thread.sleep(6000)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.bugsnag.android.mazerunner.scenarios

import android.content.Context
import com.bugsnag.android.Bugsnag
import com.bugsnag.android.Configuration

/**
* Stops the app from responding for a time period shorter than the default
*/
internal class AppNotRespondingShortScenario(config: Configuration,
context: Context) : Scenario(config, context) {
init {
config.setAutoCaptureSessions(false)
config.detectAnrs = true
}

override fun run() {
super.run()
Thread.sleep(3000)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.bugsnag.android.mazerunner.scenarios

import android.content.Context
import com.bugsnag.android.Bugsnag
import com.bugsnag.android.Configuration

/**
* Stops the app from responding for a time period after changing the threshold to be lower
*/
internal class AppNotRespondingShorterThresholdScenario(config: Configuration,
context: Context) : Scenario(config, context) {
init {
config.setAutoCaptureSessions(false)
config.detectAnrs = true
config.anrThresholdMs = 2000L
}

override fun run() {
super.run()
Thread.sleep(3000)
}

}
32 changes: 32 additions & 0 deletions sdk/src/androidTest/java/com/bugsnag/android/AnrConfigTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.bugsnag.android

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class AnrConfigTest {

private val config = Configuration("api-key")

@Test
fun testDetectAnrDefault() {
assertTrue(config.detectAnrs)
}

/**
* Verifies that attempts to set the ANR threshold below 1000ms set the value as 1000ms
*/
@Test
fun testAnrThresholdMs() {
val config = config
assertEquals(5000, config.anrThresholdMs)

config.anrThresholdMs = 10000
assertEquals(10000, config.anrThresholdMs)

arrayOf(1000, 999, 0, -5, Long.MIN_VALUE).forEach {
config.anrThresholdMs = it
assertEquals(1000, config.anrThresholdMs)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.bugsnag.android

import android.os.Looper
import org.junit.Test

class BlockedThreadDetectorTest {

private val looper = Looper.getMainLooper()

@Test(expected = IllegalArgumentException::class)
fun testInvalidBlockedThresholdMs() {
BlockedThreadDetector(-1, 1, looper) {}
}

@Test(expected = IllegalArgumentException::class)
fun testInvalidCheckIntervalMs() {
BlockedThreadDetector(1, -1, looper) {}
}

@Test(expected = IllegalArgumentException::class)
fun testInvalidThread() {
BlockedThreadDetector(1, 1, null) {}
}

@Test(expected = IllegalArgumentException::class)
fun testInvalidDelegate() {
BlockedThreadDetector(1, 1, looper, null)
}
}
105 changes: 105 additions & 0 deletions sdk/src/main/java/com/bugsnag/android/BlockedThreadDetector.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.bugsnag.android;

import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;

/**
* Detects whether a given thread is blocked by continuously posting a {@link Runnable} to it
* from a watcher thread, invoking a delegate if the message is not processed within
* a configured interval.
*/
final class BlockedThreadDetector {

static final int MIN_CHECK_INTERVAL_MS = 1000;

interface Delegate {

/**
* Invoked when a given thread has been unable to execute a {@link Runnable} within
* the {@link #blockedThresholdMs}
*
* @param thread the thread being monitored
*/
void onThreadBlocked(Thread thread);
}

final Looper looper;
final long checkIntervalMs;
final long blockedThresholdMs;
final Handler handler;
final Delegate delegate;

volatile long lastUpdateMs;
volatile boolean isAlreadyBlocked = false;

BlockedThreadDetector(long blockedThresholdMs,
Looper looper,
Delegate delegate) {
this(blockedThresholdMs, MIN_CHECK_INTERVAL_MS, looper, delegate);
}

BlockedThreadDetector(long blockedThresholdMs,
long checkIntervalMs,
Looper looper,
Delegate delegate) {
if ((blockedThresholdMs <= 0 || checkIntervalMs <= 0
|| looper == null || delegate == null)) {
throw new IllegalArgumentException();
}
this.blockedThresholdMs = blockedThresholdMs;
this.checkIntervalMs = checkIntervalMs;
this.looper = looper;
this.delegate = delegate;
this.handler = new Handler(looper);
}

void updateLivenessTimestamp() {
lastUpdateMs = SystemClock.elapsedRealtime();
}

void start() {
updateLivenessTimestamp();
handler.post(livenessCheck);
watcherThread.start();
}

final Runnable livenessCheck = new Runnable() {
@Override
public void run() {
updateLivenessTimestamp();
handler.postDelayed(this, checkIntervalMs);
}
};

final Thread watcherThread = new Thread() {
@Override
public void run() {
while (!isInterrupted()) {
// when we would next consider the app blocked if no timestamp updates take place
long now = SystemClock.elapsedRealtime();
long nextCheckIn = Math.max(lastUpdateMs + blockedThresholdMs - now, 0);

try {
Thread.sleep(nextCheckIn); // throttle checks to the configured threshold
} catch (InterruptedException exc) {
interrupt();
}
checkIfThreadBlocked();
}
}

private void checkIfThreadBlocked() {
long delta = SystemClock.elapsedRealtime() - lastUpdateMs;

if (delta > blockedThresholdMs) {
if (!isAlreadyBlocked) {
delegate.onThreadBlocked(looper.getThread());
}
isAlreadyBlocked = true; // prevents duplicate reports for the same ANR
} else {
isAlreadyBlocked = false;
}
}
};
}

0 comments on commit 4150181

Please sign in to comment.