diff --git a/.palantir/revapi.yml b/.palantir/revapi.yml index 24ca338e..f29e24ec 100644 --- a/.palantir/revapi.yml +++ b/.palantir/revapi.yml @@ -45,3 +45,8 @@ acceptedBreaks: justification: "This is a binary compatible change, which could only break custom\ \ implementations of our config interfaces, but those interfaces are not meant\ \ to be implemented by users" + - code: "java.method.addedToInterface" + new: "method java.util.List com.rollbar.notifier.wrapper.ThrowableWrapper::getRollbarThreads()" + justification: "This is a binary compatible change, which could only break custom\ + \ implementations of our config interfaces, but those interfaces are not meant\ + \ to be implemented by users" diff --git a/examples/rollbar-android/build.gradle b/examples/rollbar-android/build.gradle index 41e42406..d947cc8b 100644 --- a/examples/rollbar-android/build.gradle +++ b/examples/rollbar-android/build.gradle @@ -11,14 +11,14 @@ buildscript { apply plugin: 'com.android.application' android { - compileSdkVersion 27 + compileSdkVersion 30 buildToolsVersion "30.0.3" defaultConfig { applicationId "com.rollbar.example.android" - minSdkVersion 16 + minSdkVersion 21 // FIXME: Pending further discussion //noinspection ExpiredTargetSdkVersion - targetSdkVersion 27 + targetSdkVersion 30 versionCode 1 versionName "1.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" @@ -38,9 +38,9 @@ dependencies { androidTestImplementation('com.android.support.test.espresso:espresso-core:2.2.2', { exclude group: 'com.android.support', module: 'support-annotations' }) - implementation 'com.android.support:appcompat-v7:27.1.1' + implementation 'androidx.appcompat:appcompat:1.0.2' implementation 'com.android.support.constraint:constraint-layout:1.0.2' - implementation 'com.android.support:design:27.1.1' + implementation 'com.google.android.material:material:1.0.0' implementation 'org.slf4j:slf4j-android:1.7.25' testImplementation group: 'junit', name: 'junit', version: '4.12' } diff --git a/examples/rollbar-android/src/main/AndroidManifest.xml b/examples/rollbar-android/src/main/AndroidManifest.xml index 3e3d8183..7a40e790 100644 --- a/examples/rollbar-android/src/main/AndroidManifest.xml +++ b/examples/rollbar-android/src/main/AndroidManifest.xml @@ -16,7 +16,8 @@ android:theme="@style/AppTheme"> diff --git a/examples/rollbar-android/src/main/java/com/rollbar/example/android/MainActivity.java b/examples/rollbar-android/src/main/java/com/rollbar/example/android/MainActivity.java index 7a5bf067..f9e6c0b3 100644 --- a/examples/rollbar-android/src/main/java/com/rollbar/example/android/MainActivity.java +++ b/examples/rollbar-android/src/main/java/com/rollbar/example/android/MainActivity.java @@ -1,14 +1,16 @@ package com.rollbar.example.android; import android.os.Bundle; -import android.support.design.widget.FloatingActionButton; -import android.support.design.widget.Snackbar; -import android.support.v7.app.AppCompatActivity; -import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.View; import android.view.Menu; import android.view.MenuItem; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.snackbar.Snackbar; import com.rollbar.android.Rollbar; public class MainActivity extends AppCompatActivity { diff --git a/examples/rollbar-android/src/main/res/layout/activity_main.xml b/examples/rollbar-android/src/main/res/layout/activity_main.xml index e840ffb3..e97823c0 100644 --- a/examples/rollbar-android/src/main/res/layout/activity_main.xml +++ b/examples/rollbar-android/src/main/res/layout/activity_main.xml @@ -1,34 +1,34 @@ - + + + android:theme="@style/AppTheme.AppBarOverlay"> - - - + android:layout_height="?attr/actionBarSize" + android:background="?attr/colorPrimary" + app:popupTheme="@style/AppTheme.PopupOverlay"/> - + - + - + diff --git a/gradle.properties b/gradle.properties index b8b04e9e..22599278 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,8 @@ VERSION_NAME=2.0.0 GROUP=com.rollbar +android.useAndroidX=true + POM_DESCRIPTION=For connecting your applications built on the JVM to Rollbar for Error Reporting POM_URL=https://github.com/rollbar/rollbar-java POM_SCM_URL=https://github.com/rollbar/rollbar-java diff --git a/rollbar-android/build.gradle b/rollbar-android/build.gradle index 7a155de4..bfb777fc 100644 --- a/rollbar-android/build.gradle +++ b/rollbar-android/build.gradle @@ -18,14 +18,14 @@ apply from: "$rootDir/gradle/release.gradle" apply from: "$rootDir/gradle/android.quality.gradle" android { - compileSdkVersion 27 + compileSdkVersion 30 buildToolsVersion '30.0.3' // Going above here requires bumping the AGP to version 4+ defaultConfig { - minSdkVersion 16 + minSdkVersion 21 // FIXME: Pending further discussion //noinspection ExpiredTargetSdkVersion - targetSdkVersion 27 + targetSdkVersion 30 consumerProguardFiles 'proguard-rules.pro' manifestPlaceholders = [notifierVersion: VERSION_NAME] } diff --git a/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java b/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java new file mode 100644 index 00000000..0474da47 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java @@ -0,0 +1,38 @@ +package com.rollbar.android; + +import com.rollbar.android.anr.AnrConfiguration; + +public class AndroidConfiguration { + private final AnrConfiguration anrConfiguration; + + AndroidConfiguration(Builder builder) { + anrConfiguration = builder.anrConfiguration; + } + + public AnrConfiguration getAnrConfiguration() { + return anrConfiguration; + } + + + public static final class Builder { + private AnrConfiguration anrConfiguration; + + Builder() { + anrConfiguration = new AnrConfiguration.Builder().build(); + } + + /** + * The ANR configuration, if this field is null, no ANR would be captured + * @param anrConfiguration the ANR configuration + * @return the builder instance + */ + public Builder setAnrConfiguration(AnrConfiguration anrConfiguration) { + this.anrConfiguration = anrConfiguration; + return this; + } + + public AndroidConfiguration build() { + return new AndroidConfiguration(this); + } + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java b/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java index 5fe245c4..349135a0 100644 --- a/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java +++ b/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java @@ -10,9 +10,14 @@ import android.os.Bundle; import android.util.Log; + +import com.rollbar.android.anr.AnrDetector; +import com.rollbar.android.anr.AnrDetectorFactory; +import com.rollbar.android.anr.AnrException; import com.rollbar.android.notifier.sender.ConnectionAwareSenderFailureStrategy; import com.rollbar.android.provider.ClientProvider; import com.rollbar.api.payload.data.TelemetryType; +import com.rollbar.api.payload.data.body.RollbarThread; import com.rollbar.notifier.config.ConfigProvider; import com.rollbar.notifier.uncaughtexception.RollbarUncaughtExceptionHandler; import com.rollbar.android.provider.NotifierProvider; @@ -24,12 +29,18 @@ import com.rollbar.notifier.sender.SyncSender; import com.rollbar.notifier.sender.queue.DiskQueue; import com.rollbar.notifier.util.ObjectsUtils; +import com.rollbar.notifier.wrapper.RollbarThrowableWrapper; +import com.rollbar.notifier.wrapper.ThrowableWrapper; + +import org.slf4j.LoggerFactory; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.lang.Thread.UncaughtExceptionHandler; import java.util.Collections; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -71,6 +82,28 @@ public static Rollbar init(Context context) { return init(context, null, null); } + /** + * Initialize the singleton instance of Rollbar. + * Defaults to reading the access token from the manifest, handling uncaught exceptions, and setting + * the environment to production. + * + * @param context Android context to use. + * @param androidConfiguration configuration for Android features. + * @return the managed instance of Rollbar. + */ + public static Rollbar init(Context context, AndroidConfiguration androidConfiguration) { + return init( + context, + null, + DEFAULT_ENVIRONMENT, + DEFAULT_REGISTER_EXCEPTION_HANDLER, + DEFAULT_INCLUDE_LOGCAT, + DEFAULT_CONFIG_PROVIDER, + DEFAULT_SUSPEND_WHEN_NETWORK_IS_UNAVAILABLE, + androidConfiguration + ); + } + /** * Initialize the singleton instance of Rollbar. * @@ -155,18 +188,71 @@ public static Rollbar init(Context context, String accessToken, String environme public static Rollbar init(Context context, String accessToken, String environment, boolean registerExceptionHandler, boolean includeLogcat, ConfigProvider provider, boolean suspendWhenNetworkIsUnavailable) { + return init( + context, + accessToken, + environment, + registerExceptionHandler, + includeLogcat, + provider, + suspendWhenNetworkIsUnavailable, + makeDefaultAndroidConfiguration() + ); + } + + /** + * Initialize the singleton instance of Rollbar. + * + * @param context Android context to use. + * @param accessToken a Rollbar access token with at least post_client_item scope + * @param environment the environment to set for items + * @param registerExceptionHandler whether or not to handle uncaught exceptions. + * @param includeLogcat whether or not to include logcat output with items + * @param provider a configuration provider that can be used to customize the configuration further. + * @param suspendWhenNetworkIsUnavailable if true, sending occurrences will be suspended while the network is unavailable + * @param androidConfiguration configuration for Android features + * @return the managed instance of Rollbar. + */ + public static Rollbar init( + Context context, + String accessToken, + String environment, + boolean registerExceptionHandler, + boolean includeLogcat, + ConfigProvider provider, + boolean suspendWhenNetworkIsUnavailable, + AndroidConfiguration androidConfiguration + ) { if (isInit()) { Log.w(TAG, "Rollbar.init() called when it was already initialized."); // This is likely an activity that was destroyed and recreated, so we need to update it notifier.updateContext(context); } else { notifier = new Rollbar(context, accessToken, environment, registerExceptionHandler, - includeLogcat, provider, DEFAULT_CAPTURE_IP, DEFAULT_MAX_LOGCAT_SIZE, - suspendWhenNetworkIsUnavailable); + includeLogcat, provider, DEFAULT_CAPTURE_IP, DEFAULT_MAX_LOGCAT_SIZE, + suspendWhenNetworkIsUnavailable); + if (androidConfiguration != null) { + initAnrDetector(context, androidConfiguration); + } } + return notifier; } + private static void initAnrDetector( + Context context, + AndroidConfiguration androidConfiguration + ) { + AnrDetector anrDetector = AnrDetectorFactory.create( + context, + LoggerFactory.getLogger(AnrDetectorFactory.class), + androidConfiguration.getAnrConfiguration(), + error -> reportANR(error)); + if (anrDetector != null) { + anrDetector.init(); + } + } + private void updateContext(Context context) { if (this.senderFailureStrategy != null) { this.senderFailureStrategy.updateContext(context); @@ -836,6 +922,24 @@ public void log(Throwable error, Map custom, Level level) { log(error, custom, null, level); } + /** + * Record an error or message with extra data at the level specified. At least one of `error` or + * `description` must be non-null. If error is null, `description` will be sent as a message. If + * error is non-null, description will be sent as the description of the error. Custom data will + * be attached to message if the error is null. Custom data will extend whatever {@link + * Config#custom} returns. + * + * @param error the error (if any). + * @param custom the custom data (if any). + * @param description the description of the error, or the message to send. + * @param level the level to send it at. + * @param isUncaught whether this data comes from an uncaught exception. + */ + public void log(ThrowableWrapper error, Map custom, String description, + Level level, boolean isUncaught) { + rollbar.log(error, custom, description, level, isUncaught); + } + /** * Log an error at level specified. * @@ -1086,4 +1190,22 @@ private static void ensureInit(Runnable runnable) { } } + private static void reportANR(AnrException error){ + List rollbarThreads = error.getThreads(); + + ThrowableWrapper throwableWrapper; + + if (rollbarThreads == null) { + throwableWrapper = new RollbarThrowableWrapper(error); + } else { + throwableWrapper = new RollbarThrowableWrapper(error, rollbarThreads); + } + + notifier.log(throwableWrapper, new HashMap<>(), "ANR", Level.CRITICAL, false); + } + + private static AndroidConfiguration makeDefaultAndroidConfiguration() { + return new AndroidConfiguration.Builder().build(); + } + } diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrConfiguration.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrConfiguration.java new file mode 100644 index 00000000..f9af3916 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrConfiguration.java @@ -0,0 +1,44 @@ +package com.rollbar.android.anr; + +import com.rollbar.android.anr.watchdog.WatchdogConfiguration; + +public class AnrConfiguration { + WatchdogConfiguration watchdogConfiguration; + boolean captureHistoricalAnr; + + public AnrConfiguration(Builder builder) { + this.watchdogConfiguration = builder.watchdogConfiguration; + this.captureHistoricalAnr = builder.captureHistoricalAnr; + } + + public static final class Builder { + private boolean captureHistoricalAnr = true; + private WatchdogConfiguration watchdogConfiguration = new WatchdogConfiguration.Builder().build(); + + /** + * The WatchdogConfiguration configuration, if this field is null, no ANR would be captured. + * By default this feature is on, in build versions < 30. + * @param watchdogConfiguration the Watchdog configuration + * @return the builder instance + */ + public Builder setWatchdogConfiguration(WatchdogConfiguration watchdogConfiguration) { + this.watchdogConfiguration = watchdogConfiguration; + return this; + } + + /** + * A flag to turn on or off the HistoricalAnr detector implementation. + * This implementation is used if the build version is >= 30 + * @param captureHistoricalAnr HistoricalAnrDetector flag + * @return the builder instance + */ + public Builder setCaptureHistoricalAnr(boolean captureHistoricalAnr) { + this.captureHistoricalAnr = captureHistoricalAnr; + return this; + } + + public AnrConfiguration build() { + return new AnrConfiguration(this); + } + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetector.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetector.java new file mode 100644 index 00000000..2054b7ea --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetector.java @@ -0,0 +1,5 @@ +package com.rollbar.android.anr; + +public interface AnrDetector { + void init(); +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetectorFactory.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetectorFactory.java new file mode 100644 index 00000000..a4930e22 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetectorFactory.java @@ -0,0 +1,66 @@ +package com.rollbar.android.anr; + +import android.content.Context; +import android.os.Build; + +import com.rollbar.android.anr.historical.HistoricalAnrDetector; +import com.rollbar.android.anr.watchdog.WatchdogAnrDetector; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; + +public class AnrDetectorFactory { + + public static AnrDetector create( + Context context, + Logger logger, + AnrConfiguration anrConfiguration, + AnrListener anrListener + ) { + if (anrConfiguration == null) { + logger.warn("No ANR configuration"); + return null; + } + if (context == null) { + logger.warn("No context"); + return null; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!anrConfiguration.captureHistoricalAnr) { + logger.warn("Historical ANR capture is off"); + return null; + } + + logger.debug("Creating HistoricalAnrDetector"); + return new HistoricalAnrDetector( + context, + anrListener, + createAnrTimeStampFile(context), + createHistoricalAnrDetectorLogger() + ); + } else { + if (anrConfiguration.watchdogConfiguration == null) { + logger.warn("No Watchdog configuration"); + return null; + } + + logger.debug("Creating WatchdogAnrDetector"); + return new WatchdogAnrDetector( + context, + anrConfiguration.watchdogConfiguration, + anrListener + ); + } + } + + private static File createAnrTimeStampFile(Context context) { + return new File(context.getCacheDir(), "rollbar-anr-timestamp"); + } + + private static Logger createHistoricalAnrDetectorLogger() { + return LoggerFactory.getLogger(HistoricalAnrDetector.class); + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrException.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrException.java new file mode 100644 index 00000000..ec47578e --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrException.java @@ -0,0 +1,25 @@ +package com.rollbar.android.anr; + +import com.rollbar.api.payload.data.body.RollbarThread; + +import java.util.List; + +public final class AnrException extends RuntimeException { + + private List threads; + + public AnrException(String message, Thread thread) { + super(message); + setStackTrace(thread.getStackTrace()); + } + + public AnrException(List threads) { + super("Application Not Responding"); + this.threads = threads; + } + + public List getThreads() { + return threads; + } + +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrListener.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrListener.java new file mode 100644 index 00000000..1224d8e1 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrListener.java @@ -0,0 +1,10 @@ +package com.rollbar.android.anr; + +public interface AnrListener { + /** + * Called when an ANR is detected. + * + * @param error The error describing the ANR. + */ + void onAppNotResponding(AnrException error); +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/HistoricalAnrDetector.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/HistoricalAnrDetector.java new file mode 100644 index 00000000..ff7f5d53 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/HistoricalAnrDetector.java @@ -0,0 +1,240 @@ +package com.rollbar.android.anr.historical; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; + +import com.rollbar.android.anr.AnrDetector; +import com.rollbar.android.anr.AnrException; +import com.rollbar.android.anr.AnrListener; +import com.rollbar.android.anr.historical.stacktrace.ThreadParser; +import com.rollbar.api.payload.data.body.RollbarThread; + +import org.slf4j.Logger; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@SuppressLint("NewApi") // Validated in the Factory +public class HistoricalAnrDetector implements AnrDetector { + private final Logger logger; + private final Context context; + private final AnrListener anrListener; + private final File file; + + public HistoricalAnrDetector( + Context context, + AnrListener anrListener, + File file, + Logger logger + ) { + this.context = context; + this.anrListener = anrListener; + this.file = file; + this.logger = logger; + } + + @Override + public void init() { + if (anrListener == null) { + logger.error("AnrListener is null"); + return; + } + + Long lastAnrTimestamp = getLastAnrTimestamp(file); + if (lastAnrTimestamp == null) { + return; + } + + Thread thread = new Thread("HistoricalAnrDetectorThread") { + @Override + public void run() { + super.run(); + evaluateLastExitReasons(file, lastAnrTimestamp); + } + }; + thread.setDaemon(true); + thread.start(); + } + + private Long getLastAnrTimestamp(File file) { + if (isNotValid(file)) { + logger.error("Can't retrieve last ANR timestamp"); + return null; + } + + try { + return readLastAnrTimestamp(file); + } catch (IOException e) { + logger.error("Error reading last ANR timestamp"); + return null; + } + } + + private boolean isNotValid(File file) { + if (file != null && !file.exists()) { + createFile(file); + } + + return file == null || !file.exists() || !file.isFile() || !file.canRead(); + } + + private void createFile(File file) { + try { + file.createNewFile(); + } catch (IOException e) { + logger.error("can't create file"); + } + } + + private Long readLastAnrTimestamp(File file) throws IOException { + StringBuilder stringBuilder = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new FileReader(file))) { + String line; + if ((line = br.readLine()) != null) { + stringBuilder.append(line); + } + while ((line = br.readLine()) != null) { + stringBuilder.append("\n").append(line); + } + } catch (FileNotFoundException ignored) { + return null; + } + String content = stringBuilder.toString(); + + try { + return (content.equals("null") || content.trim().isEmpty()) ? 0L : Long.parseLong(content.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private void evaluateLastExitReasons(File file, Long lastAnrReportedTimestamp) { + List applicationExitInfoList = getApplicationExitInformation(); + Long newestAnrTimestamp = lastAnrReportedTimestamp; + + if (applicationExitInfoList.isEmpty()) { + logger.debug("Empty ApplicationExitInfo List"); + return; + } + + for (ApplicationExitInfo applicationExitInfo : applicationExitInfoList) { + if (isNotAnr(applicationExitInfo)) { + continue; + } + + long anrTimestamp = applicationExitInfo.getTimestamp(); + if (anrTimestamp <= lastAnrReportedTimestamp) { + logger.warn("ANR already sent"); + continue; + } + try { + List threads = getThreads(applicationExitInfo); + + if (threads.isEmpty()) { + logger.error("Error parsing ANR"); + continue; + } + + if (containsMainThread(threads)) { + anrListener.onAppNotResponding(new AnrException(threads)); + if (anrTimestamp > newestAnrTimestamp) { + newestAnrTimestamp = anrTimestamp; + saveAnrTimestamp(file, anrTimestamp); + } + } else { + logger.error("Main thread not found, skipping ANR"); + } + } catch (Throwable e) { + logger.error("Can't parse ANR", e); + } + } + } + + private void saveAnrTimestamp(File file, long timestamp) { + if (isNotValid(file)) { + logger.error("Can't save last ANR timestamp"); + return; + } + + try (final OutputStream outputStream = Files.newOutputStream(file.toPath())) { + outputStream.write(String.valueOf(timestamp).getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + } catch (Throwable e) { + logger.error("Error writing the ANR marker to the disk", e); + } + } + + private boolean isNotAnr(ApplicationExitInfo applicationExitInfo) { + return applicationExitInfo.getReason() != ApplicationExitInfo.REASON_ANR; + } + + private boolean containsMainThread(List threads) { + for (RollbarThread thread: threads) { + if (thread.isMain()) { + return true; + } + } + return false; + } + + private List getApplicationExitInformation() { + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + return activityManager.getHistoricalProcessExitReasons(null, 0, 0); + } + + private List getThreads(ApplicationExitInfo applicationExitInfo) throws IOException { + List lines = getLines(applicationExitInfo); + ThreadParser threadParser = new ThreadParser(); + return threadParser.parse(lines); + } + + private List getLines(ApplicationExitInfo applicationExitInfo) throws IOException { + byte[] dump = getDumpBytes(Objects.requireNonNull(applicationExitInfo.getTraceInputStream())); + return getLines(dump); + } + + private List getLines(byte[] dump) throws IOException { + List list = new ArrayList<>(); + try (BufferedReader bufferedReader = toBufferReader(dump)) { + String text; + while ((text = bufferedReader.readLine()) != null) { + list.add(text); + } + } + return list; + } + + private BufferedReader toBufferReader(byte[] dump) { + return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(dump))); + } + + private byte[] getDumpBytes(final InputStream trace) throws IOException { + try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + + int nRead; + byte[] data = new byte[1024]; + + while ((nRead = trace.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + + return buffer.toByteArray(); + } + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackFrame.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackFrame.java new file mode 100644 index 00000000..6212b8df --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackFrame.java @@ -0,0 +1,69 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import com.rollbar.api.json.JsonSerializable; + +import java.util.HashMap; +import java.util.Map; + +public class StackFrame implements JsonSerializable { + private static final String MODULE_KEY = "module"; + private static final String PACKAGE_KEY = "package"; + private static final String FILENAME_KEY = "filename"; + private static final String FUNCTION_KEY = "function"; + private static final String LINE_NUMBER_KEY = "lineno"; + + private String filename = ""; + private String function = ""; + private String module = ""; + private Integer lineno = 0; + private String _package; + + public StackTraceElement toStackTraceElement() { + return new StackTraceElement(module, function, filename, lineno); + } + + public void setFilename(final String filename) { + this.filename = filename; + } + + public void setFunction(final String function) { + this.function = function; + } + + public void setModule(final String module) { + this.module = module; + } + + public void setLineno(final Integer lineno) { + this.lineno = lineno; + } + + public String getPackage() { + return _package; + } + + public void setPackage(final String _package) { + this._package = _package; + } + + @Override + public Object asJson() { + Map values = new HashMap<>(); + if (filename != null) { + values.put(FILENAME_KEY, filename); + } + if (function != null) { + values.put(FUNCTION_KEY, function); + } + if (module != null) { + values.put(MODULE_KEY, module); + } + if (lineno != null) { + values.put(LINE_NUMBER_KEY, lineno); + } + if (_package != null) { + values.put(PACKAGE_KEY, _package); + } + return values; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackTrace.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackTrace.java new file mode 100644 index 00000000..2a89d680 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackTrace.java @@ -0,0 +1,45 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import com.rollbar.api.json.JsonSerializable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class StackTrace implements JsonSerializable { + private static final String FRAMES_KEY = "frames"; + private static final String SNAPSHOT_KEY = "snapshot"; + + private final List frames; + private Boolean snapshot; + + public StackTrace(final List frames) { + this.frames = frames; + } + + public StackTraceElement[] getStackTraceElements() { + StackTraceElement[] stackTraceElements = new StackTraceElement[frames.size()]; + for (int i = 0; i < frames.size(); i++) { + stackTraceElements[i] = frames.get(i).toStackTraceElement(); + } + return stackTraceElements; + } + + public void setSnapshot(final Boolean snapshot) { + this.snapshot = snapshot; + } + + @Override + public Object asJson() { + Map values = new HashMap<>(); + + if (frames != null) { + values.put(FRAMES_KEY, frames); + } + if (snapshot != null) { + values.put(SNAPSHOT_KEY, snapshot); + } + return values; + } + +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/ThreadParser.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/ThreadParser.java new file mode 100644 index 00000000..b0649eef --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/ThreadParser.java @@ -0,0 +1,209 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import com.rollbar.api.payload.data.body.RollbarThread; +import com.rollbar.notifier.util.BodyFactory; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.ListIterator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ThreadParser { + private final Logger LOGGER = LoggerFactory.getLogger(this.getClass()); + + private static final Pattern BEGIN_MANAGED_THREAD_RE = + Pattern.compile("\"(.*)\" (.*) ?prio=(\\d+)\\s+tid=(\\d+)\\s*(.*)"); + + private static final Pattern BEGIN_UNMANAGED_NATIVE_THREAD_RE = + Pattern.compile("\"(.*)\" (.*) ?sysTid=(\\d+)"); + + private static final Pattern NATIVE_RE = + Pattern.compile( + " *(?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*?)\\s+\\((.*)\\+(\\d+)\\)(?: \\(.*\\))?"); + private static final Pattern NATIVE_NO_LOC_RE = + Pattern.compile( + " *(?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*)\\s*\\(?(.*)\\)?(?: \\(.*\\))?"); + private static final Pattern JAVA_RE = + Pattern.compile(" *at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\((.*):([\\d-]+)\\)"); + private static final Pattern JNI_RE = + Pattern.compile(" *at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\(Native method\\)"); + private static final Pattern BLANK_RE = Pattern.compile("\\s+"); + + public List parse(List lines) { + ListIterator iterator = lines.listIterator(); + + Deque rollbarThreads = new ArrayDeque<>(); + final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); + final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); + + while (iterator.hasNext()) { + String line = iterator.next(); + if (line == null) { + LOGGER.warn("No line: Internal error while parsing thread dump"); + return new ArrayList<>(rollbarThreads); + } + + if (matches(beginManagedThreadRe, line) || matches(beginUnmanagedNativeThreadRe, line)) { + iterator.previous(); + + RollbarThread rollbarThread = parseThread(iterator); + if (rollbarThread != null) { + if (rollbarThread.isMain()) { + rollbarThreads.addFirst(rollbarThread); + } else { + rollbarThreads.addLast(rollbarThread); + } + } + } + } + return new ArrayList<>(rollbarThreads); + } + + private RollbarThread parseThread(ListIterator lines) { + String id = ""; + String name = ""; + String state = ""; + + final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); + final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); + + if (!lines.hasNext()) { + return null; + } + String text = lines.next(); + if (text == null) { + LOGGER.warn("Internal error while parsing thread dump"); + return null; + } + if (matches(beginManagedThreadRe, text)) { + Long threadId = getLong(beginManagedThreadRe, 4, null); + if (threadId == null) { + LOGGER.debug("No thread id in the dump, skipping thread"); + return null; + } + id = threadId.toString(); + name = beginManagedThreadRe.group(1); + state = beginManagedThreadRe.group(5); + + if (state != null && state.contains(" ")) { + state = state.substring(0, state.indexOf(' ')); + } + } else if (matches(beginUnmanagedNativeThreadRe, text)) { + Long systemThreadId = getLong(beginUnmanagedNativeThreadRe, 3, null); + if (systemThreadId == null) { + LOGGER.debug("No system thread id in the dump, skipping thread"); + return null; + } + id = systemThreadId.toString(); + name = beginUnmanagedNativeThreadRe.group(1); + } + + StackTrace stackTrace = parseStacktrace(lines); + return new RollbarThread( + name, + id, + "", + state, + new BodyFactory().from(stackTrace.getStackTraceElements()) + ); + } + + + private StackTrace parseStacktrace(ListIterator lines) { + final List frames = new ArrayList<>(); + + final Matcher nativeRe = NATIVE_RE.matcher(""); + final Matcher nativeNoLocRe = NATIVE_NO_LOC_RE.matcher(""); + final Matcher javaRe = JAVA_RE.matcher(""); + final Matcher jniRe = JNI_RE.matcher(""); + final Matcher blankRe = BLANK_RE.matcher(""); + + while (lines.hasNext()) { + String text = lines.next(); + if (text == null) { + LOGGER.warn("Internal error while parsing thread dump, no line"); + break; + } + if (matches(nativeRe, text)) { + final StackFrame frame = new StackFrame(); + frame.setPackage(nativeRe.group(1)); + frame.setFunction(nativeRe.group(2)); + frame.setLineno(getInteger(nativeRe, 3, null)); + frames.add(frame); + } else if (matches(nativeNoLocRe, text)) { + final StackFrame frame = new StackFrame(); + frame.setPackage(nativeNoLocRe.group(1)); + frame.setFunction(nativeNoLocRe.group(2)); + frames.add(frame); + } else if (matches(javaRe, text)) { + final StackFrame frame = new StackFrame(); + final String packageName = javaRe.group(1); + final String className = javaRe.group(2); + final String module = String.format("%s.%s", packageName, className); + frame.setModule(module); + frame.setFunction(javaRe.group(3)); + frame.setFilename(javaRe.group(4)); + frame.setLineno(getUInteger(javaRe, 5, null)); + frames.add(frame); + } else if (matches(jniRe, text)) { + final StackFrame frame = new StackFrame(); + final String packageName = jniRe.group(1); + final String className = jniRe.group(2); + final String module = String.format("%s.%s", packageName, className); + frame.setModule(module); + frame.setFunction(jniRe.group(3)); + frames.add(frame); + } else if (text.isEmpty() || matches(blankRe, text)) { + break; + } + } + + Collections.reverse(frames); + final StackTrace stackTrace = new StackTrace(frames); + stackTrace.setSnapshot(true); + return stackTrace; + } + + private boolean matches(final Matcher matcher, final String text) { + matcher.reset(text); + return matcher.matches(); + } + + private Long getLong( + final Matcher matcher, final int group, final Long defaultValue) { + final String str = matcher.group(group); + if (str == null || str.length() == 0) { + return defaultValue; + } else { + return Long.parseLong(str); + } + } + + private Integer getInteger( + final Matcher matcher, final int group, final Integer defaultValue) { + final String str = matcher.group(group); + if (str == null || str.isEmpty()) { + return defaultValue; + } else { + return Integer.parseInt(str); + } + } + + private Integer getUInteger( + final Matcher matcher, final int group, final Integer defaultValue) { + final String str = matcher.group(group); + if (str == null || str.isEmpty()) { + return defaultValue; + } else { + final Integer parsed = Integer.parseInt(str); + return parsed >= 0 ? parsed : defaultValue; + } + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/LooperHandler.java b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/LooperHandler.java new file mode 100644 index 00000000..c5782201 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/LooperHandler.java @@ -0,0 +1,19 @@ +package com.rollbar.android.anr.watchdog; + +import android.os.Handler; +import android.os.Looper; + +public class LooperHandler { + private final Handler handler; + LooperHandler() { + this.handler = new Handler(Looper.getMainLooper()); + } + + public void post(Runnable runnable) { + handler.post(runnable); + } + + public Thread getThread() { + return handler.getLooper().getThread(); + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchDog.java b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchDog.java new file mode 100644 index 00000000..3847eb91 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchDog.java @@ -0,0 +1,114 @@ +package com.rollbar.android.anr.watchdog; + +import static android.app.ActivityManager.ProcessErrorStateInfo.NOT_RESPONDING; + +import android.app.ActivityManager; +import android.content.Context; + +import com.rollbar.android.anr.AnrException; +import com.rollbar.android.anr.AnrListener; +import com.rollbar.notifier.provider.Provider; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class WatchDog extends Thread { + private final Logger LOGGER = LoggerFactory.getLogger(this.getClass()); + private static final String MESSAGE = "Application Not Responding for at least %s ms."; + + private final LooperHandler uiHandler; + private final Provider timeProvider; + private volatile long lastKnownActiveUiTimestampMs = 0; + private final AtomicBoolean reported = new AtomicBoolean(false); + private final Runnable ticker; + private final Context context; + private final AnrListener anrListener; + private final WatchdogConfiguration watchdogConfiguration; + + public WatchDog( + Context context, + AnrListener anrListener, + LooperHandler looperHandler, + WatchdogConfiguration watchdogConfiguration, + Provider timeProvider + ) { + uiHandler = looperHandler; + this.anrListener = anrListener; + this.context = context; + this.timeProvider = timeProvider; + this.watchdogConfiguration = watchdogConfiguration; + this.ticker = + () -> { + lastKnownActiveUiTimestampMs = timeProvider.provide(); + reported.set(false); + }; + } + + @Override + public void run() { + ticker.run(); + + while (!isInterrupted()) { + uiHandler.post(ticker); + + try { + Thread.sleep(watchdogConfiguration.getPollingIntervalMillis()); + } catch (InterruptedException e) { + try { + Thread.currentThread().interrupt(); + } catch (SecurityException ignored) { + LOGGER.warn("Failed to interrupt due to SecurityException: {}", e.getMessage()); + return; + } + LOGGER.warn("Interrupted: {}", e.getMessage()); + return; + } + + if (isMainThreadNotHandlerTicker()) { + if (isProcessNotResponding() && reported.compareAndSet(false, true)) { + anrListener.onAppNotResponding(makeException()); + } + } + } + } + + private AnrException makeException() { + return new AnrException(createAnrMessage(), uiHandler.getThread()); + } + + private String createAnrMessage() { + return String.format(MESSAGE, watchdogConfiguration.getTimeOutMillis()); + } + + private boolean isMainThreadNotHandlerTicker() { + long unresponsiveDurationMs = timeProvider.provide() - lastKnownActiveUiTimestampMs; + return unresponsiveDurationMs > watchdogConfiguration.getTimeOutMillis(); + } + + private boolean isProcessNotResponding() { + final ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) return true; + + List processesInErrorState = null; + try { + processesInErrorState = activityManager.getProcessesInErrorState(); + } catch (Exception e) { + LOGGER.error("Error getting ActivityManager#getProcessesInErrorState: {}", e.getMessage()); + } + + if (processesInErrorState == null) { + return false; + } + + for (ActivityManager.ProcessErrorStateInfo item : processesInErrorState) { + if (item.condition == NOT_RESPONDING) { + return true; + } + } + + return false; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogAnrDetector.java b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogAnrDetector.java new file mode 100644 index 00000000..9d8593fd --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogAnrDetector.java @@ -0,0 +1,83 @@ +package com.rollbar.android.anr.watchdog; + +import android.annotation.SuppressLint; +import android.content.Context; + +import com.rollbar.android.anr.AnrDetector; +import com.rollbar.android.anr.AnrListener; +import com.rollbar.notifier.provider.timestamp.TimestampProvider; + +import java.io.Closeable; +import java.io.IOException; + +public class WatchdogAnrDetector implements AnrDetector, Closeable { + private boolean isClosed = false; + private final Object startLock = new Object(); + + @SuppressLint("StaticFieldLeak") + private static WatchDog watchDog; + private static final Object watchDogLock = new Object(); + + public WatchdogAnrDetector( + Context context, + WatchdogConfiguration watchdogConfiguration, + AnrListener anrListener + ) { + interruptWatchdog(); + createWatchdog(context, watchdogConfiguration, anrListener); + } + + @Override + public void init() { + if (watchDog == null) return; + + Thread thread = new Thread("WatchdogAnrDetectorThread") { + @Override + public void run() { + super.run(); + synchronized (startLock) { + if (!isClosed) { + watchDog.start(); + } + } + } + }; + thread.setDaemon(true); + thread.start(); + } + + @Override + public void close() throws IOException { + synchronized (startLock) { + isClosed = true; + } + interruptWatchdog(); + } + + private void createWatchdog( + Context context, + WatchdogConfiguration watchdogConfiguration, + AnrListener anrListener + ) { + if (context == null) return; + if (anrListener == null) return; + + watchDog = new WatchDog( + context, + anrListener, + new LooperHandler(), + watchdogConfiguration, + new TimestampProvider() + ); + } + + private void interruptWatchdog() { + synchronized (watchDogLock) { + if (watchDog != null) { + watchDog.interrupt(); + watchDog = null; + } + } + } + +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogConfiguration.java b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogConfiguration.java new file mode 100644 index 00000000..b16d894f --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogConfiguration.java @@ -0,0 +1,64 @@ +package com.rollbar.android.anr.watchdog; + +public class WatchdogConfiguration { + private long pollingIntervalMillis; + private long timeOutMillis; + + WatchdogConfiguration(Builder builder) { + pollingIntervalMillis = builder.pollingIntervalMillis; + timeOutMillis = builder.timeOutMillis; + } + + /** + * Returns the Watchdog pooling interval in millis. + * Default is 500 + * + * @return the pooling interval in millis + */ + public long getPollingIntervalMillis() { + return pollingIntervalMillis; + } + + /** + * Returns the ANR timeout in millis. + * Default is 5000, 5 seconds + * + * @return the timeout in millis + */ + public long getTimeOutMillis() { + return timeOutMillis; + } + + public static final class Builder { + private long pollingIntervalMillis = 500; + private long timeOutMillis = 5000; + + /** + * Set the Watchdog pooling interval in millis. + * Default is 500 + * + * @param pollingIntervalMillis timeout in millis + * @return the builder instance + */ + public Builder setPollingIntervalMillis(long pollingIntervalMillis) { + this.pollingIntervalMillis = pollingIntervalMillis; + return this; + } + + /** + * Set the ANR timeout in millis. + * Default is 5000, 5 seconds + * + * @param timeOutMillis timeout in millis + * @return the builder instance + */ + public Builder setTimeOutMillis(long timeOutMillis) { + this.timeOutMillis = timeOutMillis; + return this; + } + + public WatchdogConfiguration build() { + return new WatchdogConfiguration(this); + } + } +} diff --git a/rollbar-android/src/test/java/com/rollbar/android/RollbarTest.java b/rollbar-android/src/test/java/com/rollbar/android/RollbarTest.java index 0ec880ab..98c65068 100644 --- a/rollbar-android/src/test/java/com/rollbar/android/RollbarTest.java +++ b/rollbar-android/src/test/java/com/rollbar/android/RollbarTest.java @@ -3,21 +3,17 @@ import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.PackageInfo; import android.os.Bundle; -import android.test.mock.MockPackageManager; import com.rollbar.android.provider.NotifierProvider; import com.rollbar.api.payload.Payload; import com.rollbar.api.payload.data.Data; import com.rollbar.api.payload.data.Level; -import com.rollbar.api.payload.data.Notifier; import com.rollbar.notifier.config.Config; import com.rollbar.notifier.config.ConfigBuilder; import com.rollbar.notifier.config.ConfigProvider; import com.rollbar.notifier.filter.Filter; -import com.rollbar.notifier.provider.Provider; import com.rollbar.notifier.sender.BufferedSender; import com.rollbar.notifier.sender.Sender; import com.rollbar.notifier.sender.SyncSender; @@ -32,21 +28,13 @@ import static org.junit.Assert.*; import org.junit.Before; -import org.junit.Rule; import org.junit.Test; import org.mockito.Mock; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; -import org.mockito.stubbing.Answer; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyObject; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.hamcrest.MockitoHamcrest.argThat; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; diff --git a/rollbar-android/src/test/java/com/rollbar/android/anr/AnrDetectorFactoryTest.java b/rollbar-android/src/test/java/com/rollbar/android/anr/AnrDetectorFactoryTest.java new file mode 100644 index 00000000..151100dc --- /dev/null +++ b/rollbar-android/src/test/java/com/rollbar/android/anr/AnrDetectorFactoryTest.java @@ -0,0 +1,49 @@ +package com.rollbar.android.anr; + +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.content.Context; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.slf4j.Logger; + +public class AnrDetectorFactoryTest { + @Mock + private Context context; + + @Mock + private AnrListener anrListener; + + @Mock + private Logger logger; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void createShouldReturnNullWhenNoAnrConfigurationIsProvided() { + AnrDetector anrDetector = AnrDetectorFactory.create(context, logger, null, anrListener); + + assertNull(anrDetector); + thenWarningLogMustSay("No ANR configuration"); + } + + @Test + public void createShouldReturnNullWhenNoContextIsProvided() { + AnrDetector anrDetector = AnrDetectorFactory.create(null, logger, new AnrConfiguration.Builder().build(), anrListener); + + assertNull(anrDetector); + thenWarningLogMustSay("No context"); + } + + private void thenWarningLogMustSay(String logMessage) { + verify(logger, times(1)).warn(logMessage); + } +} diff --git a/rollbar-android/src/test/java/com/rollbar/android/anr/historical/HistoricalAnrDetectorTest.java b/rollbar-android/src/test/java/com/rollbar/android/anr/historical/HistoricalAnrDetectorTest.java new file mode 100644 index 00000000..77c70d20 --- /dev/null +++ b/rollbar-android/src/test/java/com/rollbar/android/anr/historical/HistoricalAnrDetectorTest.java @@ -0,0 +1,261 @@ +package com.rollbar.android.anr.historical; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; + +import com.rollbar.android.anr.AnrListener; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.slf4j.Logger; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public class HistoricalAnrDetectorTest { + + @Mock + private ApplicationExitInfo applicationExitInfo; + + @Mock + private Context context; + + private File file; + + @Mock + private AnrListener anrListener; + + @Mock + private Logger logger; + + @Mock + private ActivityManager activityManager; + + private HistoricalAnrDetector historicalAnrDetector; + + private final static long ANR_TIMESTAMP = 10L; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + createTemporalFile(); + historicalAnrDetector = new HistoricalAnrDetector(context, anrListener, file, logger); + } + + @Test + public void shouldNotDetectAnrWhenAnrListenerIsNull() throws InterruptedException { + givenAnActivityManagerWithoutExitInfo(); + historicalAnrDetector = new HistoricalAnrDetector(context, null, file, logger); + + whenDetectorIsExecuted(); + + thenTheListenerIsNeverCalled(); + thenErrorLogMustSay("AnrListener is null"); + } + + @Test + public void shouldNotDetectAnrWhenApplicationExitInfoIsEmpty() throws InterruptedException { + givenAnActivityManagerWithoutExitInfo(); + + whenDetectorIsExecuted(); + + thenTheListenerIsNeverCalled(); + thenDebugLogMustSay("Empty ApplicationExitInfo List"); + } + + @Test + public void shouldNotDetectAnrWhenMainThreadIsNotParsed() throws InterruptedException, IOException { + givenAnActivityManagerWithAnAnr(anrWithoutMainThread()); + + whenDetectorIsExecuted(); + + thenTheListenerIsNeverCalled(); + thenErrorLogMustSay("Main thread not found, skipping ANR"); + } + + @Test + public void shouldDoNothingIfFileForAnrTimeStampsIsNull() throws InterruptedException, IOException { + givenAnActivityManagerWithAnAnr(anrWithoutMainThread()); + historicalAnrDetector = new HistoricalAnrDetector(context, anrListener, null, logger); + + whenDetectorIsExecuted(); + + thenTheListenerIsNeverCalled(); + thenErrorLogMustSay("Can't retrieve last ANR timestamp"); + } + + @Test + public void shouldNotSendAnrIfItHasAlreadyBeenSent() throws InterruptedException, IOException { + givenAnActivityManagerWithAnAnr(anr()); + givenAnAlreadySentAnr(); + + whenDetectorIsExecuted(); + + thenWarningLogMustSay("ANR already sent"); + } + + @Test + public void shouldDetectAnr() throws InterruptedException, IOException { + givenAnActivityManagerWithAnAnr(anr()); + + whenDetectorIsExecuted(); + + thenTheListenerIsCalled(); + } + + private void whenDetectorIsExecuted() throws InterruptedException { + historicalAnrDetector.init(); + waitForDetectorToRun(); + } + + private void givenAnActivityManagerWithAnAnr(ByteArrayInputStream anr) throws IOException { + setAnr(anr); + setActivityManagerService(); + + List list = new ArrayList<>(); + list.add(applicationExitInfo); + setExitReason(list); + } + + private void givenAnActivityManagerWithoutExitInfo() { + setActivityManagerService(); + setExitReason(new ArrayList<>()); + } + + private void setActivityManagerService() { + when(context.getSystemService(eq(Context.ACTIVITY_SERVICE))).thenReturn(activityManager); + } + + private void setAnr(ByteArrayInputStream anr) throws IOException { + givenAnAnrNotSent(); + when(applicationExitInfo.getReason()).thenReturn(ApplicationExitInfo.REASON_ANR); + when(applicationExitInfo.getTraceInputStream()).thenReturn(anr); + } + + private void givenAnAnrNotSent() { + when(applicationExitInfo.getTimestamp()).thenReturn(ANR_TIMESTAMP + 1); + } + + private void givenAnAlreadySentAnr() { + when(applicationExitInfo.getTimestamp()).thenReturn(ANR_TIMESTAMP); + } + + private void setExitReason(List applicationExitInfos) { + when(activityManager.getHistoricalProcessExitReasons(eq(null), eq(0), eq(0))).thenReturn(applicationExitInfos); + } + + private ByteArrayInputStream anr() { + String string = "\"main\" prio=5 tid=1 Sleeping\n" + + "| group=\"main\" sCount=1 ucsCount=0 flags=1 obj=0x72273478 self=0xb4000077e811ff50\n" + + "| sysTid=20408 nice=-10 cgrp=top-app sched=0/0 handle=0x79e97864f8\n" + + "| state=S schedstat=( 856373236 2319887008 1428 ) utm=74 stm=10 core=0 HZ=100\n" + + "| stack=0x7fd2fc2000-0x7fd2fc4000 stackSize=8188KB\n" + + "| held mutexes=" + + "at java.lang.Thread.sleep(Native method)" + + "- sleeping on <0x0c0f663b> (a java.lang.Object)\n" + + "at java.lang.Thread.sleep(Thread.java:450)\n" + + "- locked <0x0c0f663b> (a java.lang.Object)\n" + + "at java.lang.Thread.sleep(Thread.java:355)\n" + + "at com.rollbar.example.android.MainActivity.clickAction(MainActivity.java:77)\n" + + "at com.rollbar.example.android.MainActivity.access$000(MainActivity.java:14)\n" + + "at com.rollbar.example.android.MainActivity$1$1.onClick(MainActivity.java:34)\n" + + "at android.support.design.widget.Snackbar$1.onClick(Snackbar.java:255)\n" + + "at android.view.View.performClick(View.java:7659)\n" + + "at android.view.View.performClickInternal(View.java:7636)\n" + + "at android.view.View.-$$Nest$mperformClickInternal(unavailable:0)\n" + + "at android.view.View$PerformClick.run(View.java:30156)\n" + + "at android.os.Handler.handleCallback(Handler.java:958)\n" + + "at android.os.Handler.dispatchMessage(Handler.java:99)\n" + + "at android.os.Looper.loopOnce(Looper.java:205)\n" + + "at android.os.Looper.loop(Looper.java:294)\n" + + "at android.app.ActivityThread.main(ActivityThread.java:8177)\n" + + "at java.lang.reflect.Method.invoke(Native method)\n" + + "at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552)\n" + + "at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:971)\n" + + "\"OkHttp ConnectionPool\" daemon prio=5 tid=3 TimedWaiting\n" + + "| group=\"main\" sCount=1 ucsCount=0 flags=1 obj=0x12c4ddc0 self=0xb4000077e8175220\n" + + "| sysTid=20482 nice=0 cgrp=top-app sched=0/0 handle=0x76fd901cb0\n" + + "| state=S schedstat=( 598626 7237000 4 ) utm=0 stm=0 core=0 HZ=100\n" + + "| stack=0x76fd7fe000-0x76fd800000 stackSize=1039KB\n" + + "| held mutexes=\n" + + "at java.lang.Object.wait(Native method)\n" + + "- waiting on <0x06842b17> (a com.android.okhttp.ConnectionPool)\n" + + "at com.android.okhttp.ConnectionPool$1.run(ConnectionPool.java:106)\n" + + "- locked <0x06842b17> (a com.android.okhttp.ConnectionPool)\n" + + "at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)\n" + + "at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)\n" + + "at java.lang.Thread.run(Thread.java:1012)\n"; + return new ByteArrayInputStream(string.getBytes()); + } + + private ByteArrayInputStream anrWithoutMainThread() { + String string = "\"OkHttp ConnectionPool\" daemon prio=5 tid=3 TimedWaiting\n" + + "| group=\"main\" sCount=1 ucsCount=0 flags=1 obj=0x12c4ddc0 self=0xb4000077e8175220\n" + + "| sysTid=20482 nice=0 cgrp=top-app sched=0/0 handle=0x76fd901cb0\n" + + "| state=S schedstat=( 598626 7237000 4 ) utm=0 stm=0 core=0 HZ=100\n" + + "| stack=0x76fd7fe000-0x76fd800000 stackSize=1039KB\n" + + "| held mutexes=\n" + + "at java.lang.Object.wait(Native method)\n" + + "native: #5 com.example.library 7fa123456789 libsomething (libfoo.so+1234) (funcName+56)\n"+ + "#9 my.module.name 7f1234abcde0 libbar.so (doStuff)\n"+ + " \n"+ + "- waiting on <0x06842b17> (a com.android.okhttp.ConnectionPool)\n" + + "at com.android.okhttp.ConnectionPool$1.run(ConnectionPool.java:106)\n" + + "- locked <0x06842b17> (a com.android.okhttp.ConnectionPool)\n" + + "at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)\n" + + "at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)\n" + + "at java.lang.Thread.run(Thread.java:1012)\n"; + return new ByteArrayInputStream(string.getBytes()); + } + + private void createTemporalFile() { + try { + Path tempPath = Files.createTempFile("rollbar-anr-timestamp", ".txt"); + Files.write(tempPath, ("" + ANR_TIMESTAMP).getBytes()); + file = tempPath.toFile(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void waitForDetectorToRun() throws InterruptedException { + for(int i = 0; i<3 ; i++) { + Thread.sleep(50); + } + } + + private void thenTheListenerIsCalled() { + verify(anrListener).onAppNotResponding(any()); + } + + private void thenTheListenerIsNeverCalled() { + verify(anrListener, never()).onAppNotResponding(any()); + } + + private void thenDebugLogMustSay(String logMessage) { + verify(logger, times(1)).debug(logMessage); + } + + private void thenWarningLogMustSay(String logMessage) { + verify(logger, times(1)).warn(logMessage); + } + + private void thenErrorLogMustSay(String logMessage) { + verify(logger, times(1)).error(logMessage); + } +} diff --git a/rollbar-android/src/test/java/com/rollbar/android/anr/historical/stacktrace/StackFrameTest.java b/rollbar-android/src/test/java/com/rollbar/android/anr/historical/stacktrace/StackFrameTest.java new file mode 100644 index 00000000..2cbe77ff --- /dev/null +++ b/rollbar-android/src/test/java/com/rollbar/android/anr/historical/stacktrace/StackFrameTest.java @@ -0,0 +1,71 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import static org.junit.Assert.*; + +import org.junit.Test; + +import java.util.HashMap; + +public class StackFrameTest { + private static final String MODULE = "any module"; + private static final String PACKAGE = "any package"; + private static final String FILENAME = "any filename"; + private static final String FUNCTION = "any function"; + private static final int LINE_NO = 20; + + @Test + public void jsonRepresentationShouldBeTheExpected() { + StackFrame stackFrame = givenAStackFrame(); + + HashMap json = getJson(stackFrame); + + assertEquals(expectedJson(), json); + } + + @Test + public void jsonRepresentationShouldBeAnEmptyMapIfAllPropertiesAreNull() { + StackFrame stackFrame = givenAStackFrameWithNullProperties(); + + HashMap json = getJson(stackFrame); + + assertEquals(new HashMap<>(), json); + } + + @SuppressWarnings("unchecked") + private HashMap getJson(StackFrame stackFrame) { + return (HashMap) stackFrame.asJson(); + } + + private StackFrame givenAStackFrame() { + StackFrame stackFrame = new StackFrame(); + stackFrame.setModule(MODULE); + stackFrame.setPackage(PACKAGE); + stackFrame.setFilename(FILENAME); + stackFrame.setFunction(FUNCTION); + stackFrame.setLineno(LINE_NO); + return stackFrame; + } + + private HashMap expectedJson() { + HashMap json = new HashMap<>(); + + json.put("module", MODULE); + json.put("package", PACKAGE); + json.put("filename", FILENAME); + json.put("function", FUNCTION); + json.put("lineno", LINE_NO); + + return json; + } + + private StackFrame givenAStackFrameWithNullProperties() { + StackFrame stackFrame = new StackFrame(); + stackFrame.setModule(null); + stackFrame.setPackage(null); + stackFrame.setFilename(null); + stackFrame.setFunction(null); + stackFrame.setLineno(null); + return stackFrame; + } + +} diff --git a/rollbar-android/src/test/java/com/rollbar/android/anr/watchdog/WatchDogTest.java b/rollbar-android/src/test/java/com/rollbar/android/anr/watchdog/WatchDogTest.java new file mode 100644 index 00000000..667b3d55 --- /dev/null +++ b/rollbar-android/src/test/java/com/rollbar/android/anr/watchdog/WatchDogTest.java @@ -0,0 +1,171 @@ +package com.rollbar.android.anr.watchdog; + +import static android.app.ActivityManager.ProcessErrorStateInfo.NOT_RESPONDING; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.app.ActivityManager; +import android.content.Context; + +import com.rollbar.android.anr.AnrException; +import com.rollbar.android.anr.AnrListener; +import com.rollbar.notifier.provider.Provider; +import com.rollbar.notifier.provider.timestamp.TimestampProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.List; + +public class WatchDogTest { + private static final long ANR_TIMEOUT_MILLIS = 5000; + + private long currentTimeMs = 0L; + private AnrException anrException; + private final AnrListener anrListener = new AnrListenerFake(); + private final Thread.State blockedState = Thread.State.BLOCKED; + private final StackTraceElement stacktrace = new StackTraceElement("declaringClass", + "methodName", "fileName", 7); + + @Mock + private Thread thread; + + @Mock + private ActivityManager activityManager; + + @Mock + private Context context; + + @Mock + private LooperHandler looperHandler; + + private final Provider timeProvider = new TimestampProviderFake(); + + private WatchDog watchDog; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + currentTimeMs = 0; + watchDog = new WatchDog( + context, + anrListener, + looperHandler, + new WatchdogConfiguration.Builder().build(), + timeProvider + ); + } + + @After + public void tearDown() { + watchDog.interrupt(); + } + + @Test + public void shouldNotDetectAnrWhenTimeOutIsNotSurpassed() throws InterruptedException { + whenWatchdogStart(); + whenAnrTimeOutIsNotSurpassed(); + + thenAnrIsNotDetected(); + } + + @Test + public void shouldDetectAnrWhenMainThreadIsBlockedAndActivityManagerNotAvailable() throws InterruptedException { + givenABlockedThread(); + + whenWatchdogStart(); + whenAnrTimeOutIsSurpassed(); + + thenAnrExceptionIsTheExpected(); + } + + @Test + public void shouldDetectAnrWhenMainThreadIsBlockedAndActivityManagerHasAnr() throws InterruptedException { + givenAnActivityManagerWithAnr(); + givenABlockedThread(); + + whenWatchdogStart(); + whenAnrTimeOutIsSurpassed(); + + thenAnrExceptionIsTheExpected(); + } + + private void thenAnrIsNotDetected() { + assertNull(anrException); + } + + private void thenAnrExceptionIsTheExpected() { + assertNotNull(anrException); + assertEquals(anrException.getMessage(), "Application Not Responding for at least 5000 ms."); + assertEquals(stacktrace.getClassName(), anrException.getStackTrace()[0].getClassName()); + } + + private void whenWatchdogStart() { + watchDog.start(); + } + + private void whenAnrTimeOutIsNotSurpassed() throws InterruptedException { + int iterations = 0; + int maxIterations = 10; //just to prevent infinite execution + + while (iterations < maxIterations) { + iterations++; + currentTimeMs += 1; + defaultSleep(); + } + } + + private void whenAnrTimeOutIsSurpassed() throws InterruptedException { + int iterations = 0; + int maxIterations = 30; //just to prevent infinite execution + + while (anrException == null && iterations < maxIterations) { + iterations++; + currentTimeMs += ANR_TIMEOUT_MILLIS + 1; + defaultSleep(); + } + } + + private void givenAnActivityManagerWithAnr() { + ActivityManager.ProcessErrorStateInfo stateInfo = new ActivityManager.ProcessErrorStateInfo(); + stateInfo.condition = NOT_RESPONDING; + List anrs = new ArrayList<>(); + anrs.add(stateInfo); + + when(context.getSystemService(eq(Context.ACTIVITY_SERVICE))).thenReturn(activityManager); + when(activityManager.getProcessesInErrorState()).thenReturn(anrs); + } + + private void givenABlockedThread() { + StackTraceElement[] stackTraceElements = {stacktrace}; + + when(thread.getState()).thenReturn(blockedState); + when(thread.getStackTrace()).thenReturn(stackTraceElements); + when(looperHandler.getThread()).thenReturn(thread); + } + + private void defaultSleep() throws InterruptedException { + Thread.sleep(20); + } + + private class TimestampProviderFake extends TimestampProvider { + @Override + public Long provide() { + return currentTimeMs; + } + } + + private class AnrListenerFake implements AnrListener { + @Override + public void onAppNotResponding(AnrException error) { + anrException = error; + } + } +} diff --git a/rollbar-api/src/main/java/com/rollbar/api/payload/data/body/RollbarThread.java b/rollbar-api/src/main/java/com/rollbar/api/payload/data/body/RollbarThread.java index efff8f00..7cc33eca 100644 --- a/rollbar-api/src/main/java/com/rollbar/api/payload/data/body/RollbarThread.java +++ b/rollbar-api/src/main/java/com/rollbar/api/payload/data/body/RollbarThread.java @@ -32,7 +32,15 @@ public RollbarThread(Thread thread, Group group) { this.group = group; } - private RollbarThread( + /** + * Constructor. + * @param name the name of the thread. + * @param id the id of the thread. + * @param priority the priority of the thread. + * @param state the state of the thread. + * @param group the Group of trace chains. + */ + public RollbarThread( String name, String id, String priority, @@ -92,6 +100,19 @@ public String getName() { return name; } + /** + * Getter. + * + * @return true if this represents the main thread. + */ + public boolean isMain() { + return isMain; + } + + private boolean isMain(String string) { + return "main".equals(string); + } + @Override public Object asJson() { Map values = new HashMap<>(); @@ -147,10 +168,6 @@ public int hashCode() { return Objects.hash(name, id, priority, state, isMain, group); } - private boolean isMain(String string) { - return "main".equals(string); - } - /** * Builder class for {@link RollbarThread RollbarThread}. */ diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/Rollbar.java b/rollbar-java/src/main/java/com/rollbar/notifier/Rollbar.java index e59b95e3..b9265745 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/Rollbar.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/Rollbar.java @@ -10,6 +10,7 @@ import com.rollbar.notifier.util.ObjectsUtils; import com.rollbar.notifier.wrapper.ThrowableWrapper; import java.lang.Thread.UncaughtExceptionHandler; +import java.util.HashMap; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -611,6 +612,23 @@ public void log( this.log(wrapThrowable(error, thread), custom, description, level, isUncaught); } + /** + * Record an error or message with extra data at the level specified. At least one of `error` or + * `description` must be non-null. If error is null, `description` will be sent as a message. If + * error is non-null, description will be sent as the description of the error. Custom data will + * be attached to message if the error is null. Custom data will extend whatever {@link + * Config#custom} returns. + * + * @param error the error (if any). + */ + public void log(ThrowableWrapper error) { + try { + process(error, new HashMap<>(), null, Level.CRITICAL, false); + } catch (Exception e) { + LOGGER.error("Error while processing payload to send to Rollbar: {}", e); + } + } + /** * Record an error or message with extra data at the level specified. At least one of `error` or * `description` must be non-null. If error is null, `description` will be sent as a message. If diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/util/BodyFactory.java b/rollbar-java/src/main/java/com/rollbar/notifier/util/BodyFactory.java index 103d3ed0..a72098ac 100755 --- a/rollbar-java/src/main/java/com/rollbar/notifier/util/BodyFactory.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/util/BodyFactory.java @@ -87,6 +87,22 @@ public RollbarThread from( return new RollbarThread(thread, new Group(traceChain)); } + /** + * Builds a Group from an Array of StackTraceElement. + * + * @param stackTraceElements the stack trace elements. + * @return the Group. + */ + public Group from( + StackTraceElement[] stackTraceElements + ) { + if (stackTraceElements == null) { + return null; + } + TraceChain traceChain = traceChain(stackTraceElements); + return new Group(traceChain); + } + private Body from( ThrowableWrapper throwableWrapper, String description, @@ -105,6 +121,12 @@ private List makeRollbarThreads( if (throwableWrapper == null) { return null; } + + List wrapperRollbarThreads = throwableWrapper.getRollbarThreads(); + if (wrapperRollbarThreads != null && !wrapperRollbarThreads.isEmpty()) { + return wrapperRollbarThreads; + } + Map allStackTraces = throwableWrapper.getAllStackTraces(); if (allStackTraces == null) { return null; diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/wrapper/RollbarThrowableWrapper.java b/rollbar-java/src/main/java/com/rollbar/notifier/wrapper/RollbarThrowableWrapper.java index 7eee3ba2..87acf700 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/wrapper/RollbarThrowableWrapper.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/wrapper/RollbarThrowableWrapper.java @@ -5,6 +5,7 @@ import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -26,6 +27,8 @@ public class RollbarThrowableWrapper implements ThrowableWrapper { private final Map allStackTraces; + private final List rollbarThreads; + /** * Constructor. * @@ -39,7 +42,8 @@ public RollbarThrowableWrapper(Throwable throwable) { getInnerThrowableWrapper(throwable), throwable, Thread.currentThread(), - captureAllStackTraces(Thread.currentThread()) + captureAllStackTraces(Thread.currentThread()), + null ); } @@ -56,7 +60,29 @@ public RollbarThrowableWrapper(Throwable throwable, Thread thread) { getInnerThrowableWrapper(throwable), throwable, thread, - captureAllStackTraces(thread) + captureAllStackTraces(thread), + null + ); + } + + /** + * Constructor. + * + * @param throwable the throwable. + */ + public RollbarThrowableWrapper( + Throwable throwable, + List rollbarThreads + ) { + this( + throwable.getClass().getName(), + throwable.getMessage(), + throwable.getStackTrace(), + getInnerThrowableWrapper(throwable), + throwable, + null, + null, + rollbarThreads ); } @@ -75,42 +101,11 @@ private RollbarThrowableWrapper(Throwable throwable, boolean unusedParameter) { getInnerThrowableWrapper(throwable), throwable, null, + null, null ); } - private static ThrowableWrapper getInnerThrowableWrapper(Throwable throwable) { - if (throwable.getCause() == null) { - return null; - } - return new RollbarThrowableWrapper(throwable.getCause(), false); - } - - private static Map captureAllStackTraces(Thread thread) { - if (thread == null) { - return null; - } - - return filter(thread, Thread.getAllStackTraces()); - } - - private static Map filter( - Thread thread, Map allStackTraces - ) { - HashMap filteredStackTraces = new HashMap<>(); - - for (Map.Entry entry : allStackTraces.entrySet()) { - Thread entryThread = entry.getKey(); - - if (!thread.equals(entryThread)) { - filteredStackTraces.put(entryThread, entry.getValue()); - } - } - - return filteredStackTraces; - } - /** * Constructor. * @@ -125,7 +120,16 @@ public RollbarThrowableWrapper( StackTraceElement[] stackTraceElements, ThrowableWrapper cause ) { - this(className, message, stackTraceElements, cause, null, null, null); + this( + className, + message, + stackTraceElements, + cause, + null, + null, + null, + null + ); } private RollbarThrowableWrapper( @@ -135,7 +139,8 @@ private RollbarThrowableWrapper( ThrowableWrapper cause, Throwable throwable, Thread thread, - Map allStackTraces + Map allStackTraces, + List rollbarThreads ) { this.className = className; this.message = message; @@ -144,6 +149,39 @@ private RollbarThrowableWrapper( this.throwable = throwable; this.rollbarThread = new BodyFactory().from(thread); this.allStackTraces = allStackTraces; + this.rollbarThreads = rollbarThreads; + } + + private static ThrowableWrapper getInnerThrowableWrapper(Throwable throwable) { + if (throwable.getCause() == null) { + return null; + } + return new RollbarThrowableWrapper(throwable.getCause(), false); + } + + private static Map captureAllStackTraces(Thread thread) { + if (thread == null) { + return null; + } + + return filter(thread, Thread.getAllStackTraces()); + } + + private static Map filter( + Thread thread, Map allStackTraces + ) { + HashMap filteredStackTraces = new HashMap<>(); + + for (Map.Entry entry : allStackTraces.entrySet()) { + Thread entryThread = entry.getKey(); + + if (!thread.equals(entryThread)) { + filteredStackTraces.put(entryThread, entry.getValue()); + } + } + + return filteredStackTraces; } @Override @@ -181,6 +219,11 @@ public Map getAllStackTraces() { return allStackTraces; } + @Override + public List getRollbarThreads() { + return rollbarThreads; + } + @Override public String toString() { return "RollbarThrowableWrapper{" diff --git a/rollbar-java/src/main/java/com/rollbar/notifier/wrapper/ThrowableWrapper.java b/rollbar-java/src/main/java/com/rollbar/notifier/wrapper/ThrowableWrapper.java index 480de92e..bfc9434f 100644 --- a/rollbar-java/src/main/java/com/rollbar/notifier/wrapper/ThrowableWrapper.java +++ b/rollbar-java/src/main/java/com/rollbar/notifier/wrapper/ThrowableWrapper.java @@ -2,6 +2,7 @@ import com.rollbar.api.payload.data.body.RollbarThread; +import java.util.List; import java.util.Map; /** @@ -57,4 +58,11 @@ public interface ThrowableWrapper { * @return the map. */ Map getAllStackTraces(); + + /** + * Get a list of the RollbarThreads for this error. + * + * @return the RollbarThreads. + */ + List getRollbarThreads(); }