iosswiftlogging

OSLog (Logger) giving errors with simple string interpolation


I'm trying to update my app to use OSLog (Logger). The system I'm currently using allows me to use simple string interpolation and I was expecting the same with OSLog, but I'm seeing all types of errors on a simple test:

import SwiftUI
import OSLog

extension Logger {
    static let statistics = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NS")
}

struct MyCustom: CustomStringConvertible {
    let description = "My Custom description"
}

struct MyDebug: CustomDebugStringConvertible {
    let debugDescription = "My Debug description"
}

struct NoneOfTheAbove {
    var defaultValue = false
}

struct Person: Identifiable {
    let id = UUID()
    
    let index: Int
    let name: String
    let age: Int
    
    static let maxNameLength = 15
}

@main
struct OggTstApp: App {
    let myCustom = MyCustom()
    let myDebug = MyDebug()
    let noneOfTheAbove = NoneOfTheAbove()
    var optionalCustom: MyCustom?
    var optionalDebug: MyDebug? = MyDebug()
    
    init() {
        print("init")
        Logger.statistics.debug("debug init")
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                   testLogs()
                }
        }
    }
    
    func testLogs() {
        print("structs")
        Logger.statistics.error("\(myCustom)")
        
//        Logger.statistics.error("This is a test: \(myDebug)") // Type of expression is ambiguous without a type annotation
        let string = "\(myDebug)"
        Logger.statistics.error("\(string)")
        
//        Logger.statistics.error(noneOfTheAbove) // Cannot convert value of type 'NoneOfTheAbove' to expected argument type 'OSLogMessage'
//        Logger.statistics.error("\(noneOfTheAbove)") // Type of expression is ambiguous without a type annotation
        let noneOTA = "\(noneOfTheAbove)"
//        Logger.statistics.error(noneOTA) // Cannot convert value of type 'String' to expected argument type 'OSLogMessage'
        Logger.statistics.error("\(noneOTA)")
        
//        Logger.statistics.warning(optionalCustom) // Cannot convert value of type 'MyCustom?' to expected argument type 'OSLogMessage'
        let optCust = "\(optionalCustom)" // Warning
        Logger.statistics.warning("\(optCust)")
        
//        Logger.statistics.log("Optional not nil: \(optionalDebug)") // No exact matches in call to instance method 'appendInterpolation'
        let optNotNil = "\(optionalDebug)" // Warning
        Logger.statistics.log("\(optNotNil)")
        
        let aPerson = Person(index: 2, name: "George", age: 21)
        let people = [aPerson]
        
        people.forEach {
            testLog($0)
        }
    }
    
    func testLog(_ person: Person) {
        Logger.statistics.debug("\(person.index) \(person.name) \(person.id) \(person.age)")
//        Logger.statistics.debug("\(person.index) \(person.name, align: .left(columns: Person.maxNameLength)) \(person.id) \(person.age, format: .fixed(precision: 2))") // No exact matches in call to instance method 'appendInterpolation'
    }
}

Dong that double string interpolation to make it work feels really painful. The warnings are kind of expected, although I wish I could write some extensions to make them go away, but for now my focus are the errors.

Am I doing something wrong? Is there a trick to this? Btw, I'm only using these logs in the console, I don't care to much about being able to retrieve them (I'm ok with keeping interpolated string values as private, in case that matters).


Solution

  • Two observations:

    1. String interpolation in Logger’s OSLogMessage requires CustomStringConvertible conformance. (See below.) So we would generally just extend any types that we want to log to conform to CustomStringConvertible and be done with it. And that eliminates the need for you to create temporary strings for logging purposes.

    2. The problem with the Person example is a little different: You are using an OSLogFloatFormatting option (the precision parameter) with a non-float type. Given that you are dealing with an integer type, the idea of specifying the number of decimal places does not make sense.


    Regarding the CustomStringConvertible conformance requirement, see the definition of interpolation with the OSLogInterpolation:

    extension OSLogInterpolation {
    
        /// Defines interpolation for values conforming to CustomStringConvertible. The values
        /// are displayed using the description methods on them.
        ///
        /// Do not call this function directly. It will be called automatically when interpolating
        /// a value conforming to CustomStringConvertible in the string interpolations passed
        /// to the log APIs.
        ///
        /// - Parameters:
        ///   - value: The interpolated expression conforming to CustomStringConvertible.
        ///   - align: Left or right alignment with the minimum number of columns as
        ///     defined by the type `OSLogStringAlignment`.
        ///   - privacy: A privacy qualifier which is either private or public.
        ///     It is auto-inferred by default.
    
        public mutating func appendInterpolation<T>(
            _ value: @autoclosure @escaping () -> T, 
            align: OSLogStringAlignment = .none, 
            privacy: OSLogPrivacy = .auto
        ) where T : CustomStringConvertible
    
         …
    }
    

    The success of your MyCustom example (which is the only one that conforms to CustomStringConvertible) illustrates this. Also, this CustomStringConvertible conformance requirement is discussed in the 2020 WWDC video Exploring logging in Swift. But it does not support CustomDebugStringConvertible.

    Now, it seems that the elegant solution would be to extend OSLogInterpolation to support interpolation of other types (e.g. CustomDebugStringConvertible). But, having tried that, that results in is a compiler error error that suggests that they have chosen to explicitly forbid that:

    /…/MyApp/ContentView.swift:70:53: error: invalid log message; extending types defined in the os module is not supported
            Logger.statistics.error("MyDebug: \(myDebug)") // Type of expression is ambiguous without a type annotation
                                                        ^
    

    That having been said, you could write a Logger extension to accept other values/strings, explicitly setting privacy to the .private:

    import os.log
    
    extension Logger {
        public func error<T: CustomStringConvertible>(value: T) {
            error("\(value, privacy: .private)")
        }
    
        public func error<T: CustomDebugStringConvertible>(value: T) {
            error("\(value.debugDescription, privacy: .private)")
        }
    
        public func error(string: String) {
            error("\(string, privacy: .private)")
        }
    
        …
    }
    

    You could repeat this pattern for warning, log, etc.

    Anyway, assuming you have a Logger instance, logger, you could then do things like:

    logger.error(value: myCustom)
    logger.error(value: myDebug)
    logger.error(string: "My debug: \(myDebug)")
    logger.error(string: "\(noneOfTheAbove)")
    logger.error(value: optionalCustom)
    logger.error(value: optionalDebug)
    

    All that having been said, I confess this is not a pattern that I would be inclined to adopt. There are two issues:

    1. The whole motivating idea of OSLogMessage (rather than letting these methods take a String parameter) is to be able to specify privacy settings for individual values within the broader Logger message. You suggest that you are OK with that, but it is a shame to unnecessarily lose this aspect of logging messages.

    2. One of my favorite features in Xcode 15 is the ability to control-click (or right-click) on an Xcode logging message and choose “Jump to Source”. Once you start using this feature, it becomes an invaluable part of the debugging process. (E.g., “hey, I see an error reported in the Xcode console; let me jump to the offending code.”)

      If you call Logger’s built-in methods, it will take you to the appropriate point in the code. But, if you call one of the above extension methods, Xcode will take you to the Logger extension, not where the error was actually reported.