Architecting a Logging Service for iOS Apps

Understanding how your apps behave in production is a fundamental part of our jobs as iOS engineers. We need to gather log events in order to investigate and reproduce issues that customers run into. Here's how you create a log service with a clean architecture that's modular and easily extensible.

Written by 

Logging is where you collect information about the behavior of your app. This is especially useful in production where you often need insights into issues your customers may be experiencing. In recent years, the ELK stack has gained worldwide adoption on both frontend and backend as a great solution for remote logging. While remote logging is important, this article will demonstrate local logging to an external file.

Overview of the system

Let's have a brief overview of what we're going to implement. For our solution, we're going to have a few goals:

  • It should be easy to use logging from anywhere. You should only need one line of code to log something.
  • It should support any underlying provider of logging (external file, ELK/Logstash, etc.)
  • It should be scalable and applicable even for enterprise apps.

For this, I propose an architecture with modularization and the right level of abstractions. Take a look at the following diagram:

This architecture will get the job done. The view controllers in our app should only know about the log service which acts as an abstraction to the underlying providers there may be.

Log Service

First off, our log events will be based on this LogEvent enum. It contains the various types of log events which we will use for different purposes.

public enum LogEvent: String {
    case info = "ℹ️" // some information
    case debug = "📝" // something to debug
    case verbose = "📣" // debugging on steroids
    case warning = "⚠️" // not good, but not fatal
    case error = "☠️" // this is fatal
}

Now, onto the log service that the view controllers interact with:

public final class LogService {
    private static var providers = [LogProvider]()
    
    static let shared = LogService(providers: providers)
    
    private init(providers: [LogProvider]) {
        LogService.providers = providers
    }
    
    static func register(provider: LogProvider) {
        providers.append(provider)
    }
    
    func info(_ object: Any, filename: String = #file, funcName: String = #function, line: Int = #line) {
        LogService.providers.forEach {
            $0.log(.info, message: ("\(object)"), file: LogService.fileName(filePath: filename), function: funcName, line: line)
        }
    }
    
    func debug(_ object: Any, filename: String = #file, line: Int = #line, funcName: String = #function) {
        LogService.providers.forEach {
            $0.log(.debug, message: ("\(object)"), file: LogService.fileName(filePath: filename), function: funcName, line: line)
        }
    }
    
    func verbose(_ object: Any, filename: String = #file, line: Int = #line, funcName: String = #function) {
        LogService.providers.forEach {
            $0.log(.verbose, message: ("\(object)"), file: LogService.fileName(filePath: filename), function: funcName, line: line)
        }
    }
    
    func warning(_ object: Any, filename: String = #file, line: Int = #line, funcName: String = #function) {
        LogService.providers.forEach {
            $0.log(.warning, message: ("\(object)"), file: LogService.fileName(filePath: filename), function: funcName, line: line)
        }
    }
    
    func error(_ object: Any, filename: String = #file, line: Int = #line, funcName: String = #function) {
        LogService.providers.forEach {
            $0.log(.error, message: ("\(object)"), file: LogService.fileName(filePath: filename), function: funcName, line: line)
        }
    }
    
    private static func fileName(filePath: String) -> String {
        let components = filePath.components(separatedBy: "/")
        return components.isEmpty ? "" : components.last!
    }
}

The log service has a log function for each type of log events that will send off log events to any registered providers. Initially, you should call the register function to add the log provider(s) you want to send events to.

Log Providers

You need to implement the log provider you want to go with. This could be the console, an external file, or a remote logging service such as ELK/Logstash for collecting log events.

This is the protocol that all providers should conform to:

public protocol LogProvider {
    func log(_ event: LogEvent, message: String, file: String, function: String, line: Int)
}

This FileLogProvider struct is the concrete implementation of the protocol.

public struct FileLogProvider: LogProvider {
    
    private var dateFormatter: DateFormatter
    private var fileWriter: FileWriter
    
    public init(dateFormatter: DateFormatter, fileWriter: FileWriter) {
        self.dateFormatter = dateFormatter
        self.fileWriter = fileWriter
    }
    
    public func log(_ event: LogEvent, message: String, file: String, function: String, line: Int) {
        fileWriter.write("[\(event.rawValue) \(dateFormatter.getCurrentDateAsString()) \(file):\(function):\(line)] \(message)")
    }
}

You also need to pass a date formatter to your provider to fit your country or region. It will inherit from the standard DataFormatter class in swift:

public final class LogDateFormatter: DateFormatter {
    convenience init(dateFormat: String = "yyyy-MM-dd HH:mm:ssSSS") {
        self.init()
        self.dateFormat = dateFormat
        self.locale = Locale.current
        self.timeZone = TimeZone.current
    }
}

extension DateFormatter {
    func getCurrentDateAsString() -> String {
        return self.string(from: Date())
    }
}

Also, our FileLogProvider needs to write to an external file. This responsibility exists in a FileWriter class that we will implement. First, let's define the protocol:

public protocol FileWriter {
    func write(_ message: String)
}

The concrete FileWriter implementation used by the LogFileProvider should look like this:

public final class LogFileWriter: FileWriter {
    
    private var filePath: String
    private var fileHandle: FileHandle?
    private var queue: DispatchQueue
    
    init(filePath: String) {
        self.filePath = filePath
        self.queue = DispatchQueue(label: "Log File")
    }
    
    deinit {
        fileHandle?.closeFile()
    }
    
    public func write(_ message: String) {
        queue.sync(execute: { [weak self] in
            if let file = self?.getFileHandle() {
                let printed = message + "\n"
                if let data = printed.data(using: String.Encoding.utf8) {
                    file.seekToEndOfFile()
                    file.write(data)
                }
            }
        })
    }
    
    private func getFileHandle() -> FileHandle? {
        if fileHandle == nil {
            let fileManager = FileManager.default
            if !fileManager.fileExists(atPath: filePath) {
                fileManager.createFile(atPath: filePath, contents: nil, attributes: nil)
            }
            
            fileHandle = FileHandle(forWritingAtPath: filePath)
        }
        
        return fileHandle
    }
}

That's it. Next, we'll look at how you use this in your project.

Usage

When your application launches, you want to register your log provider(s) and pass the log service to one of your view controllers to be used.

Here's an example where this is done in SceneDelegate:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) {
        ... scene setup ...
        
        let logDateFormatter = LogDateFormatter(dateFormat: "yyyy-MM-dd HH:mm:ssSSS")
        LogService.register(provider: ConsoleLogProvider(dateFormatter: logDateFormatter))
        LogService.register(provider: FileLogProvider(dateFormatter: logDateFormatter,
                                                      fileWriter: LogFileWriter(filePath: "/Users/andreaslydemann/Desktop/TodoAppLog.txt")))

        let categoryVC = CategoryViewController(coreDataConnection: .shared, logService: .shared)
        
        window.rootViewController = UINavigationController(rootViewController: categoryVC)
        window.makeKeyAndVisible()
    }
}

Now, we can use the log service to create log statements. In this example, we are logging when a new category is being added in a todo app:

guard let categoryTitle = textField.text else { return }
            
let category = Category(title: categoryTitle)
categories.append(category)

self.logService.info("Category was added.")

Go ahead and try it out. You should be able to see any log events in your providers like this:

[ℹ️ 2020-06-06 19:47:15393 CategoryViewController.swift addButtonPressed(_:):135] Category was added.

Conclusion

We looked at how to use logging to gain a better understanding of how your apps behave in the hands of your customers. You can check out the source code that we covered here. Note that the sample code is a basic todo app without any modularization matching the diagram. That's an exercise for you when you implement this.

Do you want to become an iOS architect? Check out iOS Architect Accelerator.

Share this post

Facebook
Twitter
LinkedIn
Reddit

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.