|
| 1 | +import Foundation |
| 2 | +#if canImport(System) |
| 3 | +import System |
| 4 | +#else |
| 5 | +import SystemPackage |
| 6 | +#endif |
| 7 | + |
| 8 | +@preconcurrency import GenericJSON |
| 9 | +import Logging |
| 10 | + |
| 11 | + |
| 12 | + |
| 13 | +/** |
| 14 | + A logger that logs it’s messages to stdout in the JSON format, one log per line. |
| 15 | + |
| 16 | + The line separator is actually customizable, and can be any sequence of bytes. |
| 17 | + By default it’s “`\n`”. |
| 18 | + |
| 19 | + The separator customization allows you to choose |
| 20 | + a prefix for the JSON payload (defaults to `[]`), |
| 21 | + a suffix too (defaults to `[]` too), |
| 22 | + and an inter-JSON separator (defaults to `[0x0a]`, which is a UNIX newline). |
| 23 | + For instance if there are two messages logged, you’ll get the following written to the fd: |
| 24 | + ``` |
| 25 | + prefix JSON1 suffix separator prefix JSON2 suffix |
| 26 | + ``` |
| 27 | + |
| 28 | + This configuration is interesting mostly to generate `json-seq` stream. |
| 29 | + To do this, set the inter-JSON separator to `[]`, the prefix to `[0x1e]` and the suffix to `[0x0a]`, |
| 30 | + or use the convenience ``JSONLogger/forJSONSeq()``. |
| 31 | + |
| 32 | + Finally, another interesting configuration is to set the separator to `[0xff]` or `[0xfe]`. |
| 33 | + These bytes should not appear in valid UTF-8 strings and should be able to be used to separate JSON payloads. |
| 34 | + (Note I’m not sure why `json-seq` does not do that; there must be a good reason, though. |
| 35 | + Probably because the resulting output would not be valid UTF-8 anymore.) |
| 36 | + |
| 37 | + The output file descriptor is also customizable and is `stdout` by default. */ |
| 38 | +public struct JSONLogger : LogHandler { |
| 39 | + |
| 40 | + public var logLevel: Logger.Level = .info |
| 41 | + |
| 42 | + public var metadata: Logger.Metadata = [:] { |
| 43 | + didSet {jsonMetadataCache = jsonMetadata(metadata)} |
| 44 | + } |
| 45 | + public var metadataProvider: Logger.MetadataProvider? |
| 46 | + |
| 47 | + public var outputFileDescriptor: FileDescriptor |
| 48 | + public var lineSeparator: Data |
| 49 | + public var prefix: Data |
| 50 | + public var suffix: Data |
| 51 | + |
| 52 | + /** |
| 53 | + If `true`, the `Encodable` properties in the metadata will be encoded and kept structured in the resulting log line. |
| 54 | + If the encoding fails or this property is set to `false` the String value will be used. */ |
| 55 | + public var tryEncodingStringConvertibles: Bool |
| 56 | + |
| 57 | + public static func forJSONSeq(on fd: FileDescriptor = .standardError, metadataProvider: Logger.MetadataProvider? = LoggingSystem.metadataProvider) -> Self { |
| 58 | + return Self(fd: fd, lineSeparator: Data(), prefix: Data([0x1e]), suffix: Data([0x0a]), metadataProvider: metadataProvider) |
| 59 | + } |
| 60 | + |
| 61 | + public init(fd: FileDescriptor = .standardError, lineSeparator: Data = Data("\n".utf8), prefix: Data = Data(), suffix: Data = Data(), tryEncodingStringConvertibles: Bool = true, metadataProvider: Logger.MetadataProvider? = LoggingSystem.metadataProvider) { |
| 62 | + self.outputFileDescriptor = fd |
| 63 | + self.lineSeparator = lineSeparator |
| 64 | + self.prefix = prefix |
| 65 | + self.suffix = suffix |
| 66 | + self.tryEncodingStringConvertibles = tryEncodingStringConvertibles |
| 67 | + |
| 68 | + self.metadataProvider = metadataProvider |
| 69 | + } |
| 70 | + |
| 71 | + public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { |
| 72 | + get {metadata[metadataKey]} |
| 73 | + set {metadata[metadataKey] = newValue} |
| 74 | + } |
| 75 | + |
| 76 | + public func log(level: Logger.Level, message: Logger.Message, metadata logMetadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt) { |
| 77 | + let effectiveJSONMetadata: JSON |
| 78 | + if let m = mergedMetadata(with: logMetadata) {effectiveJSONMetadata = jsonMetadata(m)} |
| 79 | + else {effectiveJSONMetadata = jsonMetadataCache} |
| 80 | + |
| 81 | + /* We compute the data to print outside of the lock. */ |
| 82 | + let line = LogLine(level: level, message: message.description, metadata: effectiveJSONMetadata, |
| 83 | + source: source, file: file, function: function, line: line) |
| 84 | + let jsonLine: Data |
| 85 | + do {jsonLine = try JSONEncoder().encode(line)} |
| 86 | + catch { |
| 87 | + /* The encoding should never fail. |
| 88 | + * But what if it does? */ |
| 89 | + jsonLine = Data(( |
| 90 | + #"{"# + |
| 91 | + #""level":"\#(level.rawValue.safifyForJSON())","# + |
| 92 | + #""message":"MANGLED LOG MESSAGE (see JSONLogger doc) -- \#(line.message.safifyForJSON())","# + |
| 93 | + #""metadata":{"# + |
| 94 | + #""JSONLogger.LogInfo":"Original metadata removed (see JSONLogger doc)","# + |
| 95 | + #""JSONLogger.LogError":"\#(String(describing: error).safifyForJSON())""# + |
| 96 | + #"},"# + |
| 97 | + #""source":"\#(source.safifyForJSON())","# + |
| 98 | + #""file":"\#(file.safifyForJSON())","# + |
| 99 | + #""function":"\#(function.safifyForJSON())","# + |
| 100 | + #""line":\#(line)"# + |
| 101 | + #"}"# |
| 102 | + ).utf8) |
| 103 | + } |
| 104 | + let dataNoSeparator = prefix + jsonLine + suffix |
| 105 | + |
| 106 | + /* We lock, because the writeAll function might split the write in more than 1 write |
| 107 | + * (if the write system call only writes a part of the data). |
| 108 | + * If another part of the program writes to fd, we might get interleaved data, |
| 109 | + * because they cannot be aware of our lock (and we cannot be aware of theirs if they have one). */ |
| 110 | + JSONLogger.lock.withLock{ |
| 111 | + let prefix: Data |
| 112 | + if Self.isFirstLog {prefix = Data(); Self.isFirstLog = false} |
| 113 | + else {prefix = lineSeparator} |
| 114 | + /* Is there a better idea than silently drop the message in case of fail? */ |
| 115 | + _ = try? outputFileDescriptor.writeAll(prefix + dataNoSeparator) |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + /* Do _not_ use os_unfair_lock, apparently it is bad in Swift: |
| 120 | + * <https://twitter.com/grynspan/status/1392080373752995849>. */ |
| 121 | + private static var lock = NSLock() |
| 122 | + private static var isFirstLog = true |
| 123 | + |
| 124 | + private var jsonMetadataCache: JSON = .object([:]) |
| 125 | + |
| 126 | +} |
| 127 | + |
| 128 | + |
| 129 | +/* Metadata handling. */ |
| 130 | +extension JSONLogger { |
| 131 | + |
| 132 | + /** |
| 133 | + Merge the logger’s metadata, the provider’s metadata and the given explicit metadata and return the new metadata. |
| 134 | + If the provider’s metadata and the explicit metadata are `nil`, returns `nil` to signify the current `jsonMetadataCache` can be used. */ |
| 135 | + private func mergedMetadata(with explicit: Logger.Metadata?) -> Logger.Metadata? { |
| 136 | + var metadata = metadata |
| 137 | + let provided = metadataProvider?.get() ?? [:] |
| 138 | + |
| 139 | + guard !provided.isEmpty || !((explicit ?? [:]).isEmpty) else { |
| 140 | + /* All per-log-statement values are empty or not set: we return nil. */ |
| 141 | + return nil |
| 142 | + } |
| 143 | + |
| 144 | + if !provided.isEmpty { |
| 145 | + metadata.merge(provided, uniquingKeysWith: { _, provided in provided }) |
| 146 | + } |
| 147 | + if let explicit = explicit, !explicit.isEmpty { |
| 148 | + metadata.merge(explicit, uniquingKeysWith: { _, explicit in explicit }) |
| 149 | + } |
| 150 | + return metadata |
| 151 | + } |
| 152 | + |
| 153 | + private func jsonMetadata(_ metadata: Logger.Metadata) -> JSON { |
| 154 | + .object(metadata.mapValues(jsonMetadataValue(_:))) |
| 155 | + } |
| 156 | + |
| 157 | + private func jsonMetadataValue(_ metadataValue: Logger.MetadataValue) -> JSON { |
| 158 | + return switch metadataValue { |
| 159 | + case let .string(s): .string(s) |
| 160 | + case let .array(array): .array (array .map (jsonMetadataValue(_:))) |
| 161 | + case let .dictionary(dictionary): .object(dictionary.mapValues(jsonMetadataValue(_:))) |
| 162 | + case let .stringConvertible(s): |
| 163 | + if tryEncodingStringConvertibles, let c = s as? any Encodable, let encoded = try? JSON(encodable: c) { |
| 164 | + encoded |
| 165 | + } else { |
| 166 | + .string(s.description) |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + } |
| 171 | + |
| 172 | +} |
0 commit comments