Skip to content

Commit 7df6efb

Browse files
committed
Initial commit
0 parents  commit 7df6efb

File tree

8 files changed

+344
-0
lines changed

8 files changed

+344
-0
lines changed

.gitignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Finder and Xcode
2+
.DS_Store
3+
xcuserdata/
4+
5+
# The SPM build folder
6+
/.build/
7+
# This folder is used by SPM when a package is put in “edit” mode (`swift
8+
# package edit some_package`): the edited package is moved in this folder, and
9+
# removed when the package is “unedited” (`swift package unedit some_package`).
10+
/Packages/
11+
12+
# Contains a bunch of stuff… should usually be ignored I think. Currently, as
13+
# far as I’m aware, it is used by Xcode 11 to put the autogenerated Xcode
14+
# project created and maintained by Xcode when opening a Package.swift file, and
15+
# by the `swift package config` command to store its config (currently, the only
16+
# config I’m aware of are the mirrors for downloading packages).
17+
/.swiftpm/

Package.resolved

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// swift-tools-version:5.3
2+
import PackageDescription
3+
4+
5+
let package = Package(
6+
name: "json-logger",
7+
platforms: [
8+
.macOS(.v11),
9+
.tvOS(.v14),
10+
.iOS(.v14),
11+
.watchOS(.v7)
12+
],
13+
products: [
14+
.library(name: "JSONLogger", targets: ["JSONLogger"])
15+
],
16+
dependencies: {
17+
var ret = [Package.Dependency]()
18+
ret.append(.package(url: "https://github.com/apple/swift-log.git", from: "1.5.1"))
19+
#if !canImport(System)
20+
ret.append(.package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"))
21+
#endif
22+
ret.append(.package(url: "https://github.com/iwill/generic-json-swift.git", from: "2.0.2"))
23+
return ret
24+
}(),
25+
targets: [
26+
.target(name: "JSONLogger", dependencies: {
27+
var ret = [Target.Dependency]()
28+
ret.append(.product(name: "GenericJSON", package: "generic-json-swift"))
29+
ret.append(.product(name: "Logging", package: "swift-log"))
30+
#if !canImport(System)
31+
ret.append(.product(name: "SystemPackage", package: "swift-system"))
32+
#endif
33+
return ret
34+
}(), path: "Sources"),
35+
.testTarget(name: "JSONLoggerTests", dependencies: ["JSONLogger"], path: "Tests")
36+
]
37+
)

Readme.adoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
= JSONLogger
2+
François Lamboley <francois[email protected]>
3+
4+
A simple SwiftLog-compatible logger which logs in JSON, one entry per line.
5+
6+
== Usage
7+
TODO
8+
9+
== Metadata Log Format
10+
TODO

Sources/JSONLogger.swift

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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+
}

Sources/LogLine.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Foundation
2+
3+
@preconcurrency import GenericJSON
4+
import Logging
5+
6+
7+
8+
public struct LogLine : Hashable, Codable, Sendable {
9+
10+
public var level: Logger.Level
11+
public var message: String
12+
public var metadata: JSON
13+
14+
public var source: String
15+
public var file: String
16+
public var function: String
17+
public var line: UInt
18+
19+
public init(level: Logger.Level, message: String, metadata: JSON, source: String, file: String, function: String, line: UInt) {
20+
self.level = level
21+
self.message = message
22+
self.metadata = metadata
23+
self.source = source
24+
self.file = file
25+
self.function = function
26+
self.line = line
27+
}
28+
29+
}

Sources/String+Utils.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Foundation
2+
3+
4+
5+
internal extension String {
6+
7+
func safifyForJSON() -> String {
8+
let ascii = unicodeScalars.lazy.map{ scalar in
9+
switch scalar {
10+
case _ where !scalar.isASCII: return "-"
11+
case #"\"#: return #"\\"#
12+
case #"""#: return #"\""#
13+
case "\n": return #"\n"#
14+
case "\r": return #"\r"#
15+
/* `scalar.value` should never be bigger than Int32.max, but we still use bitPattern to be safe. */
16+
case _ where isprint(Int32(bitPattern: scalar.value)) == 0: return "-"
17+
default: return String(scalar)
18+
}
19+
}
20+
return ascii.joined(separator: "")
21+
}
22+
23+
}

Tests/JSONLoggerTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import XCTest
2+
@testable import JSONLogger
3+
4+
import Logging
5+
6+
7+
8+
final class JSONLoggerTests: XCTestCase {
9+
10+
override class func setUp() {
11+
LoggingSystem.bootstrap{ _ in JSONLogger() }
12+
}
13+
14+
/* From <https://apple.github.io/swift-log/docs/current/Logging/Protocols/LogHandler.html#treat-log-level-amp-metadata-as-values>. */
15+
func testFromDoc() {
16+
var logger1 = Logger(label: "first logger")
17+
logger1.logLevel = .debug
18+
logger1[metadataKey: "only-on"] = "first"
19+
20+
var logger2 = logger1
21+
logger2.logLevel = .error /* This must not override `logger1`'s log level. */
22+
logger2[metadataKey: "only-on"] = "second" /* This must not override `logger1`'s metadata. */
23+
24+
XCTAssertEqual(.debug, logger1.logLevel)
25+
XCTAssertEqual(.error, logger2.logLevel)
26+
XCTAssertEqual("first", logger1[metadataKey: "only-on"])
27+
XCTAssertEqual("second", logger2[metadataKey: "only-on"])
28+
}
29+
30+
31+
}

0 commit comments

Comments
 (0)