6

Swift Logging - NSHipster

 2 years ago
source link: https://nshipster.com/swift-log/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Swift Logging

Written by

Mattt

March 26th, 2020

In 2002, the United States Congress enacted the Sarbanes–Oxley Act, which introduced broad oversight to corporations in response to accounting scandals at companies like Enron and MCI WorldCom around that time. This act, PCI and HIPAA , formed the regulatory backdrop for a new generation of IT companies emerging from the dot-com bubble.

Around the same time, we saw the emergence of ephemeral, distributed infrastructure — what we now call “Cloud computing” — a paradigm that made systems more capable but also more complex.

To solve both the regulatory and logistical challenges of the 21st century, our field established best practices around application logging. And many of the same tools and standards are still in use today.

Just as print is a poor man’s debugger, it’s also a shallow replacement for a proper logging system, with distinct log levels and configurable output settings.

Sarbanes–Oxley is notable for giving rise to Yates v. United States: a delightful Supreme Court case that asked the question _“Are fish (🐟) tangible objects?”

Although the Court found in a 5 – 4 decision that fish are not, in fact, “tangible objects” (for purposes of the statute), we remain unconvinced for the same reasons articulated in Justice Kagan’s dissent (and pretty much anything written by Claude Shannon, for that matter).


This week on NSHipster, we’re taking a look at SwiftLog: a community-driven, open-source standard for logging in Swift.

Developed by the Swift on Server community and endorsed by the SSWG (Swift Server Work Group), its benefit isn’t limited to use on the server. Indeed, any Swift code intended to be run from the command line would benefit from adopting SwiftLog. Read on to learn how.

SwiftLog is distinct from the Unified Logging System (os_log), which is specific to Apple platforms.

Readers may already be familiar with os_log and its, *ahem* quirks (a topic for a future article, perhaps). But for the uninitiated, all you need to know is that os_log is for macOS and iOS apps and SwiftLog is for everything else.

If you’re interested in learning more about Unified Logging, you can get a quick overview of by skimming the os_log docs; for an in-depth look, check out “Unified Logging and Activity Tracing” from WWDC 2016 and “Measuring Performance Using Logging” from WWDC 2018.


As always, an example would be helpful in guiding our discussion. In the spirit of transparency and nostalgia, let’s imagine writing a Swift program that audits the finances of a ’00s Fortune 500 company.

import Foundation

struct Auditor {
    func watch(_ directory: URL) throws { … }
    func cleanup() { … }
}

do {
    let auditor = Auditor()

    defer { auditor.cleanup() }
    try auditor.watch(directory: URL(string: "ftp://…/reports")!,
                     extensions: ["xls", "ods", "qdf"]) // poll for changes
} catch {
    print("error: \(error)")
}

An Auditor type polls for changes to a directory (an FTP server, because remember: it’s 2003). Each time a file is added, removed, or changed, its contents are audited for discrepancies. If any financial oddities are encountered, they’re logged using the print function. The same goes for issues connecting to the FTP, or any other problems the program might encounter — everything’s logged using print.

The implementation details aren’t important; only the interface is relevant to our discussion.

Simple enough. We can run it from the command line like so:

$ swift run audit
starting up...
ERROR: unable to reconnect to FTP

# (try again after restarting PC under our desk)

$ swift run audit
+ connected to FTP server
! accounting discrepancy in balance sheet 
** Quicken database corruption! **
^C
shutting down...

Such a program might be technically compliant, but it leaves a lot of room for improvement:

  • For one, our output doesn’t have any timestamps associated with it. There’s no way to know whether a problem was detected an hour ago or last week.
  • Another problem is that our output lacks any coherent structure. At a glance, there’s no straightforward way to isolate program noise from real issues.
  • Finally, — and this is mostly due to an under-specified example — it’s unclear how this output is handled. Where is this output going? How is it collected, aggregated, and analyzed?

The good news is that all of these problems (and many others) can be solved by adopting a formal logging infrastructure in your project.


Adopting SwiftLog in Your Swift Program

Adding SwiftLog to an existing Swift package is a breeze. You can incorporate it incrementally without making any fundamental changes to your code and have it working in a matter of minutes.

Add swift-log as a Package Dependency

In your Package.swift manifest, add swift-log as a package dependency and add the Logging module to your target’s list of dependencies.

// swift-tools-version:5.1

import PackageDescription

let package = Package(
    name: "Auditor2000",
    products: [
        .executable(name: "audit", targets: ["audit"])
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-log.git", from: "1.2.0"),
    ],
    targets: [
        .target(name: "audit", dependencies: ["Logging"])
    ]
)

Create a Shared, Global Logger

Logger provides two initializers, the simpler of them taking a single label parameter:

let logger = Logger(label: "com.NSHipster.Auditor2000")

In POSIX systems, programs operate on three, predefined streams:

File Handle Description Name

0 stdin Standard Input

1 stdout Standard Output

2 stderr Standard Error

By default, Logger uses the built-in StreamLogHandler type to write logged messages to standard output (stdout). We can override this behavior to instead write to standard error (stderr) by using the more complex initializer, which takes a factory parameter: a closure that takes a single String parameter (the label) and returns an object conforming to LogHandler.

let logger = Logger(label: "com.NSHipster.Auditor2000",
                    factory: StreamLogHandler.standardError)

Alternatively, you can set default logger globally using the LoggingSystem.bootstrap() method.

LoggingSystem.bootstrap(StreamLogHandler.standardError)
let logger = Logger(label: "com.NSHipster.Auditor2000")

After doing this, any subsequent Logger instances created using the Logger(label:) initializer will default to the specified handler.

Replacing Print Statements with Logging Statements

Declaring our logger as a top-level constant lets us call it anywhere within our module. Let’s revisit our example and spruce it up with our new logger:

do {
    let auditor = Auditor()

    defer {
        logger.trace("Shutting down")
        auditor.cleanup()
    }

    logger.trace("Starting up")
    try auditor.watch(directory: URL(string: "ftp://…/reports")!,
                      extensions: ["xls", "ods", "qdf"]) // poll for changes
} catch {
    logger.critical("\(error)")
}

The trace, debug, and critical methods log a message at their respective log level. SwiftLog defines seven levels, ranked in ascending order of severity from trace to critical:

Level Description

.trace Appropriate for messages that contain information only when debugging a program.

.debug Appropriate for messages that contain information normally of use only when debugging a program.

.info Appropriate for informational messages.

.notice Appropriate for conditions that are not error conditions, but that may require special handling.

.warning Appropriate for messages that are not error conditions, but more severe than .notice

.error Appropriate for error conditions.

.critical Appropriate for critical error conditions that usually require immediate attention.

If we re-run our audit example with our new logging framework in place, we can see the immediate benefit of clearly-labeled, distinct severity levels in log lines:

$ swift run audit
2020-03-26T09:40:10-0700 critical: Couldn't connect to ftp://…

# (try again after plugging in loose ethernet cord)

$ swift run audit
2020-03-26T10:21:22-0700 warning: Discrepancy in balance sheet
2020-03-26T10:21:22-0700 error: Quicken database corruption
^C

Beyond merely labeling messages, which — don’t get us wrong — is sufficient benefit on its own, log levels provide a configurable level of disclosure. Notice that the messages logged with the trace method don’t appear in the example output. That’s because Logger defaults to showing only messages logged as info level or higher.

You can configure that by setting the Logger’s logLevel property.

var logger = Logger(label: "com.NSHipster.Auditor2000")
logger.logLevel = .trace

After making this change, the example output would instead look something like this:

$ swift run audit
2020-03-25T09:40:00-0700 trace: Starting up
2020-03-26T09:40:10-0700 critical: Couldn't connect to ftp://…
2020-03-25T09:40:11-0700 trace: Shutting down

# (try again after plugging in loose ethernet cord)

$ swift run audit
2020-03-25T09:41:00-0700 trace: Starting up
2020-03-26T09:41:01-0700 debug: Connected to ftp://…/reports
2020-03-26T09:41:01-0700 debug: Watching file extensions ["xls", "ods", "qdf"]
2020-03-26T10:21:22-0700 warning: Discrepancy in balance sheet
2020-03-26T10:21:22-0700 error: Quicken database corruption
^C
2020-03-26T10:30:00-0700 trace: Shutting down

Using Multiple Logging Handlers at Once

Thinking back to our objections in the original example, the only remaining concern is what we actually do with these logs.

According to 12 Factor App principles:

XI. Logs

[…]

A twelve-factor app never concerns itself with routing or storage of its output stream. It should not attempt to write to or manage logfiles. Instead, each running process writes its event stream, unbuffered, to stdout.

Collecting, routing, indexing, and analyzing logs across a distributed system often requires a constellation of open-source libraries and commercial products. Fortunately, most of these components traffic in a shared currency of syslog messages — and thanks to this package by Ian Partridge, Swift can, as well.

That said, few engineers have managed to retrieve this information from the likes of Splunk and lived to tell the tale. For us mere mortals, we might prefer this package by Will Lisac, which sends log messages to Slack.

The good news is that we can use both at once, without changing how messages are logged at the call site by using another piece of the Logging module: MultiplexLogHandler.

import struct Foundation.ProcessInfo
import Logging
import LoggingSyslog
import LoggingSlack

LoggingSystem.bootstrap { label in
    let webhookURL = URL(string:
        ProcessInfo.processInfo.environment["SLACK_LOGGING_WEBHOOK_URL"]!
    )!
    var slackHandler = SlackLogHandler(label: label, webhookURL: webhookURL)
    slackHandler.logLevel = .critical

    let syslogHandler = SyslogLogHandler(label: label)

    return MultiplexLogHandler([
        syslogHandler,
        slackHandler
    ])
}

let logger = Logger(label: "com.NSHipster.Auditor2000")

Harkening to another 12 Factor principle, we pull the webhook URL from an environment variable rather than hard-coding it.

With all of this in place, our system will log everything in syslog format to standard out (stdout), where it can be collected and analyzed by some other system.


But the real strength of this approach to logging is that it can be extended to meet the specific needs of any environment. Instead of writing syslog to stdout or Slack messages, your system could send emails, open SalesForce tickets, or trigger a webhook to activate some IoT device.

Granted, each of those examples would probably be better served by a separate monitoring service that ingests a log stream and reacts to events according to a more elaborate set of rules.

Here’s how you can extend SwiftLog to fit your needs by writing a custom log handler:

Creating a Custom Log Handler

The LogHandler protocol specifies the requirements for types that can be registered as message handlers by Logger:

protocol LogHandler {
    subscript(metadataKey _: String) -> Logger.Metadata.Value? { get set }
    var metadata: Logger.Metadata { get set }

    var logLevel: Logger.Level { get set }

    func log(level: Logger.Level,
             message: Logger.Message,
             metadata: Logger.Metadata?,
             file: String, function: String, line: UInt)
}

In the process of writing this article, I created custom handler that formats log messages for GitHub Actions so that they’re surfaced on GitHub’s UI like so:

github-actions-ui-3fe079aeea203a7d579a873d74ef310d9d4cfb9522604675179a9ddb243756e0bd269aa4472ab82ccb9712f33f513aae528112cbabec46ad7b6072c329be00a4.png

For more information, see “Workflow commands for GitHub Actions.”

If you’re interested in making your own logging handler, you can learn a lot by just browsing the code for this project. But I did want to call out a few points of interest here:

Conditional Boostrapping

When bootstrapping your logging system, you can define some logic for how things are configured. For logging formatters specific to a particular CI vendor, for example, you might check the environment to see if you’re running locally or on CI and adjust accordingly.

import Logging
import LoggingGitHubActions
import struct Foundation.ProcessInfo

LoggingSystem.bootstrap { label in
    // Are we running in a GitHub Actions workflow?
    if ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] == "true" {
        return GitHubActionsLogHandler.standardOutput(label: label)
    } else {
        return StreamLogHandler.standardOutput(label: label)
    }
}

Testing Custom Log Handlers

Testing turned out to be more of a challenge than originally anticipated. I could be missing something obvious, but there doesn’t seem to be a way to create assertions about text written to standard output. So here’s what I did instead:

First, create an internal initializer that takes a TextOutputStream parameter, and store it in a private property.

Swift symbols have internal access control by default; the keyword is included here for clarity.

public struct GitHubActionsLogHandler: LogHandler {
    private var outputStream: TextOutputStream

    internal init(outputStream: TextOutputStream) {
        self.outputStream = outputStream
    }

    …
}

Then, in the test target, create a type that adopts TextOutputStream and collects logged messages to a stored property for later inspection. By using a @testable import of the module declaring GitHubActionsLogHandler, we can access that internal initializer from before, and pass an instance of MockTextOutputStream to intercept logged messages.

import Logging
@testable import LoggingGitHubActions

final class MockTextOutputStream: TextOutputStream {
    public private(set) var lines: [String] = []

    public init(_ body: (Logger) -> Void) {
        let logger = Logger(label: #file) { label in
            GitHubActionsLogHandler(outputStream: self)
        }

        body(logger)
    }

    // MARK: - TextOutputStream

    func write(_ string: String) {
        lines.append(string)
    }
}

With these pieces in place, we can finally test that our handler works as expected:

func testLogging() {
    var logLevel: Logger.Level?
    let expectation = MockTextOutputStream { logger in
        logLevel = logger.handler.logLevel

        logger.trace("🥱")
        logger.error("😱")
    }

    XCTAssertGreaterThan(logLevel!, .trace)
    XCTAssertEqual(expectation.lines.count, 1) // trace log is ignored
    XCTAssertTrue(expectation.lines[0].hasPrefix("::error "))
    XCTAssertTrue(expectation.lines[0].hasSuffix("::😱"))
}

As to how or where messages are logged, SwiftLog is surprisingly tight-lipped. There’s an internal type that buffers writes to stdout, but it’s not exposed by the module.

If you’re in search for a replacement and would prefer not to copy-paste something as involved as that, here’s a dead-simple alternative:

struct StandardTextOutputStream: TextOutputStream {
    mutating func write(_ string: String) {
        print(string)
    }
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK