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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import PackageDescription
let package = Package(
name: "KlarLog",
platforms: [
.iOS(.v16)
.iOS(.v16),
.macOS(.v13)
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
Expand Down
121 changes: 121 additions & 0 deletions Sources/KlarLog/CategoryLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ public struct CategoryLogger: Sendable {
fileprivate func log(subsystem: String, destinations: [LogDestination], level: LogLevel, message: String) {
destinations.forEach { $0.log(subsystem: subsystem, category: category, level: level, message: message) }
}

/// Routes a log message with structured metadata to all provided destinations.
/// This method is only called by `ExposedCategoryLogger`.
///
/// - Parameters:
/// - subsystem: The subsystem identifier (e.g., "com.example.app").
/// - destinations: The list of destinations where messages should be sent.
/// - level: The severity level of the log message.
/// - message: The message text of the log.
/// - metadata: Structured data associated with this log entry.
fileprivate func log(subsystem: String, destinations: [LogDestination], level: LogLevel, message: String, metadata: LogMetadata?) {
destinations.forEach { $0.log(subsystem: subsystem, category: category, level: level, message: message, metadata: metadata) }
}
}

/// Public-facing logging interface for category-based logging.
Expand Down Expand Up @@ -98,6 +111,16 @@ public struct ExposedCategoryLogger {
private func log(_ level: LogLevel, _ message: String) {
base.log(subsystem: subsystem(), destinations: destinations(), level: level, message: message)
}

/// Routes a log message with metadata at the specified level to all configured destinations.
///
/// - Parameters:
/// - level: The severity level of the message.
/// - message: The message text to log.
/// - metadata: Structured data to attach to this log entry.
private func log(_ level: LogLevel, _ message: String, metadata: LogMetadata?) {
base.log(subsystem: subsystem(), destinations: destinations(), level: level, message: message, metadata: metadata)
}

// MARK: - Convenience logging methods

Expand Down Expand Up @@ -184,5 +207,103 @@ public struct ExposedCategoryLogger {
public func critical(_ message: String) {
self.log(.critical, message)
}

// MARK: - Structured Logging Methods

/// Logs a debug message with structured metadata.
///
/// Use for verbose diagnostic information with additional context.
///
/// - Parameters:
/// - message: The message to log.
/// - metadata: Structured data to attach to this log entry.
///
/// ### Example
/// ```swift
/// log.network.debug("Request headers", metadata: ["count": headers.count])
/// ```
public func debug(_ message: String, metadata: LogMetadata) {
self.log(.debug, message, metadata: metadata)
}

/// Logs an informational message with structured metadata.
///
/// Use for general informational messages with additional context.
///
/// - Parameters:
/// - message: The message to log.
/// - metadata: Structured data to attach to this log entry.
///
/// ### Example
/// ```swift
/// log.network.info("Response received", metadata: ["status": status, "duration": duration])
/// ```
public func info(_ message: String, metadata: LogMetadata) {
self.log(.info, message, metadata: metadata)
}

/// Logs a notice message with structured metadata.
///
/// Use for significant conditions with additional context.
///
/// - Parameters:
/// - message: The message to log.
/// - metadata: Structured data to attach to this log entry.
///
/// ### Example
/// ```swift
/// log.database.notice("Migration completed", metadata: ["version": "1.2.0", "duration": 5.3])
/// ```
public func notice(_ message: String, metadata: LogMetadata) {
self.log(.notice, message, metadata: metadata)
}

/// Logs a warning message with structured metadata.
///
/// Use for conditions that could become errors with additional diagnostic data.
///
/// - Parameters:
/// - message: The message to log.
/// - metadata: Structured data to attach to this log entry.
///
/// ### Example
/// ```swift
/// log.network.warning("Slow response", metadata: ["latency": latency, "url": url])
/// ```
public func warning(_ message: String, metadata: LogMetadata) {
self.log(.warning, message, metadata: metadata)
}

/// Logs an error message with structured metadata.
///
/// Use for failures with additional diagnostic context.
///
/// - Parameters:
/// - message: The message to log.
/// - metadata: Structured data to attach to this log entry.
///
/// ### Example
/// ```swift
/// log.storage.error("Failed to write file", metadata: ["path": path, "error": error.localizedDescription])
/// ```
public func error(_ message: String, metadata: LogMetadata) {
self.log(.error, message, metadata: metadata)
}

/// Logs a critical message with structured metadata.
///
/// Use for unrecoverable failures with diagnostic information.
///
/// - Parameters:
/// - message: The message to log.
/// - metadata: Structured data to attach to this log entry.
///
/// ### Example
/// ```swift
/// log.auth.critical("Token compromise detected", metadata: ["user_id": userId, "timestamp": Date().timeIntervalSince1970])
/// ```
public func critical(_ message: String, metadata: LogMetadata) {
self.log(.critical, message, metadata: metadata)
}
}

51 changes: 49 additions & 2 deletions Sources/KlarLog/LogDestinations/ConsoleDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ public struct ConsoleDestination: LogDestination, Sendable {
guard logForLogLevels.contains(level) else {
return
}

if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
// Xcode Previews cannot use os.Logger; fall back to print.
print("#OSLog_PREVIEW[\(level.rawValue.uppercased())][\(subsystem)][\(category)] \(message)")
} else {
let logger = os.Logger(subsystem: subsystem, category: category)

switch level {
case .debug:
logger.debug("\(message)")
Expand All @@ -68,5 +68,52 @@ public struct ConsoleDestination: LogDestination, Sendable {
}
}
}

/// Writes a message with structured metadata to the system console.
///
/// Metadata is formatted as key-value pairs and appended to the message.
///
/// - Parameters:
/// - subsystem: The logging subsystem, typically your app's bundle identifier.
/// - category: The logging category that groups related messages.
/// - level: The severity level for the message.
/// - message: The text to log.
/// - metadata: Structured data to include in the log.
public func log(subsystem: String, category: String, level: LogLevel, message: String, metadata: LogMetadata?) {
/// Only perform the action of this destination if it was configured to act on this log level.
guard logForLogLevels.contains(level) else {
return
}

// Format message with metadata
let formattedMessage: String
if let metadata = metadata, !metadata.isEmpty {
formattedMessage = "\(message) | \(metadata.formatted())"
} else {
formattedMessage = message
}

if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
// Xcode Previews cannot use os.Logger; fall back to print.
print("#OSLog_PREVIEW[\(level.rawValue.uppercased())][\(subsystem)][\(category)] \(formattedMessage)")
} else {
let logger = os.Logger(subsystem: subsystem, category: category)

switch level {
case .debug:
logger.debug("\(formattedMessage)")
case .info:
logger.info("\(formattedMessage)")
case .notice:
logger.notice("\(formattedMessage)")
case .warning:
logger.warning("\(formattedMessage)")
case .error:
logger.error("\(formattedMessage)")
case .critical:
logger.critical("\(formattedMessage)")
}
}
}
}

38 changes: 36 additions & 2 deletions Sources/KlarLog/LogDestinations/LocalFileDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,44 @@ public struct LocalFileDestination: LogDestination, Sendable {
guard logForLogLevels.contains(level) else {
return
}

let timestamp = dateFormatter.string(from: Date())
let logLine = "\(timestamp)\t[\(level.rawValue.uppercased())]\t[\(category)] \(message)"


Task.detached(priority: .utility) { [fileActor] in
await fileActor.writeLog(logLine)
}
}

/// Writes a log message with structured metadata to the file asynchronously.
///
/// The message is formatted with a timestamp and metadata (serialized as JSON)
/// and appended to the file. If the file exceeds `maxMessages`, the oldest
/// entries are removed to maintain the limit.
///
/// This method returns immediately and performs all file operations asynchronously
/// off the main thread.
///
/// - Parameters:
/// - subsystem: The subsystem identifier.
/// - category: The category name.
/// - level: The severity level.
/// - message: The message text to log.
/// - metadata: Structured data to persist with this log entry.
public func log(subsystem: String, category: String, level: LogLevel, message: String, metadata: LogMetadata?) {
/// Only perform the action of this destination if it was configured to act on this log level.
guard logForLogLevels.contains(level) else {
return
}

let timestamp = dateFormatter.string(from: Date())
let logLine: String
if let metadata = metadata, !metadata.isEmpty {
logLine = "\(timestamp)\t[\(level.rawValue.uppercased())]\t[\(category)] \(message)\t\(metadata.jsonString())"
} else {
logLine = "\(timestamp)\t[\(level.rawValue.uppercased())]\t[\(category)] \(message)"
}

Task.detached(priority: .utility) { [fileActor] in
await fileActor.writeLog(logLine)
}
Expand Down
36 changes: 32 additions & 4 deletions Sources/KlarLog/LogDestinations/LogDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,29 @@ public protocol LogDestination: Sendable {
/// - level: The severity level of the message.
/// - message: The message text to log.
func log(subsystem: String, category: String, level: LogLevel, message: String)


/// Processes a log message with structured metadata.
///
/// This method is called by `CategoryLogger` when a log message includes
/// structured metadata. By default, this strips the metadata and calls
/// the basic `log(subsystem:category:level:message:)` method.
///
/// Override this method in your destination to handle structured metadata.
///
/// - Parameters:
/// - subsystem: The subsystem identifier (e.g., "com.example.app").
/// - category: The category name (e.g., "network", "database").
/// - level: The severity level of the message.
/// - message: The message text to log.
/// - metadata: Structured data associated with this log entry.
func log(subsystem: String, category: String, level: LogLevel, message: String, metadata: LogMetadata?)

/// The log levels that this destination should emit.
///
/// Use this to filter which messages a destination processes. Messages whose
/// `level` is not included should be ignored by the destination.
///
/// It's reccomened to use a `guard` check in the `LogDestination` implementaion:
/// It's recommended to use a `guard` check in the `LogDestination` implementation:
/// ```swift
/// public struct CustomDestination: LogDestination, Sendable {
/// // Only messages whose level is included in this collection should be handled.
Expand All @@ -82,11 +98,23 @@ public protocol LogDestination: Sendable {
/// guard logForLogLevels.contains(level) else {
/// return
/// }
/// // perform you actions ...
/// // perform your actions ...
/// }
/// }
/// ```
/// - Important:In your custom `LogDestination` implementation you are responsible for implementing this behaviour.
/// - Important: In your custom `LogDestination` implementation you are responsible for implementing this behaviour.
var logForLogLevels: [LogLevel] { get }
}

// MARK: - Default Implementation

public extension LogDestination {
/// Default implementation that strips metadata and calls the basic log method.
///
/// Custom destinations can override this to handle metadata appropriately.
func log(subsystem: String, category: String, level: LogLevel, message: String, metadata: LogMetadata?) {
// By default, ignore metadata and call the basic log method
log(subsystem: subsystem, category: category, level: level, message: message)
}
}

Loading