Fetching OSLog Messages in Swift

Using the OSLog framework for logging in your Apps? How do you retrieve those logs at runtime to show them in your App? Here’s what worked for me.

Last updated: Apr 12, 2024

Swift Logging - A Recap

I’ve written about using the OSLog unified logging framework to write log messages from Swift. Here’s a quick recap:

  1. Import the OSLog framework:

    import OSLog
    
  2. Create a Logger object for each distinct area of your App you want to create logs for:

    private let logger = Logger(subsystem: "com.useyourloaf.LogMaker",
      category: "AppModel")
    
  3. Create log entries at the appropriate level:

    logger.debug("Initialising AppModel")
    logger.error("Oops, unexpected error")
    

Those log messages show up in the Xcode console and the system console on macOS but I sometimes want an easy way for an iOS user to retrieve diagnostic logs from within my App.

Fetching Logs Programmatically

Apple’s documentation is somewhat brief about retrieving logs programmatically:

Use the OSLog framework to access logged messages programmatically.

Luckily Peter Steinberger shared some sample code that helped me fill in the gaps.

  1. Start with an instance of OSLogStore created with a scope set to the current process identifier:

    import OSLog
    let store = try OSLogStore(scope: .currentProcessIdentifier)
    
  2. Next get a position in the log store for the starting date to fetch entries from:

    let position = store.position(date: date)
    
  3. Then to get the entries back from the store starting at the provided position:

    let entries = try store.getEntries(at: position,
      matching: predicate)
    

I’ll explain the use of the predicate below. It’s optional, if you omit it you get back a sequence of OSLogEntry records representing all the logs in the store for our process. That can take some time on slower devices. You’re probably going to want to filter the results.

Notes:

I should say before going further that there are some considerable limitations and issues in using OSLogStore.

  • There is an initial options parameter which allows you to specify reverse order but I’m yet to get it to work.
  • I don’t see entries returned for logs written before the last app launch regardless of the position I specify. That seems like a serious limitation.
  • Fetching the logs is slow, even with a predicate filtering the results.

For an open-source alternative see Apple’s SwiftLog package.

Filtering With Predicates

The OSLogStore method to get entries can accept an NSPredicate to filter the log entries it returns. I’ve seen reports of it not working in earlier releases but I’ve not had problems testing on iOS 17. If you’re a long time Cocoa programmer you may be familiar with NSPredicate, the archived documentation is still available.

The macOS man page for log (type man log in the macOS terminal) also has a useful section on predicate based filtering showing the field names you can use. Here’s a list of the most common ones valid for OSLog entries:

  • eventType - Valid types include logEvent for entries created with the OSLog Logger API and signpostEvent for os_signpost entries.
  • eventMessage - The event message text
  • messageType - For OSLog entries this is the log level (default, info, debug, error, or fault).
  • process - The name of the process
  • subsystem - The OSLog subsystem
  • category - The OSLog category (combine this with subsystem)

Here’s some examples to get you started:

// All entries with a single matching subsystem
let predicate = NSPredicate(format: "subsystem == %@",
  "com.apple.coredata")
// All entries with a subsystem from a set of subsystems
let predicate = NSPredicate(format: "subsystem IN %@", [
  "com.apple.coredata",
  "com.useyourloaf.MyApp"
])
// Subsystem matching a prefix
let predicate = NSPredicate(format: "subsystem BEGINSWITH %@", 
  "com.useyourloaf")
// Entries in a subsystem with a category from a set
let predicate = NSPredicate(format:
  "(subsystem == %@) && (category IN %@)", 
  "com.useyourloaf.MyApp", ["AppModel", "SceneModel"])
// Errors in a subsystem
let predicate = NSPredicate(format:
  "(subsystem == %@) && (messageType == %@)",
  "com.apple.coredata", "error")
// Entries in a subsystem with a message containing a string
let predicate = NSPredicate(format:
  "(subsystem == %@) && (eventMessage CONTAINS %@)",
  "com.apple.coredata", "NSCocoaErrorDomain")

Remember that debug level logs are not saved in production so you’ll only see them if they happen to still be in memory when you fetch the logs.

Formatting Log Entries

I created a static method in an extension of Logger. The method accepts a start date and a predicate format string used to filter the logs. The method returns an array of formatted logs or throws an error:

extension OSLogEntryLog.Level {
  fileprivate var description: String {
    switch self {
    case .undefined: "undefined"
    case .debug: "debug"
    case .info: "info"
    case .notice: "notice"
    case .error: "error"
    case .fault: "fault"
    @unknown default: "default"
    }
  }
}

extension Logger {
  static public func fetch(since date: Date,
    predicateFormat: String) async throws -> [String] {
    let store = try OSLogStore(scope: .currentProcessIdentifier)
    let position = store.position(date: date)
    let predicate = NSPredicate(format: predicateFormat)
    let entries = try store.getEntries(at: position,
      matching: predicate)
    
    var logs: [String] = []
    for entry in entries {
      try Task.checkCancellation()
      if let log = entry as? OSLogEntryLog {
        logs.append("""
          \(entry.date):\(log.subsystem):\
          \(log.category):\(log.level.description): \
          \(entry.composedMessage)\n
          """)
      } else {
        logs.append("\(entry.date): \(entry.composedMessage)\n")
      }
    }
    
    if logs.isEmpty { logs = ["Nothing found"] }
    return logs
  }
}

Notes:

  • NSPredicate is not Sendable so I pass in a predicate format string that I use to recreate the predicate.
  • Since I expect to call this method from a task and I can’t control how many log messages I might get back I’m checking for task cancellation when looping over the log entries.
  • When formatting the logs for display the entry property names are not the same as the fields used when writing the predicate.
  • The entry date and composedMessage properties come from the base OSLogEntry class.
  • For other properties, conditionally cast the entry to the OSLogEntryLog class. This is a subclass of OSLogEntry that provides the log level and via OSLogEntryWithPayload protocol conformance the category and subsystem for OSLog entries.

Showing the Logs

Finally, here’s an example SwiftUI view to show the logs:

struct LogView: View {
  @State private var text = "Loading..."
  
  var body: some View {
    ScrollView {
      Text(text)
        .textSelection(.enabled)
        .fontDesign(.monospaced)
        .padding()
    }
    .task {
      text = await fetchLogs()
    }
  }
}

The fetchLogs method uses a template to create the predicate substituting values at runtime. The predicate matches any of my own logs (based on matching a prefix of the subsystem) or any from Apple’s Core Data framework at the error or fault level:

extension LogView {
  static private let template = NSPredicate(format:
  "(subsystem BEGINSWITH $PREFIX) || ((subsystem IN $SYSTEM) && ((messageType == error) || (messageType == fault)))")

  @MainActor
  private func fetchLogs() async -> String {
    let calendar = Calendar.current
    guard let dayAgo = calendar.date(byAdding: .day,
      value: -1, to: Date.now) else {
      return "Invalid calendar"
    }
      
    do {
      let predicate = LogView.template.withSubstitutionVariables(
        [
            "PREFIX": "com.useyourloaf",
            "SYSTEM": ["com.apple.coredata"]
        ])

      let logs = try await Logger.fetch(since: dayAgo,
        predicateFormat: predicate.predicateFormat)
      return logs.joined()
    } catch {
      return error.localizedDescription
    }
  }
}

Sample output of SwiftUI log view

Learn More