How do I view logs from a REMOTE iOS device using the unified logging system?

First, it's important to know that, OSLogStore didn't work in iOS as recently as 4 months ago. Since that's so recent and documentation is so sparse, what may have been true a year ago may not be true today.

Here's some context to my question (pretty much stolen from this reddit post):

I have about a 1000 users for an open source app I developed and every now and then users will report some odd behavior.

The app uses [Logger] to log important events ... Is there a remote logging service that can be used so that I can get this info and resolve issues for users?

I only care if the unified logging system has a solution to this problem. Let's assume I'm able to converse with the user with the "odd behavior" and they are non-technical.

I've been given some hints that OSLogStore may be a way to get remote logs, but the official documentation is so sparse, I can't tell. Specifically, that init(url:) seems interesting, but maybe it only accepts file:// protocols or something.

The logging documentation says it can be used "When you are unable to attach a debugger to the app, such as when you’re diagnosing problems on a user’s machine," but nowhere does it say how to do this.


After reading the discussion in this post, I wanted to make a simple prototype to see whether it is possible to get the logs from the phone remotely. To accomplish this, I modified Steipete's code a little: I removed some code I didn't need and added a button to trigger the sending of the logs, named "Send logs to the developers".

Then, I created a codable struct called SendableLog that converted the OSLogEntryLog, making it possible to convert it to JSON. After getting the logs using getEntries() and mapping them to this new type, I converted the logs to JSON and sent an HTTP POST request to an endpoint (as suggested by @DanielKaplan) on a simple Python server I was running on my MacBook.

The Swift code (iOS 15 application):

//
//  ContentView.swift
//  OSLogStoreTesting
//
//  Created by bbruns on 23/12/2021.
//  Based on Peter Steinberger (23.08.20): https://github.com/steipete/OSLogTest/blob/master/LoggingTest/ContentView.swift.
//
import SwiftUI
import OSLog
import Combine

let subsystem = "com.bbruns.OSLogStoreTesting"

func getLogEntries() throws -> [OSLogEntryLog] {
    let logStore = try OSLogStore(scope: .currentProcessIdentifier)
    let oneHourAgo = logStore.position(date: Date().addingTimeInterval(-3600))
    let allEntries = try logStore.getEntries(at: oneHourAgo)

    return allEntries
        .compactMap { $0 as? OSLogEntryLog }
        .filter { $0.subsystem == subsystem }
}

struct SendableLog: Codable {
    let level: Int
    let date, subsystem, category, composedMessage: String
}

func sendLogs() {
    let logs = try! getLogEntries()
    let sendLogs: [SendableLog] = logs.map({ SendableLog(level: $0.level.rawValue,
                                                                 date: "\($0.date)",
                                                                 subsystem: $0.subsystem,
                                                                 category: $0.category,
                                                                 composedMessage: $0.composedMessage) })
    
    // Convert object to JSON
    let jsonData = try? JSONEncoder().encode(sendLogs)
    
    // Send to my API
    let url = URL(string: "http://x.x.x.x:8000")! // IP address and port of Python server
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.httpBody = jsonData
    
    let session = URLSession.shared
    let task = session.dataTask(with: request) { (data, response, error) in
        if let httpResponse = response as? HTTPURLResponse {
            print(httpResponse.statusCode)
        }
    }
    task.resume()
}

struct ContentView: View {
    let logger = Logger(subsystem: subsystem, category: "main")

    var logLevels = ["Default", "Info", "Debug", "Error", "Fault"]
    @State private var selectedLogLevel = 0

    init() {
        logger.log("SwiftUI is initializing the main ContentView")
    }

    var body: some View {
        return VStack {
            Text("This is a sample project to test the new logging features of iOS 15.")
                .padding()

            Picker(selection: $selectedLogLevel, label: Text("Choose Log Level")) {
                ForEach(0 ..< logLevels.count) {
                    Text(self.logLevels[$0])
                }
            }.frame(width: 400, height: 150, alignment: .center)

            Button(action: {
                switch(selectedLogLevel) {
                case 0:
                    logger.log("Default log message")
                case 1:
                    logger.info("Info log message")
                case 2:
                    logger.debug("Debug log message")
                case 3:
                    logger.error("Error log message")
                default: // 4
                    logger.fault("Fault log message")
                }
            }) {
                Text("Log with Log Level \(logLevels[selectedLogLevel])")
            }.padding()
            
            Button(action: sendLogs) {
                Text("Send logs to developers")
            }
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

I have this simple Python HTTP server listening to incoming POST requests, the IP address was set to the local IP address of my MacBook. This matches the IP address in the Swift code above.

from http.server import BaseHTTPRequestHandler, HTTPServer
import json

hostName = "x.x.x.x" # IP address of server
serverPort = 8000

class MyServer(BaseHTTPRequestHandler):
    def _set_headers(self):
        self.send_response(200)
        self.send_header('Content-type', 'text/html')
        self.end_headers()

    def do_HEAD(self):
        self._set_headers()

    def do_POST(self):
        self._set_headers()
        print("Received POST")
        self.data_string = self.rfile.read(int(self.headers['Content-Length']))

        self.send_response(200)
        self.end_headers()

        data = json.loads(self.data_string)
        print(f"JSON received: \n\n {data}")

if __name__ == "__main__":        
    webServer = HTTPServer((hostName, serverPort), MyServer)
    print("Server started http://%s:%s" % (hostName, serverPort))

    try:
        webServer.serve_forever()
    except KeyboardInterrupt:
        pass

    webServer.server_close()
    print("Server stopped.")

When I run the app and tap the Send logs to developers button, I see the following message in my terminal:

x.x.x.x - - [23/Dec/2021 13:56:47] "POST / HTTP/1.1" 200 -
JSON received:

 [{'subsystem': 'com.bbruns.OSLogStoreTesting', 'level': 3, 'composedMessage': 'SwiftUI is initializing the main ContentView', 'category': 'main', 'date': '2021-12-23 12:56:43 +0000'}]

The logs are successfully retrieved from the phone and then sent to the server.

Caveat

When I (fully) close the app and reopen it, the previous logs are gone!

When creating the log store (let logStore = try OSLogStore(scope: .currentProcessIdentifier)) the scope is set to .currentProcessIdentifier, which is the only available scope on iOS. This thread makes me believe The .system scope would include previous logs as well, but the system scope is not available on iOS.


re: @RobNapier's comment on the original post that says, “The only question is whether you can get logs off of remote devices ... and even that is pretty tricky.” I'm starting to think OSLogStore only gets local logs, but this enables you to send them anywhere, or do anything you want with them, really.

Now that OSLogStore works on iOS, you can put a button in your app labeled "Send logs to dev," where clicking it sends the logs to a custom endpoint on your server. That requires two steps:

  1. Get the local logs.

    Another part of the article you linked says:

    With OSLogStore, Apple added an API to access the log archive programmatically. It allows accessing OSLogEntryLog, which contains all the log information you’ll possibly ever need. ... Let’s look at how this works:

     func getLogEntries() throws -> [OSLogEntryLog] {
         let subsystem = Bundle.main.bundleIdentifier!
         // Open the log store.
         let logStore = try OSLogStore(scope: .currentProcessIdentifier)
    
         // Get all the logs from the last hour.
         let oneHourAgo = logStore.position(date: Date().addingTimeInterval(-3600))
    
         // Fetch log objects.
         let allEntries = try logStore.getEntries(at: oneHourAgo)
    
         // Filter the log to be relevant for our specific subsystem
         // and remove other elements (signposts, etc).
         return allEntries
             .compactMap { $0 as? OSLogEntryLog }
             .filter { $0.subsystem == subsystem }
     }
    
  2. Send them to a custom endpoint on your server. With that function in your code base, I think you can use it like this:

     let logs = getLogEntries();
     sendLogsToServer(deviceId, appVersion, ..., logs); // this is your implementation
    

The one part that gives me pause is @RobNapier said getting "logs off of remote devices ... is pretty tricky." That makes me think there is something I'm missing. Hopefully @RobNapier will point out the flaws in my thinking.