From 39d95f6add99cf170e14ef33b0b879b888e9d654 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 7 Dec 2025 20:56:07 -0700 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20Introduce=20`LogCategory`,=20st?= =?UTF-8?q?ore=20multiple=20loggers=20by=20category?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Scribe/Log.swift | 44 ++++++++++----------- Sources/Scribe/LogCategory.swift | 28 ++++++++++++++ Sources/Scribe/LogManager.swift | 66 ++++++++++++++++++++++---------- Tests/Scribe/ScribeTests.swift | 23 +++++++---- 4 files changed, 111 insertions(+), 50 deletions(-) create mode 100644 Sources/Scribe/LogCategory.swift diff --git a/Sources/Scribe/Log.swift b/Sources/Scribe/Log.swift index a73b626..c44d055 100644 --- a/Sources/Scribe/Log.swift +++ b/Sources/Scribe/Log.swift @@ -13,8 +13,8 @@ import Foundation /// Each method automatically captures source location metadata (file, function, line). /// /// ```swift -/// Log.info("User signed in", category: "Auth") -/// Log.error("Failed to fetch data", category: "Network") +/// Log.info("User signed in", category: .init("Auth")) +/// Log.error("Failed to fetch data", category: .init("Network")) /// Log.debug("Processing item \(item.id)") /// ``` /// @@ -37,7 +37,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func debug( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -57,7 +57,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func trace( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -75,7 +75,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func print( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -95,7 +95,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func info( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -113,7 +113,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func notice( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -133,7 +133,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func warn( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -151,7 +151,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func error( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -169,7 +169,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func fatal( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -189,7 +189,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func success( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -207,7 +207,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func done( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -227,7 +227,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func network( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -245,7 +245,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func api( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -265,7 +265,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func security( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -283,7 +283,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func auth( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -303,7 +303,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func metric( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -321,7 +321,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func analytics( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -341,7 +341,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func ui( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -359,7 +359,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func user( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -379,7 +379,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func database( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line @@ -397,7 +397,7 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func storage( _ message: String, - category: String = #fileID, + category: LogCategory = .init(#fileID), function: String = #function, file: String = #file, line: Int = #line diff --git a/Sources/Scribe/LogCategory.swift b/Sources/Scribe/LogCategory.swift new file mode 100644 index 0000000..e029b55 --- /dev/null +++ b/Sources/Scribe/LogCategory.swift @@ -0,0 +1,28 @@ +// +// LogCategory.swift +// Scribe +// +// Created by Kai Azim on 2025-12-07. +// + +import Foundation + +/// Groups logs under a shared, identifiable category. +/// +/// By default, categories are generated automatically on a per-file basis when using the `Log.*` functions. +/// You can also define your own categories explicitly by extending `LogCategory`: +/// +/// ```swift +/// extension LogCategory { +/// static let backend = LogCategory("Backend") +/// } +/// ``` +/// +/// This allows the same category to be reused consistently across multiple files. +public struct LogCategory: Sendable, Hashable { + public init(_ name: String) { + self.name = name + } + + public let name: String +} diff --git a/Sources/Scribe/LogManager.swift b/Sources/Scribe/LogManager.swift index 69e4e27..be3c00b 100644 --- a/Sources/Scribe/LogManager.swift +++ b/Sources/Scribe/LogManager.swift @@ -22,12 +22,20 @@ import os /// ``` public struct LogConfiguration: Sendable { /// Categories to include in logging output. `nil` allows all categories. - public var enabledCategories: Set? + public var enabledCategories: Set? /// Custom formatter closure for complete control over log line format. - /// - /// Parameters: (level, category, message, file, line, timestamp) - public var formatter: (@Sendable (LogLevel, String, String, String, Int, Date) -> String)? + public var formatter: (@Sendable (FormatterContext) -> String)? + + /// Provides all contextual information needed by a custom log formatter. + public struct FormatterContext: Sendable { + public let level: LogLevel + public let category: LogCategory + public let message: String + public let file: String + public let line: Int + public let timestamp: Date + } /// Whether to include timestamps in log output. Default is `true`. public var includeTimestamp: Bool @@ -43,8 +51,8 @@ public struct LogConfiguration: Sendable { /// - includeTimestamp: Whether to include timestamps. Default is `true`. /// - dateFormat: Timestamp format string. Default is `"yyyy-MM-dd HH:mm:ss.SSSZ"`. public init( - enabledCategories: Set? = nil, - formatter: (@Sendable (LogLevel, String, String, String, Int, Date) -> String)? = nil, + enabledCategories: Set? = nil, + formatter: (@Sendable (FormatterContext) -> String)? = nil, includeTimestamp: Bool = true, dateFormat: String = "yyyy-MM-dd HH:mm:ss.SSSZ" ) { @@ -87,8 +95,9 @@ public struct SinkID: Hashable, Sendable { public final class LogManager: @unchecked Sendable { /// Shared singleton instance. public static let shared = LogManager() - - private let logger: Logger + private let subsystem: String + private var loggersByCategory: [LogCategory: Logger] = [:] + private let dateFormatter: DateFormatter // State protected by logQueue @@ -100,10 +109,7 @@ public final class LogManager: @unchecked Sendable { private let logQueue: DispatchQueue private init() { - let subsystem = Bundle.main.bundleIdentifier ?? "com.kamidevs.scribe" - let category = "Scribe" - logger = Logger(subsystem: subsystem, category: category) - + subsystem = Bundle.main.bundleIdentifier ?? "com.kamidevs.scribe" dateFormatter = DateFormatter() _configuration = .default @@ -226,12 +232,13 @@ public final class LogManager: @unchecked Sendable { public func log( _ message: String, level: LogLevel, - category: String, + category: LogCategory, file: String = #file, function: String = #function, line: Int = #line ) { logQueue.async { + let logger = self.getLogger(for: category) let cfg = self._configuration let minLevel = self._minimumLevel @@ -243,24 +250,33 @@ public final class LogManager: @unchecked Sendable { let now = Date() let formatted: String if let custom = cfg.formatter { - formatted = custom(level, category, message, file, line, now) + formatted = custom( + LogConfiguration.FormatterContext( + level: level, + category: category, + message: message, + file: file, + line: line, + timestamp: now + ) + ) } else { let ts = cfg.includeTimestamp ? (self.dateFormatter.string(from: now) + " ") : "" let fileName = (file as NSString).lastPathComponent - formatted = "\(ts)\(level.emoji) [\(level.shortCode)] [\(category)] \(message) — \(fileName):\(line)" + formatted = "\(ts)\(level.emoji) [\(level.shortCode)] [\(category.name)] \(message) — \(fileName):\(line)" } switch level.osLogType { case .fault: - self.logger.fault("\(formatted, privacy: .public)") + logger.fault("\(formatted, privacy: .public)") case .error: - self.logger.error("\(formatted, privacy: .public)") + logger.error("\(formatted, privacy: .public)") case .debug: - self.logger.debug("\(formatted, privacy: .public)") + logger.debug("\(formatted, privacy: .public)") case .info: - self.logger.info("\(formatted, privacy: .public)") + logger.info("\(formatted, privacy: .public)") default: - self.logger.log("\(formatted, privacy: .public)") + logger.log("\(formatted, privacy: .public)") } for (_, handler) in self.sinks { @@ -268,4 +284,14 @@ public final class LogManager: @unchecked Sendable { } } } + + private func getLogger(for category: LogCategory) -> Logger { + if let logger = loggersByCategory[category] { + return logger + } + + let logger = Logger(subsystem: subsystem, category: category.name) + loggersByCategory[category] = logger + return logger + } } diff --git a/Tests/Scribe/ScribeTests.swift b/Tests/Scribe/ScribeTests.swift index b8cf9d9..6292d4a 100644 --- a/Tests/Scribe/ScribeTests.swift +++ b/Tests/Scribe/ScribeTests.swift @@ -81,14 +81,14 @@ final class ScribeTests: XCTestCase { func testConfiguration() throws { // Test configuration changes with actual API let config = LogConfiguration( - enabledCategories: ["TestCategory"], + enabledCategories: [.test], includeTimestamp: true, dateFormat: "HH:mm:ss" ) Log.logger.setConfiguration(config) - Log.info("Test message with new configuration", category: "TestCategory") + Log.info("Test message with new configuration", category: .test) XCTAssertTrue(true) } @@ -97,14 +97,14 @@ final class ScribeTests: XCTestCase { let expectation = XCTestExpectation(description: "Custom formatter called") let config = LogConfiguration( - formatter: { level, category, message, file, line, date in + formatter: { context in expectation.fulfill() - return "[\(level.shortCode)] \(category): \(message)" + return "[\(context.level.shortCode)] \(context.category): \(context.message)" } ) Log.logger.setConfiguration(config) - Log.info("Test with custom formatter", category: "Test") + Log.info("Test with custom formatter", category: .test) wait(for: [expectation], timeout: 2.0) } @@ -143,11 +143,11 @@ final class ScribeTests: XCTestCase { } } - let config = LogConfiguration(enabledCategories: ["AllowedCategory"]) + let config = LogConfiguration(enabledCategories: [.testAllowed]) Log.logger.setConfiguration(config) - Log.info("This should appear", category: "AllowedCategory") - Log.info("This should not appear", category: "BlockedCategory") + Log.info("This should appear", category: .testAllowed) + Log.info("This should not appear", category: .testBlocked) wait(for: [allowedExpectation, blockedExpectation], timeout: 2.0) } @@ -197,3 +197,10 @@ final class ScribeTests: XCTestCase { XCTAssertEqual(networkLevels.count, 2) } } + +extension LogCategory { + static let test = LogCategory("TestCategory") + + static let testAllowed = LogCategory("AllowedCategory") + static let testBlocked = LogCategory("BlockedCategory") +} From b6b77fe7f41c7ed64484be92b2aa767054222c76 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 7 Dec 2025 21:02:23 -0700 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.swift | 4 +- Sources/Scribe/LogCategory.swift | 2 +- Sources/Scribe/LogLevel.swift | 108 +++++++++++++++++++------------ Sources/Scribe/LogManager.swift | 42 ++++++------ Tests/Scribe/ScribeTests.swift | 51 ++++++++------- 5 files changed, 120 insertions(+), 87 deletions(-) diff --git a/Package.swift b/Package.swift index 1719907..bc42499 100755 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( .macOS(.v14), .tvOS(.v17), .watchOS(.v10), - .visionOS(.v1), + .visionOS(.v1) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. @@ -28,6 +28,6 @@ let package = Package( .testTarget( name: "ScribeTests", dependencies: ["Scribe"] - ), + ) ] ) diff --git a/Sources/Scribe/LogCategory.swift b/Sources/Scribe/LogCategory.swift index e029b55..9699c7c 100644 --- a/Sources/Scribe/LogCategory.swift +++ b/Sources/Scribe/LogCategory.swift @@ -23,6 +23,6 @@ public struct LogCategory: Sendable, Hashable { public init(_ name: String) { self.name = name } - + public let name: String } diff --git a/Sources/Scribe/LogLevel.swift b/Sources/Scribe/LogLevel.swift index be2f34e..5ddfeed 100644 --- a/Sources/Scribe/LogLevel.swift +++ b/Sources/Scribe/LogLevel.swift @@ -25,80 +25,80 @@ import os /// ``` public enum LogLevel: Int, Comparable, Sendable, CaseIterable, CustomStringConvertible { // MARK: - Debug & Development - + /// đŸ”Ŧ Detailed debugging for tracing execution flow. case trace = 0 - + /// 🔍 Debug information for development. case debug = 1 - + /// 📝 Standard print replacement with structured output. case print = 2 // MARK: - General Information - + /// â„šī¸ General informational messages. case info = 10 - + /// đŸ“ĸ Notable events worth attention. case notice = 11 // MARK: - Warnings & Errors - + /// âš ī¸ Warning about potential issues. case warning = 20 - + /// ❌ Error that was handled. case error = 21 - + /// đŸ’Ĩ Fatal error causing severe malfunction. case fatal = 22 // MARK: - Success & Completion - + /// ✅ Successful operation. case success = 30 - + /// ✨ Task or operation completion. case done = 31 // MARK: - Network Operations - + /// 🌐 Network-related activity. case network = 40 - + /// 🚀 API call activity. case api = 41 // MARK: - Security & Authentication - + /// 🔒 Security-related events. case security = 50 - + /// 🔑 Authentication events. case auth = 51 // MARK: - Performance & Analytics - + /// 📊 Performance metrics. case metric = 60 - + /// 📈 Analytics events. case analytics = 61 // MARK: - UI & User Interaction - + /// 🎨 UI events. case ui = 70 - + /// 👤 User actions. case user = 71 // MARK: - Database & Storage - + /// 💾 Database operations. case database = 80 - + /// đŸ“Ļ Storage operations. case storage = 81 @@ -205,15 +205,26 @@ public enum LogLevel: Int, Comparable, Sendable, CaseIterable, CustomStringConve /// The family this log level belongs to. public var family: Family { switch self { - case .trace, .debug, .print: .development - case .info, .notice: .general - case .warning, .error, .fatal: .problems - case .success, .done: .success - case .network, .api: .networking - case .security, .auth: .security - case .metric, .analytics: .performance - case .ui, .user: .ui - case .database, .storage: .data + case .trace, + .debug, + .print: .development + case .info, + .notice: .general + case .warning, + .error, + .fatal: .problems + case .success, + .done: .success + case .network, + .api: .networking + case .security, + .auth: .security + case .metric, + .analytics: .performance + case .ui, + .user: .ui + case .database, + .storage: .data } } @@ -221,31 +232,31 @@ public enum LogLevel: Int, Comparable, Sendable, CaseIterable, CustomStringConve /// Error and fatal levels. public static let allSevere: Set = [.error, .fatal] - + /// Warning level only. public static let allWarnings: Set = [.warning] - + /// All problem levels (warnings, errors, fatal). public static let allProblems: Set = allSevere.union(allWarnings) - + /// Success and done levels. public static let allSuccess: Set = [.success, .done] - + /// Network and API levels. public static let allNetwork: Set = [.network, .api] - + /// Security and auth levels. public static let allSecurity: Set = [.security, .auth] - + /// Metric and analytics levels. public static let allPerformance: Set = [.metric, .analytics] - + /// UI and user levels. public static let allUI: Set = [.ui, .user] - + /// Database and storage levels. public static let allData: Set = [.database, .storage] - + /// Verbose levels typically filtered in production (trace, debug, print). public static let noisyLevels: Set = [.trace, .debug, .print] @@ -299,9 +310,24 @@ public enum LogLevel: Int, Comparable, Sendable, CaseIterable, CustomStringConve /// Maps this log level to the appropriate `OSLogType`. public var osLogType: OSLogType { switch self { - case .trace, .debug, .print: .debug - case .info, .notice, .success, .done: .info - case .warning, .network, .api, .security, .auth, .metric, .analytics, .ui, .user, .database, .storage: .default + case .trace, + .debug, + .print: .debug + case .info, + .notice, + .success, + .done: .info + case .warning, + .network, + .api, + .security, + .auth, + .metric, + .analytics, + .ui, + .user, + .database, + .storage: .default case .error: .error case .fatal: .fault } diff --git a/Sources/Scribe/LogManager.swift b/Sources/Scribe/LogManager.swift index be3c00b..0d21dda 100644 --- a/Sources/Scribe/LogManager.swift +++ b/Sources/Scribe/LogManager.swift @@ -8,6 +8,8 @@ import Foundation import os +// MARK: - LogConfiguration + /// Configuration options for the logging system. /// /// Use this struct to customize log formatting, category filtering, and timestamp display. @@ -23,10 +25,10 @@ import os public struct LogConfiguration: Sendable { /// Categories to include in logging output. `nil` allows all categories. public var enabledCategories: Set? - + /// Custom formatter closure for complete control over log line format. public var formatter: (@Sendable (FormatterContext) -> String)? - + /// Provides all contextual information needed by a custom log formatter. public struct FormatterContext: Sendable { public let level: LogLevel @@ -36,10 +38,10 @@ public struct LogConfiguration: Sendable { public let line: Int public let timestamp: Date } - + /// Whether to include timestamps in log output. Default is `true`. public var includeTimestamp: Bool - + /// Date format string for timestamps. Default is `"yyyy-MM-dd HH:mm:ss.SSSZ"`. public var dateFormat: String @@ -66,15 +68,19 @@ public struct LogConfiguration: Sendable { public static var `default`: LogConfiguration { LogConfiguration() } } +// MARK: - SinkID + /// Unique identifier for a registered log sink, used for removal. public struct SinkID: Hashable, Sendable { fileprivate let id: UUID - + fileprivate init() { - self.id = UUID() + id = UUID() } } +// MARK: - LogManager + /// Central logging manager that handles log routing, filtering, and output. /// /// Access the shared instance via `LogManager.shared` or through `Log.logger`. @@ -97,13 +103,13 @@ public final class LogManager: @unchecked Sendable { public static let shared = LogManager() private let subsystem: String private var loggersByCategory: [LogCategory: Logger] = [:] - + private let dateFormatter: DateFormatter // State protected by logQueue private var _minimumLevel: LogLevel private var _configuration: LogConfiguration - private var sinks: [(id: SinkID, handler: @Sendable (String) -> Void)] = [] + private var sinks: [(id: SinkID, handler: @Sendable (String) -> ())] = [] // Serial queue for thread-safe state access private let logQueue: DispatchQueue @@ -117,11 +123,11 @@ public final class LogManager: @unchecked Sendable { _minimumLevel = .debug - logQueue = DispatchQueue(label: "com.kamidevs.scribe.logging", qos: .utility) + logQueue = DispatchQueue(label: "\(subsystem).logging", qos: .utility) } // MARK: - Synchronous Accessors - + /// The minimum log level threshold. Messages below this level are ignored. /// /// This property is thread-safe for both reading and writing. @@ -129,7 +135,7 @@ public final class LogManager: @unchecked Sendable { get { logQueue.sync { _minimumLevel } } set { logQueue.async { self._minimumLevel = newValue } } } - + /// Current logging configuration. /// /// This property is thread-safe for both reading and writing. @@ -154,7 +160,7 @@ public final class LogManager: @unchecked Sendable { /// Retrieves the current configuration asynchronously. /// /// - Parameter completion: Closure called with the current configuration. - public func getConfiguration(_ completion: @escaping @Sendable (LogConfiguration) -> Void) { + public func getConfiguration(_ completion: @escaping @Sendable (LogConfiguration) -> ()) { logQueue.async { completion(self._configuration) } @@ -172,7 +178,7 @@ public final class LogManager: @unchecked Sendable { /// Retrieves the current minimum log level asynchronously. /// /// - Parameter completion: Closure called with the current minimum level. - public func getMinimumLevel(_ completion: @escaping @Sendable (LogLevel) -> Void) { + public func getMinimumLevel(_ completion: @escaping @Sendable (LogLevel) -> ()) { logQueue.async { completion(self._minimumLevel) } @@ -187,14 +193,14 @@ public final class LogManager: @unchecked Sendable { /// - Parameter sink: Closure called with each formatted log line. /// - Returns: A `SinkID` that can be used to remove this specific sink later. @discardableResult - public func addSink(_ sink: @Sendable @escaping (String) -> Void) -> SinkID { + public func addSink(_ sink: @Sendable @escaping (String) -> ()) -> SinkID { let sinkID = SinkID() logQueue.async { self.sinks.append((id: sinkID, handler: sink)) } return sinkID } - + /// Removes a specific sink by its identifier. /// /// - Parameter id: The `SinkID` returned when the sink was added. @@ -210,7 +216,7 @@ public final class LogManager: @unchecked Sendable { self.sinks.removeAll() } } - + /// The number of currently registered sinks. public var sinkCount: Int { logQueue.sync { sinks.count } @@ -284,12 +290,12 @@ public final class LogManager: @unchecked Sendable { } } } - + private func getLogger(for category: LogCategory) -> Logger { if let logger = loggersByCategory[category] { return logger } - + let logger = Logger(subsystem: subsystem, category: category.name) loggersByCategory[category] = logger return logger diff --git a/Tests/Scribe/ScribeTests.swift b/Tests/Scribe/ScribeTests.swift index 6292d4a..836e3a7 100644 --- a/Tests/Scribe/ScribeTests.swift +++ b/Tests/Scribe/ScribeTests.swift @@ -2,8 +2,9 @@ import XCTest @testable import Scribe +// MARK: - ScribeTests + final class ScribeTests: XCTestCase { - override func setUp() { super.setUp() // Reset to default state before each test @@ -11,7 +12,7 @@ final class ScribeTests: XCTestCase { Log.logger.setConfiguration(.default) Log.logger.removeAllSinks() } - + func testBasicLogging() throws { // Test basic logging functionality Log.info("Test info message") @@ -92,48 +93,48 @@ final class ScribeTests: XCTestCase { XCTAssertTrue(true) } - + func testCustomFormatter() throws { let expectation = XCTestExpectation(description: "Custom formatter called") - + let config = LogConfiguration( formatter: { context in expectation.fulfill() return "[\(context.level.shortCode)] \(context.category): \(context.message)" } ) - + Log.logger.setConfiguration(config) Log.info("Test with custom formatter", category: .test) - + wait(for: [expectation], timeout: 2.0) } - + func testSinks() throws { let expectation = XCTestExpectation(description: "Sink received log") - + final class MessageCapture: @unchecked Sendable { var message: String? } let capture = MessageCapture() - + Log.logger.addSink { message in capture.message = message expectation.fulfill() } - + Log.info("Sink test message") - + wait(for: [expectation], timeout: 2.0) XCTAssertNotNil(capture.message) XCTAssertTrue(capture.message?.contains("Sink test message") ?? false) } - + func testCategoryFiltering() throws { let allowedExpectation = XCTestExpectation(description: "Allowed category logged") let blockedExpectation = XCTestExpectation(description: "Blocked category not logged") blockedExpectation.isInverted = true - + Log.logger.addSink { message in if message.contains("[AllowedCategory]") { allowedExpectation.fulfill() @@ -142,16 +143,16 @@ final class ScribeTests: XCTestCase { blockedExpectation.fulfill() } } - + let config = LogConfiguration(enabledCategories: [.testAllowed]) Log.logger.setConfiguration(config) - + Log.info("This should appear", category: .testAllowed) Log.info("This should not appear", category: .testBlocked) - + wait(for: [allowedExpectation, blockedExpectation], timeout: 2.0) } - + func testLogLevelProperties() throws { // Test LogLevel properties XCTAssertEqual(LogLevel.error.emoji, "❌") @@ -159,38 +160,38 @@ final class ScribeTests: XCTestCase { XCTAssertEqual(LogLevel.info.name, "info") XCTAssertEqual(LogLevel.network.family, .networking) } - + func testLogLevelParsing() throws { XCTAssertEqual(LogLevel.parse("error"), .error) XCTAssertEqual(LogLevel.parse("ERR"), .error) XCTAssertEqual(LogLevel.parse("❌"), .error) XCTAssertNil(LogLevel.parse("invalid")) } - + func testLogLevelComparison() throws { XCTAssertTrue(LogLevel.error > LogLevel.debug) XCTAssertTrue(LogLevel.trace < LogLevel.info) XCTAssertTrue(LogLevel.warning >= LogLevel.warning) } - + func testLogLevelSets() throws { XCTAssertTrue(LogLevel.allSevere.contains(.error)) XCTAssertTrue(LogLevel.allSevere.contains(.fatal)) XCTAssertFalse(LogLevel.allSevere.contains(.warning)) - + XCTAssertTrue(LogLevel.allProblems.contains(.warning)) XCTAssertTrue(LogLevel.allProblems.contains(.error)) - + XCTAssertTrue(LogLevel.noisyLevels.contains(.trace)) XCTAssertTrue(LogLevel.noisyLevels.contains(.debug)) } - + func testLogLevelHelpers() throws { let infoAndAbove = LogLevel.levels(minimum: .info) XCTAssertFalse(infoAndAbove.contains(.debug)) XCTAssertTrue(infoAndAbove.contains(.info)) XCTAssertTrue(infoAndAbove.contains(.error)) - + let networkLevels = LogLevel.levels(in: .networking) XCTAssertTrue(networkLevels.contains(.network)) XCTAssertTrue(networkLevels.contains(.api)) @@ -200,7 +201,7 @@ final class ScribeTests: XCTestCase { extension LogCategory { static let test = LogCategory("TestCategory") - + static let testAllowed = LogCategory("AllowedCategory") static let testBlocked = LogCategory("BlockedCategory") } From 33591b2a4c5c220e7fd10bb297dadc1744fad42e Mon Sep 17 00:00:00 2001 From: Kami Date: Mon, 8 Dec 2025 15:49:45 +1000 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20Add=20cleanup=20logs=20+=20mino?= =?UTF-8?q?r=20polish-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 2 +- README.md | 157 +++++++++++++++++++++++++------- Sources/Scribe/Log.swift | 113 +++++++++++++++-------- Sources/Scribe/LogLevel.swift | 4 +- Sources/Scribe/LogManager.swift | 157 +++++++++++++++++++++++++++++++- Tests/Scribe/ScribeTests.swift | 77 ++++++++++++++++ 6 files changed, 434 insertions(+), 76 deletions(-) diff --git a/LICENSE b/LICENSE index 3173ac6..0b2d34b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Scribe Copyright (c) 2025, [Kami](https://github.com/senpaihunters) +Scribe Copyright (c) 2025, [Kami](https://github.com/SenpaiHunters), [Kai](https://github.com/MrKai77) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index ed73977..d322a50 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,13 @@ Supports granular log levels, category filtering, custom formatting, and pluggab ## Features -- Clear, expressive log levels with emojis and short codes +- Clear log levels with emoji-first output; short codes are opt-in +- Reusable `LogCategory` type to avoid stringly-typed categories +- Per-category `Logger` instances for Console/Xcode filtering - Thread-safe, async logging via a dedicated queue - os.log integration with sensible OSLogType mapping -- Category filtering through configuration -- Customizable formatter with timestamps and file/line info +- Category filtering through configuration (`Set`) +- Customizable formatter using `LogConfiguration.FormatterContext` - Pluggable sinks for mirroring logs (e.g., tests, files, remote endpoints) - Minimal API for everyday logging via static `Log` helpers @@ -19,7 +21,7 @@ Supports granular log levels, category filtering, custom formatting, and pluggab Install via SPM from `https://github.com/SenpaiHunters/Scribe.git` ```swift -.package(url: "https://github.com/SenpaiHunters/Scribe.git", from: "1.0.0") +.package(url: "https://github.com/SenpaiHunters/Scribe.git", branch: "main") ``` Then import in your source files: @@ -33,11 +35,20 @@ import Scribe ```swift import Scribe +// Define reusable categories once +extension LogCategory { + static let app = LogCategory("App") + static let auth = LogCategory("Auth") + static let network = LogCategory("NetworkLayer") + static let apiService = LogCategory("APIService") + static let profile = LogCategory("Profile") +} + // Set minimum level (messages below this level are ignored) LogManager.shared.minimumLevel = .debug // Optional: restrict logging to specific categories -let config = LogConfiguration(enabledCategories: ["NetworkLayer", "APIService"]) +let config = LogConfiguration(enabledCategories: [.network, .apiService]) LogManager.shared.configuration = config // Optional: add a sink (e.g., for tests or file mirroring) @@ -46,11 +57,11 @@ LogManager.shared.addSink { line in } // Log messages anywhere in your app -Log.debug("Bootstrapping app", category: "App") -Log.info("User signed in", category: "Auth") -Log.warn("Slow response", category: "NetworkLayer") -Log.error("Failed to decode payload", category: "APIService") -Log.success("Profile updated", category: "Profile") +Log.debug("Bootstrapping app", category: .app) +Log.info("User signed in", category: .auth) +Log.warn("Slow response", category: .network) +Log.error("Failed to decode payload", category: .apiService) +Log.success("Profile updated", category: .profile) ``` ## API Overview @@ -76,6 +87,22 @@ Represents message severity and domain. - **OS integration**: - `level.osLogType` maps to OSLogType +### LogCategory + +Lightweight wrapper for reusable, strongly typed categories. + +- Defaults to `LogCategory(#fileID)` when you omit the `category` parameter. +- Extend it once and reuse everywhere to avoid typo-prone string literals: + +```swift +extension LogCategory { + static let apiService = LogCategory("APIService") + static let storage = LogCategory("Storage") +} +``` + +- Each category maps to its own `Logger` instance under the same subsystem, so Console and Xcode show first-class category filters without extra configuration. + ### LogManager Core logger with configuration and sinks. @@ -97,21 +124,33 @@ Core logger with configuration and sinks. Configuration struct for customizing log output. -- `enabledCategories: Set?` — categories to include; `nil` allows all -- `formatter: ((LogLevel, String, String, String, Int, Date) -> String)?` — custom formatter +- `enabledCategories: Set?` — categories to include; `nil` allows all +- `formatter: ((LogConfiguration.FormatterContext) -> String)?` — custom formatter - `includeTimestamp: Bool` — include timestamps (default: `true`) +- `includeEmoji: Bool` — include level emoji (default: `true`) +- `includeShortCode: Bool` — include level short code like `[DBG]` (default: `false`) +- `autoLoggerCacheLimit: Int?` — limit cached auto-generated `Logger` instances (e.g., `#fileID`); `nil` means unbounded; default is 100 - `dateFormat: String` — timestamp format (default: `"yyyy-MM-dd HH:mm:ss.SSSZ"`) +**FormatterContext fields:** + +- `level: LogLevel` +- `category: LogCategory` +- `message: String` +- `file: String` +- `line: Int` +- `timestamp: Date` + **Default formatter output:** -``` -[timestamp] [emoji] [SHORT] [Category] Message — File.swift:123 +```text +[timestamp] [emoji] [Category] Message — File.swift:123 ``` **Example:** -``` -2025-11-28 10:15:30.123+1000 🔍 [DBG] [App] Bootstrapping app — AppDelegate.swift:42 +```text +2025-11-28 10:15:30.123+1000 🔍 [App] Bootstrapping app — AppDelegate.swift:42 ``` ### Log @@ -131,9 +170,15 @@ Ergonomic static helpers that auto-fill file/function/line: **Usage:** ```swift -Log.api("GET /v1/profile", category: "APIService") -Log.metric("Home render time: 34ms", category: "Perf") -Log.user("Tapped Purchase", category: "UI") +extension LogCategory { + static let apiService = LogCategory("APIService") + static let perf = LogCategory("Perf") + static let ui = LogCategory("UI") +} + +Log.api("GET /v1/profile", category: .apiService) +Log.metric("Home render time: 34ms", category: .perf) +Log.user("Tapped Purchase", category: .ui) ``` ## Configuration @@ -150,7 +195,13 @@ LogManager.shared.minimumLevel = .info Restrict logging to specific categories: ```swift -let config = LogConfiguration(enabledCategories: ["NetworkLayer", "Auth", "APIService"]) +extension LogCategory { + static let networkLayer = LogCategory("NetworkLayer") + static let auth = LogCategory("Auth") + static let apiService = LogCategory("APIService") +} + +let config = LogConfiguration(enabledCategories: [.networkLayer, .auth, .apiService]) LogManager.shared.configuration = config ``` @@ -161,20 +212,39 @@ let config = LogConfiguration(enabledCategories: nil) LogManager.shared.configuration = config ``` +**Console/Xcode filtering:** Each `LogCategory` uses its own `Logger` under the shared subsystem (bundle identifier by default). In Console.app or Xcode, filter by `subsystem=` and `category=` to zero in on specific modules. + ### Custom Formatting -Provide your own formatter for complete control over log output: +Provide your own formatter for complete control over log output. The formatter receives a single `FormatterContext`, so you don't need to juggle multiple parameters: ```swift let config = LogConfiguration( - formatter: { level, category, message, file, line, timestamp in - let fileName = (file as NSString).lastPathComponent - return "[\(level.shortCode)] \(category): \(message) (\(fileName):\(line))" + formatter: { context in + let fileName = (context.file as NSString).lastPathComponent + return "[\(context.level.shortCode)] [\(context.category.name)] \(context.message) (\(fileName):\(context.line))" } ) LogManager.shared.configuration = config ``` +### Toggle Emojis or Short Codes + +- Default: emojis on, short codes off. +- Turn off emojis: + +```swift +let config = LogConfiguration(includeEmoji: false) +LogManager.shared.configuration = config +``` + +- Turn on short codes (and optionally keep emojis): + +```swift +let config = LogConfiguration(includeEmoji: true, includeShortCode: true) +LogManager.shared.configuration = config +``` + ### Disable Timestamps ```swift @@ -189,11 +259,30 @@ let config = LogConfiguration(dateFormat: "HH:mm:ss") LogManager.shared.configuration = config ``` +### Logger Cache Control + +- Cap cached auto-generated `Logger` instances (e.g., `#fileID`) to avoid unbounded growth: + +```swift +let config = LogConfiguration(autoLoggerCacheLimit: 50) +LogManager.shared.configuration = config +``` + +- Clear both auto-generated and custom logger caches (for long-running sessions or tests): + +```swift +LogManager.shared.clearLoggerCache() +``` + +- Notes: + - Auto-generated categories (`#fileID`) are cached in an internal `NSCache` with a default cap of 100. + - Custom categories you define (e.g., `LogCategory("APIService")`) are stored in a dictionary and are not evicted. + ### Combined Configuration ```swift let config = LogConfiguration( - enabledCategories: ["App", "Network"], + enabledCategories: [.init("App"), .init("Network")], includeTimestamp: true, dateFormat: "HH:mm:ss.SSS" ) @@ -246,10 +335,11 @@ let count = LogManager.shared.sinkCount - `os_log` defers formatting efficiently and integrates with Console.app. - Use `minimumLevel` to reduce overhead in production. - All configuration access is thread-safe. +- Each `LogCategory` gets its own `Logger`, so Console/Xcode filtering by category works out of the box. ## Best Practices -- Use categories to group logs by module or feature (e.g., "APIService", "Storage"). +- Use categories to group logs by module or feature (e.g., `LogCategory("APIService")`, `LogCategory("Storage")`). - Raise `minimumLevel` in production (e.g., `.info` or `.warning`). - Avoid logging PII or secrets; this package does not perform encryption or redaction. - Add a sink for test environments to assert on log output. @@ -258,11 +348,16 @@ let count = LogManager.shared.sinkCount ## Example Integration ```swift +extension LogCategory { + static let app = LogCategory("App") + static let apiService = LogCategory("APIService") +} + final class APIService { func fetchProfile() { - Log.api("GET /v1/profile", category: "APIService") + Log.api("GET /v1/profile", category: .apiService) // ... network call ... - Log.debug("Decoded Profile(id: 123)", category: "APIService") + Log.debug("Decoded Profile(id: 123)", category: .apiService) } } @@ -273,12 +368,12 @@ struct MyApp: App { LogManager.shared.minimumLevel = .info let config = LogConfiguration( - enabledCategories: ["App", "APIService"], + enabledCategories: [.app, .apiService], includeTimestamp: true ) LogManager.shared.configuration = config - Log.info("App launched", category: "App") + Log.info("App launched", category: .app) } var body: some Scene { @@ -300,7 +395,7 @@ func testLogging() { expectation.fulfill() } - Log.info("Test message", category: "Test") + Log.info("Test message", category: .init("Test")) wait(for: [expectation], timeout: 2.0) LogManager.shared.removeSink(sinkID) diff --git a/Sources/Scribe/Log.swift b/Sources/Scribe/Log.swift index c44d055..773ba58 100644 --- a/Sources/Scribe/Log.swift +++ b/Sources/Scribe/Log.swift @@ -37,12 +37,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func debug( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .debug, category: category, file: file, function: function, line: line) + log(message, level: .debug, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs a trace message for detailed execution flow tracking. @@ -57,12 +58,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func trace( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .trace, category: category, file: file, function: function, line: line) + log(message, level: .trace, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs a print-level message as a structured replacement for `Swift.print()`. @@ -75,12 +77,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func print( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .print, category: category, file: file, function: function, line: line) + log(message, level: .print, category: category, fileID: fileID, function: function, file: file, line: line) } // MARK: - General Information @@ -95,12 +98,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func info( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .info, category: category, file: file, function: function, line: line) + log(message, level: .info, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs a notice about a notable but non-critical event. @@ -113,12 +117,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func notice( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .notice, category: category, file: file, function: function, line: line) + log(message, level: .notice, category: category, fileID: fileID, function: function, file: file, line: line) } // MARK: - Warnings & Errors @@ -133,12 +138,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func warn( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .warning, category: category, file: file, function: function, line: line) + log(message, level: .warning, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs an error that occurred but was handled. @@ -151,12 +157,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func error( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .error, category: category, file: file, function: function, line: line) + log(message, level: .error, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs a fatal error that may cause app termination or severe malfunction. @@ -169,12 +176,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func fatal( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .fatal, category: category, file: file, function: function, line: line) + log(message, level: .fatal, category: category, fileID: fileID, function: function, file: file, line: line) } // MARK: - Success & Completion @@ -189,12 +197,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func success( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .success, category: category, file: file, function: function, line: line) + log(message, level: .success, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs task or operation completion. @@ -207,12 +216,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func done( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .done, category: category, file: file, function: function, line: line) + log(message, level: .done, category: category, fileID: fileID, function: function, file: file, line: line) } // MARK: - Network Operations @@ -227,12 +237,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func network( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .network, category: category, file: file, function: function, line: line) + log(message, level: .network, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs API call activity (requests, responses, errors). @@ -245,12 +256,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func api( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .api, category: category, file: file, function: function, line: line) + log(message, level: .api, category: category, fileID: fileID, function: function, file: file, line: line) } // MARK: - Security & Authentication @@ -265,12 +277,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func security( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .security, category: category, file: file, function: function, line: line) + log(message, level: .security, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs authentication events (login, logout, token refresh). @@ -283,12 +296,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func auth( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .auth, category: category, file: file, function: function, line: line) + log(message, level: .auth, category: category, fileID: fileID, function: function, file: file, line: line) } // MARK: - Performance & Analytics @@ -303,12 +317,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func metric( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .metric, category: category, file: file, function: function, line: line) + log(message, level: .metric, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs analytics events (user behavior, feature usage). @@ -321,12 +336,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func analytics( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .analytics, category: category, file: file, function: function, line: line) + log(message, level: .analytics, category: category, fileID: fileID, function: function, file: file, line: line) } // MARK: - UI & User Interaction @@ -341,12 +357,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func ui( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .ui, category: category, file: file, function: function, line: line) + log(message, level: .ui, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs user actions (taps, gestures, input). @@ -359,12 +376,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func user( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .user, category: category, file: file, function: function, line: line) + log(message, level: .user, category: category, fileID: fileID, function: function, file: file, line: line) } // MARK: - Database & Storage @@ -379,12 +397,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func database( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .database, category: category, file: file, function: function, line: line) + log(message, level: .database, category: category, fileID: fileID, function: function, file: file, line: line) } /// Logs storage operations (file I/O, cache, persistence). @@ -397,11 +416,25 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func storage( _ message: String, - category: LogCategory = .init(#fileID), + category: LogCategory? = nil, + fileID: String = #fileID, function: String = #function, file: String = #file, line: Int = #line ) { - logger.log(message, level: .storage, category: category, file: file, function: function, line: line) + log(message, level: .storage, category: category, fileID: fileID, function: function, file: file, line: line) + } + + private static func log( + _ message: String, + level: LogLevel, + category: LogCategory?, + fileID: String, + function: String, + file: String, + line: Int + ) { + let resolvedCategory = category ?? LogCategory(fileID) + logger.log(message, level: level, category: resolvedCategory, file: file, function: function, line: line) } } diff --git a/Sources/Scribe/LogLevel.swift b/Sources/Scribe/LogLevel.swift index 5ddfeed..1793034 100644 --- a/Sources/Scribe/LogLevel.swift +++ b/Sources/Scribe/LogLevel.swift @@ -175,9 +175,9 @@ public enum LogLevel: Int, Comparable, Sendable, CaseIterable, CustomStringConve case .auth: "AUT" case .metric: "MET" case .analytics: "ANL" - case .ui: "UI " + case .ui: "UI" case .user: "USR" - case .database: "DB " + case .database: "DB" case .storage: "STO" } } diff --git a/Sources/Scribe/LogManager.swift b/Sources/Scribe/LogManager.swift index 0d21dda..6705e01 100644 --- a/Sources/Scribe/LogManager.swift +++ b/Sources/Scribe/LogManager.swift @@ -42,6 +42,16 @@ public struct LogConfiguration: Sendable { /// Whether to include timestamps in log output. Default is `true`. public var includeTimestamp: Bool + /// Whether to include the level emoji in log output. Default is `true`. + public var includeEmoji: Bool + + /// Whether to include the level short code (e.g., `[DBG]`) in log output. Default is `false`. + public var includeShortCode: Bool + + /// Maximum number of cached auto-generated `Logger` instances (e.g., categories from `#fileID`). `nil` means + /// unbounded. Default is 100. + public var autoLoggerCacheLimit: Int? + /// Date format string for timestamps. Default is `"yyyy-MM-dd HH:mm:ss.SSSZ"`. public var dateFormat: String @@ -51,16 +61,26 @@ public struct LogConfiguration: Sendable { /// - enabledCategories: Categories to include. Pass `nil` to allow all categories. /// - formatter: Custom formatter closure. Pass `nil` to use the default format. /// - includeTimestamp: Whether to include timestamps. Default is `true`. + /// - includeEmoji: Whether to include the level emoji. Default is `true`. + /// - includeShortCode: Whether to include the level short code. Default is `false`. + /// - autoLoggerCacheLimit: Maximum cached auto-generated `Logger` instances. Pass `nil` for no limit. Default is + /// 100. /// - dateFormat: Timestamp format string. Default is `"yyyy-MM-dd HH:mm:ss.SSSZ"`. public init( enabledCategories: Set? = nil, formatter: (@Sendable (FormatterContext) -> String)? = nil, includeTimestamp: Bool = true, + includeEmoji: Bool = true, + includeShortCode: Bool = false, + autoLoggerCacheLimit: Int? = 100, dateFormat: String = "yyyy-MM-dd HH:mm:ss.SSSZ" ) { self.enabledCategories = enabledCategories self.formatter = formatter self.includeTimestamp = includeTimestamp + self.includeEmoji = includeEmoji + self.includeShortCode = includeShortCode + self.autoLoggerCacheLimit = autoLoggerCacheLimit self.dateFormat = dateFormat } @@ -99,11 +119,35 @@ public struct SinkID: Hashable, Sendable { /// LogManager.shared.removeSink(sinkID) /// ``` public final class LogManager: @unchecked Sendable { + private final class CachedLogger: NSObject { + let logger: Logger + let key: String + + init(logger: Logger, key: String) { + self.logger = logger + self.key = key + } + } + + private final class AutoLoggerCacheDelegate: NSObject, NSCacheDelegate { + weak var owner: LogManager? + + func cache(_ cache: NSCache, willEvictObject obj: Any) { + guard let cached = obj as? CachedLogger else { return } + owner?.handleAutoEviction(forKey: cached.key) + } + } + /// Shared singleton instance. public static let shared = LogManager() private let subsystem: String private var loggersByCategory: [LogCategory: Logger] = [:] + private let autoLoggerCache: NSCache + private let autoLoggerCacheDelegate: AutoLoggerCacheDelegate + private var autoLoggerKeys: Set = [] + private var autoLoggerOrder: [String] = [] + private let dateFormatter: DateFormatter // State protected by logQueue @@ -118,12 +162,19 @@ public final class LogManager: @unchecked Sendable { subsystem = Bundle.main.bundleIdentifier ?? "com.kamidevs.scribe" dateFormatter = DateFormatter() + autoLoggerCache = NSCache() + autoLoggerCacheDelegate = AutoLoggerCacheDelegate() + _configuration = .default dateFormatter.dateFormat = _configuration.dateFormat _minimumLevel = .debug logQueue = DispatchQueue(label: "\(subsystem).logging", qos: .utility) + + autoLoggerCacheDelegate.owner = self + autoLoggerCache.delegate = autoLoggerCacheDelegate + applyAutoCacheLimit(_configuration.autoLoggerCacheLimit) } // MARK: - Synchronous Accessors @@ -154,6 +205,7 @@ public final class LogManager: @unchecked Sendable { logQueue.async { self._configuration = cfg self.dateFormatter.dateFormat = cfg.dateFormat + self.applyAutoCacheLimit(cfg.autoLoggerCacheLimit) } } @@ -222,6 +274,24 @@ public final class LogManager: @unchecked Sendable { logQueue.sync { sinks.count } } + /// The number of cached `Logger` instances keyed by category. + public var loggerCacheCount: Int { + logQueue.sync { loggersByCategory.count + autoLoggerKeys.count } + } + + /// Clears all cached `Logger` instances, useful to prevent unbounded growth when using many dynamic categories. + /// + /// - Parameter completion: Optional callback invoked after the cache has been cleared. + public func clearLoggerCache(completion: (@Sendable () -> Void)? = nil) { + logQueue.async { + self.loggersByCategory.removeAll() + self.autoLoggerCache.removeAllObjects() + self.autoLoggerKeys.removeAll() + self.autoLoggerOrder.removeAll() + completion?() + } + } + // MARK: - Logging /// Logs a message with the specified level and metadata. @@ -267,9 +337,21 @@ public final class LogManager: @unchecked Sendable { ) ) } else { - let ts = cfg.includeTimestamp ? (self.dateFormatter.string(from: now) + " ") : "" + let tsComponent = cfg.includeTimestamp ? self.dateFormatter.string(from: now) : nil + var parts: [String] = [] + if let tsComponent { + parts.append(tsComponent) + } + if cfg.includeEmoji { + parts.append(level.emoji) + } + if cfg.includeShortCode { + parts.append("[\(level.shortCode)]") + } + parts.append("[\(self.sanitizedCategoryName(category))]") + parts.append(message) let fileName = (file as NSString).lastPathComponent - formatted = "\(ts)\(level.emoji) [\(level.shortCode)] [\(category.name)] \(message) — \(fileName):\(line)" + formatted = "\(parts.joined(separator: " ")) — \(fileName):\(line)" } switch level.osLogType { @@ -292,6 +374,13 @@ public final class LogManager: @unchecked Sendable { } private func getLogger(for category: LogCategory) -> Logger { + if isAutoGeneratedCategory(category) { + return getAutoLogger(for: category) + } + return getCustomLogger(for: category) + } + + private func getCustomLogger(for category: LogCategory) -> Logger { if let logger = loggersByCategory[category] { return logger } @@ -300,4 +389,68 @@ public final class LogManager: @unchecked Sendable { loggersByCategory[category] = logger return logger } + + private func getAutoLogger(for category: LogCategory) -> Logger { + let key = sanitizedCategoryName(category) + let nsKey = key as NSString + + if let cached = autoLoggerCache.object(forKey: nsKey) { + promoteAutoLoggerKey(key) + return cached.logger + } + + let logger = Logger(subsystem: subsystem, category: key) + let cached = CachedLogger(logger: logger, key: key) + autoLoggerCache.setObject(cached, forKey: nsKey) + autoLoggerKeys.insert(key) + autoLoggerOrder.append(key) + enforceAutoLoggerLimit(_configuration.autoLoggerCacheLimit) + return logger + } + + private func promoteAutoLoggerKey(_ key: String) { + if let idx = autoLoggerOrder.firstIndex(of: key) { + autoLoggerOrder.remove(at: idx) + autoLoggerOrder.append(key) + } + } + + private func enforceAutoLoggerLimit(_ limit: Int?) { + guard let limit else { return } + applyAutoCacheLimit(limit) + while autoLoggerOrder.count > limit { + let evictKey = autoLoggerOrder.removeFirst() + autoLoggerKeys.remove(evictKey) + autoLoggerCache.removeObject(forKey: evictKey as NSString) + } + } + + private func applyAutoCacheLimit(_ limit: Int?) { + if let limit { + autoLoggerCache.countLimit = limit + } else { + autoLoggerCache.countLimit = 0 + } + } + + fileprivate func handleAutoEviction(forKey key: String) { + logQueue.async { + self.autoLoggerKeys.remove(key) + if let idx = self.autoLoggerOrder.firstIndex(of: key) { + self.autoLoggerOrder.remove(at: idx) + } + } + } + + private func isAutoGeneratedCategory(_ category: LogCategory) -> Bool { + let name = category.name + if name.contains("/") { return true } + if name.lowercased().hasSuffix(".swift") { return true } + return false + } + + private func sanitizedCategoryName(_ category: LogCategory) -> String { + guard isAutoGeneratedCategory(category) else { return category.name } + return (category.name as NSString).lastPathComponent + } } diff --git a/Tests/Scribe/ScribeTests.swift b/Tests/Scribe/ScribeTests.swift index 836e3a7..d607b8c 100644 --- a/Tests/Scribe/ScribeTests.swift +++ b/Tests/Scribe/ScribeTests.swift @@ -11,6 +11,12 @@ final class ScribeTests: XCTestCase { Log.logger.setMinimumLevel(.debug) Log.logger.setConfiguration(.default) Log.logger.removeAllSinks() + + let clearExpectation = XCTestExpectation(description: "Logger cache cleared") + Log.logger.clearLoggerCache { + clearExpectation.fulfill() + } + wait(for: [clearExpectation], timeout: 1.0) } func testBasicLogging() throws { @@ -197,6 +203,73 @@ final class ScribeTests: XCTestCase { XCTAssertTrue(networkLevels.contains(.api)) XCTAssertEqual(networkLevels.count, 2) } + + func testLoggerCacheEvictionAndClear() throws { + let logExpectation = XCTestExpectation(description: "Logs processed with eviction") + logExpectation.expectedFulfillmentCount = 3 + + let sinkID = Log.logger.addSink { _ in + logExpectation.fulfill() + } + + let configApplied = XCTestExpectation(description: "Configuration applied") + Log.logger.setConfiguration(LogConfiguration(autoLoggerCacheLimit: 2)) + Log.logger.getConfiguration { cfg in + if cfg.autoLoggerCacheLimit == 2 { + configApplied.fulfill() + } + } + + wait(for: [configApplied], timeout: 1.0) + + Log.info("Evict one", category: .evictOneAuto) + Log.info("Evict two", category: .evictTwoAuto) + Log.info("Evict three", category: .evictThreeAuto) + + wait(for: [logExpectation], timeout: 2.0) + + XCTAssertEqual(Log.logger.loggerCacheCount, 2) + + let cleared = XCTestExpectation(description: "Cache cleared") + Log.logger.clearLoggerCache { + cleared.fulfill() + } + wait(for: [cleared], timeout: 1.0) + XCTAssertEqual(Log.logger.loggerCacheCount, 0) + + Log.logger.removeSink(sinkID) + } + + func testAutoCategoryNameIsSanitizedToFileName() throws { + let expectation = XCTestExpectation(description: "Auto category sanitized") + + let sinkID = Log.logger.addSink { line in + if line.contains("[FontLibrary.swift]") { + XCTAssertFalse(line.contains("Scribe/FontLibrary.swift")) + expectation.fulfill() + } + } + + Log.info("Test auto category", category: .init("Scribe/FontLibrary.swift")) + + wait(for: [expectation], timeout: 1.0) + Log.logger.removeSink(sinkID) + } + + func testDefaultCategoryUsesCallerFile() throws { + let expectation = XCTestExpectation(description: "Default category uses caller file") + + let sinkID = Log.logger.addSink { line in + if line.contains("[ScribeTests.swift]") { + expectation.fulfill() + } + } + + Log.info("Default category should be caller file") + + wait(for: [expectation], timeout: 1.0) + Log.logger.removeSink(sinkID) + } } extension LogCategory { @@ -204,4 +277,8 @@ extension LogCategory { static let testAllowed = LogCategory("AllowedCategory") static let testBlocked = LogCategory("BlockedCategory") + + static let evictOneAuto = LogCategory("Auto/EvictOne.swift") + static let evictTwoAuto = LogCategory("Auto/EvictTwo.swift") + static let evictThreeAuto = LogCategory("Auto/EvictThree.swift") } From 9669a07cce637ea9225c1d03886cf82c5bd0f955 Mon Sep 17 00:00:00 2001 From: Kami Date: Mon, 8 Dec 2025 15:52:40 +1000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=8E=A8=20Formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Scribe/LogManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Scribe/LogManager.swift b/Sources/Scribe/LogManager.swift index 6705e01..ff6140d 100644 --- a/Sources/Scribe/LogManager.swift +++ b/Sources/Scribe/LogManager.swift @@ -282,7 +282,7 @@ public final class LogManager: @unchecked Sendable { /// Clears all cached `Logger` instances, useful to prevent unbounded growth when using many dynamic categories. /// /// - Parameter completion: Optional callback invoked after the cache has been cleared. - public func clearLoggerCache(completion: (@Sendable () -> Void)? = nil) { + public func clearLoggerCache(completion: (@Sendable () -> ())? = nil) { logQueue.async { self.loggersByCategory.removeAll() self.autoLoggerCache.removeAllObjects() From b1745255dab6f6b49fbd575e00b0cbbf27f5a838 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 7 Dec 2025 23:15:42 -0700 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=A8=20Store=20`category.isAutoGenerat?= =?UTF-8?q?ed`=20in=20LogCategory,=20compute=20category=20name=20in=20init?= =?UTF-8?q?ializer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Scribe/Log.swift | 2 +- Sources/Scribe/LogCategory.swift | 19 ++++++++++++++++++- Sources/Scribe/LogManager.swift | 21 +++------------------ Tests/Scribe/ScribeTests.swift | 10 ++++++---- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/Sources/Scribe/Log.swift b/Sources/Scribe/Log.swift index 773ba58..fe90cd2 100644 --- a/Sources/Scribe/Log.swift +++ b/Sources/Scribe/Log.swift @@ -434,7 +434,7 @@ public enum Log: Sendable { file: String, line: Int ) { - let resolvedCategory = category ?? LogCategory(fileID) + let resolvedCategory = category ?? LogCategory(fileID, isAutoGenerated: true) logger.log(message, level: level, category: resolvedCategory, file: file, function: function, line: line) } } diff --git a/Sources/Scribe/LogCategory.swift b/Sources/Scribe/LogCategory.swift index 9699c7c..be8ed47 100644 --- a/Sources/Scribe/LogCategory.swift +++ b/Sources/Scribe/LogCategory.swift @@ -21,8 +21,25 @@ import Foundation /// This allows the same category to be reused consistently across multiple files. public struct LogCategory: Sendable, Hashable { public init(_ name: String) { - self.name = name + self.init( + name, + isAutoGenerated: false + ) + } + + @_spi(Internals) + public init(_ name: String, isAutoGenerated: Bool) { + if isAutoGenerated { + self.name = (name as NSString).lastPathComponent + } else { + self.name = name + } + + self.isAutoGenerated = isAutoGenerated } public let name: String + + @_spi(Internals) + public let isAutoGenerated: Bool } diff --git a/Sources/Scribe/LogManager.swift b/Sources/Scribe/LogManager.swift index ff6140d..3ac4b94 100644 --- a/Sources/Scribe/LogManager.swift +++ b/Sources/Scribe/LogManager.swift @@ -348,7 +348,7 @@ public final class LogManager: @unchecked Sendable { if cfg.includeShortCode { parts.append("[\(level.shortCode)]") } - parts.append("[\(self.sanitizedCategoryName(category))]") + parts.append("[\(category.name)]") parts.append(message) let fileName = (file as NSString).lastPathComponent formatted = "\(parts.joined(separator: " ")) — \(fileName):\(line)" @@ -374,10 +374,7 @@ public final class LogManager: @unchecked Sendable { } private func getLogger(for category: LogCategory) -> Logger { - if isAutoGeneratedCategory(category) { - return getAutoLogger(for: category) - } - return getCustomLogger(for: category) + category.isAutoGenerated ? getAutoLogger(for: category) : getCustomLogger(for: category) } private func getCustomLogger(for category: LogCategory) -> Logger { @@ -391,7 +388,7 @@ public final class LogManager: @unchecked Sendable { } private func getAutoLogger(for category: LogCategory) -> Logger { - let key = sanitizedCategoryName(category) + let key = category.name let nsKey = key as NSString if let cached = autoLoggerCache.object(forKey: nsKey) { @@ -441,16 +438,4 @@ public final class LogManager: @unchecked Sendable { } } } - - private func isAutoGeneratedCategory(_ category: LogCategory) -> Bool { - let name = category.name - if name.contains("/") { return true } - if name.lowercased().hasSuffix(".swift") { return true } - return false - } - - private func sanitizedCategoryName(_ category: LogCategory) -> String { - guard isAutoGeneratedCategory(category) else { return category.name } - return (category.name as NSString).lastPathComponent - } } diff --git a/Tests/Scribe/ScribeTests.swift b/Tests/Scribe/ScribeTests.swift index d607b8c..bbbf050 100644 --- a/Tests/Scribe/ScribeTests.swift +++ b/Tests/Scribe/ScribeTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import Scribe +@testable @_spi(Internals) import Scribe // MARK: - ScribeTests @@ -277,8 +277,10 @@ extension LogCategory { static let testAllowed = LogCategory("AllowedCategory") static let testBlocked = LogCategory("BlockedCategory") +} - static let evictOneAuto = LogCategory("Auto/EvictOne.swift") - static let evictTwoAuto = LogCategory("Auto/EvictTwo.swift") - static let evictThreeAuto = LogCategory("Auto/EvictThree.swift") +extension LogCategory { + static let evictOneAuto = LogCategory("Auto/EvictOne.swift", isAutoGenerated: true) + static let evictTwoAuto = LogCategory("Auto/EvictTwo.swift", isAutoGenerated: true) + static let evictThreeAuto = LogCategory("Auto/EvictThree.swift", isAutoGenerated: true) } From a3c40ead6e574f2da00c8df13f67fc2a0510323e Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 7 Dec 2025 21:17:50 -0700 Subject: [PATCH 6/8] =?UTF-8?q?=E2=9C=A8=20AsyncStream=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Scribe/LogManager.swift | 45 ++++++++++++++++++++++++++++----- Tests/Scribe/ScribeTests.swift | 27 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/Sources/Scribe/LogManager.swift b/Sources/Scribe/LogManager.swift index 3ac4b94..a871113 100644 --- a/Sources/Scribe/LogManager.swift +++ b/Sources/Scribe/LogManager.swift @@ -153,7 +153,8 @@ public final class LogManager: @unchecked Sendable { // State protected by logQueue private var _minimumLevel: LogLevel private var _configuration: LogConfiguration - private var sinks: [(id: SinkID, handler: @Sendable (String) -> ())] = [] + private var sinks: [SinkID: @Sendable (String) -> ()] = [:] + private var streamContinuations: [UUID: AsyncStream.Continuation] = [:] // Serial queue for thread-safe state access private let logQueue: DispatchQueue @@ -245,10 +246,10 @@ public final class LogManager: @unchecked Sendable { /// - Parameter sink: Closure called with each formatted log line. /// - Returns: A `SinkID` that can be used to remove this specific sink later. @discardableResult - public func addSink(_ sink: @Sendable @escaping (String) -> ()) -> SinkID { + public func addSink(_ callback: @Sendable @escaping (String) -> ()) -> SinkID { let sinkID = SinkID() logQueue.async { - self.sinks.append((id: sinkID, handler: sink)) + self.sinks[sinkID] = callback } return sinkID } @@ -258,7 +259,7 @@ public final class LogManager: @unchecked Sendable { /// - Parameter id: The `SinkID` returned when the sink was added. public func removeSink(_ id: SinkID) { logQueue.async { - self.sinks.removeAll { $0.id == id } + self.sinks[id] = nil } } @@ -273,6 +274,28 @@ public final class LogManager: @unchecked Sendable { public var sinkCount: Int { logQueue.sync { sinks.count } } + + // MARK: - Streaming + + /// Creates and returns a stream to recieve formatted log messages. + /// + /// Similar to sinks, these are useful for capturing logs in tests, writing to files, or forwarding to remote services. + /// - Returns: An AsyncStream that will stream formatted log messages. + public func stream() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + + logQueue.async { + self.streamContinuations[id] = continuation + } + + continuation.onTermination = { @Sendable _ in + self.logQueue.async { + self.streamContinuations.removeValue(forKey: id) + } + } + } + } /// The number of cached `Logger` instances keyed by category. public var loggerCacheCount: Int { @@ -367,9 +390,17 @@ public final class LogManager: @unchecked Sendable { logger.log("\(formatted, privacy: .public)") } - for (_, handler) in self.sinks { - handler(formatted) - } + self.dispatch(formatted) + } + } + + private func dispatch(_ message: String) { + for handler in sinks.values { + handler(message) + } + + for continuation in streamContinuations.values { + continuation.yield(message) } } diff --git a/Tests/Scribe/ScribeTests.swift b/Tests/Scribe/ScribeTests.swift index bbbf050..9bf78ca 100644 --- a/Tests/Scribe/ScribeTests.swift +++ b/Tests/Scribe/ScribeTests.swift @@ -135,6 +135,33 @@ final class ScribeTests: XCTestCase { XCTAssertNotNil(capture.message) XCTAssertTrue(capture.message?.contains("Sink test message") ?? false) } + + func testStreams() throws { + let expectation = XCTestExpectation(description: "Sink received log") + + final class MessageCapture: @unchecked Sendable { + var message: String? + } + let capture = MessageCapture() + + Task { + for await message in Log.logger.stream() { + capture.message = message + expectation.fulfill() + } + } + + Task { + // Wait for the stream to start + try? await Task.sleep(for: .milliseconds(500)) + + Log.info("Sink test message") + } + + wait(for: [expectation], timeout: 2.0) + XCTAssertNotNil(capture.message) + XCTAssertTrue(capture.message?.contains("Sink test message") ?? false) + } func testCategoryFiltering() throws { let allowedExpectation = XCTestExpectation(description: "Allowed category logged") From 44107aeb7bdc87af7b2bd9c3f3c950020e66b538 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 7 Dec 2025 21:37:24 -0700 Subject: [PATCH 7/8] =?UTF-8?q?=E2=9C=A8=20Support=20for=20filtering=20sin?= =?UTF-8?q?ks/streams=20by=20categories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Scribe/LogManager.swift | 70 ++++++++++++++++++------------ Tests/Scribe/ScribeTests.swift | 75 ++++++++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 32 deletions(-) diff --git a/Sources/Scribe/LogManager.swift b/Sources/Scribe/LogManager.swift index a871113..f1968c8 100644 --- a/Sources/Scribe/LogManager.swift +++ b/Sources/Scribe/LogManager.swift @@ -88,14 +88,18 @@ public struct LogConfiguration: Sendable { public static var `default`: LogConfiguration { LogConfiguration() } } -// MARK: - SinkID +// MARK: - LogSubscription -/// Unique identifier for a registered log sink, used for removal. -public struct SinkID: Hashable, Sendable { +/// Unique identifier for a registered log subscription. +/// +/// Used for both sink callbacks and async stream listeners. +public struct LogSubscription: Hashable, Sendable { fileprivate let id: UUID + fileprivate let categories: Set? - fileprivate init() { + fileprivate init(categories: Set?) { id = UUID() + self.categories = categories } } @@ -153,8 +157,8 @@ public final class LogManager: @unchecked Sendable { // State protected by logQueue private var _minimumLevel: LogLevel private var _configuration: LogConfiguration - private var sinks: [SinkID: @Sendable (String) -> ()] = [:] - private var streamContinuations: [UUID: AsyncStream.Continuation] = [:] + private var sinks: [LogSubscription: @Sendable (String) -> ()] = [:] + private var streamContinuations: [LogSubscription: AsyncStream.Continuation] = [:] // Serial queue for thread-safe state access private let logQueue: DispatchQueue @@ -243,21 +247,29 @@ public final class LogManager: @unchecked Sendable { /// /// Sinks are useful for capturing logs in tests, writing to files, or forwarding to remote services. /// - /// - Parameter sink: Closure called with each formatted log line. - /// - Returns: A `SinkID` that can be used to remove this specific sink later. + /// - Parameters: + /// - categories: Specific categories of log messages to receive. If `nil`, the callback is triggered for all + /// formatted messages. + /// - callback: Closure called with each formatted log line. + /// - Returns: A `LogSubscription` that can be used to remove this specific sink later. @discardableResult - public func addSink(_ callback: @Sendable @escaping (String) -> ()) -> SinkID { - let sinkID = SinkID() + public func addSink( + categories: Set? = nil, + _ callback: @Sendable @escaping (String) -> () + ) -> LogSubscription { + let subscription = LogSubscription(categories: categories) + logQueue.async { - self.sinks[sinkID] = callback + self.sinks[subscription] = callback } - return sinkID + + return subscription } /// Removes a specific sink by its identifier. /// /// - Parameter id: The `SinkID` returned when the sink was added. - public func removeSink(_ id: SinkID) { + public func removeSink(_ id: LogSubscription) { logQueue.async { self.sinks[id] = nil } @@ -274,21 +286,24 @@ public final class LogManager: @unchecked Sendable { public var sinkCount: Int { logQueue.sync { sinks.count } } - + // MARK: - Streaming /// Creates and returns a stream to recieve formatted log messages. - /// - /// Similar to sinks, these are useful for capturing logs in tests, writing to files, or forwarding to remote services. + /// + /// Similar to sinks, these are useful for capturing logs in tests, writing to files, or forwarding to remote + /// services. + /// - Parameter categories: Specific categories of log messages to receive. If `nil`, the continuation is triggered + /// for all formatted messages. /// - Returns: An AsyncStream that will stream formatted log messages. - public func stream() -> AsyncStream { + public func stream(categories: Set? = nil) -> AsyncStream { AsyncStream { continuation in - let id = UUID() - + let id = LogSubscription(categories: categories) + logQueue.async { self.streamContinuations[id] = continuation } - + continuation.onTermination = { @Sendable _ in self.logQueue.async { self.streamContinuations.removeValue(forKey: id) @@ -390,16 +405,17 @@ public final class LogManager: @unchecked Sendable { logger.log("\(formatted, privacy: .public)") } - self.dispatch(formatted) + self.dispatch(formatted, category: category) } } - - private func dispatch(_ message: String) { - for handler in sinks.values { - handler(message) + + private func dispatch(_ message: String, category: LogCategory) { + for (subscription, callback) in sinks where subscription.categories?.contains(category) ?? true { + callback(message) } - - for continuation in streamContinuations.values { + + for (subscription, continuation) in streamContinuations + where subscription.categories?.contains(category) ?? true { continuation.yield(message) } } diff --git a/Tests/Scribe/ScribeTests.swift b/Tests/Scribe/ScribeTests.swift index 9bf78ca..86ea188 100644 --- a/Tests/Scribe/ScribeTests.swift +++ b/Tests/Scribe/ScribeTests.swift @@ -135,10 +135,39 @@ final class ScribeTests: XCTestCase { XCTAssertNotNil(capture.message) XCTAssertTrue(capture.message?.contains("Sink test message") ?? false) } - + + func testSinksWithCategoryFilter() throws { + let allowedExpectation = XCTestExpectation(description: "Allowed category logged") + let blockedExpectation = XCTestExpectation(description: "Blocked category not logged") + blockedExpectation.isInverted = true + + final class MessageCapture: @unchecked Sendable { + var message: String? + } + let capture = MessageCapture() + + Log.logger.addSink(categories: [.testAllowed]) { message in + capture.message = message + + if message.contains("[AllowedCategory]") { + allowedExpectation.fulfill() + } + if message.contains("[BlockedCategory]") { + blockedExpectation.fulfill() + } + } + + Log.info("Sink test message allowed", category: .testAllowed) + Log.info("Sink test message blocked", category: .testBlocked) + + wait(for: [allowedExpectation, blockedExpectation], timeout: 2.0) + XCTAssertNotNil(capture.message) + XCTAssertTrue(capture.message?.contains("Sink test message allowed") ?? false) + } + func testStreams() throws { let expectation = XCTestExpectation(description: "Sink received log") - + final class MessageCapture: @unchecked Sendable { var message: String? } @@ -150,19 +179,55 @@ final class ScribeTests: XCTestCase { expectation.fulfill() } } - + Task { // Wait for the stream to start try? await Task.sleep(for: .milliseconds(500)) Log.info("Sink test message") } - + wait(for: [expectation], timeout: 2.0) XCTAssertNotNil(capture.message) XCTAssertTrue(capture.message?.contains("Sink test message") ?? false) } + func testStreamsWithCategoryFilter() throws { + let allowedExpectation = XCTestExpectation(description: "Allowed category logged") + let blockedExpectation = XCTestExpectation(description: "Blocked category not logged") + blockedExpectation.isInverted = true + + final class MessageCapture: @unchecked Sendable { + var message: String? + } + let capture = MessageCapture() + + Task { + for await message in Log.logger.stream(categories: [.testAllowed]) { + capture.message = message + + if message.contains("[AllowedCategory]") { + allowedExpectation.fulfill() + } + if message.contains("[BlockedCategory]") { + blockedExpectation.fulfill() + } + } + } + + Task { + // Wait for the stream to start + try? await Task.sleep(for: .milliseconds(500)) + + Log.info("Sink test message allowed", category: .testAllowed) + Log.info("Sink test message blocked", category: .testBlocked) + } + + wait(for: [allowedExpectation, blockedExpectation], timeout: 2.0) + XCTAssertNotNil(capture.message) + XCTAssertTrue(capture.message?.contains("Sink test message allowed") ?? false) + } + func testCategoryFiltering() throws { let allowedExpectation = XCTestExpectation(description: "Allowed category logged") let blockedExpectation = XCTestExpectation(description: "Blocked category not logged") @@ -277,7 +342,7 @@ final class ScribeTests: XCTestCase { } } - Log.info("Test auto category", category: .init("Scribe/FontLibrary.swift")) + Log.info("Test auto category", category: .init("Scribe/FontLibrary.swift", isAutoGenerated: true)) wait(for: [expectation], timeout: 1.0) Log.logger.removeSink(sinkID) From 24b332ea17c781601910bdab9b70b119cc13e715 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 7 Dec 2025 23:15:42 -0700 Subject: [PATCH 8/8] =?UTF-8?q?=E2=9C=A8=20Store=20`category.isAutoGenerat?= =?UTF-8?q?ed`=20in=20LogCategory,=20compute=20category=20name=20in=20init?= =?UTF-8?q?ializer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Scribe/Log.swift | 2 +- Sources/Scribe/LogCategory.swift | 19 ++++++++++++++++++- Sources/Scribe/LogManager.swift | 21 +++------------------ Tests/Scribe/ScribeTests.swift | 10 +++++----- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/Sources/Scribe/Log.swift b/Sources/Scribe/Log.swift index 773ba58..fe90cd2 100644 --- a/Sources/Scribe/Log.swift +++ b/Sources/Scribe/Log.swift @@ -434,7 +434,7 @@ public enum Log: Sendable { file: String, line: Int ) { - let resolvedCategory = category ?? LogCategory(fileID) + let resolvedCategory = category ?? LogCategory(fileID, isAutoGenerated: true) logger.log(message, level: level, category: resolvedCategory, file: file, function: function, line: line) } } diff --git a/Sources/Scribe/LogCategory.swift b/Sources/Scribe/LogCategory.swift index 9699c7c..be8ed47 100644 --- a/Sources/Scribe/LogCategory.swift +++ b/Sources/Scribe/LogCategory.swift @@ -21,8 +21,25 @@ import Foundation /// This allows the same category to be reused consistently across multiple files. public struct LogCategory: Sendable, Hashable { public init(_ name: String) { - self.name = name + self.init( + name, + isAutoGenerated: false + ) + } + + @_spi(Internals) + public init(_ name: String, isAutoGenerated: Bool) { + if isAutoGenerated { + self.name = (name as NSString).lastPathComponent + } else { + self.name = name + } + + self.isAutoGenerated = isAutoGenerated } public let name: String + + @_spi(Internals) + public let isAutoGenerated: Bool } diff --git a/Sources/Scribe/LogManager.swift b/Sources/Scribe/LogManager.swift index ff6140d..3ac4b94 100644 --- a/Sources/Scribe/LogManager.swift +++ b/Sources/Scribe/LogManager.swift @@ -348,7 +348,7 @@ public final class LogManager: @unchecked Sendable { if cfg.includeShortCode { parts.append("[\(level.shortCode)]") } - parts.append("[\(self.sanitizedCategoryName(category))]") + parts.append("[\(category.name)]") parts.append(message) let fileName = (file as NSString).lastPathComponent formatted = "\(parts.joined(separator: " ")) — \(fileName):\(line)" @@ -374,10 +374,7 @@ public final class LogManager: @unchecked Sendable { } private func getLogger(for category: LogCategory) -> Logger { - if isAutoGeneratedCategory(category) { - return getAutoLogger(for: category) - } - return getCustomLogger(for: category) + category.isAutoGenerated ? getAutoLogger(for: category) : getCustomLogger(for: category) } private func getCustomLogger(for category: LogCategory) -> Logger { @@ -391,7 +388,7 @@ public final class LogManager: @unchecked Sendable { } private func getAutoLogger(for category: LogCategory) -> Logger { - let key = sanitizedCategoryName(category) + let key = category.name let nsKey = key as NSString if let cached = autoLoggerCache.object(forKey: nsKey) { @@ -441,16 +438,4 @@ public final class LogManager: @unchecked Sendable { } } } - - private func isAutoGeneratedCategory(_ category: LogCategory) -> Bool { - let name = category.name - if name.contains("/") { return true } - if name.lowercased().hasSuffix(".swift") { return true } - return false - } - - private func sanitizedCategoryName(_ category: LogCategory) -> String { - guard isAutoGeneratedCategory(category) else { return category.name } - return (category.name as NSString).lastPathComponent - } } diff --git a/Tests/Scribe/ScribeTests.swift b/Tests/Scribe/ScribeTests.swift index d607b8c..c7f5de0 100644 --- a/Tests/Scribe/ScribeTests.swift +++ b/Tests/Scribe/ScribeTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import Scribe +@testable @_spi(Internals) import Scribe // MARK: - ScribeTests @@ -250,7 +250,7 @@ final class ScribeTests: XCTestCase { } } - Log.info("Test auto category", category: .init("Scribe/FontLibrary.swift")) + Log.info("Test auto category", category: .init("Scribe/FontLibrary.swift", isAutoGenerated: true)) wait(for: [expectation], timeout: 1.0) Log.logger.removeSink(sinkID) @@ -278,7 +278,7 @@ extension LogCategory { static let testAllowed = LogCategory("AllowedCategory") static let testBlocked = LogCategory("BlockedCategory") - static let evictOneAuto = LogCategory("Auto/EvictOne.swift") - static let evictTwoAuto = LogCategory("Auto/EvictTwo.swift") - static let evictThreeAuto = LogCategory("Auto/EvictThree.swift") + static let evictOneAuto = LogCategory("Auto/EvictOne.swift", isAutoGenerated: true) + static let evictTwoAuto = LogCategory("Auto/EvictTwo.swift", isAutoGenerated: true) + static let evictThreeAuto = LogCategory("Auto/EvictThree.swift", isAutoGenerated: true) }