Choosing the Right Logging Library for Android App
While choosing a logging library for an Android app, I explored multiple options, seeking a flexible solution that is highly configurable, supports multiple logging destinations, and can be dynamically configured at runtime. In this post, I'll share the factors that influenced my decision, some problems I encountered, and the solution I ended up with.
The requirements
When selecting a logging solution for an Android app, several factors need to be considered to ensure it meets the specific requirements of both the platform and the project. Here are the key aspects I considered for the project I was working on (I'm fully aware your needs may be different):
-
Android Compatibility: The library must work on Android, which has certain Java-related limitations. Not all Java APIs are available (e.g. System.Logger), not everything works on low Android versions (such as Lollipop), and some standard logging libraries depend on features unsupported by Android.
-
Testability: The logging solution should not interfere with unit testing by requiring additional setup or mocks. Ideally, it should get out of the way, allowing tests to run without having to mock or provide stubs for the logging framework. However, it wouldn't hurt if I could enable some simplified logs in tests to facilitate troubleshooting when needed.
-
Modularity: The library should work well across a large codebase split into multiple modules, including non-Android modules (plain Kotlin modules). Also, I wouldn't like the whole codebase to become tightly coupled with the particular logging implementation.
-
Multiple Logging Destinations: It should support multiple logging destinations, such as Logcat, remote services (like Logstash, DataDog, or Crashlytics), and local files. I don't want to clutter my code with direct usages of all the APIs (e.g.
Firebase.crashlytics.log("message")
). A single log statement should do the job. -
Configuration Flexibility: The library should offer a high degree of configuration flexibility to meet various needs. For example, I needed to:
- configure different logging level thresholds for each destination;
- configure different formats of log records for each destination;
- handle some logs in a special manner
- e.g. report a "non-fatal" error to Crashlytics whenever there's an exception associated with a log record.
-
Runtime Configurability: The framework should be configurable at runtime, allowing features such as logging destinations or logging level thresholds to be changed based on user choices, feature flags, or other dynamic conditions.
-
Proven and Stable: Last but not least, it would be ideal to use a well-established, battle-tested solution that does not compromise the app's stability or maintainability. Logging is crucial because if anything else in the app malfunctions, the logs are what I rely on to investigate the issue. If the logging system itself is unreliable, debugging other problems becomes significantly more difficult.
Given these complex requirements, developing a custom logging solution seemed to be a huge maintenance burden so I decided to explore existing libraries.
Why Not Use Timber?
While Timber is a popular logging solution for Android, it doesn't meet my specific needs. For example, it has a direct dependency on the Android framework, making it unsuitable for use in non-Android modules. Even if there was a multiplatform variant of Timber, it wouldn't meet other criteria related to configuration flexibility.
Actually, the same can be said about the vast majority of logging libraries I reviewed.
Why Not Something New?
I realize there are a lot of logging libraries out there. Some of them are multiplatform, which by definition means that I should be able to make them work in both Android and plain Kotlin modules. However, the solutions I found were either not as well-established as I wished for or not flexible enough. I hope to see Kotlin Multiplatform logging libraries receive more attention in the future.
Considering JUL (java.util.logging)
At some point, I seriously considered using java.util.logging
(JUL), but after reading the documentation and several discussions (such as this one) that made strong arguments in favor of SLF4J over JUL, I decided to focus on SLF4J.
Exploring SLF4J and Its Backends
I turned to SLF4J (Simple Logging Facade for Java), which I was already quite familiar with (thanks to our own, simple implementation: slf4android), but I needed to determine the most suitable backend (AKA "binding") for my needs. After some research, I found that the most flexible options were Logback and Log4j2. While comparing their capabilities, I even learned about some awesome features I would get for free (such as Mapped Diagnostic Context) if only I could make them work in my project.
By the way, using SLF4J brings more benefits, such as the ability to process logs created by other libraries, potentially giving even more context for troubleshooting production issues. I think this diagram explains it well:
There are certainly more benefits to using SLF4J, but I'm not going to cover all of them in this post.
Log4j2: A Dead End for Android
Log4j2 initially seemed promising, especially when I learned about its Kotlin-friendly API, but I quickly ran into compatibility issues with Android. When I added the dependency, the build failed with the following error:
MethodHandle.invoke and MethodHandle.invokeExact are only supported starting with Android O (--min-api 26): Lorg/apache/logging/log4j/util/ServiceLoaderUtil;callServiceLoader(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/Class;Ljava/lang/ClassLoader;Z)Ljava/lang/Iterable;
I discovered that Log4j2 requires Java 8 so I hoped that enabling core library desugaring would solve the problem, but it turned out that these specific APIs are not supported below Android O.
Since I couldn't increase the minimum Android API level to 26, I moved on to Logback.
Logback: Initial Challenges
At first, I encountered a similar issue with Logback: the latest versions require Java 11 and APIs unavailable on Android. This resulted in a runtime crash with the error:
java.lang.NoSuchMethodError: No virtual method getModule()Ljava/lang/Module; in class Ljava/lang/Class; or its super classes (declaration of 'java.lang.Class' appears in /apex/com.android.art/javalib/core-oj.jar)
I also found an Android-specific fork of Logback that resolves some of these compatibility issues and provides features tailored for Android. However, this fork introduced several problems, such as:
- Lack of Clarity on Compatibility: There is ambiguity regarding its compatibility with the classic Logback library; the versioning scheme was changed at some point without explanation.
- No Binary Compatibility: The fork is not binary-compatible with the classic Logback library, which can make running unit tests for logging configuration on the JVM (that is, without Robolectric) either impossible or cumbersome (issue reference).
- Unoptimized ProGuard Rules: I realized the current version contains unoptimized ProGuard rules that unnecessarily keep much more code than I needed (it almost completely disables code shrinking for the whole library).
- Limited Maintenance: The author (a sole maintainer) seems to have limited time to address the existing problems, as noted in a discussion thread.
The Solution: Logback 1.3.x
Eventually, I discovered from the Logback documentation that the older 1.3.x
branch of Logback is compatible with Java 8 and appears to be maintained. After switching to this version, I made a few necessary adjustments, such as adding ProGuard rules:
# We don't use servlets in Android so R8 will erase that piece of
# Logback from the release APK. We can safely ignore the warning
# about a missing class.
-dontwarn javax.servlet.ServletContainerInitializer
# When Logback parses the log patterns such as "%msg" it looks
# for the appropriate converters mapped to the keywords (such as "msg")
# and instantiates them via reflection. Therefore, all the classes
# which implement Converter interface must be kept in the release APK.
-keep class * extends ch.qos.logback.core.pattern.Converter
and modifying packaging options:
packaging.resources.excludes.add("META-INF/INDEX.LIST")
I also kept the desugaring enabled.
With these changes, I successfully integrated SLF4J and Logback into my Android project, and it allowed me to cover all the requirements with ease.
For the record, here's the link to the specific Logback dependency I used.