From c154f207ac5b9ce36a3d4cdcc3ee43a478114c71 Mon Sep 17 00:00:00 2001 From: Henri Bredt Date: Thu, 30 Oct 2025 22:51:50 +0100 Subject: [PATCH] Add structured logging support with metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements structured logging feature that allows attaching type-safe metadata to log messages. Key additions: - New LogMetadata type for structured data with dictionary literal support - MetadataValue enum supporting String, Int, Double, Bool, Arrays, Dictionaries - Updated LogDestination protocol with metadata-aware log method - Extended ExposedCategoryLogger with metadata parameters for all log levels - ConsoleDestination formats metadata as key-value pairs - LocalFileDestination persists metadata as JSON - Added macOS 13.0 platform support to Package.swift - Updated Sample.swift with structured logging examples Usage: logger.network.info("Request completed", metadata: [ "status": 200, "duration": 0.45, "url": "https://api.example.com" ]) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Package.swift | 3 +- Sources/KlarLog/CategoryLogger.swift | 121 ++++++++++++ .../LogDestinations/ConsoleDestination.swift | 51 ++++- .../LocalFileDestination.swift | 38 +++- .../LogDestinations/LogDestination.swift | 36 +++- Sources/KlarLog/Metadata.swift | 182 ++++++++++++++++++ Sources/KlarLog/Sample.swift | 69 +++++-- 7 files changed, 480 insertions(+), 20 deletions(-) create mode 100644 Sources/KlarLog/Metadata.swift diff --git a/Package.swift b/Package.swift index 8f0c76b..6a7d792 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,8 @@ import PackageDescription let package = Package( name: "KlarLog", platforms: [ - .iOS(.v16) + .iOS(.v16), + .macOS(.v13) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. diff --git a/Sources/KlarLog/CategoryLogger.swift b/Sources/KlarLog/CategoryLogger.swift index 6a33d77..6a798de 100644 --- a/Sources/KlarLog/CategoryLogger.swift +++ b/Sources/KlarLog/CategoryLogger.swift @@ -43,6 +43,19 @@ public struct CategoryLogger: Sendable { fileprivate func log(subsystem: String, destinations: [LogDestination], level: LogLevel, message: String) { destinations.forEach { $0.log(subsystem: subsystem, category: category, level: level, message: message) } } + + /// Routes a log message with structured metadata to all provided destinations. + /// This method is only called by `ExposedCategoryLogger`. + /// + /// - Parameters: + /// - subsystem: The subsystem identifier (e.g., "com.example.app"). + /// - destinations: The list of destinations where messages should be sent. + /// - level: The severity level of the log message. + /// - message: The message text of the log. + /// - metadata: Structured data associated with this log entry. + fileprivate func log(subsystem: String, destinations: [LogDestination], level: LogLevel, message: String, metadata: LogMetadata?) { + destinations.forEach { $0.log(subsystem: subsystem, category: category, level: level, message: message, metadata: metadata) } + } } /// Public-facing logging interface for category-based logging. @@ -98,6 +111,16 @@ public struct ExposedCategoryLogger { private func log(_ level: LogLevel, _ message: String) { base.log(subsystem: subsystem(), destinations: destinations(), level: level, message: message) } + + /// Routes a log message with metadata at the specified level to all configured destinations. + /// + /// - Parameters: + /// - level: The severity level of the message. + /// - message: The message text to log. + /// - metadata: Structured data to attach to this log entry. + private func log(_ level: LogLevel, _ message: String, metadata: LogMetadata?) { + base.log(subsystem: subsystem(), destinations: destinations(), level: level, message: message, metadata: metadata) + } // MARK: - Convenience logging methods @@ -184,5 +207,103 @@ public struct ExposedCategoryLogger { public func critical(_ message: String) { self.log(.critical, message) } + + // MARK: - Structured Logging Methods + + /// Logs a debug message with structured metadata. + /// + /// Use for verbose diagnostic information with additional context. + /// + /// - Parameters: + /// - message: The message to log. + /// - metadata: Structured data to attach to this log entry. + /// + /// ### Example + /// ```swift + /// log.network.debug("Request headers", metadata: ["count": headers.count]) + /// ``` + public func debug(_ message: String, metadata: LogMetadata) { + self.log(.debug, message, metadata: metadata) + } + + /// Logs an informational message with structured metadata. + /// + /// Use for general informational messages with additional context. + /// + /// - Parameters: + /// - message: The message to log. + /// - metadata: Structured data to attach to this log entry. + /// + /// ### Example + /// ```swift + /// log.network.info("Response received", metadata: ["status": status, "duration": duration]) + /// ``` + public func info(_ message: String, metadata: LogMetadata) { + self.log(.info, message, metadata: metadata) + } + + /// Logs a notice message with structured metadata. + /// + /// Use for significant conditions with additional context. + /// + /// - Parameters: + /// - message: The message to log. + /// - metadata: Structured data to attach to this log entry. + /// + /// ### Example + /// ```swift + /// log.database.notice("Migration completed", metadata: ["version": "1.2.0", "duration": 5.3]) + /// ``` + public func notice(_ message: String, metadata: LogMetadata) { + self.log(.notice, message, metadata: metadata) + } + + /// Logs a warning message with structured metadata. + /// + /// Use for conditions that could become errors with additional diagnostic data. + /// + /// - Parameters: + /// - message: The message to log. + /// - metadata: Structured data to attach to this log entry. + /// + /// ### Example + /// ```swift + /// log.network.warning("Slow response", metadata: ["latency": latency, "url": url]) + /// ``` + public func warning(_ message: String, metadata: LogMetadata) { + self.log(.warning, message, metadata: metadata) + } + + /// Logs an error message with structured metadata. + /// + /// Use for failures with additional diagnostic context. + /// + /// - Parameters: + /// - message: The message to log. + /// - metadata: Structured data to attach to this log entry. + /// + /// ### Example + /// ```swift + /// log.storage.error("Failed to write file", metadata: ["path": path, "error": error.localizedDescription]) + /// ``` + public func error(_ message: String, metadata: LogMetadata) { + self.log(.error, message, metadata: metadata) + } + + /// Logs a critical message with structured metadata. + /// + /// Use for unrecoverable failures with diagnostic information. + /// + /// - Parameters: + /// - message: The message to log. + /// - metadata: Structured data to attach to this log entry. + /// + /// ### Example + /// ```swift + /// log.auth.critical("Token compromise detected", metadata: ["user_id": userId, "timestamp": Date().timeIntervalSince1970]) + /// ``` + public func critical(_ message: String, metadata: LogMetadata) { + self.log(.critical, message, metadata: metadata) + } } diff --git a/Sources/KlarLog/LogDestinations/ConsoleDestination.swift b/Sources/KlarLog/LogDestinations/ConsoleDestination.swift index 0e4f3c7..49fd61f 100644 --- a/Sources/KlarLog/LogDestinations/ConsoleDestination.swift +++ b/Sources/KlarLog/LogDestinations/ConsoleDestination.swift @@ -45,13 +45,13 @@ public struct ConsoleDestination: LogDestination, Sendable { guard logForLogLevels.contains(level) else { return } - + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { // Xcode Previews cannot use os.Logger; fall back to print. print("#OSLog_PREVIEW[\(level.rawValue.uppercased())][\(subsystem)][\(category)] \(message)") } else { let logger = os.Logger(subsystem: subsystem, category: category) - + switch level { case .debug: logger.debug("\(message)") @@ -68,5 +68,52 @@ public struct ConsoleDestination: LogDestination, Sendable { } } } + + /// Writes a message with structured metadata to the system console. + /// + /// Metadata is formatted as key-value pairs and appended to the message. + /// + /// - Parameters: + /// - subsystem: The logging subsystem, typically your app's bundle identifier. + /// - category: The logging category that groups related messages. + /// - level: The severity level for the message. + /// - message: The text to log. + /// - metadata: Structured data to include in the log. + public func log(subsystem: String, category: String, level: LogLevel, message: String, metadata: LogMetadata?) { + /// Only perform the action of this destination if it was configured to act on this log level. + guard logForLogLevels.contains(level) else { + return + } + + // Format message with metadata + let formattedMessage: String + if let metadata = metadata, !metadata.isEmpty { + formattedMessage = "\(message) | \(metadata.formatted())" + } else { + formattedMessage = message + } + + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + // Xcode Previews cannot use os.Logger; fall back to print. + print("#OSLog_PREVIEW[\(level.rawValue.uppercased())][\(subsystem)][\(category)] \(formattedMessage)") + } else { + let logger = os.Logger(subsystem: subsystem, category: category) + + switch level { + case .debug: + logger.debug("\(formattedMessage)") + case .info: + logger.info("\(formattedMessage)") + case .notice: + logger.notice("\(formattedMessage)") + case .warning: + logger.warning("\(formattedMessage)") + case .error: + logger.error("\(formattedMessage)") + case .critical: + logger.critical("\(formattedMessage)") + } + } + } } diff --git a/Sources/KlarLog/LogDestinations/LocalFileDestination.swift b/Sources/KlarLog/LogDestinations/LocalFileDestination.swift index 750347d..170afaa 100644 --- a/Sources/KlarLog/LogDestinations/LocalFileDestination.swift +++ b/Sources/KlarLog/LogDestinations/LocalFileDestination.swift @@ -109,10 +109,44 @@ public struct LocalFileDestination: LogDestination, Sendable { guard logForLogLevels.contains(level) else { return } - + let timestamp = dateFormatter.string(from: Date()) let logLine = "\(timestamp)\t[\(level.rawValue.uppercased())]\t[\(category)] \(message)" - + + Task.detached(priority: .utility) { [fileActor] in + await fileActor.writeLog(logLine) + } + } + + /// Writes a log message with structured metadata to the file asynchronously. + /// + /// The message is formatted with a timestamp and metadata (serialized as JSON) + /// and appended to the file. If the file exceeds `maxMessages`, the oldest + /// entries are removed to maintain the limit. + /// + /// This method returns immediately and performs all file operations asynchronously + /// off the main thread. + /// + /// - Parameters: + /// - subsystem: The subsystem identifier. + /// - category: The category name. + /// - level: The severity level. + /// - message: The message text to log. + /// - metadata: Structured data to persist with this log entry. + public func log(subsystem: String, category: String, level: LogLevel, message: String, metadata: LogMetadata?) { + /// Only perform the action of this destination if it was configured to act on this log level. + guard logForLogLevels.contains(level) else { + return + } + + let timestamp = dateFormatter.string(from: Date()) + let logLine: String + if let metadata = metadata, !metadata.isEmpty { + logLine = "\(timestamp)\t[\(level.rawValue.uppercased())]\t[\(category)] \(message)\t\(metadata.jsonString())" + } else { + logLine = "\(timestamp)\t[\(level.rawValue.uppercased())]\t[\(category)] \(message)" + } + Task.detached(priority: .utility) { [fileActor] in await fileActor.writeLog(logLine) } diff --git a/Sources/KlarLog/LogDestinations/LogDestination.swift b/Sources/KlarLog/LogDestinations/LogDestination.swift index a9827a8..d54b447 100644 --- a/Sources/KlarLog/LogDestinations/LogDestination.swift +++ b/Sources/KlarLog/LogDestinations/LogDestination.swift @@ -65,13 +65,29 @@ public protocol LogDestination: Sendable { /// - level: The severity level of the message. /// - message: The message text to log. func log(subsystem: String, category: String, level: LogLevel, message: String) - + + /// Processes a log message with structured metadata. + /// + /// This method is called by `CategoryLogger` when a log message includes + /// structured metadata. By default, this strips the metadata and calls + /// the basic `log(subsystem:category:level:message:)` method. + /// + /// Override this method in your destination to handle structured metadata. + /// + /// - Parameters: + /// - subsystem: The subsystem identifier (e.g., "com.example.app"). + /// - category: The category name (e.g., "network", "database"). + /// - level: The severity level of the message. + /// - message: The message text to log. + /// - metadata: Structured data associated with this log entry. + func log(subsystem: String, category: String, level: LogLevel, message: String, metadata: LogMetadata?) + /// The log levels that this destination should emit. /// /// Use this to filter which messages a destination processes. Messages whose /// `level` is not included should be ignored by the destination. /// - /// It's reccomened to use a `guard` check in the `LogDestination` implementaion: + /// It's recommended to use a `guard` check in the `LogDestination` implementation: /// ```swift /// public struct CustomDestination: LogDestination, Sendable { /// // Only messages whose level is included in this collection should be handled. @@ -82,11 +98,23 @@ public protocol LogDestination: Sendable { /// guard logForLogLevels.contains(level) else { /// return /// } - /// // perform you actions ... + /// // perform your actions ... /// } /// } /// ``` - /// - Important:In your custom `LogDestination` implementation you are responsible for implementing this behaviour. + /// - Important: In your custom `LogDestination` implementation you are responsible for implementing this behaviour. var logForLogLevels: [LogLevel] { get } } +// MARK: - Default Implementation + +public extension LogDestination { + /// Default implementation that strips metadata and calls the basic log method. + /// + /// Custom destinations can override this to handle metadata appropriately. + func log(subsystem: String, category: String, level: LogLevel, message: String, metadata: LogMetadata?) { + // By default, ignore metadata and call the basic log method + log(subsystem: subsystem, category: category, level: level, message: message) + } +} + diff --git a/Sources/KlarLog/Metadata.swift b/Sources/KlarLog/Metadata.swift new file mode 100644 index 0000000..57536de --- /dev/null +++ b/Sources/KlarLog/Metadata.swift @@ -0,0 +1,182 @@ +// +// Metadata.swift +// KlarLog +// + +import Foundation + +/// A type-safe container for structured logging metadata. +/// +/// `LogMetadata` allows you to attach structured data to log messages, enabling +/// richer context and easier log analysis. Values can be strings, numbers, booleans, +/// or nested structures. +/// +/// ## Usage +/// +/// ```swift +/// logger.network.info("Request completed", metadata: [ +/// "url": "https://api.example.com", +/// "duration": 123.5, +/// "status": 200, +/// "success": true +/// ]) +/// ``` +/// +/// Metadata is formatted differently based on the destination: +/// - `ConsoleDestination`: Appended as key-value pairs +/// - `LocalFileDestination`: Serialized as JSON +/// - Custom destinations: Format as needed +public struct LogMetadata: Sendable, ExpressibleByDictionaryLiteral { + /// The underlying storage for metadata values. + private let storage: [String: MetadataValue] + + /// Creates metadata from a dictionary literal. + /// + /// - Parameter elements: Key-value pairs where values conform to `MetadataConvertible`. + public init(dictionaryLiteral elements: (String, MetadataConvertible)...) { + var storage: [String: MetadataValue] = [:] + for (key, value) in elements { + storage[key] = value.metadataValue + } + self.storage = storage + } + + /// Creates metadata from a dictionary. + /// + /// - Parameter dictionary: A dictionary of metadata key-value pairs. + public init(_ dictionary: [String: MetadataConvertible]) { + var storage: [String: MetadataValue] = [:] + for (key, value) in dictionary { + storage[key] = value.metadataValue + } + self.storage = storage + } + + /// Returns all metadata as a dictionary. + public var dictionary: [String: MetadataValue] { + storage + } + + /// Returns true if the metadata is empty. + public var isEmpty: Bool { + storage.isEmpty + } + + /// Formats the metadata as a human-readable string. + /// + /// Output format: `key1=value1 key2=value2` + /// + /// - Returns: A formatted string representation of the metadata. + public func formatted() -> String { + storage + .sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\($0.value.stringValue)" } + .joined(separator: " ") + } + + /// Formats the metadata as JSON. + /// + /// - Returns: A JSON string representation, or empty string if encoding fails. + public func jsonString() -> String { + let dict = storage.mapValues { $0.jsonValue } + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) else { + return "{}" + } + return string + } +} + +/// A type-erased metadata value that can hold various data types. +/// +/// `MetadataValue` wraps primitive types (String, Int, Double, Bool) and provides +/// consistent serialization interfaces for logging destinations. +public enum MetadataValue: Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([MetadataValue]) + case dictionary([String: MetadataValue]) + + /// Returns a string representation of the value. + public var stringValue: String { + switch self { + case .string(let value): + return "\"\(value)\"" + case .int(let value): + return String(value) + case .double(let value): + return String(value) + case .bool(let value): + return String(value) + case .array(let values): + let items = values.map { $0.stringValue }.joined(separator: ", ") + return "[\(items)]" + case .dictionary(let dict): + let items = dict.sorted(by: { $0.key < $1.key }) + .map { "\($0.key): \($0.value.stringValue)" } + .joined(separator: ", ") + return "{\(items)}" + } + } + + /// Returns a JSON-compatible value. + public var jsonValue: Any { + switch self { + case .string(let value): + return value + case .int(let value): + return value + case .double(let value): + return value + case .bool(let value): + return value + case .array(let values): + return values.map { $0.jsonValue } + case .dictionary(let dict): + return dict.mapValues { $0.jsonValue } + } + } +} + +/// A protocol for types that can be converted to metadata values. +/// +/// Conform custom types to this protocol to use them in structured logging. +public protocol MetadataConvertible: Sendable { + var metadataValue: MetadataValue { get } +} + +// MARK: - Standard Type Conformances + +extension String: MetadataConvertible { + public var metadataValue: MetadataValue { .string(self) } +} + +extension Int: MetadataConvertible { + public var metadataValue: MetadataValue { .int(self) } +} + +extension Double: MetadataConvertible { + public var metadataValue: MetadataValue { .double(self) } +} + +extension Float: MetadataConvertible { + public var metadataValue: MetadataValue { .double(Double(self)) } +} + +extension Bool: MetadataConvertible { + public var metadataValue: MetadataValue { .bool(self) } +} + +extension Array: MetadataConvertible where Element: MetadataConvertible { + public var metadataValue: MetadataValue { + .array(self.map { $0.metadataValue }) + } +} + +extension Dictionary: MetadataConvertible where Key == String, Value: MetadataConvertible { + public var metadataValue: MetadataValue { + .dictionary(self.mapValues { $0.metadataValue }) + } +} diff --git a/Sources/KlarLog/Sample.swift b/Sources/KlarLog/Sample.swift index cab0ba5..775b9c3 100644 --- a/Sources/KlarLog/Sample.swift +++ b/Sources/KlarLog/Sample.swift @@ -39,32 +39,79 @@ let logger = KlarLog( struct SampleView: View { @State private var logs: String = "" + @State private var requestCount: Int = 0 + var body: some View { - VStack{ - Text("KlarLog") + VStack(spacing: 20) { + Text("KlarLog Sample") + .font(.headline) .onAppear { - // 4.1 Use logger + // 4.1 Basic logging logger.general.info("App launched") + + // 4.2 Structured logging with metadata + logger.general.info("App launched", metadata: [ + "version": "1.0.0", + "device": "iPhone", + "timestamp": Date().timeIntervalSince1970 + ]) } - + Text(logs) - + .font(.caption) + .multilineTextAlignment(.leading) + Button { - // 4.2 Use logger + // Basic logging logger.auth.notice("Signed out") + + // Structured logging with metadata + logger.auth.notice("User signed out", metadata: [ + "userId": "12345", + "sessionDuration": 3600.5, + "wasAutomatic": false + ]) } label: { Text("Sign out") } - + + Button { + requestCount += 1 + + // Structured logging for network requests + logger.general.info("API request completed", metadata: [ + "url": "https://api.example.com/users", + "method": "GET", + "status": 200, + "duration": 0.45, + "requestCount": requestCount + ]) + } label: { + Text("Simulate API Request (\(requestCount))") + } + + Button { + // Error logging with context + logger.general.error("Failed to save data", metadata: [ + "error": "File not found", + "path": "/tmp/data.json", + "retryCount": 3 + ]) + } label: { + Text("Log Error with Context") + } + Button { - // 5. access file logger logs + // 5. Access file logger logs let fileDestination = logger.destinations.file - Task{ - logs = await fileDestination.readLogs().first ?? "file log empty" + Task { + let allLogs = await fileDestination.readLogs() + logs = allLogs.suffix(3).joined(separator: "\n") } } label: { - Text("Load logs") + Text("Load Last 3 Logs") } } + .padding() } }