Nabil Kazi Mobile Team Lead | Product Designer

Logging and remote error reporting in mobile apps

8 min read 2307

Introduction

Logging, as well as remote crash and error reporting frameworks, have been around for a while now. The use of both these frameworks is quite different depending upon the case.

In this article, we will cover the uses of both of these types of frameworks, including problems in the release builds of our mobile apps and some suggested solutions. I also include a centralized framework which will help us avoid these problems and get the most out of logging and remote error reporting.

Logging frameworks

First, let’s define what exactly logging and error reporting frameworks do.
Ever used the log statements in Android or the print statements in iOS? They are logging frameworks. They allow us, the developers, to print pretty much anything in the console window of our IDEs.

Need to check the value of a variable within a method? Log it.
Need to check the API response? Log it.
Need to check the API JSON parsing error? Log it.
Need to check the error exceptions in Catch blocks? Log it.
And the list goes on.

The most common usage of logs is while debugging. Currently, all major IDEs come equipped with built in debuggers. It allows the developers to add breakpoints and navigate through the code. It also allows us to access the variable values while stepping through the code.

Still, a vast number of developers depend on the traditional logging approach! Don’t believe me? See these memes for yourself:

Meme about debugging Drake meme about debugging

Apart from the loggers available by default in both Java and Swift, there are various log frameworks built on top of them. These frameworks extend the capabilities of loggers and their uses. Common examples are Timber (Android), Willow (iOS), and CocoaLumberjack (iOS).

Now that we have a fair idea about what logging frameworks are, let’s move on to crash and error reporting frameworks.

Crash and error reporting frameworks

We use logs while an app is in development. Developers use them to access variable values at each stage, identify crashes, and debug the issue. Log outputs are visible in the IDE’s console.

We made a custom demo for .
No really. Click here to check it out.

So what about getting error and crash reports while the app is already in production?

Let’s consider a scenario: You have tested your app thoroughly on your device, then publish the app in its respective store. A few users complain about app crashes or functionalities not working on their device.

What would you do here?

Because there are a vast number of device manufacturers, operating systems, custom ROMs, and device sizes, it’s almost impossible to test an app across all of these permutations and combinations. This leaves room for possible errors in the production environment. But how can you debug such errors when you don’t have access to the physical device?

Thankfully, some tools let us do this. Firebase Crashlytics is a popular tool. Once integrated into an app, it automatically captures the app crash reports and saves them on the console. The developers can then easily access these log reports and debug the error.

It also allows us to capture non-fatal errors and logs from our app. These can be API error responses, catch exceptions, or whatever we wish to log.

What’s the difference?

If you’ll notice, there’s something common here in both these frameworks. You see, the main purpose of both logging frameworks and crash and error reporting frameworks is debugging errors. The primary difference is that one is used during development and the other in production.

Now that we have an understanding of both of these framework types and their uses, let’s learn about what problems we might face once we start using them in the traditional approach. Once we understand the problem, we’ll be in a better position to devise a solution.

Problems and solutions with remote error reporting

Problem 1: Exposure of sensitive log messages in release builds

If your mobile apps have gone through vulnerability assessment and penetration testing (VAPT), you might have come across this one vulnerability: “Log messages reveal sensitive information. Disable loggers in production builds.”

This is very common while in development. We log the API responses and catch errors and other variables. What we forget is how to remove these log commands before creating the production build.

If someone plugs their device into the computer and observes the logs printed in the console, they might be able to view everything that we have logged. This can include sensitive parameters, entire API responses, or other private information.

Even if we do remember to remove these log commands, we’ll have to remove or comment out these loggers manually across the entire source code. A hectic and repetitive process!

Solution 1: Debug and release environment-based logging

With the build-type of the app, whether it’s a release build or a debug, we can control which log statements need to be printed in the console and which can be ignored. Using this we can forget worrying about logging sensitive information in the production apps.

Problem 2: API issues and non-fatal errors in production

Most of our mobile apps are powered by data from remote APIs. If the expected data structure doesn’t match that of the API response coded in the app, the functionality dependent on it may fail.

But, when an app is in production and an API structure change like this happens, our app’s functionality won’t work. How would we know about such scenarios earlier, so that we can release a fix before it affects too many users? Do we keep monitoring the entire functionality of the app daily? Do we wait for someone to report?

No, we can’t do that! What we need is a process in which we can report and get notified of these issues as soon as possible.

Solution 2: Log-level-based remote error reporting

Firebase Crashlytics, with its custom error reporting, provides a solution: We need to identify the level of our logs. Some can be just informational, some can be an error, some can be for debugging.

The API errors, for example, would fall in the “error” category. We can devise a logic in which we share the log statements with the correct level as “error” to our Firebase remote error reporting. In this way, we can track the non-fatal yet functionality-breaking issues and address them as quickly as possible.

But, does that mean we would have to write this code everywhere across the app? This takes us to our next problem…

Problem 3: Scattered code and maintainability

Problems one and two have a few viable solutions: Adding build flags and using Firebase Crashlytics for remote error logging. But implementing them around each log statement wouldn’t be a good solution.

Our log statements are scattered across the entire app. While debugging, we end up releasing a flurry of log statements into our code. I know this because I’m guilty of doing it. We can’t go on adding our custom logic around each of these log statements.

Let’s look at it from a code maintainability perspective, too. What happens when we want to change the logic of our loggers? Do we go on changing it around every log statement across the entire codebase? No way! We code to make the lives of our users easier. Why not make ours too?

Solution 3: Centralized logging framework based on build type and log level

Now, the missing piece: We need all of our above solutions to work hand in hand. A single class that will control both the build-type-based and the log-level-based logs, and no repeated if-else logic around every log statement in the codebase. This will avoid code scattering and help in code maintainability and scalability.

Let’s build a framework around the log-levels and build-types, including which statements should be executed where and when.

Log Level Log Level – Usage Build Type Console Remote Log
Error A non-fatal error has occurred, and caused the app’s functionality to break, e.g. a wrong JSON format. The app cannot parse this format and hence the functionality of the app stopped working. Debug ✔️
Release ✔️
Warning An unexpected error has occurred in the app that shouldn’t have occurred in the first place, e.g. a device-specific exception in a function, or code moving into a catch block that wasn’t expected. Debug ✔️
Release ✔️
Info Log messages added to observe the behavior of the app, e.g. screen opened or closed, the API call returned successfully, or DB queries returning success. Debug ✔️
Release
Debug Log messages that are added to debug a particular error, e.g. variable values or API responses values. Debug ✔️
Release

Now that we have the solution designed, let’s move ahead quickly and check the implementation of the same in both Android and iOS.

We’ll be using existing third-party logging frameworks that will help us create loggers based on the build type during runtime. For remote error reporting, we will be using Firebase Crashlytics. You can learn more about customizing your crash reports with Crashlytics here.

The blueprint for both implementations goes like this:

  1. Create build-type-specific loggers using a third-party logging framework
  2. Add our log-level logic in the release loggers
  3. Replace traditional log statements with our custom ones

Android

For creating build-type specific loggers, we will be using one of the best logging libraries in Android: Timber. If you are already using it, great! If not, I highly recommend using this in your projects. We will be creating our log-level-based error reporting framework using the capabilities that Timber provides.

Please note that I am skipping the integration details of Timber and Firebase Crashlytics. It is best described on their official pages, which I have linked in this section.

Let’s dive into creating our framework.

First, let’s implement the build-type logic in the framework initialization. We will be using two different loggers: One for debug mode, and the other for release. The release mode logger will be our custom one:

public class App extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        if (BuildConfig.DEBUG) {
            Timber.plant(new Timber.DebugTree());
        }
        else {
            Timber.plant(new LoggingController());
        }
    }
}

Now, let’s implement our custom remote logger for the release mode, which we mentioned above. This will contain the log-level logic:

public class LoggingController extends Timber.Tree
{
    @Override protected void log(int logLevel, String tag, @NonNull String message, Throwable t)
    {
        if (logLevel == Log.ERROR || logLevel == Log.WARN) {
            FirebaseCrashlytics.getInstance().recordException(t);
        }else{
            return;
        }
    }
}

Let’s check the example usage:

Timber.d("Test debug message");
Timber.i("Test info message");
Timber.w(new RuntimeException(), "Test warning message");
Timber.e(new RuntimeException(),"Test error message");

Instead of using Log.d() or Log.e(), we will now have to use the Timber.d() or Timber.e(). The rest will be handled by our framework!

iOS

In iOS, to implement build-type specific loggers, we will be using Willow. Created by Nike, it’s one of the best Swift implementations of a custom logger.

We will be creating our log-level-based error reporting framework using the capabilities that Willow provides.

Please note that, like with our previous Android implementation, I am skipping the integration details of Willow and Firebase Crashlytics. It is best described on their official pages, which I have linked previously in this article.

Let’s dig straight into creating our framework.

First, let’s implement the build-type logic in the framework configuration. We will be using two different loggers: One for debug mode and the other for release. The release mode logger will be our custom one:

var logger: Logger!
public struct LoggingConfiguration {

func configure() {
        #if DEBUG
        logger = buildDebugLogger()
        #else
        logger = buildReleaseLogger()
        #endif
    }

    private func buildReleaseLogger() -> Logger {
        let consoleWriter = LoggingController.sharedInstance
        let queue = DispatchQueue(label: "serial.queue", qos: .utility)
        return Logger(logLevels: [.error,.warn], writers: [consoleWriter],executionMethod: .asynchronous(queue: queue))
    }

    private func buildDebugLogger() -> Logger {
        let consoleWriter = ConsoleWriter()
        return Logger(logLevels: [.all], writers: [consoleWriter], executionMethod: .synchronous(lock: NSRecursiveLock()))
    }
}

Now, let’s implement our custom remote logger for the release mode, which we mentioned above. This will have the log-level logic:

open class LoggingController: LogWriter{
    static public var sharedInstance = LoggingController()
    static public var attributeKey = "error"
    private init(){}

    public func writeMessage(_ message: String, logLevel: LogLevel) {
        // Since this is a release logger, we won't be using this...
    }

    public func writeMessage(_ message: LogMessage, logLevel: LogLevel) {
        if logLevel == .error || logLevel == .warn{
            if let error = message.attributes[LoggingController.attributeKey] as? Error{
                 Crashlytics.crashlytics().record(error: error)
            }
        }
    }
}
extension Error{
    func getLogMessage()->LogMessage{
        return ErrorLogMessage(name: "Error", error: self)
    }
}
struct ErrorLogMessage: LogMessage {
    var name: String
    var attributes: [String: Any]

    init(name:String,error:Error) {
        self.name = name
        self.attributes = [LoggingController.attributeKey:error]
    }
}

We’ll have to initialize this framework in AppDelegate:

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        FirebaseApp.configure()
        LoggingConfiguration().configure()
        return true
    }
}

You can see the example usage here:

// Debug Logs
logger.debugMessage("Logging Debug message")

// Info Logs
logger.infoMessage("Logging Info message")

// Error & Warning Logs
let logMessage = getSampleErrorObj().getLogMessage()
logger.error(logMessage)

func getSampleErrorObj()->Error{
    let userInfo = [] // You can add any relevant error info here to help debug it
    return NSError.init(domain: NSCocoaErrorDomain, code: -1001, userInfo: userInfo)
}

So instead of using the traditional print() command, we would now have to use the logger.debugMessage() or logger.error(), for example. Everything else is handled by our framework!

Conclusion

We did it! We built our remote error reporting and logging framework. Well, not exactly a framework, but more like a “wrapper” framework that extends upon the capabilities of existing libraries.

Because this is our custom implementation, and the entire logic resides in a single controller, we can extend its capability any time to add more filters and enhance our loggers. This should also keep our code clean and help with maintainability.

I hope you learned something new and useful today. Keep learning and building, and happy logging!

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Nabil Kazi Mobile Team Lead | Product Designer

Leave a Reply