diff --git a/Benchmarks/MaxLogLevelWarning/Benchmarks/MaxLogLevelWarningBenchmarks/MaxLogLevelWarning.swift b/Benchmarks/MaxLogLevelWarning/Benchmarks/MaxLogLevelWarningBenchmarks/MaxLogLevelWarning.swift index 2719bfce..fc8cd673 100644 --- a/Benchmarks/MaxLogLevelWarning/Benchmarks/MaxLogLevelWarningBenchmarks/MaxLogLevelWarning.swift +++ b/Benchmarks/MaxLogLevelWarning/Benchmarks/MaxLogLevelWarningBenchmarks/MaxLogLevelWarning.swift @@ -30,4 +30,14 @@ public let benchmarks: @Sendable () -> Void = { makeBenchmark(loggerLevel: .warning, logLevel: .warning, "_generic") { logger in logger.log(level: .warning, "hello, benchmarking world") } + makeBenchmark(loggerLevel: .notice, logLevel: .notice, "_attributed_generic") { logger in + logger.log( + level: .notice, + "hello, benchmarking world", + metadata: [ + "public-key": "\("public-value", sensitivity: .public)", + "private-key": "\("private-value", sensitivity: .sensitive)", + ] + ) + } } diff --git a/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.notice_log_with_notice_log_level.p90.json b/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.notice_log_with_notice_log_level.p90.json index 09a79911..a6b24141 100644 --- a/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.notice_log_with_notice_log_level.p90.json +++ b/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.notice_log_with_notice_log_level.p90.json @@ -1,4 +1,4 @@ { - "instructions" : 619, + "instructions" : 622, "objectAllocCount" : 0 } \ No newline at end of file diff --git a/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.notice_log_with_notice_log_level_attributed_generic.p90.json b/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.notice_log_with_notice_log_level_attributed_generic.p90.json new file mode 100644 index 00000000..09a79911 --- /dev/null +++ b/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.notice_log_with_notice_log_level_attributed_generic.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 619, + "objectAllocCount" : 0 +} \ No newline at end of file diff --git a/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.warning_log_with_warning_log_level.p90.json b/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.warning_log_with_warning_log_level.p90.json index 27083d84..d367531f 100644 --- a/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.warning_log_with_warning_log_level.p90.json +++ b/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.warning_log_with_warning_log_level.p90.json @@ -1,4 +1,4 @@ { - "instructions" : 1651, + "instructions" : 1669, "objectAllocCount" : 0 } \ No newline at end of file diff --git a/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.warning_log_with_warning_log_level_generic.p90.json b/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.warning_log_with_warning_log_level_generic.p90.json index 27083d84..d367531f 100644 --- a/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.warning_log_with_warning_log_level_generic.p90.json +++ b/Benchmarks/MaxLogLevelWarning/Thresholds/Xcode 26.1/MaxLogLevelWarning.warning_log_with_warning_log_level_generic.p90.json @@ -1,4 +1,4 @@ { - "instructions" : 1651, + "instructions" : 1669, "objectAllocCount" : 0 } \ No newline at end of file diff --git a/Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift b/Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift index b7d94ce3..621c5bb0 100644 --- a/Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift +++ b/Benchmarks/NoTraits/Benchmarks/NoTraitsBenchmarks/NoTraits.swift @@ -24,4 +24,40 @@ public let benchmarks: @Sendable () -> Void = { makeBenchmark(loggerLevel: .error, logLevel: .debug, "_generic") { logger in logger.log(level: .debug, "hello, benchmarking world") } + makeBenchmark(loggerLevel: .error, logLevel: .error, "_1_attribute") { logger in + logger.log( + level: .error, + "hello, benchmarking world", + metadata: [ + "public-key": "\("public-value", sensitivity: .public)" + ] + ) + } + makeBenchmark(loggerLevel: .error, logLevel: .debug, "_1_attribute") { logger in + logger.log( + level: .debug, + "hello, benchmarking world", + metadata: [ + "public-key": "\("public-value", sensitivity: .public)" + ] + ) + } + makeBenchmark(loggerLevel: .error, logLevel: .error, "_2_attributes") { logger in + logger.log( + level: .error, + "hello, benchmarking world", + metadata: [ + "public-key": "\("public-value", attributes: [BenchmarkSensitivity.public, BenchmarkColor.red])" + ] + ) + } + makeBenchmark(loggerLevel: .error, logLevel: .debug, "_2_attributes") { logger in + logger.log( + level: .debug, + "hello, benchmarking world", + metadata: [ + "public-key": "\("public-value", attributes: [BenchmarkSensitivity.public, BenchmarkColor.red])" + ] + ) + } } diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_1_attribute.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_1_attribute.p90.json new file mode 100644 index 00000000..957dc64a --- /dev/null +++ b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_1_attribute.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 985, + "objectAllocCount" : 0 +} \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_2_attributes.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_2_attributes.p90.json new file mode 100644 index 00000000..957dc64a --- /dev/null +++ b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_2_attributes.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 985, + "objectAllocCount" : 0 +} \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_generic.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_generic.p90.json index 2cd2fd18..fd530e1a 100644 --- a/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_generic.p90.json +++ b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.debug_log_with_error_log_level_generic.p90.json @@ -1,4 +1,4 @@ { - "instructions" : 984, + "instructions" : 981, "objectAllocCount" : 0 } \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_1_attribute.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_1_attribute.p90.json new file mode 100644 index 00000000..7144c99c --- /dev/null +++ b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_1_attribute.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 5443, + "objectAllocCount" : 2 +} \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_2_attributes.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_2_attributes.p90.json new file mode 100644 index 00000000..6f2bbe4e --- /dev/null +++ b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_2_attributes.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 6599, + "objectAllocCount" : 3 +} \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_generic.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_generic.p90.json index 2d0c1277..884df51e 100644 --- a/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_generic.p90.json +++ b/Benchmarks/NoTraits/Thresholds/Xcode 16.4/NoTraits.error_log_with_error_log_level_generic.p90.json @@ -1,4 +1,4 @@ { - "instructions" : 1368, + "instructions" : 1386, "objectAllocCount" : 0 } \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_1_attribute.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_1_attribute.p90.json new file mode 100644 index 00000000..ad9fda31 --- /dev/null +++ b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_1_attribute.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 897, + "objectAllocCount" : 0 +} \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_2_attributes.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_2_attributes.p90.json new file mode 100644 index 00000000..ad9fda31 --- /dev/null +++ b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_2_attributes.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 897, + "objectAllocCount" : 0 +} \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_generic.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_generic.p90.json index 674bde63..500f65e5 100644 --- a/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_generic.p90.json +++ b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.debug_log_with_error_log_level_generic.p90.json @@ -1,4 +1,4 @@ { - "instructions" : 893, + "instructions" : 896, "objectAllocCount" : 0 } \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_1_attribute.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_1_attribute.p90.json new file mode 100644 index 00000000..a8e234ab --- /dev/null +++ b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_1_attribute.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 5339, + "objectAllocCount" : 2 +} \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_2_attributes.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_2_attributes.p90.json new file mode 100644 index 00000000..56fdcd32 --- /dev/null +++ b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_2_attributes.p90.json @@ -0,0 +1,4 @@ +{ + "instructions" : 6515, + "objectAllocCount" : 3 +} \ No newline at end of file diff --git a/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_generic.p90.json b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_generic.p90.json index 798db07c..58db4294 100644 --- a/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_generic.p90.json +++ b/Benchmarks/NoTraits/Thresholds/Xcode 26.1/NoTraits.error_log_with_error_log_level_generic.p90.json @@ -1,4 +1,4 @@ { - "instructions" : 1649, + "instructions" : 1667, "objectAllocCount" : 0 } \ No newline at end of file diff --git a/Benchmarks/Sources/BenchmarksFactory/NoOpLogHandler.swift b/Benchmarks/Sources/BenchmarksFactory/NoOpLogHandler.swift index 272634fc..0c7cb94b 100644 --- a/Benchmarks/Sources/BenchmarksFactory/NoOpLogHandler.swift +++ b/Benchmarks/Sources/BenchmarksFactory/NoOpLogHandler.swift @@ -14,6 +14,40 @@ import Logging +// MARK: - Benchmark attributes + +public enum BenchmarkSensitivity: Int64, Logger.MetadataAttributeKey, Sendable { + case sensitive = 1 + case `public` = 2 +} + +extension Logger.MetadataValue.StringInterpolation { + @inlinable + public mutating func appendInterpolation( + _ value: T, + sensitivity: BenchmarkSensitivity + ) { + self.appendInterpolation(value, attributes: { $0[BenchmarkSensitivity.self] = sensitivity }) + } +} + +public enum BenchmarkColor: Int64, Logger.MetadataAttributeKey, Sendable { + case red = 1 + case blue = 2 +} + +extension Logger.MetadataValue.StringInterpolation { + @inlinable + public mutating func appendInterpolation( + _ value: T, + color: BenchmarkColor + ) { + self.appendInterpolation(value, attributes: { $0[BenchmarkColor.self] = color }) + } +} + +// MARK: - NoOpLogHandler + struct NoOpLogHandler: LogHandler { let label: String public var metadataProvider: Logger.MetadataProvider? @@ -31,6 +65,13 @@ struct NoOpLogHandler: LogHandler { func log( event: LogEvent ) { + // Access metadata attributes + if let metadata = event.metadata { + for (_, value) in metadata { + let _ = value.attributes + } + } + // Do nothing } @@ -43,6 +84,13 @@ struct NoOpLogHandler: LogHandler { function: String, line: UInt ) { + // Access metadata attributes + if let metadata = explicitMetadata { + for (_, value) in metadata { + let _ = value.attributes + } + } + // Do nothing } diff --git a/Package.swift b/Package.swift index 44e1916c..8f3d7451 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,9 @@ let package = Package( name: "swift-log", products: [ .library(name: "Logging", targets: ["Logging"]), + .library(name: "LoggingAttributes", targets: ["LoggingAttributes"]), .library(name: "InMemoryLogging", targets: ["InMemoryLogging"]), + .library(name: "OSLogHandler", targets: ["OSLogHandler"]), ], traits: [ .trait(name: "MaxLogLevelDebug", description: "Debug and above available (compiles out trace)"), @@ -31,18 +33,34 @@ let package = Package( name: "Logging", dependencies: [] ), + .target( + name: "LoggingAttributes", + dependencies: ["Logging"] + ), .target( name: "InMemoryLogging", dependencies: ["Logging"] ), + .target( + name: "OSLogHandler", + dependencies: ["Logging", "LoggingAttributes"] + ), .testTarget( name: "LoggingTests", dependencies: ["Logging"] ), + .testTarget( + name: "LoggingAttributesTests", + dependencies: ["LoggingAttributes", "Logging"] + ), .testTarget( name: "InMemoryLoggingTests", dependencies: ["InMemoryLogging", "Logging"] ), + .testTarget( + name: "OSLogHandlerTests", + dependencies: ["OSLogHandler", "Logging", "LoggingAttributes"] + ), ] ) diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index 4a4ef46c..f7c037d8 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -6,7 +6,9 @@ let package = Package( name: "swift-log", products: [ .library(name: "Logging", targets: ["Logging"]), + .library(name: "LoggingAttributes", targets: ["LoggingAttributes"]), .library(name: "InMemoryLogging", targets: ["InMemoryLogging"]), + .library(name: "OSLogHandler", targets: ["OSLogHandler"]), ], traits: [ .trait(name: "MaxLogLevelDebug", description: "Debug and above available (compiles out trace)"), @@ -31,18 +33,34 @@ let package = Package( name: "Logging", dependencies: [] ), + .target( + name: "LoggingAttributes", + dependencies: ["Logging"] + ), .target( name: "InMemoryLogging", dependencies: ["Logging"] ), + .target( + name: "OSLogHandler", + dependencies: ["Logging", "LoggingAttributes"] + ), .testTarget( name: "LoggingTests", dependencies: ["Logging"] ), + .testTarget( + name: "LoggingAttributesTests", + dependencies: ["LoggingAttributes", "Logging"] + ), .testTarget( name: "InMemoryLoggingTests", dependencies: ["InMemoryLogging", "Logging"] ), + .testTarget( + name: "OSLogHandlerTests", + dependencies: ["OSLogHandler", "Logging", "LoggingAttributes"] + ), ] ) diff --git a/Sources/InMemoryLogging/InMemoryLogHandler.swift b/Sources/InMemoryLogging/InMemoryLogHandler.swift index f863489b..af1b5997 100644 --- a/Sources/InMemoryLogging/InMemoryLogHandler.swift +++ b/Sources/InMemoryLogging/InMemoryLogHandler.swift @@ -57,18 +57,23 @@ public struct InMemoryLogHandler: LogHandler { self.metadata = metadata } - public init(level: Logger.Level, message: Logger.Message, error: (any Error)?, metadata: Logger.Metadata) { + public init( + level: Logger.Level, + message: Logger.Message, + error: (any Error)?, + metadata: Logger.Metadata + ) { self.level = level self.message = message self.error = error self.metadata = metadata } - public static func == (lhs: InMemoryLogHandler.Entry, rhs: InMemoryLogHandler.Entry) -> Bool { + public static func == (lhs: Entry, rhs: Entry) -> Bool { lhs.level == rhs.level && lhs.message == rhs.message - && errorsEqual(lhs.error, rhs.error) && lhs.metadata == rhs.metadata + && errorsEqual(lhs.error, rhs.error) } private static func errorsEqual(_ lhs: (any Error)?, _ rhs: (any Error)?) -> Bool { @@ -126,14 +131,23 @@ public struct InMemoryLogHandler: LogHandler { } public func log(event: LogEvent) { - // Start with the metadata provider.. - var mergedMetadata: Logger.Metadata = self.metadataProvider?.get() ?? [:] - // ..merge in self.metadata, overwriting existing keys - mergedMetadata = mergedMetadata.merging(self.metadata) { $1 } - // ..merge in metadata from this log call, overwriting existing keys - mergedMetadata = mergedMetadata.merging(event.metadata ?? [:]) { $1 } - - self.logStore.append(level: event.level, message: event.message, error: event.error, metadata: mergedMetadata) + var merged = self.metadata + + if let provider = self.metadataProvider { + let provided = provider.get() + merged.merge(provided, uniquingKeysWith: { _, rhs in rhs }) + } + + if let eventMetadata = event.metadata { + merged.merge(eventMetadata, uniquingKeysWith: { _, rhs in rhs }) + } + + self.logStore.append( + level: event.level, + message: event.message, + error: event.error, + metadata: merged + ) } @available(*, deprecated, renamed: "log(event:)") diff --git a/Sources/Logging/Docs.docc/DisableLogLevelsDuringCompilation.md b/Sources/Logging/Docs.docc/DisableLogLevelsDuringCompilation.md index e6b97ed4..f0145f55 100644 --- a/Sources/Logging/Docs.docc/DisableLogLevelsDuringCompilation.md +++ b/Sources/Logging/Docs.docc/DisableLogLevelsDuringCompilation.md @@ -3,7 +3,9 @@ SwiftLog provides compile-time traits to eliminate less severe log levels from your binary reducing the runtime overhead. -## Motivation +## Overview + +### Motivation When deploying applications to production, you often know in advance which log levels will never be needed. For example, a production service might only need @@ -11,7 +13,7 @@ warning and above, while trace and debug levels are only useful during development. By using traits, you can completely remove these unnecessary log levels at compile time, achieving zero runtime overhead. -## Available traits +### Available traits SwiftLog defines seven maximum log level traits, ordered from most permissive to most restrictive: @@ -39,7 +41,7 @@ are specified, the most restrictive one takes effect. > Note: Traits should only be set by the applications and not libraries as > any traits defined in a transitive dependency will affect the whole resolution tree. -## Example +### Example To enable a trait, specify it when declaring your package dependency: diff --git a/Sources/Logging/Docs.docc/ImplementingALogHandler.md b/Sources/Logging/Docs.docc/ImplementingALogHandler.md index 3ede87ae..d9853cae 100644 --- a/Sources/Logging/Docs.docc/ImplementingALogHandler.md +++ b/Sources/Logging/Docs.docc/ImplementingALogHandler.md @@ -129,9 +129,6 @@ public struct PrintLogHandler: LogHandler { let timestamp = ISO8601DateFormatter().string(from: Date()) let levelString = event.level.rawValue.uppercased() - // Get provider metadata - let providerMetadata = metadataProvider?.get() ?? [:] - // Merge handler metadata with message metadata let combinedMetadata = Self.prepareMetadata( base: self.metadata, @@ -183,6 +180,49 @@ public struct PrintLogHandler: LogHandler { } ``` +#### Reading metadata attributes in LogHandlers + +Metadata values can carry attributes alongside their string representation. Attributes are embedded inside +`MetadataValue` via the `.stringConvertible` case and are accessible through the `value.attributes` property. This +enables features like sensitivity annotations without any changes to your handler's protocol conformance. + +To read attributes in your log handler: + +```swift +public func log(event: LogEvent) { + // Merge handler metadata, provider metadata, and event metadata as usual + var merged = self.metadata + + if let provider = self.metadataProvider { + merged.merge(provider.get(), uniquingKeysWith: { _, rhs in rhs }) + } + + if let eventMetadata = event.metadata { + merged.merge(eventMetadata, uniquingKeysWith: { _, rhs in rhs }) + } + + // Read attributes from individual values: + for (key, value) in merged { + let attributes = value.attributes // Empty if the value carries no attributes + + // Process based on your handler's needs + // For example, check for a custom attribute: + // if attributes[MyAttribute.self] == .flagged { ... } + } +} +``` + +**Key considerations:** + +- **Opt-in inspection**: Attributes are invisible unless you call `value.attributes`. Handlers that do not care about + attributes work without any changes. + +- **No new protocol requirements**: Reading attributes does not require implementing any new `LogHandler` properties + or subscripts. The `metadata` property and `metadataKey` subscript work exactly as before. + +- **Attributes flow through metadata**: Attributed values flow naturally through metadata merging, `MetadataProvider`, + and `MultiplexLogHandler` — no special handling needed. + ### Performance considerations 1. **Avoid blocking**: Don't block the calling thread for I/O operations. diff --git a/Sources/Logging/Docs.docc/Proposals/SLG-0004-metadata-value-attributes.md b/Sources/Logging/Docs.docc/Proposals/SLG-0004-metadata-value-attributes.md new file mode 100644 index 00000000..7eb2bbb3 --- /dev/null +++ b/Sources/Logging/Docs.docc/Proposals/SLG-0004-metadata-value-attributes.md @@ -0,0 +1,503 @@ +# SLG-0004: Metadata value attributes + +Add an extensible per-value attribute mechanism for metadata. + +## Overview + +- Proposal: SLG-0004 +- Author(s): [Vladimir Kukushkin](https://github.com/kukushechkin) +- Status: **Awaiting Review** +- Issue: [apple/swift-log#204](https://github.com/apple/swift-log/issues/204) +- Implementation: + - [apple/swift-log#453](https://github.com/apple/swift-log/pull/453) +- Related links: + - [Lightweight proposals process description](https://github.com/apple/swift-log/blob/main/Sources/Logging/Docs.docc/Proposals/Proposals.md) + - [First iteration of the proposal](https://forums.swift.org/t/proposal-slg-0004-metadata-values-privacy-attribute/85249) + +### Introduction + +Introduce an extensible mechanism for attaching per-value attributes to metadata. The core `Logging` module provides +the protocol and storage. Attribute packages define concrete attributes using the `MetadataAttributeKey` protocol. + +### Motivation + +Metadata values in `swift-log` are opaque strings by the time the `LogHandler` receives them. The call site often +knows things about a value that the handler cannot infer — for example, whether the value should be redacted in +different environments. + +Today, there is no way to express this. A single log statement can contain values that need different treatment, and +log levels cannot help because they are per-statement, not per-value: + +```swift +logger.info("Login", metadata: [ + "action": "\(action)", // safe to log + "user_email": "\(email)", // should be redacted in production, but nothing tells the handler +]) +``` + +The workaround is handler-side key-based rules (`redact: ["email", "card-*", ...]`), but this is fragile: + +- Rules break when libraries rename keys. +- Rules require coordination across all dependencies. +- The rule mapping is invisible at the call site. + +### Proposed solution + +Attributes are embedded directly in `MetadataValue` via the existing `.stringConvertible` case. A custom +`MetadataValue.StringInterpolation` type produces attributed values when any interpolation segment specifies +attributes. Libraries define attribute types conforming to `MetadataAttributeKey` and provide ergonomic string +interpolation overloads. Handlers read the attributes via `value.attributes` and act on them: + +```swift +import Logging + +public enum Sensitivity: Int64, Logger.MetadataAttributeKey, Sendable { + case sensitive = 1 + case `public` = 2 +} + +extension Logger.MetadataValue.StringInterpolation { + public mutating func appendInterpolation( + _ value: T, sensitivity: Sensitivity + ) { + self.appendInterpolation(value, attributes: { $0[Sensitivity.self] = sensitivity }) + } +} + +logger.info("Request", metadata: [ + "method": "\(req.method)", + "user_id": "\(req.userId, sensitivity: .sensitive)", +]) +``` + +Handlers that support specific attributes read them from `value.attributes` and act accordingly. Handlers that do not +understand attributes see plain `MetadataValue` instances — attributes are invisible unless explicitly inspected. The +existing `metadata:` parameter is the only parameter needed; no new log method overloads are added. + +Attributed values can also be created programmatically with the `.attributed` factory, which behaves like an additional +enum case without breaking existing exhaustive switches: + +```swift +let value: Logger.MetadataValue = .attributed(userId, attributes: [Sensitivity.sensitive]) +``` + +Intermediate handlers can read **and modify** attributes as metadata flows through a handler chain. The `attributes` +property supports both getting and setting, enabling composable pipelines where each handler in the chain can enrich, +transform, or strip attributes before forwarding: + +```swift +// An enriching handler that tags all metadata with a processing timestamp attribute +func log(event: LogEvent) { + var mutatedEvent = event + if var eventMetadata = event.metadata { + for (key, _) in eventMetadata { + var attrs = eventMetadata[key]!.attributes + attrs[ProcessingStage.self] = .enriched + eventMetadata[key]?.attributes = attrs + } + mutatedEvent.metadata = eventMetadata + } + self.inner.log(event: mutatedEvent) +} +``` + +Attributes are **classifications, not enforcement**. An attribute does not guarantee any particular handler behavior — +the handler may not support it. Applications needing guaranteed behavior must enforce it at a different abstraction. + +The attribute mechanism is extensible: packages define their own attribute types using `MetadataAttributeKey`. A +chained `LogHandler` can read the attributes it cares about, act on them, and forward the rest to the next handler in +the chain — enabling composable handler pipelines (e.g., redaction handler -> metrics-extraction handler -> output +handler). Two common attributes — sensitivity and value type hints — will be defined in separate packages so that +the core `Logging` module remains free of domain-specific attributes while providing shared vocabulary across libraries. + +### Detailed design + +#### `MetadataAttributeKey` protocol + +```swift +extension Logger { + /// A protocol for defining custom metadata attribute keys. + /// + /// Conform to this protocol to define a custom attribute that can be stored in + /// ``MetadataValueAttributes``. Each conforming type acts as both the key (identified + /// by its metatype) and the value. + /// + /// This protocol is designed for **small, fixed-vocabulary attributes** represented as + /// enums. Each attribute value is stored as an `Int64` raw value, so attributes occupy minimal + /// space (one inline slot without heap allocation for the common single-attribute case). + /// Attributes that need to carry associated data, strings, or richer payloads are outside + /// the scope of this protocol. + /// + /// ## Example + /// + /// ```swift + /// public enum Priority: Int64, Sendable, MetadataAttributeKey { + /// case low = 1 + /// case high = 2 + /// } + /// ``` + public protocol MetadataAttributeKey: Sendable, + RawRepresentable where RawValue == Int64 {} +} +``` + +Each conforming type serves as both the key and the value for an attribute. The metatype (`Key.Type`) identifies +the attribute at runtime — no coordination between packages is needed. + +#### `MetadataValueAttributes` + +```swift +extension Logger { + /// Attributes that can be associated with metadata values. + /// + /// `MetadataValueAttributes` stores one attribute inline without heap allocation. When more than one attribute + /// is needed, additional attributes spill over to a heap-allocated array. + /// Use the generic subscript to get/set attributes by their ``MetadataAttributeKey`` type. + public struct MetadataValueAttributes: Sendable, Equatable, ExpressibleByArrayLiteral { + /// Create empty metadata value attributes. + public init() + + /// Create metadata value attributes from an array literal of attribute values. + /// + /// ```swift + /// let attrs: MetadataValueAttributes = [Sensitivity.sensitive, ValueType.int64] + /// ``` + public init(arrayLiteral elements: any MetadataAttributeKey...) + + /// Get or set a custom attribute by its key type. + /// + /// - Parameter key: The metatype of the attribute key to access. + /// - Returns: The attribute value, or `nil` if not set. + public subscript(key: Key.Type) -> Key? { get set } + } +} +``` + +The common case (0–1 attributes) avoids heap allocation. `Equatable` compares entries as an unordered set (O(n²), +n typically 0–2). + +#### `MetadataValue.attributes` computed property + +```swift +extension Logger.MetadataValue { + /// The attributes associated with this metadata value, if any. + /// + /// **Getting:** When the value is a ``stringConvertible(_:)`` wrapping an attributed carrier, + /// returns that carrier's attributes. For all other cases returns empty attributes. + /// + /// **Setting:** Replaces the value with `.stringConvertible(AttributedStringCarrier(...))` + /// carrying the given attributes. For `.string` and `.stringConvertible` cases the string + /// representation is preserved. For `.dictionary` and `.array` cases the setter is a no-op. + public var attributes: Logger.MetadataValueAttributes { get set } +} +``` + +This is the handler-side read/write API. Internally, the `Logging` module stores attributes in an internal +`AttributedStringCarrier` class wrapped by `.stringConvertible`. The getter check is a concrete type comparison — no +protocol conformance lookup. Users attach attributes via string interpolation and read them via this property. The +setter allows handlers to modify or add attributes as they process metadata values through the handler chain. + +#### `MetadataValue.attributed` factory method + +```swift +extension Logger.MetadataValue { + /// Creates an attributed metadata value from a string-convertible value and attributes. + public static func attributed( + _ value: some CustomStringConvertible & Sendable, + attributes: Logger.MetadataValueAttributes + ) -> Self +} +``` + +This convenience factory behaves like an additional enum case, producing a +`.stringConvertible(AttributedStringCarrier(...))` value without requiring string interpolation: + +```swift +let value: Logger.MetadataValue = .attributed(userId, attributes: [Sensitivity.sensitive]) +``` + +#### String interpolation + +`MetadataValue` replaces its empty `ExpressibleByStringInterpolation` conformance with a custom `StringInterpolation` +type. When no attributes are specified, the result is `.string(...)` — identical to the previous behavior. When any +interpolation segment specifies attributes, the result is `.stringConvertible(...)` wrapping the internal carrier: +```swift +extension Logger.MetadataValue: ExpressibleByStringInterpolation { + /// Custom string interpolation that optionally captures metadata attributes. + /// + /// When no attributes are specified, produces `.string(...)` — identical to the + /// default `DefaultStringInterpolation` behavior. When any interpolation segment + /// specifies attributes (via the `attributes:` parameter), the result is + /// `.stringConvertible(AttributedStringCarrier(...))` carrying both the string + /// and the accumulated attributes. + /// + /// Attribute packages (like `LoggingAttributes`) can add overloads with + /// domain-specific parameters (e.g. `sensitivity:`) that call through to + /// `appendInterpolation(_:attributes:)`. + public struct StringInterpolation: StringInterpolationProtocol, Sendable { + public init(literalCapacity: Int, interpolationCount: Int) + public mutating func appendLiteral(_ literal: String) + + /// Interpolation with a custom attributes closure. + /// + /// When called, the result will be `.stringConvertible(AttributedStringCarrier(...))` + /// instead of `.string(...)`. + /// + /// ```swift + /// "\(userId, attributes: { $0[MyAttribute.self] = .flagged })" + /// ``` + public mutating func appendInterpolation( + _ value: T, + attributes: @Sendable (inout Logger.MetadataValueAttributes) -> Void + ) where T: CustomStringConvertible & Sendable + + /// Plain interpolation without attributes. + public mutating func appendInterpolation( + _ value: T + ) where T: CustomStringConvertible & Sendable + + /// Fallback interpolation for types that are not `CustomStringConvertible`. + public mutating func appendInterpolation( + _ value: T + ) where T: Sendable + + /// Unconstrained fallback for non-`Sendable` types. + /// + /// The value is immediately converted to `String` via `String(describing:)` and never + /// stored, so no `Sendable` safety issue arises. This overload exists so that interpolating + /// a non-`Sendable` type into a `MetadataValue` continues to compile, matching the behavior + /// of `DefaultStringInterpolation`. + public mutating func appendInterpolation( + _ value: T + ) + } + + /// Creates a metadata value from string interpolation. + /// + /// If any interpolation segment specified attributes, the result is + /// `.stringConvertible(AttributedStringCarrier(...))`. Otherwise, `.string(...)`. + public init(stringInterpolation: StringInterpolation) +} +``` + +Attribute packages add ergonomic overloads that wrap the closure-based method — for example: + +```swift +extension Logger.MetadataValue.StringInterpolation { + public mutating func appendInterpolation( + _ value: T, sensitivity: Sensitivity + ) { + self.appendInterpolation(value, attributes: { $0[Sensitivity.self] = sensitivity }) + } +} +``` + +This enables the clean call-site syntax: `"\(userId, sensitivity: .sensitive)"`. + +When a single `MetadataValue` contains multiple interpolated segments with different attributes, the merging behavior +is defined by each attribute's `appendInterpolation` implementation. + +#### No changes to `Logger`, `LogHandler`, `LogEvent`, or `MetadataProvider` + +Unlike earlier iterations of this proposal, the piggyback approach requires **no new API** on `Logger`, `LogHandler`, +`LogEvent`, or `MetadataProvider`: + +- **`Logger`**: No new `attributedMetadata:` log method overloads. Attributes flow through the existing `metadata:` + parameter. +- **`LogHandler`**: No new protocol requirements. No `attributedMetadata` property, no `attributedMetadataKey` + subscript. Existing handlers work unchanged. +- **`LogEvent`**: No dual storage. `metadata` remains a simple `Logger.Metadata?` stored property. Values inside the + dictionary may carry attributes, but `LogEvent` is unaware of this. +- **`MetadataProvider`**: No `.attributed()` factory or `getAttributedMetadata()` method. Providers return + `Logger.Metadata` where values may carry attributes via string interpolation. + +### API stability + +- **Existing `Logger` users.** No changes to existing API. The same `metadata:` parameter carries attributed values. +- **Existing `LogHandler` implementations.** No protocol changes. Handlers that only call `.description` on metadata + values see the string as before — attributes are invisible. Handlers that want to inspect attributes read + `value.attributes`. + +### Future directions + +- **Attribute-aware equality.** The current `MetadataValue` equality for `.stringConvertible` values compares only + `.description`, ignoring attributes. Two values with the same text but different attributes compare as equal. A + future proposal could refine equality to include attributes when both sides carry them, but this + requires careful consideration of backward compatibility since existing code may rely on description-only equality. +- **Recursive attribute inspection.** The `.attributes` property only inspects top-level values. Metadata values nested + inside `.dictionary` or `.array` cases carry their own attributes on leaf values, but handler-level iteration (for + example, redaction) operates on top-level entries only. A future proposal could introduce recursive attribute + traversal utilities for handlers that need deep inspection. +- **Typed metadata values.** A separate ecosystem package could add typed variants to `MetadataValue` (`.int64`, + `.double`, `.bool`), reducing the need for attributes that compensate for stringly-typed metadata. + +### Alternatives considered + +#### Wrapper type with parallel API ("attributed metadata value" approach) + +Introduce an `AttributedMetadataValue` struct wrapping `MetadataValue` + `MetadataValueAttributes`, a separate +`AttributedMetadata` typealias, new `attributedMetadata:` overloads on all Logger methods, dual storage in +`LogEvent`, and new `LogHandler` protocol requirements. This was the design in the first iteration of this proposal. + +It provides strong type separation but creates a parallel API surface: 7 new convenience methods, new protocol +requirements with default implementations, dual `MetadataStorage` enum in `LogEvent`, and `mapValues` bridging +between plain and attributed representations. The piggyback approach achieves the same functionality by embedding +attributes inside `MetadataValue` via the existing `.stringConvertible` case, avoiding the parallel universe +entirely. + +#### Sidecar dictionary on `LogEvent` + +Store attributes in a separate `[String: MetadataValueAttributes]` dictionary on `LogEvent`, keyed by the same +metadata keys. This keeps `MetadataValue` unchanged but introduces sync issues — attributes can drift out of sync +with metadata keys when handlers merge or transform metadata. The piggyback approach avoids this by co-locating +attributes with the value they describe. + +#### New enum case on `MetadataValue` + +Add a dedicated `.attributed(String, MetadataValueAttributes)` case to the `MetadataValue` enum. This would make +attributes statically visible in the type — no runtime downcast needed, and `switch` statements would handle +attributed values explicitly. However, `MetadataValue` is a public enum that is exhaustively matched by every +`LogHandler` in the ecosystem. Adding a case is source-breaking: every existing handler's `switch` over +`MetadataValue` would fail to compile until updated. The piggyback approach avoids this by reusing the existing +`.stringConvertible` case, which handlers already handle. + +#### Concrete stored property instead of extensible mechanism + +Add concrete stored properties (e.g., `var sensitivity: Sensitivity?`) on `MetadataValueAttributes`. Simpler, but +closed — chained handlers cannot define their own attributes. The extensible mechanism keeps the core `Logging` module +free of domain-specific attributes. + +#### Bitmask storage + +Pack attribute values into an inline `UInt64` using declared bit offsets. O(1) access, but requires authors to +coordinate bit layout and risks collisions between independent packages. + +#### Pure dynamic array storage + +Use a dynamic array for all attributes with no inline slot. Simpler, but requires heap allocation even for the first +attribute. + +#### No per-value attributes + +Rely on handler-side configuration (key-name-based rules). Simpler, but fragile — rules break when keys are renamed, +require coordination across all dependencies, and are invisible at the call site. + +### Example attributes + +The following examples illustrate how ecosystem packages could define attributes using the `MetadataAttributeKey` +protocol. These are not part of this proposal. + +#### Sensitivity + +A sensitivity attribute for marking metadata values that contain private or personally identifiable information: + +```swift +public enum Sensitivity: Int64, Logger.MetadataAttributeKey, Sendable { + case sensitive = 1 + case `public` = 2 +} + +extension Logger.MetadataValue.StringInterpolation { + public mutating func appendInterpolation( + _ value: T, sensitivity: Sensitivity + ) { + self.appendInterpolation(value, attributes: { $0[Sensitivity.self] = sensitivity }) + } +} + +logger.info("Request", metadata: [ + "method": "\(req.method)", + "user_id": "\(req.userId, sensitivity: .sensitive)", +]) +``` + +A handler reads the attribute: + +```swift +for (key, value) in mergedMetadata { + if value.attributes[Sensitivity.self] == .sensitive { + // redact this value + } +} +``` + +#### Value type hint + +A type hint for structured logging backends that benefit from native types: + +```swift +public enum ValueType: Int64, Logger.MetadataAttributeKey, Sendable { + case string = 1 + case int64 = 2 + case double = 3 + case bool = 4 +} + +extension Logger.MetadataValue.StringInterpolation { + public mutating func appendInterpolation( + _ value: T, valueType: ValueType + ) { + self.appendInterpolation(value, attributes: { $0[ValueType.self] = valueType }) + } +} + +logger.info("Metrics", metadata: [ + "latency_ms": "\(latency, valueType: .double)", + "retry_count": "\(retries, valueType: .int64)", +]) +``` + +#### Metric extraction + +A metric attribute for a chained `LogHandler` that dual-writes to swift-metrics: + +```swift +public enum MetricKind: Int64, Logger.MetadataAttributeKey, Sendable { + case counter = 1 + case gauge = 2 + case histogram = 3 +} + +extension Logger.MetadataValue.StringInterpolation { + public mutating func appendInterpolation( + _ value: T, metricKind: MetricKind + ) { + self.appendInterpolation(value, attributes: { $0[MetricKind.self] = metricKind }) + } +} + +logger.info("Request completed", metadata: [ + "duration_ms": "\(duration, metricKind: .histogram)", + "error_count": "\(errors, metricKind: .counter)", +]) +``` + +#### Attribute-enriching intermediate handler + +An intermediate `LogHandler` that reads and modifies attributes before forwarding to the next handler in the chain. +This pattern enables composable pipelines — for example, a handler that adds a default sensitivity to all metadata +values that do not already carry one: + +```swift +struct DefaultSensitivityHandler: LogHandler { + private var inner: any LogHandler + + // ... logLevel, metadata, metadataProvider forwarded to inner ... + + func log(event: LogEvent) { + var mutatedEvent = event + if var eventMetadata = event.metadata { + for (key, _) in eventMetadata { + if eventMetadata[key]?.attributes[Sensitivity.self] == nil { + eventMetadata[key]?.attributes = [Sensitivity.sensitive] + } + } + mutatedEvent.metadata = eventMetadata + } + self.inner.log(event: mutatedEvent) + } +} +``` + +With this handler in the chain, any metadata value without an explicit sensitivity annotation is treated as sensitive +by default — a "deny by default" policy that individual call sites can override with `.public`. diff --git a/Sources/Logging/Docs.docc/Proposals/SLG-0004-metadata-values-privacy-attributes.md b/Sources/Logging/Docs.docc/Proposals/SLG-0004-metadata-values-privacy-attributes.md deleted file mode 100644 index 9143e09a..00000000 --- a/Sources/Logging/Docs.docc/Proposals/SLG-0004-metadata-values-privacy-attributes.md +++ /dev/null @@ -1,467 +0,0 @@ -# SLG-0004: Metadata values privacy attribute - -Introduce an attributed metadata system that allows attaching attributes to metadata values, with privacy level as the first attribute enabling developers to mark metadata as `.private` or `.public`. - -## Overview - -- Proposal: SLG-0004 -- Author(s): [Vladimir Kukushkin](https://github.com/kukushechkin) -- Status: **Deferred** -- Issue: https://github.com/apple/swift-log/issues/204 -- Implementation: - - [apple/swift-log#418](https://github.com/apple/swift-log/pull/418) -- Related links: - - [Lightweight proposals process description](https://github.com/apple/swift-log/blob/main/Sources/Logging/Docs.docc/Proposals/Proposals.md) - - [Review discussion](https://forums.swift.org/t/proposal-slg-0004-metadata-values-privacy-attribute/85249/16) - -### Introduction - -This proposal introduces an **attributed metadata system** that allows attaching attributes to metadata values. Privacy level is the first concrete attribute, enabling developers to mark metadata values as `.private` or `.public` so `LogHandler` implementations can redact sensitive values before logging. - -### Motivation - -SwiftLog lacks a mechanism to attach attributes to metadata values. This prevents marking metadata as sensitive and limits future extensibility for other metadata attributes. - -Applications need privacy controls for: -- Compliance with privacy regulations. -- Backend integration with privacy-aware logging systems. -- Systematic control over what data appears in logs. - -Beyond privacy, an attributed metadata system provides a foundation for future extensions such as retention policies, data classification, or other metadata attributes. - -### Proposed solution - -Introduce new `AttributedMetadata` data type, enabling users to mark metadata values with `.private` and `.public` privacy label: - -```swift -// Add privacy level to metadata values obtained through a metadata provider -let serviceIp = "127.0.0.1" -var logger = Logger(label: "my-app", metadataProvider: .init {[ - "service.ip": "\(serviceIp, privacy: .private)", -]}) - -// Add privacy level to metadata values attached to a `Loghandler` instance -let requestId = UUID() -logger[attributedMetadataKey: "request.id"] = "\(requestId, privacy: .private)" - -// Mark sensitive values as private and non-sensitive as public at logging call site -let userId = UUID() -let action = "login" -logger.info("User action", attributedMetadata: [ - "user.id": "\(userId, privacy: .private)", - "action": "\(action)" // default is .public for ease of adoption -]) -``` - -This allows `Logger` users to mark certain metadata values as "I only trust LogHandlers, who know how to handle sensitive data, to see this value". - -### Detailed design - -#### Attributed Metadata - -To support privacy labels and enable future extensibility, we introduce a generalized **attributed metadata** system. This provides a foundation for attaching arbitrary attributes to metadata values, with privacy labels being the first concrete use case: - -```swift -// Plain metadata (existing) -let metadataValue: MetadataValue = .string("value") -logger.info("Message", metadata: ["key": metadataValue]) - -// Attributed metadata (new) — a value with attributes -logger.info("Message", attributedMetadata: [ - "key": AttributedMetadataValue(value: metadataValue, attributes: ...) -]) -``` - -The attributed metadata system instroduces new public API: - -1. **`AttributedMetadataValue`**: Wraps a standard `MetadataValue` with associated attributes. -2. **`MetadataValueAttributes`**: Container for attributes. -3. **`AttributedMetadata`**: Dictionary type mapping `String` keys to attributed values. -4. **Parallel logging API**: New log methods accepting `attributedMetadata` parameter. - -This design separates the transport mechanism (attributed metadata) from specific attribute semantics (privacy labels), allowing future extensions without breaking existing attributed metadata code. - -#### Privacy levels - -With attributed metadata as the foundation, privacy labels are implemented as the first concrete attribute: - -```swift -extension Logger { - /// A metadata value with associated privacy attributes. - /// - /// `AttributedMetadataValue` wraps a standard `MetadataValue` with privacy attributes, - /// allowing you to mark metadata as private or public for privacy-aware logging. - /// - /// ## Creating Attributed Metadata - /// - /// Use string interpolation with privacy parameter: - /// - /// ```swift - /// // Preferred: String interpolation with privacy level specified - /// let userId = "12345" - /// let action = "login" - /// logger.info("User action", attributedMetadata: [ - /// "user.id": "\(userId, privacy: .private)", - /// "action": "\(action, privacy: .public)" // explicit .public for non-sensitive data - /// ]) - /// - /// // Direct creation - /// let attributed = Logger.AttributedMetadataValue( - /// .string("12345"), - /// privacy: .private - /// ) - /// ``` - /// - /// ## Important Limitations - /// - /// - **No nested privacy**: When marking a dictionary or array as private, all contained - /// values are treated with the same privacy level. Fine-grained privacy within nested - /// structures is not currently supported. - /// - /// - **Metadata only**: Privacy levels apply only to metadata values, not to log messages. - /// Avoid including sensitive data in log message string interpolation. - public struct AttributedMetadataValue: CustomStringConvertible, Sendable { - /// The redaction marker used for private values. - internal static let redactionMarker = "" - - public let value: MetadataValue - public let attributes: MetadataValueAttributes - - /// String representation redacts private values to the redaction marker. - public var description: String { - attributes.privacy == .public ? value.description : Self.redactionMarker - } - - public init(_ value: MetadataValue, attributes: MetadataValueAttributes) - public init(_ value: MetadataValue, privacy: PrivacyLevel) - } - - extension AttributedMetadataValue { - /// Attributes that can be associated with metadata values. - public struct MetadataValueAttributes: CustomStringConvertible, Hashable, Sendable { - /// Privacy level for metadata values. - /// - /// Privacy levels allow you to mark metadata values as either private (sensitive data that should be - /// protected) or public (safe to log in any context). - /// - /// ## Usage - /// - /// Use string interpolation with the privacy parameter to create attributed metadata values: - /// - /// ```swift - /// let userId = "12345" - /// let action = "login" - /// logger.info("User action", attributedMetadata: [ - /// "user.id": "\(userId, privacy: .private)", - /// "action": "\(action, privacy: .public)", - /// "ip": "\(ipAddress, privacy: .private)" - /// ]) - /// ``` - @frozen - public enum PrivacyLevel: Sendable, CaseIterable, Equatable, Hashable, CustomStringConvertible { - /// Private data that should be redacted in non-secure contexts. - case `private` - - /// Public data safe for general logging. - case `public` - } - - /// The privacy level of this metadata value. - public var privacy: PrivacyLevel - - /// Create metadata value attributes with the specified privacy level. - /// - /// - Parameter privacy: The privacy level for this metadata. Defaults to `.public`. - public init(privacy: PrivacyLevel = .public) - } - - /// The underlying metadata value without privacy attributes. - public var value: MetadataValue - - /// The privacy attributes associated with this metadata value. - public var attributes: MetadataValueAttributes - - /// Create an attributed metadata value with the specified attributes. - /// - /// - Parameters: - /// - value: The metadata value to wrap. - /// - attributes: The attributes for this value. - public init(_ value: MetadataValue, attributes: MetadataValueAttributes) - - /// Convenience initializer for creating attributed metadata with a privacy level. - /// - /// - Parameters: - /// - value: The metadata value to wrap. - /// - privacy: The privacy level for this value. - public init(_ value: MetadataValue, privacy: PrivacyLevel) - } - - /// Metadata dictionary with privacy attributes. - /// - /// A dictionary mapping string keys to ``AttributedMetadataValue`` instances, used with - /// the `attributedMetadata` parameter of logging methods. - /// - /// ## Example - /// - /// ```swift - /// let userId = "12345" - /// let requestId = "req-789" - /// let metadata: Logger.AttributedMetadata = [ - /// "user.id": "\(userId, privacy: .private)", - /// "request.id": "\(requestId, privacy: .public)", - /// "action": "purchase" // String literal defaults to .public - /// ] - /// logger.info("User action", attributedMetadata: metadata) - /// ``` - public typealias AttributedMetadata = [String: AttributedMetadataValue] -} -``` - -#### String interpolation for attributed metadata - -```swift -extension Logger.AttributedMetadataValue: ExpressibleByStringLiteral, ExpressibleByStringInterpolation { - /// Custom string interpolation that captures privacy levels from interpolated values. - /// - /// This enables syntax like: - /// ```swift - /// logger.info("User action", attributedMetadata: [ - /// "user.id": "\(userId, privacy: .private)", - /// "action": "\(action, privacy: .public)" - /// ]) - /// ``` - public struct StringInterpolation: StringInterpolationProtocol { - public init(literalCapacity: Int, interpolationCount: Int) - public mutating func appendLiteral(_ literal: String) - - /// Interpolation with explicit privacy parameter. - public mutating func appendInterpolation(_ value: T, privacy: Logger.PrivacyLevel = .public) - where T: CustomStringConvertible - } - - /// Creates an attributed metadata value from a string literal (defaults to .public). - public init(stringLiteral value: String) - - /// Creates an attributed metadata value from string interpolation. - public init(stringInterpolation: StringInterpolation) -} -``` - -#### New Logger methods - -```swift -extension Logger { - // Attributed metadata storage - var attributedMetadata: Logger.AttributedMetadata { get set } - subscript(attributedMetadataKey: String) -> Logger.AttributedMetadataValue? { get set } - - // Log family of methods - public func log(level: Level, _ message: @autoclosure () -> Message, - attributedMetadata: @autoclosure () -> AttributedMetadata?, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) - public func trace(_ message: @autoclosure () -> Message, - attributedMetadata: @autoclosure () -> AttributedMetadata?, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) - public func debug(_ message: @autoclosure () -> Message, - attributedMetadata: @autoclosure () -> AttributedMetadata?, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) - public func info(_ message: @autoclosure () -> Message, - attributedMetadata: @autoclosure () -> AttributedMetadata?, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) - public func notice(_ message: @autoclosure () -> Message, - attributedMetadata: @autoclosure () -> AttributedMetadata?, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) - public func warning(_ message: @autoclosure () -> Message, - attributedMetadata: @autoclosure () -> AttributedMetadata?, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) - public func error(_ message: @autoclosure () -> Message, - attributedMetadata: @autoclosure () -> AttributedMetadata?, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) - public func critical(_ message: @autoclosure () -> Message, - attributedMetadata: @autoclosure () -> AttributedMetadata?, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) -} -``` - -#### AttributedMetadata support in LogHandler protocol - -```swift -protocol LogHandler { - func log(level: Logger.Level, message: Logger.Message, - attributedMetadata: Logger.AttributedMetadata?, source: String, - file: String, function: String, line: UInt) - - var attributedMetadata: Logger.AttributedMetadata { get set } - subscript(attributedMetadataKey: String) -> Logger.AttributedMetadataValue? { get set } -} - -// Default implementations provided for backward compatibility -extension LogHandler { - // Default attributed metadata log method - redacts private values using helper - public func log(level: Logger.Level, message: Logger.Message, - attributedMetadata: Logger.AttributedMetadata?, - source: String, file: String, function: String, line: UInt) { - let processedMetadata = attributedMetadata.flatMap(Self.attributedToPlain) - self.log(level: level, message: message, metadata: processedMetadata, - source: source, file: file, function: function, line: line) - } - - // Default attributed metadata storage - uses helpers for conversion - public var attributedMetadata: Logger.AttributedMetadata { - get { self.metadata.mapValues(Self.plainToAttributed) } - set { self.metadata = newValue.mapValues(Self.attributedToPlain) } - } - - public subscript(attributedMetadataKey key: String) -> Logger.AttributedMetadataValue? { - get { - guard let plainValue = self[metadataKey: key] else { return nil } - return Self.plainToAttributed(plainValue) - } - set { - if let attributedValue = newValue { - self[metadataKey: key] = Self.attributedToPlain(attributedValue) - } else { - self[metadataKey: key] = nil - } - } - } -} -``` - -#### AttributedMetadata support in MetadataProvider - -```swift -extension Logger.MetadataProvider { - /// Creates a new metadata provider that returns attributed metadata. - /// - /// Attributed metadata providers allow you to specify privacy levels and other - /// attributes for each metadata value. When accessed through the plain ``get()`` - /// method (for backward compatibility), private values are redacted to `""`. - /// - /// ### Example - /// - /// ```swift - /// let provider = Logger.MetadataProvider { - /// [ - /// "request-id": "\(RequestContext.current.id, privacy: .public)", - /// "user-id": "\(RequestContext.current.userId, privacy: .private)" - /// ] - /// } - /// ``` - /// - /// - Parameter provideAttributed: A closure that extracts attributed metadata from the current execution context. - /// - Returns: A metadata provider that returns attributed metadata. - @_disfavoredOverload - public static func init(_ provideAttributed: @escaping @Sendable () -> AttributedMetadata) -> MetadataProvider - - /// Returns attributed metadata if this is an attributed provider. - /// - /// Handlers supporting attributed metadata should call this first, - /// falling back to `get()` if it returns nil. - public func getAttributed() -> AttributedMetadata? -} -``` - -### API stability - -- All existing APIs unchanged. -- Plain and attributed metadata APIs coexist. -- No deprecation planned. -- Adoption is optional and incremental on both application and Log Handler sides. -- Default implementation ensures existing handlers work; logging private metadata is an application concern requiring a compatible `LogHandler`. - -### Future directions - -- Add more attributes (e.g., value data type, etc) and potential future unification of plain and attributed metadata APIs. -- Introduce "custom attributes", allowing applications to define application-specific attributes to work with application-specific log handlers. - -### Alternatives considered - -1. **Key-based redaction:** Configure which keys should be treated as private rather than marking each value: - -```swift -logger.privateKeys = ["user.id", "password", "email"] -logger.info("User action", metadata: [ - "user.id": "12345", // Automatically private - "action": "login" // Automatically public -]) -``` - -Advantages: -- Simpler API (no new types). -- Centralized configuration. -- Safer at scale (new sensitive fields update all logs automatically). -- Easier migration. - -**Not chosen because:** - -- **Privacy belongs to data, not identifiers:** The same private data might be logged under different keys ("email", "user.email", "contact"), and the same key might contain different data with different privacy requirements in different contexts. Key-based redaction creates a synchronization problem—developers must maintain a separate list of "private keys" that stays in sync with actual logging code across the codebase, with no compile-time or review-time verification. - -- **Code review visibility:** With value-based privacy, reviewers see privacy decisions at the call site: `"email": user.email.private()` makes it immediately clear that data is sensitive. With key-based redaction, reviewers must cross-reference a separate configuration file, making security review significantly harder. - -- **No synchronization needed:** Value-based privacy is self-contained—privacy travels with the data at the point of use. No separate configuration to maintain, no risk of configuration drift, no runtime surprises when a key is missing from the private keys list. - -- **Pattern complexity:** Supporting patterns/regex adds complexity and potential performance concerns. - -The current design prioritizes **explicitness and data-centric privacy** over **configuration-based simplicity**. Privacy decisions are made where data is logged, making them visible during code review and keeping privacy attributes coupled to the data they protect. - -2. **Convenience methods (`.private()` and `.public()`):** Add extension methods to `String` and `MetadataValue` for creating attributed metadata: - -```swift -extension String { - public func `private`() -> Logger.AttributedMetadataValue - public func `public`() -> Logger.AttributedMetadataValue -} - -logger.info("User action", attributedMetadata: [ - "user.id": "12345".private(), - "action": "login".public() -]) -``` - -**Not chosen because:** - -- **Consistency with existing patterns:** `Logger.Message` and `Logger.MetadataValue` already use string interpolation extensively. -- **Natural syntax:** `"\(value, privacy: .private)"` reads clearly and fits Swift's interpolation conventions. -- **Less API surface:** No need for multiple extension methods across different types. - -Metadata values already support string interpolation in SwiftLog. Rather than inventing additional API surface area with new methods, we leverage the existing string interpolation infrastructure with a custom `StringInterpolation` type. This provides: - -3. **Default privacy level to `.private`:** Make attributed metadata values default to `.private` privacy level when no explicit privacy parameter is provided: - -```swift -logger.info("User action", attributedMetadata: [ - "user.id": "\(userId, privacy: .private)", // Explicit private (redundant with default) - "action": "\(action, privacy: .public)" // Must explicitly mark as public -]) -``` - -Advantages: -- Security-by-default: requires explicit opt-out for logging non-sensitive data. -- Safer for accidental inclusion of sensitive data. - -**Not chosen because:** - -Privacy should be an explicit opt-in action from the user. The current design (defaulting to `.public`) prioritizes **ease of adoption** over **security-by-default**: -- Easier adoption - less boilerplate for common non-sensitive metadata. -- Matches the mental model that "most data is safe to log". -- Lower friction for migration from plain metadata. -- Forces developers to think about privacy only for sensitive data, rather than requiring `.public` annotations everywhere. - -4. **Pass all metadata to non-privacy-aware handlers:** Security risk; current design filters private data by default. - -5. **Message-level privacy:** Less granular than metadata-level privacy and requires message handling changes. - -6. **Privacy level handling configuration** to be an attribute of the `Logger` instead of LogHandler configuration. This would centralize the configuration across various `LogHandler` implementations. However, existing `LogHandler` already have configurations and they might want to customize the behavior even further. - -7. **Handler metadata merging:** LogHandlers are responsible for merging their own `metadata` property, `metadataProvider` output, and the explicit `attributedMetadata` parameter. This is consistent with how plain metadata works - handlers control merging. Plain handler metadata and provider values should be treated as public (`.public` privacy level). Attributed metadata from the log call takes precedence. - -8. **Add a third `.auto`/`.default` privacy attribute value:** Libraries and applications can mark a metadata value as `privacy: .default` and rely on the `LogHandler` to configure what the default is. While this might've been a solution to overcome default `.public` values for all the attributed metadata, in reality it is confusing from the semantic point of view. If someone wants to mark something as `.default`, because this _might_ be sensitive, then it should be marked as `.private` or not logged at all. If something does not need to be marked as `.private`, then it is `.public`. A custom `LogHandler` with an allow list of metadata keys/messages/modules can be used as an escape hatch in case the application does not trust its dependencies. diff --git a/Sources/Logging/Docs.docc/Reference/LogEvent.md b/Sources/Logging/Docs.docc/Reference/LogEvent.md index d164c85a..204dfaae 100644 --- a/Sources/Logging/Docs.docc/Reference/LogEvent.md +++ b/Sources/Logging/Docs.docc/Reference/LogEvent.md @@ -5,3 +5,15 @@ ### Creating a log event - ``init(level:message:metadata:source:file:function:line:)`` +- ``init(level:message:error:metadata:source:file:function:line:)`` + +### Inspecting a log event + +- ``level`` +- ``message`` +- ``error`` +- ``metadata`` +- ``source`` +- ``file`` +- ``function`` +- ``line`` diff --git a/Sources/Logging/Docs.docc/Reference/Logger-MetadataProvider.md b/Sources/Logging/Docs.docc/Reference/Logger-MetadataProvider.md index 1d2a039a..f4b563e1 100644 --- a/Sources/Logging/Docs.docc/Reference/Logger-MetadataProvider.md +++ b/Sources/Logging/Docs.docc/Reference/Logger-MetadataProvider.md @@ -4,7 +4,7 @@ ### Creating a metadata provider -- ``init(_:)`` +- ``init(_:)-(()->Logger.Metadata)`` ### Invoking the provider diff --git a/Sources/Logging/Docs.docc/Reference/Logger.md b/Sources/Logging/Docs.docc/Reference/Logger.md index 6f4084fe..d830a2d9 100644 --- a/Sources/Logging/Docs.docc/Reference/Logger.md +++ b/Sources/Logging/Docs.docc/Reference/Logger.md @@ -66,6 +66,11 @@ - ``subscript(metadataKey:)`` - ``MetadataValue`` +### Metadata attribute types + +- ``MetadataAttributeKey`` +- ``MetadataValueAttributes`` + ### Inspecting a logger - ``label`` diff --git a/Sources/Logging/Docs.docc/Reference/SwiftLogNoOpLogHandler.md b/Sources/Logging/Docs.docc/Reference/SwiftLogNoOpLogHandler.md index 8697fd43..e21ae88b 100644 --- a/Sources/Logging/Docs.docc/Reference/SwiftLogNoOpLogHandler.md +++ b/Sources/Logging/Docs.docc/Reference/SwiftLogNoOpLogHandler.md @@ -1,5 +1,7 @@ # ``Logging/SwiftLogNoOpLogHandler`` +A log handler that silently discards all log messages. + ## Topics ### Creating a Swift Log no-op log handler diff --git a/Sources/Logging/Docs.docc/index.md b/Sources/Logging/Docs.docc/index.md index dd8ccf21..2fa79e41 100644 --- a/Sources/Logging/Docs.docc/index.md +++ b/Sources/Logging/Docs.docc/index.md @@ -17,7 +17,7 @@ This separation allows libraries to adopt the API while applications choose any compatible logging backend implementation without requiring changes from libraries. -## Getting Started +### Getting Started Use this package if you're writing a cross-platform application (for example, Linux and macOS) or library, and want to target this logging API. diff --git a/Sources/Logging/LogEvent.swift b/Sources/Logging/LogEvent.swift index 16a11f00..3f552b6d 100644 --- a/Sources/Logging/LogEvent.swift +++ b/Sources/Logging/LogEvent.swift @@ -30,12 +30,9 @@ public struct LogEvent: Sendable { public var error: (any Error)? /// The metadata associated with this event, if any. - public var metadata: Logger.Metadata? { - get { self._metadata } - set { self._metadata = newValue } - } - - private var _metadata: Logger.Metadata? + /// + /// Metadata values may carry attributes accessible via ``Logger/MetadataValue/attributes``. + public var metadata: Logger.Metadata? /// The source where this log event originated, for example the logging module. /// @@ -116,7 +113,7 @@ public struct LogEvent: Sendable { self.level = level self.message = message self.error = error - self._metadata = metadata + self.metadata = metadata self._source = source self.file = file self.function = function diff --git a/Sources/Logging/Logger+Attributes.swift b/Sources/Logging/Logger+Attributes.swift new file mode 100644 index 00000000..92981510 --- /dev/null +++ b/Sources/Logging/Logger+Attributes.swift @@ -0,0 +1,445 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +// MARK: - MetadataAttributeKey protocol + +extension Logger { + /// A protocol for defining custom metadata attribute keys. + /// + /// Conform to this protocol to define a custom attribute that can be stored in + /// ``MetadataValueAttributes``. Each conforming type acts as both the key (identified + /// by its metatype) and the value. + /// + /// This protocol is designed for **small, fixed-vocabulary attributes** represented as + /// enums. Each attribute value is stored as an `Int64` raw value, so attributes occupy minimal + /// space (one inline slot without heap allocation for the common single-attribute case). + /// Attributes that need to carry associated data, strings, or richer payloads are outside + /// the scope of this protocol. + /// + /// ## Example + /// + /// ```swift + /// public enum Priority: Int64, Sendable, MetadataAttributeKey { + /// case low = 1 + /// case high = 2 + /// } + /// ``` + public protocol MetadataAttributeKey: Sendable, RawRepresentable where RawValue == Int64 {} + + /// An entry in the metadata attributes storage. + @usableFromInline + internal struct MetadataAttributeEntry: Sendable, Equatable { + @usableFromInline + internal var key: ObjectIdentifier + + @usableFromInline + internal var value: Int64 + + @inlinable + internal init(key: ObjectIdentifier, value: Int64) { + self.key = key + self.value = value + } + } +} + +// MARK: - MetadataValueAttributes storage + +extension Logger { + /// Attributes that can be associated with metadata values. + /// + /// `MetadataValueAttributes` stores one attribute inline without heap allocation. When more than one attribute + /// is needed, additional attributes spill over to a heap-allocated array. + /// Use the generic subscript to get/set attributes by their ``MetadataAttributeKey`` type. + public struct MetadataValueAttributes: Sendable { + @usableFromInline + internal var _inline: MetadataAttributeEntry? + + @usableFromInline + internal var _overflow: [MetadataAttributeEntry]? + + /// Create empty metadata value attributes. + @inlinable + public init() {} + + /// Create metadata value attributes using a builder closure. + /// + /// ```swift + /// let attrs = Logger.MetadataValueAttributes { + /// $0[Sensitivity.self] = .sensitive + /// $0[Priority.self] = .high + /// } + /// ``` + @inlinable + public init(_ build: (inout Self) -> Void) { + self.init() + build(&self) + } + + /// Get or set a custom attribute by its key type. + /// + /// - Parameter key: The metatype of the attribute key to access. + /// - Returns: The attribute value, or `nil` if not set. + @inlinable + public subscript(key: Key.Type) -> Key? { + get { + let id = ObjectIdentifier(Key.self) + if let inline = self._inline, inline.key == id { return Key(rawValue: inline.value) } + if let overflow = self._overflow { + for entry in overflow { + if entry.key == id { return Key(rawValue: entry.value) } + } + } + return nil + } + set { + let id = ObjectIdentifier(Key.self) + if let v = newValue { + self._upsert(key: id, value: v.rawValue) + } else { + self._remove(key: id) + } + } + } + + @inlinable + internal mutating func _upsert(key id: ObjectIdentifier, value: Int64) { + let entry = MetadataAttributeEntry(key: id, value: value) + if let inline = self._inline, inline.key == id { + self._inline = entry + return + } + if let idx = self._overflow?.firstIndex(where: { $0.key == id }) { + self._overflow?[idx] = entry + return + } + if self._inline == nil { + self._inline = entry + } else { + if self._overflow == nil { + self._overflow = [entry] + } else { + self._overflow?.append(entry) + } + } + } + + @inlinable + internal mutating func _remove(key id: ObjectIdentifier) { + if let inline = self._inline, inline.key == id { + if var overflow = self._overflow, !overflow.isEmpty { + self._inline = overflow.removeLast() + self._overflow = overflow.isEmpty ? nil : overflow + } else { + self._inline = nil + } + return + } + if let idx = self._overflow?.firstIndex(where: { $0.key == id }) { + self._overflow?.remove(at: idx) + if self._overflow?.isEmpty == true { + self._overflow = nil + } + } + } + + @inlinable + internal mutating func _merge(from other: Self) { + if let inline = other._inline { + self._upsert(key: inline.key, value: inline.value) + } + if let overflow = other._overflow { + for entry in overflow { + self._upsert(key: entry.key, value: entry.value) + } + } + } + + /// Compare all entries as an unordered set. + /// + /// The inline slot and overflow array may hold the same logical entries in different + /// positions depending on insertion order. This method checks set equality by + /// verifying that every entry on one side exists on the other. O(n²) but n is + /// typically 1–2. + @inlinable + internal func _isEqual(to other: Self) -> Bool { + let lhsOverflowCount = self._overflow?.count ?? 0 + let rhsOverflowCount = other._overflow?.count ?? 0 + let lhsCount = (self._inline != nil ? 1 : 0) + lhsOverflowCount + let rhsCount = (other._inline != nil ? 1 : 0) + rhsOverflowCount + guard lhsCount == rhsCount else { return false } + guard lhsCount > 0 else { return true } + + // Check that every lhs entry exists in rhs. + // With matching counts, this is sufficient for set equality. + if let inline = self._inline { + if !other._contains(inline) { return false } + } + if let overflow = self._overflow { + for entry in overflow { + if !other._contains(entry) { return false } + } + } + return true + } + + @inlinable + internal func _contains(_ entry: MetadataAttributeEntry) -> Bool { + if self._inline == entry { return true } + if let overflow = self._overflow { + for e in overflow { + if e == entry { return true } + } + } + return false + } + } +} + +extension Logger.MetadataValueAttributes: Equatable { + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs._isEqual(to: rhs) + } +} + +extension Logger.MetadataValueAttributes: ExpressibleByArrayLiteral { + /// Creates metadata value attributes from an array literal of attribute values. + /// + /// Each element is any ``Logger/MetadataAttributeKey`` conforming value. The key is + /// inferred from the concrete type of the value. + /// + /// ```swift + /// let attrs: Logger.MetadataValueAttributes = [Sensitivity.sensitive, ValueType.int64] + /// ``` + /// Each element goes through existential boxing to extract `type(of:)` and `.rawValue`. + /// This is acceptable for a literal initializer but should not be used in hot paths; + /// prefer the type-safe subscript (`attrs[Key.self] = value`) for performance-sensitive code. + @inlinable + public init(arrayLiteral elements: any Logger.MetadataAttributeKey...) { + self.init() + for element in elements { + self._upsert(key: ObjectIdentifier(type(of: element)), value: element.rawValue) + } + } +} + +// MARK: - AttributedStringCarrier + +extension Logger { + /// Internal carrier that wraps a string with metadata attributes. + /// + /// Produced by `MetadataValue.StringInterpolation` when any interpolation + /// segment specifies attributes, or by ``MetadataValue/attributed(_:attributes:)``. + /// This is the only type that carries attributes; the `.attributes` property on + /// `MetadataValue` checks for it with a concrete type comparison rather than a + /// protocol conformance lookup. + @usableFromInline + internal final class AttributedStringCarrier: CustomStringConvertible, Sendable { + @usableFromInline + internal let string: String + + @usableFromInline + internal let metadataAttributes: Logger.MetadataValueAttributes + + @usableFromInline + internal init(string: String, metadataAttributes: Logger.MetadataValueAttributes) { + self.string = string + self.metadataAttributes = metadataAttributes + } + + @inlinable + internal var description: String { self.string } + } +} + +// MARK: - MetadataValue attributes API + +extension Logger.MetadataValue { + /// The attributes associated with this metadata value, if any. + /// + /// **Getting:** When the value is a ``stringConvertible(_:)`` wrapping an + /// `AttributedStringCarrier`, returns that carrier's attributes. + /// For all other cases returns empty attributes. + /// + /// **Setting:** Replaces the value with `.stringConvertible(AttributedStringCarrier(...))` + /// carrying the given attributes. For `.string` and `.stringConvertible` cases the string + /// representation is preserved. For `.dictionary` and `.array` cases the setter is a no-op. + /// + /// Only handlers that inspect attributes pay the cost of the getter check. + @inlinable + public var attributes: Logger.MetadataValueAttributes { + get { + if case .stringConvertible(let box) = self, + let carrier = box as? Logger.AttributedStringCarrier + { + return carrier.metadataAttributes + } + return .init() + } + set { + switch self { + case .string(let s): + self = .stringConvertible( + Logger.AttributedStringCarrier(string: s, metadataAttributes: newValue) + ) + case .stringConvertible(let box): + self = .stringConvertible( + Logger.AttributedStringCarrier(string: box.description, metadataAttributes: newValue) + ) + case .dictionary, .array: + assertionFailure("Cannot set attributes on .dictionary or .array metadata values") + return + } + } + } + + /// Creates an attributed metadata value from a string-convertible value and attributes. + /// + /// This is a convenience factory that behaves like an additional enum case, producing + /// a `.stringConvertible(AttributedStringCarrier(...))` value without string interpolation: + /// + /// ```swift + /// let value: Logger.MetadataValue = .attributed(userId, attributes: [Sensitivity.sensitive]) + /// ``` + @inlinable + public static func attributed( + _ value: some CustomStringConvertible & Sendable, + attributes: Logger.MetadataValueAttributes + ) -> Self { + .stringConvertible( + Logger.AttributedStringCarrier( + string: String(describing: value), + metadataAttributes: attributes + ) + ) + } +} + +// MARK: - Attributed string interpolation + +// Extension has to be done on explicit type rather than Logger.Metadata.Value as workaround for +// https://bugs.swift.org/browse/SR-9687 +extension Logger.MetadataValue: ExpressibleByStringInterpolation { + /// Custom string interpolation that optionally captures metadata attributes. + /// + /// When no attributes are specified, produces `.string(...)` — identical to the + /// default `DefaultStringInterpolation` behavior. When any interpolation segment + /// specifies attributes (via the `attributes:` parameter), the result is + /// `.stringConvertible(AttributedStringCarrier(...))` carrying both the string + /// and the accumulated attributes. + /// + /// Attribute packages (like `LoggingAttributes`) can add overloads with + /// domain-specific parameters (e.g. `sensitivity:`) that call through to + /// `appendInterpolation(_:attributes:)`. + public struct StringInterpolation: StringInterpolationProtocol, Sendable { + @usableFromInline + internal var output: String = "" + + @usableFromInline + internal var attributes: Logger.MetadataValueAttributes = .init() + + @usableFromInline + internal var hasAttributes: Bool = false + + @inlinable + public init(literalCapacity: Int, interpolationCount: Int) { + self.output.reserveCapacity(literalCapacity + interpolationCount * 2) + } + + @inlinable + public mutating func appendLiteral(_ literal: String) { + self.output.append(literal) + } + + /// Interpolation with a custom attributes closure. + /// + /// When called, the result will be `.stringConvertible(AttributedStringCarrier(...))` + /// instead of `.string(...)`. + /// + /// ```swift + /// "\(userId, attributes: { $0[MyAttribute.self] = .flagged })" + /// ``` + @inlinable + public mutating func appendInterpolation( + _ value: T, + attributes: @Sendable (inout Logger.MetadataValueAttributes) -> Void + ) where T: CustomStringConvertible & Sendable { + self.output.append(value.description) + attributes(&self.attributes) + self.hasAttributes = true + } + + /// Interpolation with pre-built attributes. + /// + /// ```swift + /// "\(userId, attributes: [Sensitivity.sensitive])" + /// ``` + @inlinable + public mutating func appendInterpolation( + _ value: T, + attributes: Logger.MetadataValueAttributes + ) where T: CustomStringConvertible & Sendable { + self.output.append(value.description) + if self.hasAttributes { + self.attributes._merge(from: attributes) + } else { + self.attributes = attributes + } + self.hasAttributes = true + } + + /// Plain interpolation without attributes. + @inlinable + public mutating func appendInterpolation( + _ value: T + ) where T: CustomStringConvertible & Sendable { + self.output.append(value.description) + } + + /// Fallback interpolation for types that are not `CustomStringConvertible`. + @inlinable + public mutating func appendInterpolation( + _ value: T + ) where T: Sendable { + self.output.append(String(describing: value)) + } + + /// Unconstrained fallback for non-`Sendable` types. + @inlinable + public mutating func appendInterpolation( + _ value: T + ) { + self.output.append(String(describing: value)) + } + } + + /// Creates a metadata value from string interpolation. + /// + /// If any interpolation segment specified attributes, the result is + /// `.stringConvertible(AttributedStringCarrier(...))`. Otherwise, `.string(...)`. + @inlinable + public init(stringInterpolation: StringInterpolation) { + if stringInterpolation.hasAttributes { + self = .stringConvertible( + Logger.AttributedStringCarrier( + string: stringInterpolation.output, + metadataAttributes: stringInterpolation.attributes + ) + ) + } else { + self = .string(stringInterpolation.output) + } + } +} diff --git a/Sources/Logging/Logger.swift b/Sources/Logging/Logger.swift index 6fc33842..d3535c1e 100644 --- a/Sources/Logging/Logger.swift +++ b/Sources/Logging/Logger.swift @@ -251,6 +251,7 @@ extension Logger { /// - parameters: /// - level: The severity level of the `message`. /// - message: The message to be logged. The `message` parameter supports any string interpolation literal. + /// - error: An `Error` related to the event. /// - metadata: One-off metadata to attach to this log message. /// - source: The source this log message originates from. The value defaults /// to the module that emits the log message. @@ -313,9 +314,23 @@ extension Logger { function: String = #function, line: UInt = #line ) { - self.log(level: level, message(), metadata: metadata(), source: nil, file: file, function: function, line: line) + self.log( + level: level, + message(), + error: nil, + metadata: metadata(), + source: nil, + file: file, + function: function, + line: line + ) } + /// Log a message using the log level and attributed metadata that you provide. + /// + /// If the `logLevel` passed to this method is more severe than the `Logger`'s ``logLevel``, the library + /// logs the message, otherwise nothing will happen. + /// /// Add, change, or remove a logging metadata item. /// /// > Note: Changing the logging metadata only affects the instance of the `Logger` where you change it. @@ -1157,6 +1172,9 @@ extension Logger { /// `.array([.string("foo"), .string("bar \(buz)")])`, instead use the more natural `["foo", "bar \(buz)"]`. case array([Metadata.Value]) } +} + +extension Logger { /// The log level. /// @@ -1404,10 +1422,6 @@ extension Logger.MetadataValue: CustomStringConvertible { } } -// Extension has to be done on explicit type rather than Logger.Metadata.Value as workaround for -// https://bugs.swift.org/browse/SR-9687 -extension Logger.MetadataValue: ExpressibleByStringInterpolation {} - // Extension has to be done on explicit type rather than Logger.Metadata.Value as workaround for // https://bugs.swift.org/browse/SR-9686 extension Logger.MetadataValue: ExpressibleByDictionaryLiteral { @@ -1439,6 +1453,7 @@ extension Logger.MetadataValue: ExpressibleByArrayLiteral { // MARK: - Sendable support helpers extension Logger.MetadataValue: Sendable {} + extension Logger: Sendable {} extension Logger.Level: Sendable {} extension Logger.Message: Sendable {} diff --git a/Sources/Logging/MetadataProvider.swift b/Sources/Logging/MetadataProvider.swift index 0c52e880..dc353c14 100644 --- a/Sources/Logging/MetadataProvider.swift +++ b/Sources/Logging/MetadataProvider.swift @@ -53,12 +53,24 @@ extension Logger { /// } /// ``` /// + /// ### Metadata with Attributes + /// + /// Metadata providers can return values that carry attributes via string interpolation: + /// + /// ```swift + /// let provider = Logger.MetadataProvider { + /// [ + /// "trace-id": "\(Baggage.current?.traceID, sensitivity: .public)", + /// "user-id": "\(RequestContext.current.userId, sensitivity: .sensitive)" + /// ] + /// } + /// ``` + /// /// We recommend referring to [swift-distributed-tracing](https://github.com/apple/swift-distributed-tracing) /// for metadata providers which make use of its tracing and metadata propagation infrastructure. It is however /// possible to make use of metadata providers independently of tracing and instruments provided by that library, /// if necessary. public struct MetadataProvider: _SwiftLogSendable { - /// Provide ``Logger.Metadata`` from the current context. @usableFromInline internal let _provideMetadata: @Sendable () -> Metadata @@ -70,6 +82,8 @@ extension Logger { } /// Invokes the metadata provider and returns the generated contextual metadata. + /// + /// - Returns: Metadata dictionary, where values may carry attributes. public func get() -> Metadata { self._provideMetadata() } @@ -88,13 +102,12 @@ extension Logger.MetadataProvider { /// - Returns: A pseudo-`MetadataProvider` merging metadata from the given `MetadataProvider`s. public static func multiplex(_ providers: [Logger.MetadataProvider]) -> Logger.MetadataProvider? { assert(!providers.isEmpty, "providers MUST NOT be empty") + return Logger.MetadataProvider { - providers.reduce(into: [:]) { metadata, provider in - let providedMetadata = provider.get() - guard !providedMetadata.isEmpty else { - return - } - metadata.merge(providedMetadata, uniquingKeysWith: { _, rhs in rhs }) + providers.reduce(into: Logger.Metadata()) { merged, provider in + let provided = provider.get() + guard !provided.isEmpty else { return } + merged.merge(provided, uniquingKeysWith: { _, rhs in rhs }) } } } diff --git a/Sources/LoggingAttributes/SensitivityAttribute.swift b/Sources/LoggingAttributes/SensitivityAttribute.swift new file mode 100644 index 00000000..320714ca --- /dev/null +++ b/Sources/LoggingAttributes/SensitivityAttribute.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Logging API open source project +// +// Copyright (c) 2018-2025 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 +// +//===----------------------------------------------------------------------===// + +public import Logging + +extension Logger { + /// Sensitivity classification for metadata values. + /// + /// Tells the handler whether this value contains sensitive data or is safe to emit as-is. + /// This is a classification hint, not a security guarantee — the handler may or may not + /// act on it. + /// + /// ## Usage + /// + /// Use string interpolation with the sensitivity parameter: + /// + /// ```swift + /// logger.info("User action", metadata: [ + /// "user.id": "\(userId, sensitivity: .sensitive)", + /// "action": "\(action, sensitivity: .public)", + /// ]) + /// ``` + public enum Sensitivity: Int64, Sendable, CaseIterable, Equatable, Hashable, CustomStringConvertible, + MetadataAttributeKey + { + /// This value contains sensitive data that handlers may choose to redact. + case sensitive = 1 + + /// This value is safe to emit as-is. + case `public` = 2 + + /// A textual representation of the sensitivity classification. + public var description: String { + switch self { + case .sensitive: "sensitive" + case .public: "public" + } + } + } +} + +// MARK: - MetadataValueAttributes sensitivity convenience + +extension Logger.MetadataValueAttributes { + /// The sensitivity classification for this metadata value, if set. + @inlinable + public var sensitivity: Logger.Sensitivity? { + get { self[Logger.Sensitivity.self] } + set { self[Logger.Sensitivity.self] = newValue } + } + + /// Create metadata value attributes with the specified sensitivity classification. + @inlinable + public init(sensitivity: Logger.Sensitivity) { + self.init() + self[Logger.Sensitivity.self] = sensitivity + } +} + +// MARK: - MetadataValue sensitivity convenience + +extension Logger.MetadataValue { + /// The sensitivity classification for this metadata value, if set. + public var sensitivity: Logger.Sensitivity? { + self.attributes[Logger.Sensitivity.self] + } +} + +// MARK: - StringInterpolation sensitivity convenience + +extension Logger.MetadataValue.StringInterpolation { + /// Interpolation with explicit sensitivity parameter. + /// + /// When a single metadata value contains multiple interpolated segments with + /// different sensitivity levels, the **strictest level wins** — if any segment is + /// `.sensitive`, the entire value becomes `.sensitive`. A `.public` segment cannot + /// downgrade a previously set `.sensitive` classification. + /// + /// ```swift + /// // The value is .sensitive because any segment is .sensitive: + /// let mixed: Logger.MetadataValue = + /// "User \(userId, sensitivity: .sensitive) did \(action, sensitivity: .public)" + /// // mixed.sensitivity == .sensitive + /// ``` + @inlinable + public mutating func appendInterpolation(_ value: T, sensitivity: Logger.Sensitivity) + where T: CustomStringConvertible & Sendable { + self.appendInterpolation( + value, + attributes: { attrs in + if attrs[Logger.Sensitivity.self] != .sensitive { + attrs[Logger.Sensitivity.self] = sensitivity + } + } + ) + } +} diff --git a/Sources/LoggingAttributes/SensitivityAwareLogHandlerWrapper.swift b/Sources/LoggingAttributes/SensitivityAwareLogHandlerWrapper.swift new file mode 100644 index 00000000..12b5ed0b --- /dev/null +++ b/Sources/LoggingAttributes/SensitivityAwareLogHandlerWrapper.swift @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public import Logging + +/// A wrapper that adds sensitivity-aware logging capabilities to any `LogHandler`. +/// +/// This wrapper intercepts log events, merges handler and provider metadata, applies redaction +/// based on the configured `sensitivityBehavior`, then forwards the processed event to the +/// wrapped handler. +/// +/// ## Example Usage +/// +/// ```swift +/// let streamHandler = StreamLogHandler.standardOutput(label: "my-app") +/// var handler = SensitivityAwareLogHandlerWrapper( +/// wrapping: streamHandler, +/// sensitivityBehavior: .redact +/// ) +/// +/// let logger = Logger(label: "my-app") { _ in handler } +/// +/// logger.log(level: .info, "User action", metadata: [ +/// "user.id": "\(userId, sensitivity: .sensitive)", +/// "action": "\(action, sensitivity: .public)" +/// ]) +/// ``` +public struct SensitivityAwareLogHandlerWrapper: LogHandler { + /// The redaction marker used for redacted values. + public static let redactionMarker = "" + + /// Defines how sensitive metadata should be handled. + public enum SensitivityBehavior: Sendable { + /// Log all metadata including values marked as sensitive. + case log + + /// Redact metadata values marked with `.sensitive`. + case redact + } + + private var wrappedHandler: any LogHandler + + /// The sensitivity behavior for this handler. + public var sensitivityBehavior: SensitivityBehavior + + /// The metadata provider. + /// + /// Stored on the wrapper, not forwarded to the wrapped handler. The wrapper merges + /// provider metadata in its own `log(event:)` before forwarding the fully-merged event + /// to the wrapped handler, avoiding double-merge. + public var metadataProvider: Logger.MetadataProvider? + + public var logLevel: Logger.Level { + get { self.wrappedHandler.logLevel } + set { self.wrappedHandler.logLevel = newValue } + } + + public var metadata: Logger.Metadata = [:] + + /// Creates a sensitivity-aware wrapper around an existing log handler. + /// + /// - Parameters: + /// - wrappedHandler: The log handler to wrap. + /// - sensitivityBehavior: How sensitive metadata should be handled. Defaults to `.redact`. + public init(wrapping wrappedHandler: any LogHandler, sensitivityBehavior: SensitivityBehavior = .redact) { + var handler = wrappedHandler + self.metadataProvider = handler.metadataProvider + handler.metadataProvider = nil + self.wrappedHandler = handler + self.sensitivityBehavior = sensitivityBehavior + } + + public func log(event: LogEvent) { + var merged = self.metadata + + if let provider = self.metadataProvider { + let provided = provider.get() + merged.merge(provided, uniquingKeysWith: { _, rhs in rhs }) + } + + if let eventMetadata = event.metadata { + merged.merge(eventMetadata, uniquingKeysWith: { _, rhs in rhs }) + } + + guard !merged.isEmpty else { + self.wrappedHandler.log(event: event) + return + } + + if self.sensitivityBehavior == .log { + var mutatedEvent = event + mutatedEvent.metadata = merged + self.wrappedHandler.log(event: mutatedEvent) + return + } + + var processed = Logger.Metadata() + for (key, value) in merged { + if value.attributes.sensitivity == .sensitive { + processed[key] = .string(Self.redactionMarker) + } else { + processed[key] = value + } + } + + var mutatedEvent = event + mutatedEvent.metadata = processed + self.wrappedHandler.log(event: mutatedEvent) + } + + public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { + get { self.metadata[metadataKey] } + set { self.metadata[metadataKey] = newValue } + } +} diff --git a/Sources/OSLogHandler/OSLogHandler.docc/OSLogHandler.md b/Sources/OSLogHandler/OSLogHandler.docc/OSLogHandler.md new file mode 100644 index 00000000..c2fca393 --- /dev/null +++ b/Sources/OSLogHandler/OSLogHandler.docc/OSLogHandler.md @@ -0,0 +1,75 @@ +# ``OSLogHandler`` + +A sensitivity-aware log handler that uses Apple's unified logging system (os.Logger). + +## Overview + +`OSLogHandler` integrates swift-log with Apple's unified logging system, providing native OSLog privacy support for +metadata values. The handler automatically redacts sensitive metadata in production logs while maintaining full +visibility during development. + +## Output Format + +The handler formats log messages with metadata followed by a suffix indicating which keys contain sensitive values: + +### Production Logs + +In production environments (Console.app, non-debug builds), sensitive metadata is completely redacted: + +``` +Login successful action=password timestamp=2025-01-21 (key user.id is marked private) +``` + +The `` marker replaces all sensitive metadata. The suffix tells you which keys were redacted. + +### Debug Builds + +In debug builds, all values are visible for troubleshooting: + +``` +Login successful action=password timestamp=2025-01-21 user.id=12345 (key user.id is marked private) +``` + +### Multiple Sensitive Keys + +When multiple keys are marked sensitive, the suffix uses plural form: + +``` +User action action=login (keys session.token, user.id are marked private) +``` + +In debug: `User action action=login session.token=secret user.id=12345 (keys session.token, user.id are marked private)` + +## Usage + +Create an `OSLogHandler` with your app's subsystem and category: + +```swift +import Logging +import LoggingAttributes +import OSLogHandler + +let handler = OSLogHandler(subsystem: "com.example.myapp", category: "authentication") +let logger = Logger(label: "auth") { _ in handler } + +let userId = "12345" +logger.info("Login successful", attributedMetadata: [ + "user.id": "\(userId, sensitivity: .sensitive)", + "action": "\("password", sensitivity: .public)", +]) +``` + +## Sensitivity Behavior + +- **`.sensitive` metadata**: Redacted as `` in production logs via OSLog's native privacy annotations +- **`.public` metadata**: Always visible +- **Plain metadata**: Treated as `.public` by default + +The `(keys ... are marked private)` suffix makes it easy to identify which data is sensitive at a glance, even when +values are redacted in production. + +## Topics + +### Creating a Handler + +- ``OSLogHandler/init(subsystem:category:)`` diff --git a/Sources/OSLogHandler/OSLogHandler.swift b/Sources/OSLogHandler/OSLogHandler.swift new file mode 100644 index 00000000..5546bce0 --- /dev/null +++ b/Sources/OSLogHandler/OSLogHandler.swift @@ -0,0 +1,171 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Logging API open source project +// +// Copyright (c) 2018-2025 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(os) +public import Logging +import LoggingAttributes +import os + +/// A redaction-aware log handler that uses Apple's unified logging system (os.Logger). +/// +/// This handler leverages OSLog's native privacy support to automatically redact +/// metadata values marked with `.sensitive` when viewing logs outside of +/// development environments. +/// +/// ## Features +/// +/// - **Native Privacy Support**: Uses OSLog's `privacy: .private` and `privacy: .public` annotations +/// - **System Integration**: Logs appear in Console.app and can be viewed with `log` command +/// - **Performance**: Zero-cost when logging is disabled at the system level +/// - **Subsystem Organization**: Groups logs by subsystem and category for better filtering +/// +/// ## Usage +/// +/// ```swift +/// let handler = OSLogHandler(subsystem: "com.example.myapp", category: "network") +/// let logger = Logger(label: "network") { _ in handler } +/// +/// let userId = "12345" +/// logger.info("User logged in", metadata: [ +/// "user.id": "\(userId, sensitivity: .sensitive)", +/// "action": "\(\"login\", sensitivity: .public)" +/// ]) +/// ``` +/// +/// ## Redaction Behavior +/// +/// - `.sensitive` metadata -> OSLog `privacy: .private` (redacted as `` in logs) +/// - `.public` metadata -> OSLog `privacy: .public` (always visible) +/// - Plain metadata -> Treated as `.public` by default +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +public struct OSLogHandler: LogHandler { + private var osLogger: os.Logger + + public var metadata: Logging.Logger.Metadata = [:] + public var metadataProvider: Logging.Logger.MetadataProvider? + public var logLevel: Logging.Logger.Level = .info + + /// Controls whether to append a suffix listing redacted keys. + public var showRedactedKeysList: Bool = true + + /// Creates an OSLog handler with the specified subsystem and category. + public init(subsystem: String, category: String) { + self.osLogger = os.Logger(subsystem: subsystem, category: category) + } + + public func log(event: LogEvent) { + var merged = self.metadata + + if let provider = self.metadataProvider { + let provided = provider.get() + merged.merge(provided, uniquingKeysWith: { _, rhs in rhs }) + } + + if let eventMetadata = event.metadata { + merged.merge(eventMetadata, uniquingKeysWith: { _, rhs in rhs }) + } + + if let error = event.error { + if merged["error.message"] == nil { + merged["error.message"] = "\(error)" + } + if merged["error.type"] == nil { + merged["error.type"] = "\(String(reflecting: type(of: error)))" + } + } + + if merged.isEmpty { + self.osLogger.log(level: self.mapLogLevel(event.level), "\(event.message.description)") + } else if merged.contains(where: { $0.value.attributes.sensitivity == .sensitive }) { + self.logToOSLogWithRedaction(level: event.level, message: event.message, metadata: merged) + } else { + let metadataString = merged.sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + self.osLogger.log( + level: self.mapLogLevel(event.level), + "\(event.message.description) \(metadataString, privacy: .public)" + ) + } + } + + public subscript(metadataKey key: String) -> Logging.Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + // MARK: - Private Helpers + + private func logToOSLogWithRedaction( + level: Logging.Logger.Level, + message: Logging.Logger.Message, + metadata: Logging.Logger.Metadata + ) { + let osLogType = self.mapLogLevel(level) + + let publicMetadata = metadata.filter { $0.value.attributes.sensitivity != .sensitive } + let redactedMetadata = metadata.filter { $0.value.attributes.sensitivity == .sensitive } + + let redactedKeysSuffix = self.showRedactedKeysList ? self.formatRedactedKeysSuffix(redactedMetadata) : "" + + let publicString = self.formatMetadataValues(publicMetadata) + let redactedString = self.formatMetadataValues(redactedMetadata) + + switch (!publicString.isEmpty, !redactedString.isEmpty) { + case (true, true): + self.osLogger.log( + level: osLogType, + "\(message.description) \(publicString, privacy: .public) \(redactedString, privacy: .private)\(redactedKeysSuffix, privacy: .public)" + ) + case (true, false): + self.osLogger.log(level: osLogType, "\(message.description) \(publicString, privacy: .public)") + case (false, true): + self.osLogger.log( + level: osLogType, + "\(message.description) \(redactedString, privacy: .private)\(redactedKeysSuffix, privacy: .public)" + ) + case (false, false): + self.osLogger.log(level: osLogType, "\(message.description)") + } + } + + private func mapLogLevel(_ level: Logging.Logger.Level) -> OSLogType { + switch level { + case .trace: return .debug + case .debug: return .debug + case .info: return .info + case .notice: return .default + case .warning: return .error + case .error: return .error + case .critical: return .fault + } + } + + private func formatMetadataValues(_ metadata: Logging.Logger.Metadata) -> String { + metadata + .sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + } + + private func formatRedactedKeysSuffix(_ metadata: Logging.Logger.Metadata) -> String { + if metadata.isEmpty { return "" } + let keys = metadata.keys.sorted().joined(separator: ", ") + let keyWord = metadata.count == 1 ? "key" : "keys" + let isWord = metadata.count == 1 ? "is" : "are" + return " (\(keyWord) \(keys) \(isWord) marked private)" + } +} + +#endif diff --git a/Tests/InMemoryLoggingTests/InMemoryLogHandlerTests.swift b/Tests/InMemoryLoggingTests/InMemoryLogHandlerTests.swift index cde34c5d..a76b59ce 100644 --- a/Tests/InMemoryLoggingTests/InMemoryLogHandlerTests.swift +++ b/Tests/InMemoryLoggingTests/InMemoryLogHandlerTests.swift @@ -22,15 +22,10 @@ struct InMemoryLogHandlerTests { let (logHandler, logger) = self.makeTestLogger() logger.info("hello", metadata: ["key1": "value1", "key2": ["a", "b", "c"]]) - #expect( - logHandler.entries == [ - InMemoryLogHandler.Entry( - level: .info, - message: "hello", - metadata: ["key1": "value1", "key2": ["a", "b", "c"]] - ) - ] - ) + #expect(logHandler.entries.count == 1) + #expect(logHandler.entries[0].level == .info) + #expect(logHandler.entries[0].message == "hello") + #expect(logHandler.entries[0].metadata == ["key1": "value1", "key2": ["a", "b", "c"]]) } @Test @@ -39,16 +34,10 @@ struct InMemoryLogHandlerTests { logger[metadataKey: "test"] = "value" logger.info("hello", metadata: ["key1": "value1", "key2": ["a", "b", "c"]]) - #expect( - logHandler.entries == [ - InMemoryLogHandler.Entry( - level: .info, - message: "hello", - metadata: ["key1": "value1", "key2": ["a", "b", "c"], "test": "value"] - ) - ] - ) - // Metadata also sticks onto the logger + #expect(logHandler.entries.count == 1) + #expect(logHandler.entries[0].level == .info) + #expect(logHandler.entries[0].message == "hello") + #expect(logHandler.entries[0].metadata == ["key1": "value1", "key2": ["a", "b", "c"], "test": "value"]) #expect(logger[metadataKey: "test"] == "value") } @@ -62,11 +51,54 @@ struct InMemoryLogHandlerTests { logger[metadataKey: "c"] = "2" logger.info("hello", metadata: ["c": "3"]) - #expect( - logHandler.entries == [ - InMemoryLogHandler.Entry(level: .info, message: "hello", metadata: ["a": "1", "b": "2", "c": "3"]) + #expect(logHandler.entries.count == 1) + #expect(logHandler.entries[0].level == .info) + #expect(logHandler.entries[0].message == "hello") + #expect(logHandler.entries[0].metadata == ["a": "1", "b": "1", "c": "3"]) + } + + @Test + func clear() { + let (logHandler, logger) = self.makeTestLogger() + logger.info("hello", metadata: ["key1": "value1", "key2": ["a", "b", "c"]]) + logHandler.clear() + logger.info("hello2") + + #expect(logHandler.entries.count == 1) + #expect(logHandler.entries[0].level == .info) + #expect(logHandler.entries[0].message == "hello2") + #expect(logHandler.entries[0].metadata == [:]) + } + + @Test + func metadataWithAttributesIsPreservedInEntry() { + var (logHandler, logger) = self.makeTestLogger() + logger.logLevel = .trace + logger[metadataKey: "global"] = "value" + + logger.log( + level: .info, + "test", + metadata: [ + "key": "\("value", attributes: { _ in })" ] ) + + #expect(logHandler.entries.count == 1) + #expect(logHandler.entries[0].metadata["key"]?.description == "value") + #expect(logHandler.entries[0].metadata["global"]?.description == "value") + } + + private func makeTestLogger(metadataProvider: Logger.MetadataProvider? = nil) -> (InMemoryLogHandler, Logger) { + var logHandler = InMemoryLogHandler() + logHandler.metadataProvider = metadataProvider + let logger = Logger( + label: "MyApp", + factory: { _ in + logHandler + } + ) + return (logHandler, logger) } @Test @@ -81,7 +113,7 @@ struct InMemoryLogHandlerTests { level: .info, message: "hello", error: TestError.first, - metadata: [:], + metadata: [:] ) ) @@ -91,7 +123,7 @@ struct InMemoryLogHandlerTests { level: .info, message: "hello", error: nil, - metadata: [:], + metadata: [:] ) ) @@ -116,33 +148,6 @@ struct InMemoryLogHandlerTests { ) } - @Test - func clear() { - let (logHandler, logger) = self.makeTestLogger() - logger.info("hello", metadata: ["key1": "value1", "key2": ["a", "b", "c"]]) - logHandler.clear() - logger.info("hello2") - - // Only hello2 is here - #expect( - logHandler.entries == [ - InMemoryLogHandler.Entry(level: .info, message: "hello2", metadata: [:]) - ] - ) - } - - private func makeTestLogger(metadataProvider: Logger.MetadataProvider? = nil) -> (InMemoryLogHandler, Logger) { - var logHandler = InMemoryLogHandler() - logHandler.metadataProvider = metadataProvider - let logger = Logger( - label: "MyApp", - factory: { _ in - logHandler - } - ) - return (logHandler, logger) - } - enum TestError: Error { case first case second diff --git a/Tests/LoggingAttributesTests/SensitivityTests.swift b/Tests/LoggingAttributesTests/SensitivityTests.swift new file mode 100644 index 00000000..8499f375 --- /dev/null +++ b/Tests/LoggingAttributesTests/SensitivityTests.swift @@ -0,0 +1,515 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import LoggingAttributes +import Testing + +@testable import Logging + +@Suite("Redaction Tests") +struct RedactionTests { + + // MARK: - Test Data Constants + + private enum TestData { + static let userId = UUID(uuidString: "12345678-1234-5678-1234-567812345678")! + static let action = "login" + static let sessionId = UUID(uuidString: "87654321-4321-8765-4321-876543218765")! + static let requestId = UUID(uuidString: "ABCDEF01-2345-6789-ABCD-EF0123456789")! + static let redactionMarker = SensitivityAwareLogHandlerWrapper.redactionMarker + } + + // MARK: - Test Fixtures + + private func makeRecorderLogger() -> (RedactionLogRecorder, Logger) { + let recorder = RedactionLogRecorder() + let handler = RedactionTestLogHandler(recorder: recorder) + var logger = Logger(label: "test") { _ in handler } + logger.logLevel = .trace + return (recorder, logger) + } + + private func makeStreamLogger( + sensitivityBehavior: SensitivityAwareLogHandlerWrapper.SensitivityBehavior = .redact + ) + -> (TestOutputStream, Logger) + { + let stream = TestOutputStream() + let streamHandler = StreamLogHandler(label: "test", stream: stream, metadataProvider: nil) + let handler = SensitivityAwareLogHandlerWrapper( + wrapping: streamHandler, + sensitivityBehavior: sensitivityBehavior + ) + return (stream, Logger(label: "test") { _ in handler }) + } + + // MARK: - Tests + + @Test("Sensitivity enum properties") + func testSensitivity() { + #expect("\(Logger.Sensitivity.sensitive)" == "sensitive") + #expect("\(Logger.Sensitivity.public)" == "public") + #expect(Logger.Sensitivity.allCases.contains(.sensitive)) + #expect(Logger.Sensitivity.allCases.contains(.public)) + } + + @Test("MetadataValueAttributes initialization") + func testMetadataValueAttributes() { + let defaultAttrs = Logger.MetadataValueAttributes() + #expect(defaultAttrs.sensitivity == nil) + + let publicAttrs = Logger.MetadataValueAttributes(sensitivity: .public) + #expect(publicAttrs.sensitivity == .public) + + let redactAttrs = Logger.MetadataValueAttributes(sensitivity: .sensitive) + #expect(redactAttrs.sensitivity == .sensitive) + } + + @Test("MetadataValue carries sensitivity via string interpolation") + func testMetadataValueCarriesSensitivity() { + let sensitiveValue: Logger.MetadataValue = "\(TestData.userId, sensitivity: .sensitive)" + #expect(sensitiveValue.description == TestData.userId.uuidString) + #expect(sensitiveValue.sensitivity == .sensitive) + + let publicValue: Logger.MetadataValue = "\(TestData.action, sensitivity: .public)" + #expect(publicValue.description == TestData.action) + #expect(publicValue.sensitivity == .public) + } + + @Test("SensitivityAwareLogHandlerWrapper redacts redacted metadata") + func testSensitivityAwareLogHandlerWrapperRedaction() { + let (stream, logger) = makeStreamLogger() + + logger.log( + level: .info, + "User action", + metadata: [ + "user.id": "\(TestData.userId, sensitivity: .sensitive)", + "action": "\(TestData.action, sensitivity: .public)", + "session.id": "\(TestData.sessionId, sensitivity: .sensitive)", + ] + ) + + #expect(stream.output.contains("action=\(TestData.action)")) + #expect(stream.output.contains("user.id=\(TestData.redactionMarker)")) + #expect(stream.output.contains("session.id=\(TestData.redactionMarker)")) + #expect(!stream.output.contains(TestData.userId.uuidString)) + #expect(!stream.output.contains(TestData.sessionId.uuidString)) + } + + @Test("SensitivityAwareLogHandlerWrapper logs redacted metadata when configured") + func testSensitivityAwareLogHandlerWrapperLogsRedacted() { + let (stream, logger) = makeStreamLogger(sensitivityBehavior: .log) + + logger.log( + level: .info, + "User action", + metadata: [ + "user.id": "\(TestData.userId, sensitivity: .sensitive)", + "action": "\(TestData.action, sensitivity: .public)", + ] + ) + + #expect(stream.output.contains("user.id=\(TestData.userId.uuidString)")) + #expect(stream.output.contains("action=\(TestData.action)")) + #expect(!stream.output.contains(TestData.redactionMarker)) + } + + @Test("SensitivityAwareLogHandlerWrapper factory pattern works") + func testSensitivityAwareLogHandlerWrapperFactoryPattern() { + let streamHandler1 = StreamLogHandler.standardOutput(label: "stdout-test") + let wrapper1 = SensitivityAwareLogHandlerWrapper(wrapping: streamHandler1) + #expect(wrapper1.logLevel == .info) + #expect(wrapper1.sensitivityBehavior == .redact) + + let streamHandler2 = StreamLogHandler.standardError(label: "stderr-test") + let wrapper2 = SensitivityAwareLogHandlerWrapper(wrapping: streamHandler2, sensitivityBehavior: .log) + #expect(wrapper2.logLevel == .info) + #expect(wrapper2.sensitivityBehavior == .log) + + let provider = Logger.MetadataProvider { ["env": "test"] } + let streamHandler3 = StreamLogHandler.standardOutput(label: "test") + var wrapper3 = SensitivityAwareLogHandlerWrapper(wrapping: streamHandler3) + wrapper3.metadataProvider = provider + #expect(wrapper3.metadataProvider != nil) + #expect(wrapper3.sensitivityBehavior == .redact) + } + + @Test("SensitivityAwareLogHandlerWrapper handles plain metadata") + func testSensitivityAwareLogHandlerWrapperPlainMetadata() { + let stream = TestOutputStream() + + let streamHandler = StreamLogHandler(label: "test", stream: stream, metadataProvider: nil) + let handler = SensitivityAwareLogHandlerWrapper(wrapping: streamHandler) + + let logger = Logger(label: "test") { _ in handler } + + logger.info("Plain message", metadata: ["key": "value", "count": "42"]) + + #expect(stream.output.contains("key=value")) + #expect(stream.output.contains("count=42")) + } + + @Test("SensitivityAwareLogHandlerWrapper merges global metadata") + func testSensitivityAwareLogHandlerWrapperGlobalMetadata() { + let stream = TestOutputStream() + let streamHandler = StreamLogHandler(label: "test", stream: stream, metadataProvider: nil) + var handler = SensitivityAwareLogHandlerWrapper(wrapping: streamHandler, sensitivityBehavior: .redact) + handler[metadataKey: "service"] = "auth" + handler[metadataKey: "version"] = "1.0" + + let logger = Logger(label: "test") { _ in handler } + + logger.log( + level: .info, + "Request", + metadata: [ + "user.id": "\(TestData.userId, sensitivity: .sensitive)", + "request.id": "\(TestData.requestId, sensitivity: .public)", + ] + ) + + #expect(stream.output.contains("service=auth")) + #expect(stream.output.contains("version=1.0")) + #expect(stream.output.contains("request.id=\(TestData.requestId.uuidString)")) + #expect(stream.output.contains("user.id=\(TestData.redactionMarker)")) + #expect(!stream.output.contains(TestData.userId.uuidString)) + } + + @Test("Handler sensitivityBehavior property") + func testHandlerSensitivityBehavior() { + let streamHandler = StreamLogHandler.standardOutput(label: "test") + var handler = SensitivityAwareLogHandlerWrapper(wrapping: streamHandler) + + #expect(handler.sensitivityBehavior == .redact) + + handler.sensitivityBehavior = .log + #expect(handler.sensitivityBehavior == .log) + + var handler2 = handler + handler2.sensitivityBehavior = .redact + #expect(handler.sensitivityBehavior == .log) + #expect(handler2.sensitivityBehavior == .redact) + } + + @Test("SensitivityAwareLogHandlerWrapper strips sensitivity attribute after redaction") + func testSensitivityAttributeStrippedAfterRedaction() { + let recorder = RedactionLogRecorder() + let innerHandler = RedactionTestLogHandler(recorder: recorder) + let wrapper = SensitivityAwareLogHandlerWrapper( + wrapping: innerHandler, + sensitivityBehavior: .redact + ) + var logger = Logger(label: "test") { _ in wrapper } + logger.logLevel = .trace + + logger.log( + level: .info, + "User action", + metadata: [ + "user.id": "\(TestData.userId, sensitivity: .sensitive)", + "action": "\(TestData.action, sensitivity: .public)", + ] + ) + + #expect(recorder.messages.count == 1) + let finalMetadata = recorder.messages[0].metadata + + // The redacted value should have the redaction marker and NO sensitivity attribute + #expect(finalMetadata?["user.id"]?.description == TestData.redactionMarker) + // After redaction, attributes are stripped (it's now a plain .string) + #expect(finalMetadata?["user.id"]?.sensitivity == nil) + + // The public value should retain its sensitivity attribute + #expect(finalMetadata?["action"]?.description == TestData.action) + #expect(finalMetadata?["action"]?.sensitivity == .public) + } + + @Test("Logger metadata with sensitivity via string interpolation") + func testLoggerMetadataWithSensitivity() { + let (_, logger) = makeRecorderLogger() + var mutableLogger = logger + + mutableLogger[metadataKey: "user.id"] = "\(TestData.userId, sensitivity: .sensitive)" + mutableLogger[metadataKey: "action"] = "\(TestData.action, sensitivity: .public)" + + #expect(mutableLogger[metadataKey: "user.id"]?.sensitivity == .sensitive) + #expect(mutableLogger[metadataKey: "action"]?.sensitivity == .public) + } + + @Test("Logger value semantics for metadata with attributes") + func testLoggerValueSemanticsMetadataWithAttributes() { + let recorder = RedactionLogRecorder() + var logger1 = Logger(label: "test") { _ in RedactionTestLogHandler(recorder: recorder) } + + logger1[metadataKey: "key1"] = "\("value1", sensitivity: .sensitive)" + + var logger2 = logger1 + logger2[metadataKey: "key2"] = "\("value2", sensitivity: .public)" + + #expect(logger1[metadataKey: "key1"]?.description == "value1") + #expect(logger1[metadataKey: "key2"] == nil) + + #expect(logger2[metadataKey: "key1"]?.description == "value1") + #expect(logger2[metadataKey: "key2"]?.description == "value2") + } + + @Test("String interpolation with sensitivity parameter") + func testStringInterpolationWithSensitivity() { + let redactedValue: Logger.MetadataValue = "\(TestData.userId, sensitivity: .sensitive)" + #expect(redactedValue.description == TestData.userId.uuidString) + #expect(redactedValue.sensitivity == .sensitive) + + let publicValue: Logger.MetadataValue = "\(TestData.action, sensitivity: .public)" + #expect(publicValue.description == TestData.action) + #expect(publicValue.sensitivity == .public) + + // Test multiple interpolations (strictest sensitivity wins) + let mixedRedactedFirst: Logger.MetadataValue = + "User \(TestData.userId, sensitivity: .sensitive) performed \(TestData.action, sensitivity: .public)" + #expect( + mixedRedactedFirst.description == "User \(TestData.userId.uuidString) performed \(TestData.action)" + ) + #expect(mixedRedactedFirst.sensitivity == .sensitive) + + let mixedPublicFirst: Logger.MetadataValue = + "User \(TestData.userId, sensitivity: .public) performed \(TestData.action, sensitivity: .sensitive)" + #expect(mixedPublicFirst.description == "User \(TestData.userId.uuidString) performed \(TestData.action)") + #expect(mixedPublicFirst.sensitivity == .sensitive) + + // Test string literal (no sensitivity) + let literal: Logger.MetadataValue = "literal value" + #expect(literal.description == "literal value") + #expect(literal.sensitivity == nil) + + // Test interpolation without sensitivity parameter + let noSensitivity: Logger.MetadataValue = "\(TestData.userId)" + #expect(noSensitivity.description == TestData.userId.uuidString) + #expect(noSensitivity.sensitivity == nil) + } + + @Test("String interpolation in logging") + func testStringInterpolationInLogging() { + let (recorder, logger) = makeRecorderLogger() + + logger.log( + level: .info, + "User action", + metadata: [ + "user.id": "\(TestData.userId, sensitivity: .sensitive)", + "action": "\(TestData.action, sensitivity: .public)", + "session": "sess-\(TestData.userId, sensitivity: .sensitive)", + ] + ) + + #expect(recorder.messages.count == 1) + let metadata = recorder.messages[0].metadata + #expect(metadata != nil) + #expect(metadata?["user.id"]?.sensitivity == .sensitive) + #expect(metadata?["user.id"]?.description == TestData.userId.uuidString) + #expect(metadata?["action"]?.sensitivity == .public) + #expect(metadata?["action"]?.description == TestData.action) + #expect(metadata?["session"]?.sensitivity == .sensitive) + #expect(metadata?["session"]?.description == "sess-\(TestData.userId.uuidString)") + } + + @Test("MetadataProvider returns metadata with attributes") + func testMetadataProviderWithAttributes() { + let provider = Logger.MetadataProvider { + [ + "public-key": "\("public-value", sensitivity: .public)", + "private-key": "\("private-value", sensitivity: .sensitive)", + "plain-key": "plain-value", + ] + } + + let metadata = provider.get() + #expect(metadata.count == 3) + #expect(metadata["public-key"]?.sensitivity == .public) + #expect(metadata["private-key"]?.sensitivity == .sensitive) + #expect(metadata["plain-key"]?.sensitivity == nil) + #expect(metadata["public-key"]?.description == "public-value") + #expect(metadata["private-key"]?.description == "private-value") + } + + @Test("MetadataProvider multiplex with empty provider") + func testMetadataProviderMultiplexWithEmptyProvider() { + let emptyProvider = Logger.MetadataProvider { [:] } + let nonEmptyProvider = Logger.MetadataProvider { ["key": "value"] } + + let multiplexed = Logger.MetadataProvider.multiplex([emptyProvider, nonEmptyProvider]) + #expect(multiplexed != nil) + + let metadata = multiplexed?.get() + #expect(metadata != nil) + #expect(metadata?.count == 1) + #expect(metadata?["key"]?.description == "value") + + let multiplexedEmpty = Logger.MetadataProvider.multiplex([emptyProvider, emptyProvider]) + #expect(multiplexedEmpty != nil) + + let emptyMetadata = multiplexedEmpty?.get() + #expect(emptyMetadata != nil) + #expect(emptyMetadata?.isEmpty == true) + } + + @Test("MetadataProvider multiplex with attributed values") + func testMetadataProviderMultiplexWithAttributedValues() { + let plainProvider = Logger.MetadataProvider { + ["env": "production", "host": "server-1"] + } + let attributedProvider = Logger.MetadataProvider { + [ + "user-id": "\("u-123", sensitivity: .sensitive)", + "request-id": "\("r-456", sensitivity: .public)", + ] + } + + let multiplexed = Logger.MetadataProvider.multiplex([plainProvider, attributedProvider]) + #expect(multiplexed != nil) + + let metadata = multiplexed!.get() + #expect(metadata.count == 4) + #expect(metadata["env"]?.description == "production") + #expect(metadata["env"]?.sensitivity == nil) + #expect(metadata["user-id"]?.sensitivity == .sensitive) + #expect(metadata["request-id"]?.sensitivity == .public) + } + + @Test("MetadataProvider multiplex last-writer-wins") + func testMetadataProviderMultiplexLastWriterWins() { + let provider1 = Logger.MetadataProvider { + ["key": "\("from-first", sensitivity: .public)"] + } + let provider2 = Logger.MetadataProvider { + ["key": "\("from-second", sensitivity: .sensitive)"] + } + + let multiplexed = Logger.MetadataProvider.multiplex([provider1, provider2])! + let metadata = multiplexed.get() + + #expect(metadata["key"]?.description == "from-second") + #expect(metadata["key"]?.sensitivity == .sensitive) + } + + @Test( + "Logger convenience methods with metadata carrying attributes", + arguments: [ + Logger.Level.trace, + .debug, + .info, + .notice, + .warning, + .error, + .critical, + ] + ) + func testLoggerConvenienceMethodsWithSensitiveMetadata(level: Logger.Level) { + let (stream, logger) = makeStreamLogger() + var mutableLogger = logger + mutableLogger.logLevel = .trace + + mutableLogger.log( + level: level, + "\(level) message", + metadata: [ + "public-\(level)": "\("\(level)-public", sensitivity: .public)", + "private-\(level)": "\("\(level)-private", sensitivity: .sensitive)", + ] + ) + + #expect(stream.output.contains("\(level) message")) + #expect(stream.output.contains("public-\(level)=\(level)-public")) + #expect(stream.output.contains("private-\(level)=\(TestData.redactionMarker)")) + } + + @Test("MetadataValue description always shows raw values regardless of sensitivity") + func testMetadataValueDescription() { + let publicValue: Logger.MetadataValue = "\("visible-data", sensitivity: .public)" + #expect(publicValue.description == "visible-data") + + let sensitiveValue: Logger.MetadataValue = "\("secret-data", sensitivity: .sensitive)" + #expect(sensitiveValue.description == "secret-data") + + let plainValue: Logger.MetadataValue = "\("plain")" + #expect(plainValue.description == "plain") + } + +} + +// MARK: - Test Helpers + +internal final class TestOutputStream: TextOutputStream, @unchecked Sendable { + var output: String = "" + + func write(_ string: String) { + self.output += string + } +} + +internal final class RedactionLogRecorder: @unchecked Sendable { + private let lock = NSLock() + private var _messages: [(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?)] = [] + + func record( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata? + ) { + self.lock.withLock { + self._messages.append((level: level, message: message, metadata: metadata)) + } + } + + var messages: [(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?)] { + self.lock.withLock { self._messages } + } +} + +internal struct RedactionTestLogHandler: LogHandler { + var logLevel: Logger.Level = .trace + var metadataProvider: Logger.MetadataProvider? + private let recorder: RedactionLogRecorder + + var metadata = Logger.Metadata() + + init(recorder: RedactionLogRecorder) { + self.recorder = recorder + } + + subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { + get { self.metadata[metadataKey] } + set { self.metadata[metadataKey] = newValue } + } + + func log(event: LogEvent) { + var merged = self.metadata + + if let provider = self.metadataProvider { + let provided = provider.get() + merged.merge(provided, uniquingKeysWith: { _, rhs in rhs }) + } + + if let eventMetadata = event.metadata { + merged.merge(eventMetadata, uniquingKeysWith: { _, rhs in rhs }) + } + + self.recorder.record( + level: event.level, + message: event.message, + metadata: merged.isEmpty ? nil : merged + ) + } +} diff --git a/Tests/LoggingTests/LogEventTest.swift b/Tests/LoggingTests/LogEventTest.swift index 9f294bcc..f6767481 100644 --- a/Tests/LoggingTests/LogEventTest.swift +++ b/Tests/LoggingTests/LogEventTest.swift @@ -98,6 +98,65 @@ struct LogEventTest { #expect(event.metadata == nil) } + // MARK: - Attributes on metadata values + + @Test func metadataValueCarriesAttributesThroughStringInterpolation() { + enum TestAttr: Int64, Logger.MetadataAttributeKey { + case flagged = 1 + } + + let value: Logger.MetadataValue = "\("secret", attributes: { $0[TestAttr.self] = .flagged })" + #expect(value.attributes[TestAttr.self] == .flagged) + #expect(value.description == "secret") + } + + @Test func metadataValueWithoutAttributesProducesString() { + let value: Logger.MetadataValue = "\("plain")" + #expect(value.attributes == Logger.MetadataValueAttributes()) + + if case .string(let s) = value { + #expect(s == "plain") + } else { + Issue.record("Expected .string case for non-attributed interpolation") + } + } + + @Test func metadataValueAttributesAreEmptyForPlainValues() { + let values: [Logger.MetadataValue] = [ + .string("test"), + .stringConvertible(42), + .array(["a", "b"]), + .dictionary(["k": "v"]), + ] + for value in values { + #expect(value.attributes == Logger.MetadataValueAttributes()) + } + } + + @Test func metadataValuesWithAttributesFlowThroughLogEvent() { + enum TestAttr: Int64, Logger.MetadataAttributeKey { + case flagged = 1 + } + + let event = LogEvent( + level: .info, + message: "test", + error: nil, + metadata: [ + "secret": "\("value", attributes: { $0[TestAttr.self] = .flagged })", + "plain": "no-attrs", + ], + source: nil, + file: "M/F.swift", + function: "f()", + line: 1 + ) + + #expect(event.metadata?["secret"]?.attributes[TestAttr.self] == .flagged) + #expect(event.metadata?["secret"]?.description == "value") + #expect(event.metadata?["plain"]?.attributes[TestAttr.self] == nil) + } + @Test func loggerDerivedSourceMatchesModuleName() { let recorder = Recorder() let handler = LogEventHandler(recorder: recorder) diff --git a/Tests/LoggingTests/LoggingTest.swift b/Tests/LoggingTests/LoggingTest.swift index 2ccc2ff6..b856c2ed 100644 --- a/Tests/LoggingTests/LoggingTest.swift +++ b/Tests/LoggingTests/LoggingTest.swift @@ -17,6 +17,11 @@ import Testing @testable import Logging +private enum TestAttr: Int64, Logger.MetadataAttributeKey { + case x = 1 + case y = 2 +} + extension LogHandler { fileprivate func with(logLevel: Logger.Level) -> any LogHandler { var result = self @@ -852,6 +857,73 @@ struct LoggingTest { logger1.error("hey") } + @Test func multiplexLogHandlerMetadataWithAttributes_settingAndReading() { + let logging1 = TestLogging() + let logging2 = TestLogging() + + let logger1 = logging1.make(label: "1") + let logger2 = logging2.make(label: "2") + + var multiplexLogger = Logger( + label: "test", + factory: { _ in + MultiplexLogHandler([logger1, logger2]) + } + ) + + // Set metadata with attributes via string interpolation + multiplexLogger[metadataKey: "key1"] = "\("value1", attributes: { $0[TestAttr.self] = .x })" + + // The value survives the round-trip + let retrieved = multiplexLogger[metadataKey: "key1"] + #expect(retrieved != nil) + #expect(retrieved?.description == "value1") + + // Attributes are preserved inside .stringConvertible + #expect(retrieved?.attributes[TestAttr.self] == .x) + } + + @Test func multiplexLogHandlerMetadataWithAttributes_forwardsEventWithAttributes() { + let logging1 = TestLogging() + let logging2 = TestLogging() + + var logger = Logger( + label: "test", + factory: { + MultiplexLogHandler([logging1.make(label: $0), logging2.make(label: $0)]) + } + ) + logger.logLevel = .debug + + // Log with metadata containing attributed values + logger.info("hello", metadata: ["request-id": "\("abc123")"]) + + // Both handlers should receive the message + logging1.history.assertExist(level: .info, message: "hello") + logging2.history.assertExist(level: .info, message: "hello") + } + + @Test func metadataWithAttributes_flowsThroughHandler() { + let logging = TestLogging() + + var logger = Logger( + label: "test", + factory: { logging.make(label: $0) } + ) + logger.logLevel = .debug + + // Log with metadata values that carry attributes + logger.info( + "attributed event", + metadata: [ + "user-id": "\("12345", attributes: { $0[TestAttr.self] = .x })" + ] + ) + + // The handler should receive the message via LogEvent + logging.history.assertExist(level: .info, message: "attributed event") + } + /// Protects an object such that it can only be accessed while holding a lock. private final class LockedValueBox: @unchecked Sendable { private let lock = Lock() diff --git a/Tests/LoggingTests/MetadataAttributesTest.swift b/Tests/LoggingTests/MetadataAttributesTest.swift new file mode 100644 index 00000000..d43509df --- /dev/null +++ b/Tests/LoggingTests/MetadataAttributesTest.swift @@ -0,0 +1,454 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing + +@testable import Logging + +// MARK: - Test Attribute Keys + +enum AttrA: Int64, Logger.MetadataAttributeKey { + case a1 = 1 + case a2 = 2 +} + +enum AttrB: Int64, Logger.MetadataAttributeKey { + case b1 = 1 + case b2 = 2 +} + +enum AttrC: Int64, Logger.MetadataAttributeKey { + case c1 = 1 + case c2 = 2 +} + +enum AttrD: Int64, Logger.MetadataAttributeKey { + case d1 = 1 + case d2 = 2 +} + +enum AttrE: Int64, Logger.MetadataAttributeKey { + case e1 = 1 + case e2 = 2 +} + +// MARK: - Tests + +@Suite("MetadataValueAttributes Tests") +struct MetadataAttributesTests { + + @Test("Empty attributes") + func testEmpty() { + let attrs = Logger.MetadataValueAttributes() + #expect(attrs[AttrA.self] == nil) + #expect(attrs[AttrB.self] == nil) + } + + @Test("Set and get single attribute (inline)") + func testSingleInline() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = .a1 + #expect(attrs[AttrA.self] == .a1) + } + + @Test("Set and get multiple attributes") + func testMultipleAttributes() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = .a1 + attrs[AttrB.self] = .b2 + attrs[AttrC.self] = .c1 + + #expect(attrs[AttrA.self] == .a1) + #expect(attrs[AttrB.self] == .b2) + #expect(attrs[AttrC.self] == .c1) + } + + @Test("Update existing attribute") + func testUpdate() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = .a1 + #expect(attrs[AttrA.self] == .a1) + + attrs[AttrA.self] = .a2 + #expect(attrs[AttrA.self] == .a2) + } + + @Test("Remove inline attribute") + func testRemoveInline() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = .a1 + + attrs[AttrA.self] = nil + #expect(attrs[AttrA.self] == nil) + } + + @Test("Remove overflow attribute") + func testRemoveOverflow() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = .a1 + attrs[AttrB.self] = .b1 + attrs[AttrC.self] = .c1 + + attrs[AttrB.self] = nil + #expect(attrs[AttrA.self] == .a1) + #expect(attrs[AttrB.self] == nil) + #expect(attrs[AttrC.self] == .c1) + } + + @Test("Remove inline promotes from overflow") + func testRemoveInlinePromotesOverflow() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = .a1 + attrs[AttrB.self] = .b1 // overflow + + // Remove inline — overflow entry should be promoted + attrs[AttrA.self] = nil + + #expect(attrs[AttrA.self] == nil) + #expect(attrs[AttrB.self] == .b1) + } + + @Test("Five attributes") + func testFiveAttributes() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = .a1 + attrs[AttrB.self] = .b1 + attrs[AttrC.self] = .c1 + attrs[AttrD.self] = .d1 + attrs[AttrE.self] = .e1 + + #expect(attrs[AttrA.self] == .a1) + #expect(attrs[AttrB.self] == .b1) + #expect(attrs[AttrC.self] == .c1) + #expect(attrs[AttrD.self] == .d1) + #expect(attrs[AttrE.self] == .e1) + } + + @Test("Update overflow attribute") + func testUpdateOverflow() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = .a1 + attrs[AttrB.self] = .b1 + + attrs[AttrB.self] = .b2 + #expect(attrs[AttrB.self] == .b2) + #expect(attrs[AttrA.self] == .a1) + } + + @Test("Equality with same content") + func testEquality() { + var attrs1 = Logger.MetadataValueAttributes() + attrs1[AttrA.self] = .a1 + attrs1[AttrB.self] = .b1 + + var attrs2 = Logger.MetadataValueAttributes() + attrs2[AttrA.self] = .a1 + attrs2[AttrB.self] = .b1 + + #expect(attrs1 == attrs2) + + attrs2[AttrB.self] = .b2 + #expect(attrs1 != attrs2) + } + + @Test("Equality is order-independent") + func testOrderIndependentEquality() { + var attrs1 = Logger.MetadataValueAttributes() + attrs1[AttrA.self] = .a1 + attrs1[AttrB.self] = .b1 + attrs1[AttrC.self] = .c1 + + var attrs2 = Logger.MetadataValueAttributes() + attrs2[AttrC.self] = .c1 + attrs2[AttrA.self] = .a1 + attrs2[AttrB.self] = .b1 + + #expect(attrs1 == attrs2) + } + + @Test("Setting nil on non-existent attribute is no-op") + func testSetNilNonExistent() { + var attrs = Logger.MetadataValueAttributes() + attrs[AttrA.self] = nil + #expect(attrs[AttrA.self] == nil) + + attrs[AttrB.self] = .b1 + attrs[AttrA.self] = nil + #expect(attrs[AttrA.self] == nil) + #expect(attrs[AttrB.self] == .b1) + } + + // MARK: - MetadataValue.attributed factory + + @Test("Create attributed value with .attributed factory") + func testAttributedFactory() { + let value: Logger.MetadataValue = .attributed("hello", attributes: [AttrA.a1]) + #expect(value.description == "hello") + #expect(value.attributes[AttrA.self] == .a1) + } + + @Test("Attributed factory with multiple attributes") + func testAttributedFactoryMultipleAttributes() { + let value: Logger.MetadataValue = .attributed("test", attributes: [AttrA.a1, AttrB.b2]) + #expect(value.description == "test") + #expect(value.attributes[AttrA.self] == .a1) + #expect(value.attributes[AttrB.self] == .b2) + } + + @Test("Attributed factory with empty attributes") + func testAttributedFactoryEmptyAttributes() { + let value: Logger.MetadataValue = .attributed("plain", attributes: .init()) + #expect(value.description == "plain") + #expect(value.attributes[AttrA.self] == nil) + } + + @Test("Attributed factory produces same result as string interpolation") + func testAttributedFactoryMatchesStringInterpolation() { + let factory: Logger.MetadataValue = .attributed("42", attributes: [AttrA.a1]) + let interpolated: Logger.MetadataValue = "\(42, attributes: [AttrA.a1])" + #expect(factory.description == interpolated.description) + #expect(factory.attributes == interpolated.attributes) + } + + // MARK: - Array literal construction + + @Test("Array literal with single attribute") + func testArrayLiteralSingle() { + let attrs: Logger.MetadataValueAttributes = [AttrA.a1] + #expect(attrs[AttrA.self] == .a1) + } + + @Test("Array literal with multiple attributes") + func testArrayLiteralMultiple() { + let attrs: Logger.MetadataValueAttributes = [AttrA.a1, AttrB.b2, AttrC.c1] + #expect(attrs[AttrA.self] == .a1) + #expect(attrs[AttrB.self] == .b2) + #expect(attrs[AttrC.self] == .c1) + } + + @Test("Array literal equals builder closure") + func testArrayLiteralEqualsBuilder() { + let fromLiteral: Logger.MetadataValueAttributes = [AttrA.a1, AttrB.b2] + let fromBuilder = Logger.MetadataValueAttributes { + $0[AttrA.self] = .a1 + $0[AttrB.self] = .b2 + } + #expect(fromLiteral == fromBuilder) + } + + @Test("Array literal with duplicate key uses last value") + func testArrayLiteralDuplicateKey() { + let attrs: Logger.MetadataValueAttributes = [AttrA.a1, AttrA.a2] + #expect(attrs[AttrA.self] == .a2) + } + + // MARK: - MetadataValue.attributes setter + + @Test("Set attributes on .string value") + func testSetAttributesOnString() { + var value: Logger.MetadataValue = "hello" + #expect(value.attributes[AttrA.self] == nil) + + value.attributes = [AttrA.a1] + + #expect(value.description == "hello") + #expect(value.attributes[AttrA.self] == .a1) + } + + @Test("Set attributes replaces existing attributes") + func testSetAttributesReplacesExisting() { + var value: Logger.MetadataValue = .attributed("test", attributes: [AttrA.a1]) + #expect(value.attributes[AttrA.self] == .a1) + + let newAttrs: Logger.MetadataValueAttributes = [AttrB.b2] + value.attributes = newAttrs + + #expect(value.description == "test") + #expect(value.attributes[AttrA.self] == nil) + #expect(value.attributes[AttrB.self] == .b2) + } + + // MARK: - Intermediate handler modifies attributes + + @Test("Intermediate handler adds attributes before forwarding") + func testIntermediateHandlerAddsAttributes() { + let recorder = AttributeRecorder() + let inner = AttributeRecordingHandler(recorder: recorder) + let enriching = AttributeEnrichingHandler(wrapping: inner) + + var logger = Logger(label: "test") { _ in enriching } + logger.logLevel = .trace + + logger.info( + "event", + metadata: [ + "tagged": .attributed(42, attributes: [AttrA.a1]), + "plain": "no-attrs", + ] + ) + + #expect(recorder.messages.count == 1) + let metadata = recorder.messages[0] + + // The enriching handler should have added AttrB to all values + #expect(metadata["tagged"]?.attributes[AttrA.self] == .a1) + #expect(metadata["tagged"]?.attributes[AttrB.self] == .b1) + #expect(metadata["plain"]?.attributes[AttrB.self] == .b1) + } + + @Test("Intermediate handler replaces attributes before forwarding") + func testIntermediateHandlerReplacesAttributes() { + let recorder = AttributeRecorder() + let inner = AttributeRecordingHandler(recorder: recorder) + let stripping = AttributeStrippingHandler(wrapping: inner) + + var logger = Logger(label: "test") { _ in stripping } + logger.logLevel = .trace + + logger.info( + "event", + metadata: [ + "tagged": .attributed(42, attributes: [AttrA.a1]) + ] + ) + + #expect(recorder.messages.count == 1) + let metadata = recorder.messages[0] + + // The stripping handler should have removed all attributes + #expect(metadata["tagged"]?.attributes[AttrA.self] == nil) + #expect(metadata["tagged"]?.description == "42") + } +} + +// MARK: - Test Helpers for intermediate handler tests + +private final class AttributeRecorder: @unchecked Sendable { + private let lock = Lock() + private var _messages: [Logger.Metadata] = [] + + func record(metadata: Logger.Metadata) { + self.lock.withLock { self._messages.append(metadata) } + } + + var messages: [Logger.Metadata] { + self.lock.withLock { self._messages } + } +} + +private struct AttributeRecordingHandler: LogHandler { + var logLevel: Logger.Level = .trace + var metadata = Logger.Metadata() + var metadataProvider: Logger.MetadataProvider? + private let recorder: AttributeRecorder + + init(recorder: AttributeRecorder) { + self.recorder = recorder + } + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + func log(event: LogEvent) { + var merged = self.metadata + if let eventMetadata = event.metadata { + merged.merge(eventMetadata, uniquingKeysWith: { _, rhs in rhs }) + } + self.recorder.record(metadata: merged) + } +} + +/// Intermediate handler that enriches all metadata values with AttrB before forwarding. +private struct AttributeEnrichingHandler: LogHandler { + var logLevel: Logger.Level { + get { self.inner.logLevel } + set { self.inner.logLevel = newValue } + } + + var metadata: Logger.Metadata { + get { self.inner.metadata } + set { self.inner.metadata = newValue } + } + + var metadataProvider: Logger.MetadataProvider? { + get { self.inner.metadataProvider } + set { self.inner.metadataProvider = newValue } + } + + private var inner: any LogHandler + + init(wrapping inner: any LogHandler) { + self.inner = inner + } + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { self.inner[metadataKey: key] } + set { self.inner[metadataKey: key] = newValue } + } + + func log(event: LogEvent) { + var mutatedEvent = event + if var eventMetadata = event.metadata { + for (key, value) in eventMetadata { + var attrs = value.attributes + attrs[AttrB.self] = .b1 + eventMetadata[key]?.attributes = attrs + } + mutatedEvent.metadata = eventMetadata + } + self.inner.log(event: mutatedEvent) + } +} + +/// Intermediate handler that strips all attributes before forwarding. +private struct AttributeStrippingHandler: LogHandler { + var logLevel: Logger.Level { + get { self.inner.logLevel } + set { self.inner.logLevel = newValue } + } + + var metadata: Logger.Metadata { + get { self.inner.metadata } + set { self.inner.metadata = newValue } + } + + var metadataProvider: Logger.MetadataProvider? { + get { self.inner.metadataProvider } + set { self.inner.metadataProvider = newValue } + } + + private var inner: any LogHandler + + init(wrapping inner: any LogHandler) { + self.inner = inner + } + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { self.inner[metadataKey: key] } + set { self.inner[metadataKey: key] = newValue } + } + + func log(event: LogEvent) { + var mutatedEvent = event + if var eventMetadata = event.metadata { + for (key, _) in eventMetadata { + eventMetadata[key]?.attributes = .init() + } + mutatedEvent.metadata = eventMetadata + } + self.inner.log(event: mutatedEvent) + } +} diff --git a/Tests/OSLogHandlerTests/OSLogHandlerTests.swift b/Tests/OSLogHandlerTests/OSLogHandlerTests.swift new file mode 100644 index 00000000..cc9b54a3 --- /dev/null +++ b/Tests/OSLogHandlerTests/OSLogHandlerTests.swift @@ -0,0 +1,201 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Logging API open source project +// +// Copyright (c) 2018-2025 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(os) && compiler(>=6.0) +import Logging +import LoggingAttributes +import OSLogHandler +import Testing + +@Suite("OSLog Handler Tests") +struct OSLogHandlerTests { + // MARK: - Test Data + + private enum TestData { + static let subsystem = "com.example.test" + static let category = "test-category" + static let userId = "user-12345" + static let sessionId = "session-67890" + } + + // MARK: - Initialization Tests + + @Test("OSLogHandler can be initialized") + func testInitialization() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + let handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + #expect(handler.logLevel == .info) + #expect(handler.metadata.isEmpty) + #expect(handler.metadataProvider == nil) + } + + @Test("OSLogHandler integrates with Logger") + func testLoggerIntegration() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + let handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + let logger = Logger(label: "test") { _ in handler } + #expect(logger.logLevel == .info) + } + + // MARK: - Plain Metadata Tests + + @Test("OSLogHandler logs plain metadata") + func testPlainMetadataLogging() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + let handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + let logger = Logger(label: "test") { _ in handler } + + logger.info("Plain message", metadata: ["key": "value"]) + } + + @Test("OSLogHandler supports all log levels") + func testAllLogLevels() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + var handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + handler.logLevel = .trace + let logger = Logger(label: "test") { _ in handler } + + logger.trace("Trace message") + logger.debug("Debug message") + logger.info("Info message") + logger.notice("Notice message") + logger.warning("Warning message") + logger.error("Error message") + logger.critical("Critical message") + } + + // MARK: - Metadata with Sensitivity Tests + + @Test("OSLogHandler logs metadata with sensitivity") + func testMetadataWithSensitivity() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + let handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + let logger = Logger(label: "test") { _ in handler } + + logger.info( + "User action", + metadata: [ + "user.id": "\(TestData.userId, sensitivity: .sensitive)", + "action": "\("login", sensitivity: .public)", + ] + ) + } + + @Test("OSLogHandler handles empty metadata") + func testEmptyMetadata() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + let handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + let logger = Logger(label: "test") { _ in handler } + + logger.info("Message with empty metadata", metadata: [:]) + } + + @Test("OSLogHandler handles nil metadata") + func testNilMetadata() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + let handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + let logger = Logger(label: "test") { _ in handler } + + logger.info("Message") + } + + // MARK: - Metadata Storage Tests + + @Test("Handler metadata storage via subscript") + func testHandlerMetadataSubscript() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + var handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + + handler[metadataKey: "key1"] = "value1" + #expect(handler[metadataKey: "key1"]?.description == "value1") + + handler[metadataKey: "key1"] = "updated" + #expect(handler[metadataKey: "key1"]?.description == "updated") + + handler[metadataKey: "key1"] = nil + #expect(handler[metadataKey: "key1"] == nil) + } + + @Test("Handler metadata with sensitivity via subscript") + func testHandlerMetadataWithSensitivitySubscript() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + var handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + + handler[metadataKey: "user.id"] = "\(TestData.userId, sensitivity: .sensitive)" + #expect(handler[metadataKey: "user.id"]?.sensitivity == .sensitive) + #expect(handler[metadataKey: "user.id"]?.description == TestData.userId) + + handler[metadataKey: "user.id"] = nil + #expect(handler[metadataKey: "user.id"] == nil) + } + + @Test("Handler plain metadata property") + func testHandlerPlainMetadataProperty() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + var handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + + handler.metadata = ["key1": "value1", "key2": "value2"] + #expect(handler.metadata.count == 2) + #expect(handler.metadata["key1"]?.description == "value1") + } + + @Test("Handler metadata with sensitivity via property") + func testHandlerMetadataWithSensitivityProperty() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + return + } + + var handler = OSLogHandler(subsystem: TestData.subsystem, category: TestData.category) + + handler.metadata = [ + "public-key": "\("public-value", sensitivity: .public)", + "private-key": "\("private-value", sensitivity: .sensitive)", + ] + + #expect(handler.metadata.count == 2) + #expect(handler.metadata["public-key"]?.sensitivity == .public) + #expect(handler.metadata["private-key"]?.sensitivity == .sensitive) + } +} +#endif