ju ka

Logging in Swift

In this blogpost I want to talk about a logging framework in Swift.

The Swift Server Work Group (SSWG) accepted in 2019 the proposal of SwiftLog. Let's have a deeper look at it now. 🤓

How?

The swift-log module contains 2 parts, which we will have a look at in the next sections. Let's first have a look at how to produce log entries. Afterwards we dive into handling these logs and how there two parts are connected.

Produce Logs

To get started with logging, we first need an instance of Logger.

We can create an instance of the struct by calling the public initializer like this:

import Logging

let logger = Logger(label: "TestLogger")

I didn't want to repeat the Logger creation code so I came up with a little protocol and this default implementation:

import Logging

public protocol Log {
    static var log: Logger { get }
}

public extension Log {
    static var log: Logger {
        Logger(label: String(describing: self))
    }

    var log: Logger {
        Self.log
    }
}

With this protocol in place you can use your Logger in a static or entity context 🚀:

struct ItemLoader: Log {
    static func create() -> ItemLoader {
        log.trace("Initializing ItemLoader!")
        return ItemLoader()
    }
    
    func loadItem(completion: @escaping () -> Void) {
        log.trace("Start loading items...")
    }
}

Now we can use our logger and add it in our codebase. One thing you should keep in mind is, that this Log protocol uses a computed property that creates a new Logger instance on every call. This wasn't an issue for me since Logger is a lightweight struct but it creates a new LogHandler from the LoggingSystem factory which we'll come back later. So keep that caveat in mind if you use it on a hot code path 🔥.

Since a little while I use assertionFailure() more and more in my codebases. That's why I added a little extension to Logger.

errorAndAssert() and criticalAndAssert() are great ways to get notified directly in debug builds of your app. In your production code you will (only) receive an error in your logs since the assertionFailure() has no effect in release builds.

public extension Logger {
    func errorAndAssert(_ message: @autoclosure () -> Logger.Message,
                        metadata: @autoclosure () -> Logger.Metadata? = nil,
                        source: @autoclosure () -> String? = nil,
                        file: StaticString = #file,
                        function: StaticString = #function,
                        line: UInt = #line) {
        error(message(), metadata: metadata(), file: "\(file)", function: "\(function)", line: line)
        assertionFailure(message().description, file: file, line: line)
    }

    func criticalAndAssert(_ message: @autoclosure () -> Logger.Message,
                           metadata: @autoclosure () -> Logger.Metadata? = nil,
                           source: @autoclosure () -> String? = nil,
                           file: StaticString = #file,
                           function: StaticString = #function,
                           line: UInt = #line) {
        critical(message(), metadata: metadata(), file: "\(file)", function: "\(function)", line: line)
        assertionFailure(message().description, file: file, line: line)
    }
}

One more thing: Metadata

A good logging system should be able to handle metadata and forward it to the LogHandler.

swift-log will get you covered 🥷🏻! At first I was a little confused by the way the SSWG chose to handle metadata. If you want to add some metadata to your log entries I encourage you to have a look at these examples. Basically, you should write your metadata like this:

log.trace("Some trace log entry.", metadata: ["value": "42"])

Handle Logs

So now we know how to send logs in the blackbox logging system but how will they get handled?

The framework introduces a LogHandler for this purpose. There are already some LogHandler implementations mentioned in the swift-log README which is a great starting point for your research.

In PDF Archiver I use Sentry.io as a crash reporter. Since it really helps if you get a little context of the situation the user was in, while a crash has happend, I created the SentryBreadcrumbLogger. It is an implementation of the LogHandler protocol. Essentially, it creates a Breadcrumb that contains all information that a entry log contains.

import Sentry

let crumb = Breadcrumb()
crumb.level = sentryLevel
crumb.category = "\(file) \(function):\(line)"
crumb.message = message.description
crumb.data = metadata?.reduce(into: [String: Any]()) { (result, metadata) in
    result[metadata.key] = metadata.value
}
crumb.timestamp = Date()
SentrySDK.addBreadcrumb(crumb: crumb)

But how do we connect the Logger with one (or even more) LogHandler implementations?

The LoggingSystem will take care of this part. It's an enum that contains a bootstrap method which must only be called once. Let's have a look at an example first:

LoggingSystem.bootstrap { label in
    # Factory that makes a `StreamLogHandler` to directs its output to `stdout`
    StreamLogHandler.standardOutput(label: label)
}

In this basic example the closure that was passed to the bootstrap() method defines the factory which creates a LogHandler when it is needed. You can even add multiple LogHandlers by wrapping them in a MultiplexLogHandler which is great when you want to see your logs on the console and also send them to another entity, e.g. save them as breadcrumbs.

LoggingSystem.bootstrap { label in
    let logLevel: Logger.Level = .trace
    var sysLogger = StreamLogHandler.standardOutput(label: label)
    sysLogger.logLevel = logLevel
    let sentryLogger = SentryBreadcrumbLogger(metadata: [:], logLevel: logLevel)
    return MultiplexLogHandler([sysLogger, sentryLogger])
}

Why?

Ok let's recap: we know how to create a Logger instance and that LoggingSystem.bootstrap() defines a factory that returns a LogHandler.

You might ask yourself: Why did you writing about a logging system? 🤓

Granted, this is not fancy topic but I like to learn on examples and maybe these ones are useful for someone else too. But I also want to add the statement: Your libraries should make use of swift-log!

Your library can (or should) create internal Logger instances and create logs with different log levels as you think it is necessary. If some app developer uses your library and calls the LoggingSystem.bootstrap() function on startup, he will get all logs in one system and can pipe them to whatever LogHandler is useful for him. With swift-log the Swift community has a great API that can be used in different frameworks and across platforms!

Pros

  • Standardized framework that everyone can use
  • Easy to implement (LogHandler)
  • Easy to use/write log entries (Logger)

Cons

  • High performance paths might better use OSLog
  • Fixed log level for all subsystems, e.g. you can't set the level to error for a third-party library you use

Sources

Tagged with: