Error handling on Android part 3: sending crash reports to an error reporting API

Jamie Lynch

Error handling on Android

Accent divider.

How many times have you been in the middle of using a new shiny app, only to have it crash on you?

Android app displaying a crash dialog
Android app displaying a crash dialog

This is the third in a series of posts that will investigate how the exception handling mechanism works in Java and Android, and how crash reporting SDKs can capture diagnostic information, so that you're not flying blind in production.

Sending crash reports to an error reporting API

We previously learnt how to implement an #UncaughtExceptionHandler and generate error reports for uncaught exceptions in JVM applications. Our goal now is to deliver those crash reports to an error reporting API in a reliable manner, while gracefully handling conditions such as loss of network connectivity.

Making network requests without Retrofit

Retrofit is the de facto standard library for performing RESTful requests on Android. Unfortunately due to its size and the fact that we only need to perform HTTP calls to 1-2 different endpoints, Retrofit isn't a good fit for most general purpose Android libraries. Using Retrofit in a library would force the dependency on anyone using our crash reporting SDK, and developers who chose a different network stack in their app may not appreciate this impact on the size of their APK.

Instead, we'll use the slightly more verbose HttpURLConnection to make our network calls, which is part of the Android framework. In modern versions of Android, #HttpURLConnection uses OkHttp under the hood anyway, meaning we gain at least some of the performance benefits of Retrofit without having to rely on it directly as a dependency.

Before we do anything, we have to figure out how to serialise a JSON object from our Kotlin data classes, which we'll then send in a POST request to Bugsnag's error reporting API.

Serialising an error report to JSON

Serialising an error report is subject to the same constraints as before - we can't add a large dependency to our SDK as this also will force it upon all our users. In production, Bugsnag strikes a middle ground by vendoring some classes from GSON's streaming API, meaning for each JSON object, a serializer object similar to below is created. We want to format the JSON we send to look something like the following:

{
 "stacktrace":[
   {
     "file":"MainActivity.kt",
     "lineNumber":56,
     "columnNumber":23,
     "method":"com.example.MainActivity.onCreate"
   }
 ]
}

We'll achieve this by using GSON's streaming API. We'll wrap this in a #JsonWriter class, whose method #keyValuePair will write a JSON key and value to an object:

class JsonWriter(private val writer: OutputStreamWriter) {
   fun keyValuePair(key: String, value: String) {
       writer.write(key)
       // serialise the rest of the JSON correctly
   }
}

For each object in the error reporting API payload, we'll need to create a serializer class that converts an object to JSON. For a #StackTraceElement, this would look something like the following:

class StacktraceSerializer(private val trace: StackTraceElement) {
   fun toStream(writer: JsonWriter) {
       with(writer) {
           keyValuePair("method", trace.methodName)
           keyValuePair("file", trace.fileName)
           keyValuePair("lineNumber", trace.lineNumber.toString())
       }
   }
}

Using a streaming API to serialize JSON is a little more involved than how GSON would typically be used in an Android app and requires additional validation of the JSON schema, but it brings several advantages.

Firstly, streaming may require less memory at any one time, so may perform better for large crash reports which contain lots of metadata. More importantly, we can easily alter the #OutputStream supplied to the #JsonWriter, meaning we can seamlessly switch between writing to a network connection, or caching a report on disk, which becomes very useful when handling network connectivity problems.

Handling HTTP delivery failure

Network requests don't always succeed first time. There are several potential failure modes: the device may have no connection; the Android OS may kill your crashing process halfway through a request; or your servers may even be temporarily down.

We want to capture information on as many crashes as possible, which means that we need to persist a report to disk so that the next time connection is available, we can attempt to send the cached report again. We can do this by repurposing the #JsonWriter which we developed in the previous section:

class JsonWriter(private val writer: OutputStreamWriter) {
   fun keyValuePair(key: String, value: String) {
       // serialize JSON key-value pair
   }
}

fun cacheOnDisk() {
   val fos = FileOutputStream("my-file.json")
   val out = OutputStreamWriter(fos, "UTF-8")
   val jsonWriter = JsonWriter(out)
   stacktraceSerializer.toStream(jsonWriter)
}

As our #JsonWriter takes an #OutputStreamWriter, saving a report to disk is merely a case of changing the #OutputStream to that of a file, rather than a HTTP connection. We can cache on disk when an HTTP request fails, then detect when network connectivity is restored and load the crash report from disk so we can send it in the background.

Performing network requests in the background

If we don’t perform our network requests in the background we risk triggering a NetworkOnMainThreadException in our app — and crashing in a crash reporter is generally not a good sign! As we can't depend on large libraries such as RxJava or Kotlin Coroutines, we'll use Java's powerful Executor framework instead. A simplified version of our implementation may look something like this:

/**
* Sends a cached report that has already been deserialized, then deletes it from disk.
*/
fun sendCachedReport(report: Report) {
   val threadPool = Executors.newFixedThreadPool(4)

   threadPool.submit {
       val success = delivery.deliver(report)
       if (success) {
           fileStore.delete(report)
       }
   }
}

All that remains is to invoke this method when connectivity is restored, or when the application initialises after a crash, which makes our crash reporting SDK deliver reports seemingly by magic.

We've captured fatal errors, but now is a good time to think about capturing non-fatal errors, such as caught exceptions and ANRs, which we'll cover in the next post.

Would you like to know more?

Hopefully this has helped you learn a bit more about Error Handling on Android. If you have any questions or feedback, please feel free to get in touch.

———

Try Bugsnag's Android crash reporting.

Share