I was implementing Firebase Crashlytics for crash reporting for an Android app, and came across their documentation for customizing the reports.
Having things like additional logging and user info for non-fatal exceptions are great, but what I wanted to use were the custom keys that could be used to log application state when the exception occurs. What’s really nice is that the state info is clearly displayed in the Firebase console.
My requirements for implementing crash reporting were:
- Crash reporting is only for the release build, as you don’t really want to pollute Crashlytics with errors during the debug and development phase (especially since you can’t remove them).
- Non-fatal exceptions could also be reported. Of course you don’t need to report every exception, probably just circumstances like the app being left in an unexpected state or an error in the logic, etc.
- During development, exceptions would just be logged to LogCat, as usual.
- To implement it in a clean way, so future changes won’t necessarily have to ripple throughout the codebase.
Here are some possible ways of do it:
1.Use the Crashlytics API
How about just using calling the Crashlytics API where it is required?
try { // some operation } catch (Exception ex) { Crashlytics.setUserIdentifier("user id"); Crashlytics.setString("param 1", "some value"); Crashlytics.setInt("param 2", 10); Crashlytics.log("message"); Crashlytics.logException(ex); }
Since we only want crash reporting for the release version, we would need to conditionally invoke it only for the release build.
try { // some operation } catch (Exception ex) { if (!BuildConfig.DEBUG) { Crashlytics.setUserIdentifier("test id"); Crashlytics.setString("param 1", "some value"); Crashlytics.setInt("param 2", 10); Crashlytics.log("message"); Crashlytics.logException(ex); } }
How about during development, we still want to log the exception. Right?
try { // some operation } catch (Exception ex) { if (!BuildConfig.DEBUG) { Crashlytics.setUserIdentifier("test id"); Crashlytics.setString("param 1", "some value"); Crashlytics.setInt("param 2", 10); Crashlytics.log("message"); Crashlytics.logException(ex); } else { // logging with SLF4J log.info("User ID=test id"); log.info("param 1=some value"); log.info("param 2= " + 10); log.error("message", ex); } }
Having blocks of code like this scattered in the codebase is starting to look a bit ugly. Also what if you wanted to replace Crashlytics with another crash reporting tool – lots of changes in the code required!
2. The Timber Way
If you use Timber for logging (and I do), another way would be to encapsulate the Crashlytics calls in a Timber.Tree which is only planted for the release build. This idea has already been mooted in various blogs and is based on the Timber example app .
public class ReleaseTree extends Timber.Tree { @Override protected void log(int priority, @Nullable String tag, @NotNull String message, @Nullable Throwable t) { // only pass on log level WARN or ERROR if (priority == Log.VERBOSE || priority == Log.DEBUG || priority == Log.INFO) { return; } Crashlytics.setInt("priority", priority); Crashlytics.setString("tag", tag); Crashlytics.setString("message", message); if (t == null) { Crashlytics.logException(new Exception(message)); } else { Crashlytics.logException(t); } } }
So for the debug build you would plant the debug Tree.
Timber.plant(new Timber.DebugTree());
For the release build, plant the Tree that uses Crashlytics instead.
Timber.plant(new ReleaseTree());
The problem here is that the overridden log method only passes a String message and (possibly) a Throwable. So how would we pass the additional data like the custom key state and user info, etc.
Well I suppose we could created a formatted String (e.g. in JSON or your own custom format) containing all that information and pass it in the String message parameter. But then just passing the formatted String to Crashlytics would end up with a long log message in the console that you would have to decipher, and you would not get the state info in that nice neat table.
For instance you would have to decipher a long String like this
|key1=test string|key2=true|key3=1|key4=2.1|key5=3.0|...|
(don’t do this, it is just a simple example)
instead of:
If you want to make full use of the Crashlytics API for custom reporting, then you would have to parse the message string and make the appropriate Crashlytics calls.
This is doable, but is starting to get a bit messy again with the need to format and parse the custom data string.
3. Just Log It
As I already want to log the exception somewhere depending on the build, could I just incorporate the Crashlytics API into the logging?
I was using SLF4J for logging anyway, in my case the SLF4J-Timber library (but would also work for SLF4J-Android ).
Logger log = LoggerFactory.getLogger(this.getClass()); try { // some operation } catch (Exception ex) { log.error("message", ex); }
I can’t extend the SLF4J Logger that is retrieved from the LoggerFactory, but I can use the decorator pattern to create a wrapper for that Logger.
public abstract class LoggerWrapper { private final Logger log; public LoggerWrapper(Class clazz) { log = LoggerFactory.getLogger(clazz); } public LoggerWrapper(String className) { log = LoggerFactory.getLogger(className); } public Logger getLogger() { return log; } public void trace(String msg) { log.trace(msg); } public void trace(String format, Object arg) { log.trace(format, arg); } // all the other delegated log methods . . . }
(Hint: In Android Studio, to handle delegating the methods in the wrapper class just use Code -> Delegate Methods… to generate the code, and you only have to delegate the methods that you intend to use.)
I can now subclass the log wrapper and add additional error methods to pass in the custom Crashlytics info.
public class ErrorLogger extends LoggerWrapper { public ErrorLogger(Class clazz) { super(clazz); } public ErrorLogger(String className) { super(className); } // additional methods for custom crash reporting @Override public void error(String userId, Map<String, String> stringState, Map<String, Boolean> boolState, Map<String, Integer> intState, Map<String, Double> doubleState, Map<String, Float> floatState, List<String> messages, Throwable t) { // Crashlytics calls here using data from the parameters // e.g. // stringState.entrySet().stream().forEach(entry -&gt; Crashlytics.setString(entry.getKey(), entry.getValue())); } }
Unfortunately, this can be a bit messy as you would have to pass in different parameters for each type of state data (String, Boolean, Integer, Double, Float). This is so you would know whether to call Crashlytics.setString(), Crashlytics.setBool(), Crashlytics.setInt(), etc.
How about encapsulating that custom data into a data class, so that we would only need to pass one parameter.
Here is one that I use:
Now I just need to pass that in the log wrapper subclass, and make the appropriate Crashlytics calls.
Of course for the debug build, I would have a different log wrapper subclass where the additional log method just logs to LogCat.
private static final String MAP_FORMAT_STRING = "{} = {}"; // additional methods for error reporting public void error(ErrorReport errorReport) { errorReport.getStateString().entrySet().stream().forEach(entry -> getLogger().error(MAP_FORMAT_STRING, entry.getKey(), entry.getValue())); errorReport.getStateBool().entrySet().stream().forEach(entry -> getLogger().error(MAP_FORMAT_STRING, entry.getKey(), entry.getValue())); errorReport.getStateInt().entrySet().stream().forEach(entry -v getLogger().error(MAP_FORMAT_STRING, entry.getKey(), entry.getValue())); errorReport.getStateDouble().entrySet().stream().forEach(entry -> getLogger().error(MAP_FORMAT_STRING, entry.getKey(), entry.getValue())); errorReport.getStateFloat().entrySet().stream().forEach(entry -> getLogger().error(MAP_FORMAT_STRING, entry.getKey(), entry.getValue())); errorReport.getMessages().stream().forEach(m -> getLogger().error(m)); errorReport.getException().ifPresent(ex -> getLogger().error("Exception:", ex)); }
Now I just have to use my log wrapper subclass instead of the SLF4J Logger.
ErrorLogger log = new ErrorLogger(this.getClass()); // instead of ... // Logger log = LoggerFactory.getLogger(this.getClass()); try { // some operation } catch (Exception ex) { ErrorReport errorReport = new ErrorReport(); errorReport.setId("test id") .addState("param 1", "some value") .addState("param 2", true) .addState("param 3", 10) .addMessage("message") .setException(ex); log.error(errorReport); }
This is just one possible way that I have chosen, please let me know if anyone has any other good ways of doing this.
Additional Things to Do
I have kept the code examples in this post as simple as possible, but you would probably have to make additional changes to incorporate these ideas. For instance you might want to disable Crashlytics in the debug build, either programmatically or in the manifest.
<meta-data android:name="firebase_crash_collection_enabled" android:value="false" />
Also it is up to you how to separate the different versions of the classes for use in different builds. I just use different source sets for release and debug.