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/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/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 a73b626..fe90cd2 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,12 +37,13 @@ public enum Log: Sendable { /// - line: The source line number (auto-captured). public static func debug( _ message: String, - category: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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: String = #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, 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 new file mode 100644 index 0000000..be8ed47 --- /dev/null +++ b/Sources/Scribe/LogCategory.swift @@ -0,0 +1,45 @@ +// +// 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.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/LogLevel.swift b/Sources/Scribe/LogLevel.swift index be2f34e..1793034 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 @@ -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" } } @@ -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 69e4e27..3ac4b94 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. @@ -22,16 +24,34 @@ 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 - + + /// 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 @@ -41,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 (LogLevel, String, String, String, Int, Date) -> String)? = nil, + 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 } @@ -58,15 +88,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`. @@ -85,37 +119,66 @@ 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 logger: 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 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() + autoLoggerCache = NSCache() + autoLoggerCacheDelegate = AutoLoggerCacheDelegate() + _configuration = .default dateFormatter.dateFormat = _configuration.dateFormat _minimumLevel = .debug - logQueue = DispatchQueue(label: "com.kamidevs.scribe.logging", qos: .utility) + logQueue = DispatchQueue(label: "\(subsystem).logging", qos: .utility) + + autoLoggerCacheDelegate.owner = self + autoLoggerCache.delegate = autoLoggerCacheDelegate + applyAutoCacheLimit(_configuration.autoLoggerCacheLimit) } // MARK: - Synchronous Accessors - + /// The minimum log level threshold. Messages below this level are ignored. /// /// This property is thread-safe for both reading and writing. @@ -123,7 +186,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. @@ -142,13 +205,14 @@ public final class LogManager: @unchecked Sendable { logQueue.async { self._configuration = cfg self.dateFormatter.dateFormat = cfg.dateFormat + self.applyAutoCacheLimit(cfg.autoLoggerCacheLimit) } } /// 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) } @@ -166,7 +230,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) } @@ -181,14 +245,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. @@ -204,12 +268,30 @@ public final class LogManager: @unchecked Sendable { self.sinks.removeAll() } } - + /// The number of currently registered sinks. public var sinkCount: Int { 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 () -> ())? = 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. @@ -226,12 +308,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 +326,45 @@ 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 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("[\(category.name)]") + parts.append(message) let fileName = (file as NSString).lastPathComponent - formatted = "\(ts)\(level.emoji) [\(level.shortCode)] [\(category)] \(message) — \(fileName):\(line)" + formatted = "\(parts.joined(separator: " ")) — \(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 +372,70 @@ public final class LogManager: @unchecked Sendable { } } } + + private func getLogger(for category: LogCategory) -> Logger { + category.isAutoGenerated ? getAutoLogger(for: category) : getCustomLogger(for: category) + } + + private func getCustomLogger(for category: LogCategory) -> Logger { + if let logger = loggersByCategory[category] { + return logger + } + + let logger = Logger(subsystem: subsystem, category: category.name) + loggersByCategory[category] = logger + return logger + } + + private func getAutoLogger(for category: LogCategory) -> Logger { + let key = category.name + 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) + } + } + } } diff --git a/Tests/Scribe/ScribeTests.swift b/Tests/Scribe/ScribeTests.swift index b8cf9d9..c7f5de0 100644 --- a/Tests/Scribe/ScribeTests.swift +++ b/Tests/Scribe/ScribeTests.swift @@ -1,17 +1,24 @@ import XCTest -@testable import Scribe +@testable @_spi(Internals) import Scribe + +// MARK: - ScribeTests final class ScribeTests: XCTestCase { - override func setUp() { super.setUp() // Reset to default state before each test 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 { // Test basic logging functionality Log.info("Test info message") @@ -81,59 +88,59 @@ 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) } - + func testCustomFormatter() throws { 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) } - + 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 +149,16 @@ final class ScribeTests: XCTestCase { blockedExpectation.fulfill() } } - - 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) } - + func testLogLevelProperties() throws { // Test LogLevel properties XCTAssertEqual(LogLevel.error.emoji, "❌") @@ -159,41 +166,119 @@ 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)) 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", isAutoGenerated: true)) + + 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 { + static let test = LogCategory("TestCategory") + + static let testAllowed = LogCategory("AllowedCategory") + static let testBlocked = LogCategory("BlockedCategory") + + 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) }