Paul B
Paul B

Reputation: 5125

Hybrid logger that logs with OSLog and to a file on Apple systems

Apple recommends using OSLog `Logger. It is promoted on WWDC al least since 2020 (for example see WWDC 2023 Session 10226 Debug with structured logging). It is supposed to be super efficient and so on. It greatest limitation is that:

iOS has very limited facilities for reading the system log. Currently, an iOS app can only read entries created by that specific process, using .currentProcessIdentifier scope. This is annoying if, say, the app crashed and you want to know what it was doing before the crash. What you need is a way to get all log entries written by your app (r. 57880434).

Source: Your Friend the System Log

To let users send logs for a certain period even in case of crashes I couldn't find a better solution but to duplicate logging to a set of files with rotation. If Logger had some kind of delegate that would look like the best option to do some extra work at right time, but unfortunately it doesn't.

For interoperability I decided to create a logger that has exactly the same function signatures as the system one OSLog Logger. Since Logger is a struct I can't inherit it and override its functions to inject file logging along with inherited system logging.

So I decided to wrap Logger into another struct HybrydLogger and just replicate original functions with their signatures like this:

    public func error(_ message: OSLogMessage) {
        osLogger.error("\(message.description)")
        fileLogger.log(level: .error, message.description)
    }

There are only a few methods to reimplement, such as (debug, fault, and similar). But I stumbled upon a limitation which seems to be intentional.

When I call HybridLogger.shared.error("Configuration failed: \(res, privacy: .public)"), I get error: "String interpolation cannot be used in this context; if you are calling an os_log function, try a different overload."

Looks like it has something to deal with the note in the doc.

You don’t create instances of OSLogMessage directly. Instead, the system creates them for you when writing messages to the unified logging system using a Logger.

From the Swift forum:

The logging APIs use special compiler features to evaluate the privacy level at compile time. As the diagnostic says, you must use a static (i.e., known at compile time) method or property of ‘OSLogPrivacy’; it can’t be a variable that’s evaluated at run time. The implication is that you can’t create your own wrapper for these APIs without using compiler-internal features.

UPD: So the question is: what are the options to accomplish the task of persistent logging while keeping the advantages of Apple "structured logging".

Upvotes: -2

Views: 65

Answers (1)

Paul B
Paul B

Reputation: 5125

The best option I came up with for now is to replace OSLog Logger with Apple's (open source) SwiftLog which allows for multiplexing different loggers. By combining LoggingOSLog, which logs using OSLog and XCGLogger that writes to a file with rotation as MultiplexLogHandlers for the SwiftLog logger I can get the task done.

import Logging
import LoggingOSLog
import XCGLogger
import FileLogging

let xcgLogger: XCGLogger = {
    let log = XCGLogger(identifier: "advancedLogger", includeDefaultDestinations: false)
    // Setup `xcgLogger` in a way that it logs only to a file and doesn't duplicate 
    // LoggingOSLog output to console.
}

...
        LoggingSystem.bootstrap { label in
            let handlers:[LogHandler] = [
                LoggingOSLog(label: label, logLevel: .debug),
                XCGLoggerHandler(label: label, logger: xcgLogger),
            ]
            return MultiplexLogHandler(handlers)
        }
...

The advantages of such approach:

  • Minimal changes in code base. Just replace import OSLog with import Logging. Use Logger as before, just without extra formatting (i.e. `.privacy). There is a way to support same string interpolation capabilities though (see below).
  • The same colored (info, error) Xcode console output. (only warnings are displayed as info).
  • Same .logarchive logs on device. You can retrieve them if you have physical access to the device and process in Console, Instruments, Xcode. Those logs are aligned with other entries logged by other systems.

Disadvantages:

  • Xcode console function Jump to code does jump to the code, but not to the Logger's line but always to the same os_log() line in LoggingOSLog which is logical. Native OSLog OSLogMessage has some compiler related magic that serves for that, and that magic is not easy to replicate.
  • .debug(), .error() and other similar function signatures are slightly different (the don't include complex string interpolation features like .public specifier, for example) – you'll need to change the code related to Logger through all the project a bit, if you previously adopted OSLog logging. That is not easy to overcome without modifying SwiftLog:

A version of Apple's SwiftLog that adds some improved formatting for app development and includes OSLog-ish string interpolation.

Alternative straightforward approach

If it was up to me to decide I would just introduce two separate loggers and would accept the fact that I need two lines of code (one for the system logger and one for the file logger) per logging operation. Maybe it's a job for Swift macro but it may have the same limitations as wrapping os_log() into your own function.

Side notes

On desktops people try to solve this task by making their own loggers: Enhance macOS logging with syslog-ng’s native macOS system() source).

Upvotes: 0

Related Questions