7

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.

mfaani
  • 33,269
  • 19
  • 164
  • 293
Daniel Kaplan
  • 62,768
  • 50
  • 234
  • 356
  • If this is iOS 15 only, see https://steipete.com/posts/logging-in-swift/#update-ios-15, but you need to have done things before-hand. There's not much you're going to be able to do if your app is already in the field and you haven't added code to get what you need. – Rob Napier Dec 22 '21 at 20:26
  • @RobNapier yep, read that whole thing before asking my question. It doesn't mention remote logging, afaict. Also, I'm fine making a new release to add this functionality. – Daniel Kaplan Dec 22 '21 at 21:21
  • Doesn't the "When you are unable to attach a debugger.." part refer to retrieving the logs via sysdiagnose? – Bjorn B. Dec 22 '21 at 21:36
  • I'm certain there's no remote logging (a la syslog or something like that). The only question is whether you can get logs off of remote devices (by extracting them from the log store), and even that is pretty tricky. I always just build my own custom logging engine. I never found Apple to have any interest in helping non-Apple devs get access to logs. – Rob Napier Dec 22 '21 at 21:37
  • 1
    @BjornB. I've never heard of a way to get sysdiagnose output without users jumping through unworkable hoops. I don't think it's even possible without at least having a Mac (which most iOS app users don't). – Rob Napier Dec 22 '21 at 21:42
  • Strangely, from what I understood, it's just to write logs into a "file", even when "debugger is not attached". So you can access anytime the "file". But it's up to you to retrieve the data, and it seems that you want to send all the log file to yourself, and that doesn't seem to be the case for the API. It's like `CocoaLumberjack` but integrated inside Apple OS. – Larme Dec 22 '21 at 21:48
  • @RobNapier check out my answer below. – Daniel Kaplan Dec 22 '21 at 22:44

2 Answers2

3

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.

mfaani
  • 33,269
  • 19
  • 164
  • 293
Bjorn B.
  • 506
  • 3
  • 10
  • Isn't there more than one way to create an OSLogStore? Maybe those other init methods could have better persistence. – Daniel Kaplan Dec 23 '21 at 20:01
  • 1
    @DanielKaplan There also is a `init(url:)` that "Creates a log store based on a log archive". I have no clue how to use this other initializer, yet. – Bjorn B. Dec 23 '21 at 20:23
  • Yep, I saw that too. This has been around for macOS for quite a while. I keep thinking there'll be a macOS article somewhere that explains how to use it in more detail. – Daniel Kaplan Dec 23 '21 at 22:10
2

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.

mfaani
  • 33,269
  • 19
  • 164
  • 293
Daniel Kaplan
  • 62,768
  • 50
  • 234
  • 356
  • I can't remember if `getEntries` does what you think it's going to do. When I last looked at it, it didn't on iOS. But Peter Steinberger suggests it does if you're on iOS 15. (I've never had an 15-only app, so I've never tested that.) – Rob Napier Dec 22 '21 at 22:48
  • @RobNapier in what sense do you think it might do something different? FWIW, if you can cast it into a `OSLogEntryLog`, `OSLogEntryLog` extends [OSLogEntry](https://developer.apple.com/documentation/oslog/oslogentry), and [it lets you get the message](https://developer.apple.com/documentation/oslog/oslogentry/3366033-composedmessage). – Daniel Kaplan Dec 22 '21 at 22:56
  • 1
    When I last worked with it, it wouldn't actually give you any records. Give it a try. – Rob Napier Dec 22 '21 at 22:59
  • 1
    @RobNapier I made multiple logs. On my simulator I was able to to retrieve them with literally the code provided. – mfaani Jan 19 '22 at 13:36
  • 1
    Daniel, FWIW I tried using [`OSLogStoreSystem`](https://developer.apple.com/documentation/oslog/oslogstorescope/oslogstoresystem) instead of [`OSLogStoreCurrentProcessIdentifier`](https://developer.apple.com/documentation/oslog/oslogstorescope/oslogstorecurrentprocessidentifier) i.e. I tried doing `OSLogStore(scope: OSLogStore.Scope(rawValue: 0)!)`. It failed with the following error: _The operation couldn’t be completed. (Foundation._GenericObjCError error 0.)_ – mfaani Jan 19 '22 at 13:42