Logging information from iOS Widgets

December 1, 2020

Lately users have been emailing me with a few odd things happening with their Apollo iOS 14 home screen widgets, and some well-placed logs can really help with identifying what’s going wrong. iOS has a sophisticated built in logging mechanism, os_log, and now with SwiftLogger in iOS 14, but unfortunately they don’t provide an easy for users to provide you with the logs so they’re not optimal in this case.

Normally I use CocoaLumberjack for this in Apollo because a logging can be pretty complex and I like to use a battle-tested solution, but for whatever reason I cannot get it working in my Widget Extension. I’ve tried setting it up to log to the shared app group container as well as disabling async logging to no avail.

However this little logging use case in widgets is simple enough that I figure I’ll just whip up a simple little logger (per the suggestion of Brian Mueller), and I thought I’d include it here in case anyone else would benefit from it.

The main gist of it is that it writes to the shared app group container (make sure you have App Groups set up) so both the Widget Extension as well as the main app can access it. It uses just a single file (that is created if it doesn’t exist), and once it gets too long (I defined as 2MB) it trims the the older half of the logs so that the log file doesn’t bloat unnecessarily (it does this by just finding a newline near half point from the Data, rather than reading the entire String into memory). It also automatically capture the line, file, and function the issue occurs in. Per Florian Bürger be careful with using DateFormatter willy-nilly, I’m not encountering any performance issues, but if you encounter any consider caching the DateFormatter instance for reuse.

class WidgetLogger {
    static let fileURL: URL = {
        /// Write to shared app group container so both the widget and the host app can access
        return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.christianselig.apollo")!.appendingPathComponent("widget.log")
    }()
    
    static func log(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
        let dateFormatter: DateFormatter = DateFormatter()
        dateFormatter.dateFormat = "MMM d, HH:mm:ss.SSS"
        let dateString = dateFormatter.string(from: Date())
        
        let timestampedMessage = "\(dateString) [\((file as NSString).lastPathComponent)/\(function)/\(line)]: \(message)\n"
        
        guard let messageData = timestampedMessage.data(using: .utf8) else {
            print("Could not encode String to Data.")
            return
        }
        
        if FileManager.default.fileExists(atPath: fileURL.path) {
            do {
                let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
                let twoMegabytes = 2 * 1_024 * 1_024

                // In order to avoid having a log file that is enormous, trim out the oldest entries if file size is larger than 3 MB
                // (Checking file size is more performant than counting total lines each time)
                if let size = fileAttributes[.size] as? Int, size > twoMegabytes {
                    // Find the first newline after the halfway point in the file, and only keep everything past that point to trim the file
                    let logsData = try Data(contentsOf: fileURL, options: .mappedIfSafe)
                    let newlineData = "\n".data(using: .utf8)!
                    let dataSize = logsData.count
                    let halfwayPoint = Int(CGFloat(dataSize) / CGFloat(2.0))
                    
                    guard let range = logsData.range(of: newlineData, options: [], in: halfwayPoint ..< dataSize) else {
                        assertionFailure("A newline should have been found")
                        return
                    }
                    
                    let remainingLogs = logsData.subdata(in: range.endIndex ..< dataSize)
                    try remainingLogs.write(to: fileURL, options: .atomicWrite)
                }
                
                let fileHandle = try FileHandle(forWritingTo: fileURL)
                fileHandle.seekToEndOfFile()
                fileHandle.write(messageData)
                fileHandle.closeFile()
            } catch {
                print("Error trying to write to end of file: \(error)")
            }
        } else {
            do {
                try timestampedMessage.write(to: fileURL, atomically: true, encoding: .utf8)
            } catch {
                print("Error creating file to log to: \(error)")
            }
        }
    }
}

Usage:

WidgetLogger.log("Called getTimeline at \(Date())`)

You can then add a way for the user to email this file to you from within your app, I have a little “Logs” button that they can shoot over as part of troubleshooting. The code for attaching it to MFMailComposeViewController (which might not be the best choice with the iOS 14 feature of setting alternate email clients as the default, since that API doesn’t work with it yet) is:

if let data = try? Data(contentsOf: WidgetLogger.fileURL) {
    let mailViewController = MFMailComposeViewController()
    mailViewController.addAttachmentData(data, mimeType: "text/plain", fileName: WidgetLogger.fileURL.lastPathComponent)
}

Or add it as a file to a UIActivityViewController that they can share:

let activityViewController = UIActivityViewController(activityItems: [WidgetLogger.fileURL], applicationActivities: nil)

Or just make it into a String and do whatever you want with it!

String(contentsOf: WidgetLogger.fileURL, encoding: .utf8)

Anyway, that’s it! Happy logging!