Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion WireLogging/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ let package = Package(
dependencies: ["WireLogging", "WireLoggingSupport"]
),

.target(name: "WireLegacyLogging"),
.target(
name: "WireLegacyLogging",
dependencies: [
"WireLogging"
]
),
.target(
name: "WireLegacyLoggingSupport",
dependencies: ["WireLegacyLogging"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,5 @@ public extension WireLogger {
static let search = WireLogger(tag: "search")
static let wireCells = WireLogger(tag: "wire-cells")
static let workAgent = WireLogger(tag: "work-agent")

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# ``WireLogging``
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: really clear documentation 🙏


A secure logging framework that prevents accidental logging of sensitive data by restricting string interpolation.

## Overview

The `WireLogging` framework provides a type-safe logging API that prevents accidental exposure of sensitive information in logs. Unlike standard Swift string interpolation, `WireLogMessage` only allows `StaticString` values to be interpolated by default, ensuring that dynamic values cannot be accidentally logged without explicit handling.

## Security Model

The core principle of `WireLogging` is that **only compile-time constants (`StaticString`) can be interpolated directly**. Any attempt to interpolate dynamic values will fail at compile time:

```swift
// ✅ Allowed - StaticString
logger.info("User logged in")

// ✅ Allowed - StaticString interpolation
let name = "World" as StaticString
logger.info("Hello, \(name)!")

// ❌ Compile error - String is not allowed
let userId = "12345"
logger.info("User ID: \(userId)") // Error: no matching appendInterpolation

// ❌ Compile error - Int is not allowed
let count = 42
logger.info("Count: \(count)") // Error: no matching appendInterpolation
```

## Extending for Custom Types

To log custom types or dynamic values, you must explicitly extend `WireLogInterpolation` and implement `appendInterpolation` methods. This ensures that:

1. Logging of sensitive data is intentional
2. Appropriate obfuscation can be applied
3. Structured attributes can be added for better log analysis

### Example: Logging a UUID

```swift
extension WireLogInterpolation {
mutating func appendInterpolation(_ userID: UUID) {
// Obfuscate sensitive data
let obfuscated = String(userID.uuidString.prefix(8)) + "***"
writeText(obfuscated)

// Add structured attribute for log analysis
writeAttribute(.selfUserID(userID))
}
}

// Now this compiles and safely logs the UUID
let userId = UUID()
logger.info("Processing request for user: \(userId)")
```

### Example: Logging a Custom Type

```swift
struct User {
let id: UUID
let email: String
let password: String // Sensitive!
}

extension WireLogInterpolation {
mutating func appendInterpolation(_ user: User) {
// Only log safe information
writeText("User(id: \(user.id.uuidString.prefix(8))***)")

// Never log sensitive fields like password
// Add structured attributes for analysis using predefined methods
writeAttribute(.selfUserID(user.id))
}
}
```

## Building Log Messages

When implementing `appendInterpolation`, use:

- **`writeText(_:)`** - Adds text content to the log message. The provided value is **not obfuscated**, so ensure sensitive data is handled before calling this method.
- **`writeAttribute(_:)`** - Adds structured attributes that can be used for log analysis. Attributes are separate from the message content and may be formatted differently by the logging handler. Prefer using predefined static methods on `WireLogAttribute` (e.g., `.selfUserID(_:)`) rather than initializing new instances directly.

## Topics

Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//
// Wire
// Copyright (C) 2025 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
import os
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: So this is only the osLog implementation given by Apple right? What about Cocoalumberjack (to write to filesystem)?


public struct OSLogHandler: WireLogHandlerProtocol {

var subsystem: String
private let cache: LoggerCache

public init(subsystem: String) {
self.subsystem = subsystem
self.cache = LoggerCache(subsystem: subsystem)
}

public func log(
tag: WireLogTag,
type: WireLogType,
message: WireLogMessage,
additionalAttributes: [WireLogAttribute]
) {

var attributes = [String: String]() // additionalAttributes overwrite message attributes
for attribute in message.interpolation.attributes + additionalAttributes {
attributes[attribute.key] = attribute.value
}

var attributesString = ""
for attributesKey in attributes.keys.sorted() {
attributesString += "[\(attributesKey)=\(attributes[attributesKey]!)]"
}

Comment on lines +44 to +48
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String concatenation in a loop (line 46) creates multiple intermediate String objects. Use a String builder pattern or join the array for better performance, especially when many attributes are present.

Suggested change
var attributesString = ""
for attributesKey in attributes.keys.sorted() {
attributesString += "[\(attributesKey)=\(attributes[attributesKey]!)]"
}
let attributesString = attributes.keys.sorted()
.map { "[\($0)=\(attributes[$0]!)]" }
.joined()

Copilot uses AI. Check for mistakes.
let message = "\(attributesString) \(message.interpolation.content)"

let logger = cache.logger(for: tag)
logger.log(
level: type.mappedToOSLogType(),
"\(message, privacy: .public)"
)

}

// MARK: - Logger Caching

/// Cache entry containing a logger and its last access time.
private struct CacheEntry {
let logger: Logger
var lastAccessTime: Date
}

/// Thread-safe cache manager for Logger instances.
///
/// Evicts loggers that haven't been accessed recently (lazy eviction on access).
///
/// Caching is necessary because `os.Logger` requires its category to be set at initialization time.
/// Since each log tag maps to a different category, we need a separate `Logger` instance per tag.
/// This cache stores `Logger` instances keyed by tag to avoid recreating them on every log call.

private final class LoggerCache: @unchecked Sendable {
private let subsystem: String
private var dictionary: [WireLogTag: CacheEntry] = [:]
private let queue = DispatchQueue(label: "com.wire.logging.oslogger.cache")

/// Time interval after which unused loggers are evicted (5 minutes).
private static let evictionTimeout: TimeInterval = 5 * 60

init(subsystem: String) {
self.subsystem = subsystem
}

func logger(for tag: WireLogTag) -> Logger {
// Synchronously get or create logger and update access time
let logger = queue.sync {
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every log call triggers an async eviction task (line 107-109), which creates many unnecessary async dispatches. Consider adding a throttling mechanism (e.g., only evict every N calls or after a time interval) or moving eviction to a background timer to reduce overhead.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I can replace it with locking.

let now = Date()

// Check if we have a cached entry
if var entry = dictionary[tag] {
// Always update access time if entry exists (even if it was stale)
entry.lastAccessTime = now
dictionary[tag] = entry
return entry.logger
}

// Create new logger and cache it
let logger = Logger(subsystem: subsystem, category: tag.rawValue)
dictionary[tag] = CacheEntry(logger: logger, lastAccessTime: now)
return logger
}

// Asynchronously clean up stale entries (non-blocking)
queue.async {
self.evictStaleEntries()
}
Comment on lines +107 to +109
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every log call triggers an async eviction task (line 107-109), which creates many unnecessary async dispatches. Consider adding a throttling mechanism (e.g., only evict every N calls or after a time interval) or moving eviction to a background timer to reduce overhead.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A valid point, I'll think about it.


return logger
}

private func evictStaleEntries() {
let cutoffTime = Date().addingTimeInterval(-Self.evictionTimeout)
var keysToRemove: [WireLogTag] = []

for (tag, entry) in dictionary where entry.lastAccessTime < cutoffTime {
keysToRemove.append(tag)
}
for key in keysToRemove {
dictionary.removeValue(forKey: key)
}
}
}

}

private extension WireLogType {

func mappedToOSLogType() -> OSLogType {

// Note:
// - OSLogTypes are `default`, `info`, `debug`, `error` and `fault`
// - `trace` is an alias for `debug`
// - `notice` is an alias for `default`
// - `warning` is an alias for `error`
// - `critical` is an alias for `fault`

switch self {
case .debug:
.debug
case .info:
.info
case .notice:
.default
case .warn:
.error
case .error:
.error
case .critical:
.fault
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// Wire
// Copyright (C) 2025 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

// sourcery: AutoMockable
public protocol WireLogHandlerProtocol: Sendable {

/// Logs a message with the specified tag, type, and attributes.
///
/// - Parameters:
/// - tag: The log tag used to categorize the log message.
/// - type: The severity level of the log message.
/// - message: The log message containing content and attributes.
/// - additionalAttributes: Additional attributes to include with the log message.
/// Note: Attributes in `additionalAttributes` override any attributes in `message` with the same key.

func log(
tag: WireLogTag,
type: WireLogType,
message: WireLogMessage,
additionalAttributes: [WireLogAttribute]
)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// Wire
// Copyright (C) 2025 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

public import Foundation

public extension WireLogAttribute {

static var processID: WireLogAttribute { .init("process_id", "\(ProcessInfo.processInfo.processIdentifier)") }
static var processName: WireLogAttribute { .init("process_name", ProcessInfo.processInfo.processName) }

static func selfUserID(_ value: UUID) -> WireLogAttribute { .init("self_user_id", value.uuidString) }

}
39 changes: 39 additions & 0 deletions WireLogging/Sources/WireLogging/LogMessage/WireLogAttribute.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// Wire
// Copyright (C) 2025 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

public struct WireLogAttribute: Sendable {

public var key: String
public var value: String

public init(
key: String,
value: String
) {
self.key = key
self.value = value
}

public init(
_ key: String,
_ value: String
) {
self.init(key: key, value: value)
}

}
Loading
Loading