Logging in Swift


Logging is one of the rare cases when you could probably justify having a singleton, but with Swift Protocol Extension you don’t need to.

Let’s integrate Logging in a way that:

  • Doesn’t cause 3rd party dependencies to leak across your codebase
  • Hides the existence of singleton from the codebase
  • Supports writing fully testable code
  • Ability to suppress logs from specific modules or classes

Interface

Protocol oriented programming is a very useful technique for Swift source, we can create protocols and provide default implementations for whole or specific parts of it.

Let’s declare our interfaces first:

public enum LogTag: String {
    case Observable
    case Model
    case ViewModel
    case View
}

public enum LogLevel: Int {
    case Verbose = 0
    case Debug = 1
    case Info = 2
    case Warning = 3
    case Error = 4
}

public protocol Loggable {
    var defaultLoggingTag: LogTag { get }

    func log(level: LogLevel, @autoclosure _ message: () -> Any, _ path: String, _ function: String, line: Int)
    func log(level: LogLevel, tag: LogTag, @autoclosure message: () -> Any, _ path: String, _ function: String, line: Int)
}
  • LogTag is an enumeration declaring the specific module in our application.
  • LogLevel is enumeration for the severity of the log.
  • log is a function that we will use for logging, one version will use underlying type defaultLoggingTag and the other one allows us to provide it explicitly.

Default implementation

We want to provide default implementation for our logging function, and only require the users of our protocol to provide defaultLoggingTag:

public extension Loggable {
    func log(level: LogLevel, @autoclosure _ message: () -> Any, _ path: String = #file, _ function: String = #function, line: Int = #line) {
        log(level, tag: defaultLoggingTag, message: message, path, function, line: line)
    }

    func log(level: LogLevel, tag: LogTag, @autoclosure message: () -> Any, _ path: String = #file, _ function: String = #function, line: Int = #line) {
        Logger.sharedInstance.log(level, tag: tag, className: String(self.dynamicType), message: message, path, function, line: line)
    }
}

Logger singleton

We need to provide implementation of Logger that our protocol extension is supposed to use:

protocol LoggerType {
    func log(level: LogLevel, tag: LogTag, className: String, @autoclosure message: () -> Any, _ path: String, _ function: String, line: Int)
}

final class Logger {
    internal var activeLogger: LoggerType?
    internal var disabledSymbols = Set<String>()
    private(set) static var sharedInstance = Logger()

    /// Overrides shared instance, useful for testing
    static func setSharedInstance(logger: Logger) {
        sharedInstance = logger
    }

    func setupLogger(logger: LoggerType) {
        assert(activeLogger == nil, "Changing logger is disallowed to maintain consistency")
        activeLogger = logger
    }

    func ignoreClass(type: AnyClass) {
        disabledSymbols.insert(String(type))
    }

    func ignoreTag(tag: LogTag) {
        disabledSymbols.insert(tag.rawValue)
    }

    func log(level: LogLevel, tag: LogTag, className: String, @autoclosure message: () -> Any, _ path: String, _ function: String, line: Int) {
        guard logAllowed(tag, className: className) else { return }
        activeLogger?.log(level, tag: tag, className: className, message: message, path, function, line: line)
    }

    private func logAllowed(tag: LogTag, className: String) -> Bool {
        return !disabledSymbols.contains(className) && !disabledSymbols.contains(tag.rawValue)
    }
}

Our Logger instance is only responsible for log suppressing symbols, as this is functionality often missing from external loggers.

It doesn’t log message anywhere directly, instead, it forwards it to specific LoggerType implementation:

  • We can use any 3rd party logger solution we want, and changing it, later on, requires no changes in the codebase, other than the adapter itself.
  • We can provide a fake logger for writing tests:
final class LoggerStub: LoggerType {

    var prototype: LogMessagePrototype?
    var flushTimeout: Int64?

    func log(level: LogLevel, tag: LogTag, className: String, @autoclosure message: () -> Any, _ path: String, _ function: String, line: Int) {
        prototype = LogMessagePrototype(level: level, tag: tag, className: className, message: "\(message())", path: path, function: function, line: line)
    }

    func flush(secondTimeout: Int64) {
        flushTimeout = secondTimeout
    }
}

let setup: () -> (sut: Logger, stub: LoggerStub) = {
    let sut = Logger()
    let stub = LoggerStub()
    sut.setupLogger(stub)
    Logger.setSharedInstance(sut)
    return (sut, stub)
}

func testLoggableLogWithDefaultTagArgumentIsForwarded() {
    let (_, stub) = setup()
    let expected = LogMessagePrototype(level: .Warning, tag: defaultLoggingTag, className: String(self.dynamicType), message: "Test", path: "path", function: "function", line: 66)
    let actual: LogMessagePrototype? = {
        self.log(expected.level, expected.message, expected.path, expected.function, line: expected.line)
        return stub.prototype
    }()

    XCTAssertEqual(expected, actual)
}

func testIgnoredTagsAreHonored() {
    let (sut, stub) = setup()
    let expected: LogMessagePrototype? = nil
    let actual: LogMessagePrototype? = {
        sut.ignoreTag(.Model)
        sut.log(.Info, tag: .Model, className: "", message: "", "", "", line: 0)
        return stub.prototype
    }()

    XCTAssertEqual(expected, actual)
}

Note: LogMessagePrototype is just a struct containing all params the function accepts.

If you are working on a Model layer in your app, and you don’t care about UI logs, you can create a user breakpoint on the configuration point and do:


A simple implementation that fulfills all of our initial requirements. It’s also an example of how you can create testable protocol extensions.