+ initial BaggageContextCarrier types for "framework contexts"

This commit is contained in:
Konrad `ktoso` Malawski 2020-07-09 11:13:38 +09:00 committed by Konrad `ktoso` Malawski
parent c78c490045
commit fa304c69a5
9 changed files with 779 additions and 8 deletions

16
Package.resolved Normal file
View File

@ -0,0 +1,16 @@
{
"object": {
"pins": [
{
"package": "swift-log",
"repositoryURL": "https://github.com/apple/swift-log.git",
"state": {
"branch": null,
"revision": "57c6bd04256ba47590ee2285e208f731210c5c10",
"version": "1.3.0"
}
}
]
},
"version": 1
}

View File

@ -4,14 +4,34 @@ import PackageDescription
let package = Package(
name: "swift-baggage-context",
products: [
.library(name: "Baggage", targets: ["Baggage"])
.library(name: "Baggage",
targets: [
"Baggage"
]
),
.library(name: "BaggageLogging",
targets: [
"BaggageLogging"
]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-log.git", from: "1.3.0")
],
targets: [
.target(
name: "Baggage",
dependencies: []
),
.target(
name: "BaggageLogging",
dependencies: [
.product(name: "Logging", package: "swift-log")
]
),
// ==== --------------------------------------------------------------------------------------------------------
// MARK: Tests
@ -22,6 +42,14 @@ let package = Package(
]
),
.testTarget(
name: "BaggageLoggingTests",
dependencies: [
"Baggage",
"BaggageLogging"
]
),
// ==== --------------------------------------------------------------------------------------------------------
// MARK: Performance / Benchmarks
@ -29,8 +57,9 @@ let package = Package(
name: "Benchmarks",
dependencies: [
"Baggage",
"BaggageLogging",
"SwiftBenchmarkTools",
]
]
),
.target(
name: "SwiftBenchmarkTools",

View File

@ -35,7 +35,7 @@
/// Libraries may also want to provide an extension, offering the values that users are expected to reach for
/// using the following pattern:
///
/// extension BaggageContext {
/// extension BaggageContextProtocol {
/// var testID: TestIDKey.Value {
/// get {
/// self[TestIDKey.self]
@ -44,7 +44,7 @@
/// }
/// }
/// }
public struct BaggageContext {
public struct BaggageContext: BaggageContextProtocol {
private var _storage = [AnyBaggageContextKey: ValueContainer]()
/// Create an empty `BaggageContext`.
@ -60,10 +60,9 @@ public struct BaggageContext {
}
}
public var baggageItems: [AnyBaggageContextKey: Any] {
// TODO: key may not be unique
self._storage.reduce(into: [:]) {
$0[$1.key] = $1.value.value
public func forEach(_ callback: (AnyBaggageContextKey, Any) -> Void) {
self._storage.forEach { key, container in
callback(key, container.value)
}
}
@ -82,6 +81,32 @@ extension BaggageContext: CustomStringConvertible {
}
}
public protocol BaggageContextProtocol {
/// Provides type-safe access to the baggage's values.
///
/// Rather than using this subscript directly, users are encouraged to offer a convenience accessor to their values,
/// using the following pattern:
///
/// extension BaggageContextProtocol {
/// var testID: TestIDKey.Value {
/// get {
/// self[TestIDKey.self]
/// } set {
/// self[TestIDKey.self] = newValue
/// }
/// }
/// }
subscript<Key: BaggageContextKey>(_ key: Key.Type) -> Key.Value? { get set }
/// Iterates over the baggage context's contents invoking the callback one-by one.
///
/// - Parameter callback: invoked with the type erased key and value stored for the key in this baggage.
func forEach(_ callback: (AnyBaggageContextKey, Any) -> Void)
}
// ==== ------------------------------------------------------------------------
// MARK: Baggage keys
/// `BaggageContextKey`s are used as keys in a `BaggageContext`. Their associated type `Value` gurantees type-safety.
/// To give your `BaggageContextKey` an explicit name you may override the `name` property.
public protocol BaggageContextKey {

View File

@ -0,0 +1,54 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Baggage Context open source project
//
// Copyright (c) 2020 Moritz Lang and the Swift Baggage Context project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
// ==== ----------------------------------------------------------------------------------------------------------------
// MARK: Framework Context Protocols
/// Framework context protocols may conform to this protocol if they are used to carry a baggage object.
///
/// Notice that the baggage context property is spelled as `baggage`, this is purposefully designed in order to read well
/// with framework context's which often will be passed as `context: FrameworkContext` and used as `context.baggage`.
///
/// Such carrier protocol also conforms to `BaggageContextProtocol` meaning that it has the same convenient accessors
/// as the actual baggage type. Users should be able to use the `context.myValue` the same way if a raw baggage context,
/// or a framework context was passed around as `context` parameter, allowing for easier migrations between those two when needed.
public protocol BaggageContextCarrier: BaggageContextProtocol {
/// The underlying `BaggageContext`.
var baggage: BaggageContext { get set }
}
extension BaggageContextCarrier {
public subscript<Key: BaggageContextKey>(baggageKey: Key.Type) -> Key.Value? {
get {
self.baggage[baggageKey]
} set {
self.baggage[baggageKey] = newValue
}
}
public func forEach(_ callback: (AnyBaggageContextKey, Any) -> Void) {
self.baggage.forEach(callback)
}
}
/// A baggage itself also is a carrier of _itself_.
extension BaggageContext: BaggageContextCarrier {
public var baggage: BaggageContext {
get {
self
}
set {
self = newValue
}
}
}

View File

@ -0,0 +1,110 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Baggage Context open source project
//
// Copyright (c) 2020 Moritz Lang and the Swift Baggage Context project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import Baggage
import Logging
// ==== ----------------------------------------------------------------------------------------------------------------
// MARK: BaggageContext (as additional Logger.Metadata) LogHandler
/// Proxying log handler which adds `BaggageContext` as metadata when log events are to be emitted.
public struct BaggageMetadataLogHandler: LogHandler {
var underlying: Logger
let context: BaggageContext
public init(logger underlying: Logger, context: BaggageContext) {
self.underlying = underlying
self.context = context
}
public var logLevel: Logger.Level {
get {
self.underlying.logLevel
}
set {
self.underlying.logLevel = newValue
}
}
public func log(
level: Logger.Level,
message: Logger.Message,
metadata: Logger.Metadata?,
source: String,
file: String,
function: String,
line: UInt
) {
guard self.underlying.logLevel <= level else {
return
}
var effectiveMetadata = self.baggageAsMetadata()
if let metadata = metadata {
effectiveMetadata.merge(metadata, uniquingKeysWith: { _, r in r })
}
self.underlying.log(level: level, message, metadata: effectiveMetadata, source: source, file: file, function: function, line: line)
}
public var metadata: Logger.Metadata {
get {
[:]
}
set {
newValue.forEach { k, v in
self.underlying[metadataKey: k] = v
}
}
}
/// Note that this does NOT look up inside the baggage.
///
/// This is because a context lookup either has to use the specific type key, or iterate over all keys to locate one by name,
/// which may be incorrect still, thus rather than making an potentially slightly incorrect lookup, we do not implement peeking
/// into a baggage with String keys through this handler (as that is not a capability `BaggageContext` offers in any case.
public subscript(metadataKey metadataKey: Logger.Metadata.Key) -> Logger.Metadata.Value? {
get {
self.underlying[metadataKey: metadataKey]
}
set {
self.underlying[metadataKey: metadataKey] = newValue
}
}
private func baggageAsMetadata() -> Logger.Metadata {
var effectiveMetadata: Logger.Metadata = [:]
self.context.forEach { key, value in
if let convertible = value as? String {
effectiveMetadata[key.name] = .string(convertible)
} else if let convertible = value as? CustomStringConvertible {
effectiveMetadata[key.name] = .stringConvertible(convertible)
} else {
effectiveMetadata[key.name] = .stringConvertible(BaggageValueCustomStringConvertible(value))
}
}
return effectiveMetadata
}
struct BaggageValueCustomStringConvertible: CustomStringConvertible {
let value: Any
init(_ value: Any) {
self.value = value
}
var description: String {
"\(self.value)"
}
}
}

View File

@ -0,0 +1,29 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Baggage Context open source project
//
// Copyright (c) 2020 Moritz Lang and the Swift Baggage Context project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import Baggage
import Logging
extension Logger {
/// Returns a logger that in addition to any explicit metadata passed to log statements,
/// also includes the `BaggageContext` adapted into metadata values.
///
/// The rendering of baggage values into metadata values is performed on demand,
/// whenever a log statement is effective (i.e. will be logged, according to active `logLevel`).
public func with(context: BaggageContext) -> Logger {
Logger(
label: self.label,
factory: { _ in BaggageMetadataLogHandler(logger: self, context: context) }
)
}
}

View File

@ -0,0 +1,33 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Baggage Context open source project
//
// Copyright (c) 2020 Moritz Lang and the Swift Baggage Context project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import Baggage
import Logging
/// A `BaggageContextLogging` purpose is to be adopted by frameworks which already provide a "FrameworkContext",
/// and to such frameworks to pass their context as `BaggageContextCarrier`.
public protocol LoggingBaggageContextCarrier: BaggageContextCarrier {
/// The logger associated with this carrier context.
///
/// It should automatically populate the loggers metadata based on the `BaggageContext` associated with this context object.
///
/// ### Implementation note
///
/// Libraries and/or frameworks which conform to this protocol with their "Framework Context" types,
/// SHOULD implement this logger by wrapping the "raw" logger associated with this context with the `logger.with(BaggageContext:)` function,
/// which efficiently handles the bridging of baggage to logging metadata values.
///
/// Writes to the `logger` metadata SHOULD NOT be reflected in the `baggage`,
/// however writes to the underlying `baggage` SHOULD be reflected in the `logger`.
var logger: Logger { get set }
}

View File

@ -0,0 +1,138 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Baggage Context open source project
//
// Copyright (c) 2020 Moritz Lang and the Swift Baggage Context project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import Baggage
import BaggageLogging
import Logging
import XCTest
final class LoggingBaggageContextCarrierTests: XCTestCase {
func test_ContextWithLogger_dumpBaggage() throws {
let baggage = BaggageContext()
let logger = Logger(label: "TheLogger")
var context: LoggingBaggageContextCarrier = ExampleFrameworkContext(context: baggage, logger: logger)
context.testID = 42
func frameworkFunctionDumpsBaggage(param: String, context: LoggingBaggageContextCarrier) -> String {
var s = ""
context.baggage.forEach { key, item in
s += "\(key.name): \(item)\n"
}
return s
}
let result = frameworkFunctionDumpsBaggage(param: "x", context: context)
XCTAssertEqual(
result,
"""
TestIDKey: 42
"""
)
}
func test_ContextWithLogger_log_withBaggage() throws {
let baggage = BaggageContext()
let logging = TestLogging()
let logger = Logger(label: "TheLogger", factory: { label in logging.make(label: label) })
var context: LoggingBaggageContextCarrier = ExampleFrameworkContext(context: baggage, logger: logger)
context.secondTestID = "value"
context.testID = 42
context.logger.info("Hello")
context.testID = nil
context.logger.warning("World")
logging.history.assertExist(level: .info, message: "Hello", metadata: [
"TestIDKey": .stringConvertible(42),
"secondIDExplicitlyNamed": "value",
])
logging.history.assertExist(level: .warning, message: "World", metadata: [
"secondIDExplicitlyNamed": "value",
])
}
}
public struct ExampleFrameworkContext: LoggingBaggageContextCarrier {
public var baggage: BaggageContext
private var _logger: Logger
public var logger: Logger {
get {
self._logger.with(context: self.baggage)
}
set {
self._logger = newValue
}
}
public init(context baggage: BaggageContext, logger: Logger) {
self.baggage = baggage
self._logger = logger
}
}
struct CoolFrameworkContext: LoggingBaggageContextCarrier {
private var _logger: Logger = Logger(label: "some frameworks logger")
var logger: Logger {
get {
self._logger.with(context: self.baggage)
}
set {
self._logger = newValue
}
}
var baggage: BaggageContext = .init()
// framework context defines other values as well
let frameworkField: String = ""
// including the popular eventLoop
let eventLoop: FakeEventLoop
}
struct FakeEventLoop {}
private extension BaggageContextProtocol {
var testID: Int? {
get {
self[TestIDKey.self]
}
set {
self[TestIDKey.self] = newValue
}
}
var secondTestID: String? {
get {
self[SecondTestIDKey.self]
}
set {
self[SecondTestIDKey.self] = newValue
}
}
}
private enum TestIDKey: BaggageContextKey {
typealias Value = Int
}
private enum SecondTestIDKey: BaggageContextKey {
typealias Value = String
static let name: String? = "secondIDExplicitlyNamed"
}

View File

@ -0,0 +1,337 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Baggage Context open source project
//
// Copyright (c) 2020 Moritz Lang and the Swift Baggage Context project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Logging API open source project
//
// Copyright (c) 2018-2019 Apple Inc. and the Swift Logging API project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift Logging API project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import Foundation
@testable import Logging
import XCTest
/// Copy of swift-log's TestLogging
internal struct TestLogging {
private let _config = Config() // shared among loggers
private let recorder = Recorder() // shared among loggers
func make(label: String) -> LogHandler {
TestLogHandler(label: label, config: self.config, recorder: self.recorder)
}
var config: Config { self._config }
var history: History { self.recorder }
}
internal struct TestLogHandler: LogHandler {
private let logLevelLock = NSLock()
private let metadataLock = NSLock()
private let recorder: Recorder
private let config: Config
private var logger: Logger // the actual logger
let label: String
init(label: String, config: Config, recorder: Recorder) {
self.label = label
self.config = config
self.recorder = recorder
self.logger = Logger(label: "test", StreamLogHandler.standardOutput(label: label))
self.logger.logLevel = .debug
}
func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt) {
let metadata = (self._metadataSet ? self.metadata : MDC.global.metadata).merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
self.logger.log(level: level, message, metadata: metadata, source: source, file: file, function: function, line: line)
self.recorder.record(level: level, metadata: metadata, message: message, source: source)
}
private var _logLevel: Logger.Level?
var logLevel: Logger.Level {
get {
// get from config unless set
self.logLevelLock.withLock { self._logLevel } ?? self.config.get(key: self.label)
}
set {
self.logLevelLock.withLock { self._logLevel = newValue }
}
}
private var _metadataSet = false
private var _metadata = Logger.Metadata() {
didSet {
self._metadataSet = true
}
}
public var metadata: Logger.Metadata {
get {
// return self.logger.metadata
self.metadataLock.withLock { self._metadata }
}
set {
// self.logger.metadata = newValue
self.metadataLock.withLock { self._metadata = newValue }
}
}
// TODO: would be nice to delegate to local copy of logger but StdoutLogger is a reference type. why?
subscript(metadataKey metadataKey: Logger.Metadata.Key) -> Logger.Metadata.Value? {
get {
// return self.logger[metadataKey: metadataKey]
self.metadataLock.withLock { self._metadata[metadataKey] }
}
set {
// return logger[metadataKey: metadataKey] = newValue
self.metadataLock.withLock {
self._metadata[metadataKey] = newValue
}
}
}
}
internal class Config {
private static let ALL = "*"
private let lock = NSLock()
private var storage = [String: Logger.Level]()
func get(key: String) -> Logger.Level {
self.get(key) ?? self.get(Config.ALL) ?? Logger.Level.debug
}
func get(_ key: String) -> Logger.Level? {
guard let value = (self.lock.withLock { self.storage[key] }) else {
return nil
}
return value
}
func set(key: String = Config.ALL, value: Logger.Level) {
self.lock.withLock { self.storage[key] = value }
}
func clear() {
self.lock.withLock { self.storage.removeAll() }
}
}
internal class Recorder: History {
private let lock = NSLock()
private var _entries = [LogEntry]()
func record(level: Logger.Level, metadata: Logger.Metadata?, message: Logger.Message, source: String) {
self.lock.withLock {
self._entries.append(LogEntry(level: level, metadata: metadata, message: message.description, source: source))
}
}
var entries: [LogEntry] {
self.lock.withLock { self._entries }
}
}
internal protocol History {
var entries: [LogEntry] { get }
}
internal extension History {
func atLevel(level: Logger.Level) -> [LogEntry] {
self.entries.filter { entry in
level == entry.level
}
}
var trace: [LogEntry] {
self.atLevel(level: .debug)
}
var debug: [LogEntry] {
self.atLevel(level: .debug)
}
var info: [LogEntry] {
self.atLevel(level: .info)
}
var warning: [LogEntry] {
self.atLevel(level: .warning)
}
var error: [LogEntry] {
self.atLevel(level: .error)
}
}
internal struct LogEntry {
let level: Logger.Level
let metadata: Logger.Metadata?
let message: String
let source: String
}
extension History {
func assertExist(level: Logger.Level,
message: String,
metadata: Logger.Metadata? = nil,
source: String? = nil,
file: StaticString = #file,
line: UInt = #line) {
let source = source ?? Logger.currentModule(filePath: "\(file)")
let entry = self.find(level: level, message: message, metadata: metadata, source: source)
XCTAssertNotNil(
entry,
"""
entry not found: \(level), \(source), \(String(describing: metadata)), \(message)
All entries:
\(self.entries.map { "\($0)" }.joined(separator: "\n"))
""",
file: file,
line: line
)
}
func assertNotExist(level: Logger.Level,
message: String,
metadata: Logger.Metadata? = nil,
source: String? = nil,
file: StaticString = #file,
line: UInt = #line) {
let source = source ?? Logger.currentModule(filePath: "\(file)")
let entry = self.find(level: level, message: message, metadata: metadata, source: source)
XCTAssertNil(
entry,
"entry was found: \(level), \(source), \(String(describing: metadata)), \(message)",
file: file,
line: line
)
}
func find(level: Logger.Level, message: String, metadata: Logger.Metadata? = nil, source: String) -> LogEntry? {
self.entries.first { entry in
entry.level == level &&
entry.message == message &&
entry.metadata ?? [:] == metadata ?? [:] &&
entry.source == source
}
}
}
public class MDC {
private let lock = NSLock()
private var storage = [Int: Logger.Metadata]()
public static var global = MDC()
private init() {}
public subscript(metadataKey: String) -> Logger.Metadata.Value? {
get {
self.lock.withLock {
self.storage[self.threadId]?[metadataKey]
}
}
set {
self.lock.withLock {
if self.storage[self.threadId] == nil {
self.storage[self.threadId] = Logger.Metadata()
}
self.storage[self.threadId]![metadataKey] = newValue
}
}
}
public var metadata: Logger.Metadata {
self.lock.withLock {
self.storage[self.threadId] ?? [:]
}
}
public func clear() {
self.lock.withLock {
_ = self.storage.removeValue(forKey: self.threadId)
}
}
public func with(metadata: Logger.Metadata, _ body: () throws -> Void) rethrows {
metadata.forEach { self[$0] = $1 }
defer {
metadata.keys.forEach { self[$0] = nil }
}
try body()
}
public func with<T>(metadata: Logger.Metadata, _ body: () throws -> T) rethrows -> T {
metadata.forEach { self[$0] = $1 }
defer {
metadata.keys.forEach { self[$0] = nil }
}
return try body()
}
// for testing
internal func flush() {
self.lock.withLock {
self.storage.removeAll()
}
}
private var threadId: Int {
#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS)
return Int(pthread_mach_thread_np(pthread_self()))
#else
return Int(pthread_self())
#endif
}
}
internal extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
self.lock()
defer {
self.unlock()
}
return body()
}
}
internal struct TestLibrary {
private let logger = Logger(label: "TestLibrary")
private let queue = DispatchQueue(label: "TestLibrary")
public init() {}
public func doSomething() {
self.logger.info("TestLibrary::doSomething")
}
public func doSomethingAsync(completion: @escaping () -> Void) {
// libraries that use global loggers and async, need to make sure they propagate the
// logging metadata when creating a new thread
let metadata = MDC.global.metadata
self.queue.asyncAfter(deadline: .now() + 0.1) {
MDC.global.with(metadata: metadata) {
self.logger.info("TestLibrary::doSomethingAsync")
completion()
}
}
}
}