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 LogHandler
s 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