Writing a third-generation log browser using SwiftUI: 1 Getting log entries

The most difficult feature of macOS is getting to grips with its log. I know developers of the highest calibre and advanced users who simply can’t make any sense of it, and I’ve heard of researchers who’d rather disassemble code than try reading the log.

My first attempt to open access to the Unified log was an AppleScript-based app named LogLogger, released here a month after Apple unleashed the new log system on us in Sierra, nearly eight years ago.

That developed into Consolation, written in Swift 3 and released in its first beta four months later.

With Consolation in version 3, at the end of 2019, I released the first beta of Ulbow, with its cleaner interface, filters and colour formatting of log entries, the second generation.

Over those years, each app has relied on calling the log show command and parsing resulting extracts in JSON. This is largely because Apple didn’t provide an API with direct access to read the Unified log until macOS Catalina five years ago. That API is Spartan in comparison with the riches of log show, and appears to be little-used or exemplified. The time has come now to switch from parsing JSON to grappling direct with macOS, in a third generation of log utilities based on OSLog.

Get log entries with OSLog

On the face of it, getting entries from the log is straightforward using OSLogStore.getEntries(). This takes two important parameters: an OSLogPosition indicating the starting point for the sequence of entries, and an NSPredicate to be used to filter those entries. An outline sequence of calls is:
let store = try OSLogStore.local()
to select the local active log store, rather than a logarchive saved previously.
entries = try store.getEntries(with: [], at: secsInterval, matching: pred)
where secsInterval is an OSLogPosition and pred is an NSPredicate, or nil to fetch all log entries. This returns a sequence of OSLogEntry entries.

A quick glance at the documentation for OSLogEntry, though, shows that it contains only three fields out of the many accessible in each log entry:

composedMessage, a string containing the message field for that entry
date, a Date containing the timestamp of that entry
storeCategory, an enumeration setting how long that entry should be retained in the log.

It looks as if OSLog provides only very basic access to the log, until you consider the other classes descended from OSLogEntry. Together with two protocols, these actually expand to support five different types of log entry, of which three contain many fields. This is because different classes of log entry contain different fields, as shown in the diagram below.

In any sequence of entries, most will be OSLogEntryLog, with many Signposts of OSLogEntrySignpost class holding 15 fields in all. OSLogEntryBoundary are least frequent, and only likely to be encountered in the announcement at the start of the kernel boot process.

To separate these out from the sequence of entries returned by OSLogStore.getEntries(), you therefore have to determine which class each entry belongs to, with code such as
for ent in entries {
if let log = ent as? OSLogEntryLog {
// process OSLogEntryLog class
} else if let log = ent as? OSLogEntryActivity {
// process OSLogEntryActivity (Activity) class
} …
and so on.

To store extracted field data for each log entry, I thus create a LogEntry structure with a variable to contain data for each of the fields available, and each entry is used to complete one of those structures in a sequence. That and my code are provided in the Appendix at the end of this article.

Time period

The next challenge in accessing the log using OSLog is specifying the time period for which you want entries. This appears enigmatic as OSLogStore.getEntries() doesn’t allow you to specify two time points, merely a location in the log as an OSLogPosition, which comes in three flavours:

a specified date and time point
a time interval since the latest log entry
a time interval since the last boot

Using any of those three, passed as a parameter to OSLogStore.getEntries(), entries will continue until they reach the end of the log. To illustrate how to use these in practice, here are two examples.

If you want log entries between 04:31:15 and 04:32:30, you can obtain the OSLogPosition for 04:31:15, getEntries() using that, check the timestamp on each log entry until the first for 04:32:30 appears, then return out of the iteration. Alternatively, you could set the OSLogPosition at the end and use the OSLogEnumerator.Options to iterate in reverse (although others have reported that doesn’t work).

If you want the last 12 seconds of log entries, you can set the TimeInterval since the latest log entry to -12.0 seconds, and use the OSLogPosition to start iterations 12 seconds ago. Here you’ll want to keep track of the total entries returned and perhaps limit that to 1,000 or 10,000, or your code might blow up somewhere, and that’s simple to do using a loop count and return.

Predicates

Those difficulties pale in comparison to the last of the parameters to OSLogStore.getEntries(), the predicate. This is simplest when you want all entries, as you then pass nil. For this article, I’ll give the next simplest solution, a single predicate specifying the subsystem. This can be generated using
let pred = NSPredicate(format: “subsystem == %@”, predicate)
where predicate is a String containing the name of the subsystem, such as com.apple.Bluetooth.

LogUI

I have now wrapped this in a simple SwiftUI interface as a demonstration, in LogUI (to rhyme with doggy), available as a notarized app from here: logui01

This gives you control over three parameters:

a subsystem to use as a predicate
the number of seconds to go back from the current end of the log, to start collecting from, as explained above
the maximum number of log entries to display

as configured in its Settings. Its single-page Help file explains the colour code used to display extracts.

In the next article I’ll consider how best to display log extracts using SwiftUI, and how I arrived at the solution in LogUI.

Reference

OSLog, Apple Developer Documentation

Appendix: Source code

struct LogEntry: Identifiable {
let id = UUID()
var type: Int = 0
var activityIdentifier: String = “”
var category: String = “”
var composedMessage: String = “”
var date: String = “”
var level: String = “”
var parentActivityIdentifier: String = “”
var process: String = “”
var processIdentifier: String = “”
var sender: String = “”
var signpostIdentifier: String = “”
var signpostName: String = “”
var signpostType: String = “”
var storeCategory: String = “”
var subsystem: String = “”
var threadIdentifier: String = “”
}

func getMessages() -> Bool {
var predicate = UserDefaults.standard.string(forKey: “predicate”) ?? “”
let period = UserDefaults.standard.string(forKey: “period”) ?? “-5.0”
let maxCount = UserDefaults.standard.string(forKey: “maxCount”) ?? “1000”
let theMaxCount = Int(maxCount) ?? 1000
var theSecs = Double(period) ?? –5.0
if theSecs > 0.0 {
theSecs = -theSecs
}
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = “yyyy-MM-dd HH:mm:ss.SSSZ”
do {
let store = try OSLogStore.local()
var theCount = 0
let secsInterval = store.position(timeIntervalSinceEnd: theSecs)
var entries: AnySequence<OSLogEntry>
logList = []
if !predicate.isEmpty {
let pred = NSPredicate(format: “subsystem == %@”, predicate)
entries = try store.getEntries(with: [], at: secsInterval, matching: pred)
} else {
entries = try store.getEntries(with: [], at: secsInterval, matching: nil)
}
for ent in entries {
var theLineEntry = LogEntry()
if let log = ent as? OSLogEntryLog {
theLineEntry.type = 1
theLineEntry.category = log.category
// etc.
} else if let log = ent as? OSLogEntryActivity {
// etc.
} else {
let log = ent as OSLogEntry
theLineEntry.type = 10
theLineEntry.composedMessage = log.composedMessage
theLineEntry.date = dateFormatter.string(from: log.date)
theLineEntry.storeCategory = “(log.storeCategory.rawValue)”
}
if (theLineEntry.type != 4) && (theLineEntry.type != 0) {
logList.append(theLineEntry)
theCount += 1
}
if theCount > theMaxCount {
return true
}
}
return true
} catch {
return false
}
}