Skip to content

Commit 2a25dbc

Browse files
committed
SLG-0004: OSLogHandler
1 parent cc63316 commit 2a25dbc

5 files changed

Lines changed: 465 additions & 0 deletions

File tree

Package.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ let package = Package(
88
.library(name: "Logging", targets: ["Logging"]),
99
.library(name: "LoggingAttributes", targets: ["LoggingAttributes"]),
1010
.library(name: "InMemoryLogging", targets: ["InMemoryLogging"]),
11+
.library(name: "OSLogHandler", targets: ["OSLogHandler"]),
1112
],
1213
traits: [
1314
.trait(name: "MaxLogLevelDebug", description: "Debug and above available (compiles out trace)"),
@@ -40,6 +41,10 @@ let package = Package(
4041
name: "InMemoryLogging",
4142
dependencies: ["Logging"]
4243
),
44+
.target(
45+
name: "OSLogHandler",
46+
dependencies: ["Logging", "LoggingAttributes"]
47+
),
4348
.testTarget(
4449
name: "LoggingTests",
4550
dependencies: ["Logging"]
@@ -52,6 +57,10 @@ let package = Package(
5257
name: "InMemoryLoggingTests",
5358
dependencies: ["InMemoryLogging", "Logging"]
5459
),
60+
.testTarget(
61+
name: "OSLogHandlerTests",
62+
dependencies: ["OSLogHandler", "Logging", "LoggingAttributes"]
63+
),
5564
]
5665
)
5766

Package@swift-6.1.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ let package = Package(
88
.library(name: "Logging", targets: ["Logging"]),
99
.library(name: "LoggingAttributes", targets: ["LoggingAttributes"]),
1010
.library(name: "InMemoryLogging", targets: ["InMemoryLogging"]),
11+
.library(name: "OSLogHandler", targets: ["OSLogHandler"]),
1112
],
1213
traits: [
1314
.trait(name: "MaxLogLevelDebug", description: "Debug and above available (compiles out trace)"),
@@ -40,6 +41,10 @@ let package = Package(
4041
name: "InMemoryLogging",
4142
dependencies: ["Logging"]
4243
),
44+
.target(
45+
name: "OSLogHandler",
46+
dependencies: ["Logging", "LoggingAttributes"]
47+
),
4348
.testTarget(
4449
name: "LoggingTests",
4550
dependencies: ["Logging"]
@@ -52,6 +57,10 @@ let package = Package(
5257
name: "InMemoryLoggingTests",
5358
dependencies: ["InMemoryLogging", "Logging"]
5459
),
60+
.testTarget(
61+
name: "OSLogHandlerTests",
62+
dependencies: ["OSLogHandler", "Logging", "LoggingAttributes"]
63+
),
5564
]
5665
)
5766

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# ``OSLogHandler``
2+
3+
A sensitivity-aware log handler that uses Apple's unified logging system (os.Logger).
4+
5+
## Overview
6+
7+
`OSLogHandler` integrates swift-log with Apple's unified logging system, providing native OSLog privacy support for
8+
metadata values. The handler automatically redacts sensitive metadata in production logs while maintaining full
9+
visibility during development.
10+
11+
## Output Format
12+
13+
The handler formats log messages with metadata followed by a suffix indicating which keys contain sensitive values:
14+
15+
### Production Logs
16+
17+
In production environments (Console.app, non-debug builds), sensitive metadata is completely redacted:
18+
19+
```
20+
Login successful action=password timestamp=2025-01-21 <private> (key user.id is marked private)
21+
```
22+
23+
The `<private>` marker replaces all sensitive metadata. The suffix tells you which keys were redacted.
24+
25+
### Debug Builds
26+
27+
In debug builds, all values are visible for troubleshooting:
28+
29+
```
30+
Login successful action=password timestamp=2025-01-21 user.id=12345 (key user.id is marked private)
31+
```
32+
33+
### Multiple Sensitive Keys
34+
35+
When multiple keys are marked sensitive, the suffix uses plural form:
36+
37+
```
38+
User action action=login <private> (keys session.token, user.id are marked private)
39+
```
40+
41+
In debug: `User action action=login session.token=secret user.id=12345 (keys session.token, user.id are marked private)`
42+
43+
## Usage
44+
45+
Create an `OSLogHandler` with your app's subsystem and category:
46+
47+
```swift
48+
import Logging
49+
import LoggingAttributes
50+
import OSLogHandler
51+
52+
let handler = OSLogHandler(subsystem: "com.example.myapp", category: "authentication")
53+
let logger = Logger(label: "auth") { _ in handler }
54+
55+
let userId = "12345"
56+
logger.info("Login successful", attributedMetadata: [
57+
"user.id": "\(userId, sensitivity: .sensitive)",
58+
"action": "\("password", sensitivity: .public)",
59+
])
60+
```
61+
62+
## Sensitivity Behavior
63+
64+
- **`.sensitive` metadata**: Redacted as `<private>` in production logs via OSLog's native privacy annotations
65+
- **`.public` metadata**: Always visible
66+
- **Plain metadata**: Treated as `.public` by default
67+
68+
The `(keys ... are marked private)` suffix makes it easy to identify which data is sensitive at a glance, even when
69+
values are redacted in production.
70+
71+
## Topics
72+
73+
### Creating a Handler
74+
75+
- ``OSLogHandler/init(subsystem:category:)``
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift Logging API open source project
4+
//
5+
// Copyright (c) 2018-2025 Apple Inc. and the Swift Logging API project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift Logging API project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
#if canImport(os)
16+
public import Logging
17+
import LoggingAttributes
18+
import os
19+
20+
/// A redaction-aware log handler that uses Apple's unified logging system (os.Logger).
21+
///
22+
/// This handler leverages OSLog's native privacy support to automatically redact
23+
/// metadata values marked with `.sensitive` when viewing logs outside of
24+
/// development environments.
25+
///
26+
/// ## Features
27+
///
28+
/// - **Native Privacy Support**: Uses OSLog's `privacy: .private` and `privacy: .public` annotations
29+
/// - **System Integration**: Logs appear in Console.app and can be viewed with `log` command
30+
/// - **Performance**: Zero-cost when logging is disabled at the system level
31+
/// - **Subsystem Organization**: Groups logs by subsystem and category for better filtering
32+
///
33+
/// ## Usage
34+
///
35+
/// ```swift
36+
/// let handler = OSLogHandler(subsystem: "com.example.myapp", category: "network")
37+
/// let logger = Logger(label: "network") { _ in handler }
38+
///
39+
/// let userId = "12345"
40+
/// logger.info("User logged in", metadata: [
41+
/// "user.id": "\(userId, sensitivity: .sensitive)",
42+
/// "action": "\(\"login\", sensitivity: .public)"
43+
/// ])
44+
/// ```
45+
///
46+
/// ## Redaction Behavior
47+
///
48+
/// - `.sensitive` metadata -> OSLog `privacy: .private` (redacted as `<private>` in logs)
49+
/// - `.public` metadata -> OSLog `privacy: .public` (always visible)
50+
/// - Plain metadata -> Treated as `.public` by default
51+
@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *)
52+
public struct OSLogHandler: LogHandler {
53+
private var osLogger: os.Logger
54+
55+
public var metadata: Logging.Logger.Metadata = [:]
56+
public var metadataProvider: Logging.Logger.MetadataProvider?
57+
public var logLevel: Logging.Logger.Level = .info
58+
59+
/// Controls whether to append a suffix listing redacted keys.
60+
public var showRedactedKeysList: Bool = true
61+
62+
/// Creates an OSLog handler with the specified subsystem and category.
63+
public init(subsystem: String, category: String) {
64+
self.osLogger = os.Logger(subsystem: subsystem, category: category)
65+
}
66+
67+
public func log(event: LogEvent) {
68+
var merged = self.metadata
69+
70+
if let provider = self.metadataProvider {
71+
let provided = provider.get()
72+
merged.merge(provided, uniquingKeysWith: { _, rhs in rhs })
73+
}
74+
75+
if let eventMetadata = event.metadata {
76+
merged.merge(eventMetadata, uniquingKeysWith: { _, rhs in rhs })
77+
}
78+
79+
if let error = event.error {
80+
if merged["error.message"] == nil {
81+
merged["error.message"] = "\(error)"
82+
}
83+
if merged["error.type"] == nil {
84+
merged["error.type"] = "\(String(reflecting: type(of: error)))"
85+
}
86+
}
87+
88+
if merged.isEmpty {
89+
self.osLogger.log(level: self.mapLogLevel(event.level), "\(event.message.description)")
90+
} else if merged.contains(where: { $0.value.attributes.sensitivity == .sensitive }) {
91+
self.logToOSLogWithRedaction(level: event.level, message: event.message, metadata: merged)
92+
} else {
93+
let metadataString = merged.sorted(by: { $0.key < $1.key })
94+
.map { "\($0.key)=\($0.value)" }
95+
.joined(separator: " ")
96+
self.osLogger.log(
97+
level: self.mapLogLevel(event.level),
98+
"\(event.message.description) \(metadataString, privacy: .public)"
99+
)
100+
}
101+
}
102+
103+
public subscript(metadataKey key: String) -> Logging.Logger.Metadata.Value? {
104+
get { self.metadata[key] }
105+
set { self.metadata[key] = newValue }
106+
}
107+
108+
// MARK: - Private Helpers
109+
110+
private func logToOSLogWithRedaction(
111+
level: Logging.Logger.Level,
112+
message: Logging.Logger.Message,
113+
metadata: Logging.Logger.Metadata
114+
) {
115+
let osLogType = self.mapLogLevel(level)
116+
117+
let publicMetadata = metadata.filter { $0.value.attributes.sensitivity != .sensitive }
118+
let redactedMetadata = metadata.filter { $0.value.attributes.sensitivity == .sensitive }
119+
120+
let redactedKeysSuffix = self.showRedactedKeysList ? self.formatRedactedKeysSuffix(redactedMetadata) : ""
121+
122+
let publicString = self.formatMetadataValues(publicMetadata)
123+
let redactedString = self.formatMetadataValues(redactedMetadata)
124+
125+
switch (!publicString.isEmpty, !redactedString.isEmpty) {
126+
case (true, true):
127+
self.osLogger.log(
128+
level: osLogType,
129+
"\(message.description) \(publicString, privacy: .public) \(redactedString, privacy: .private)\(redactedKeysSuffix, privacy: .public)"
130+
)
131+
case (true, false):
132+
self.osLogger.log(level: osLogType, "\(message.description) \(publicString, privacy: .public)")
133+
case (false, true):
134+
self.osLogger.log(
135+
level: osLogType,
136+
"\(message.description) \(redactedString, privacy: .private)\(redactedKeysSuffix, privacy: .public)"
137+
)
138+
case (false, false):
139+
self.osLogger.log(level: osLogType, "\(message.description)")
140+
}
141+
}
142+
143+
private func mapLogLevel(_ level: Logging.Logger.Level) -> OSLogType {
144+
switch level {
145+
case .trace: return .debug
146+
case .debug: return .debug
147+
case .info: return .info
148+
case .notice: return .default
149+
case .warning: return .error
150+
case .error: return .error
151+
case .critical: return .fault
152+
}
153+
}
154+
155+
private func formatMetadataValues(_ metadata: Logging.Logger.Metadata) -> String {
156+
metadata
157+
.sorted(by: { $0.key < $1.key })
158+
.map { "\($0.key)=\($0.value)" }
159+
.joined(separator: " ")
160+
}
161+
162+
private func formatRedactedKeysSuffix(_ metadata: Logging.Logger.Metadata) -> String {
163+
if metadata.isEmpty { return "" }
164+
let keys = metadata.keys.sorted().joined(separator: ", ")
165+
let keyWord = metadata.count == 1 ? "key" : "keys"
166+
let isWord = metadata.count == 1 ? "is" : "are"
167+
return " (\(keyWord) \(keys) \(isWord) marked private)"
168+
}
169+
}
170+
171+
#endif

0 commit comments

Comments
 (0)