diff --git a/Sources/SQLiteData/CloudKit/Internal/Logging.swift b/Sources/SQLiteData/CloudKit/Internal/Logging.swift index 0fd87f5e..777565f5 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Logging.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Logging.swift @@ -3,7 +3,13 @@ import TabularData import os + // MARK: - Deprecated + // This file is deprecated. The logging functionality has been moved to + // the AppleLoggerAdapter in Sources/SQLiteData/CloudKit/Loggers/AppleLoggerAdapter.swift + // This extension is kept for backward compatibility only. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @available(*, deprecated, message: "Use AppleLoggerAdapter instead. This extension will be removed in a future version.") extension Logger { func log(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) { let prefix = "SQLiteData (\(syncEngine.database.databaseScope.label).db)" @@ -232,17 +238,6 @@ } } - extension CKDatabase.Scope { - var label: String { - switch self { - case .public: "global" - case .private: "private" - case .shared: "shared" - @unknown default: "unknown" - } - } - } - extension CKError.Code { fileprivate var loggingDescription: String { switch self { diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index 8ea33c42..7f60d9b7 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -1,10 +1,9 @@ #if canImport(CloudKit) import Foundation - import os @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func defaultMetadatabase( - logger: Logger, + logger: any SyncEngineLogger, url: URL ) throws -> any DatabaseWriter { logger.debug( diff --git a/Sources/SQLiteData/CloudKit/Loggers/AppleLoggerAdapter.swift b/Sources/SQLiteData/CloudKit/Loggers/AppleLoggerAdapter.swift new file mode 100644 index 00000000..ab9b8de1 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Loggers/AppleLoggerAdapter.swift @@ -0,0 +1,319 @@ +#if canImport(CloudKit) + import CloudKit + import TabularData + import os + + /// An adapter that implements `SyncEngineLogger` using Apple's `os.Logger`. + /// + /// This adapter preserves the existing logging behavior, including tabular + /// formatting of sync events for better readability in Console.app. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct AppleLoggerAdapter: SyncEngineLogger { + private let logger: Logger + + /// Creates a new Apple logger adapter. + /// + /// - Parameters: + /// - subsystem: The subsystem identifier for the logger. + /// - category: The category for the logger. + public init(subsystem: String = "SQLiteData", category: String = "CloudKit") { + self.logger = Logger(subsystem: subsystem, category: category) + } + + /// Creates a new Apple logger adapter with a pre-configured logger. + /// + /// - Parameter logger: The `os.Logger` instance to use. + public init(logger: Logger) { + self.logger = logger + } + + public func log(_ event: SyncEngine.Event, databaseScope: String) { + let prefix = "SQLiteData (\(databaseScope).db)" + var actions: [String] = [] + var recordTypes: [String] = [] + var recordNames: [String] = [] + var zoneNames: [String] = [] + var ownerNames: [String] = [] + var errors: [String] = [] + var reasons: [String] = [] + var tabularDescription: String { + var dataFrame: DataFrame = [:] + if !actions.isEmpty { + dataFrame.append(column: Column(name: "action", contents: actions)) + } + if !recordTypes.isEmpty { + dataFrame.append(column: Column(name: "recordType", contents: recordTypes)) + } + if !recordNames.isEmpty { + dataFrame.append(column: Column(name: "recordName", contents: recordNames)) + } + if !zoneNames.isEmpty { + dataFrame.append(column: Column(name: "zoneName", contents: zoneNames)) + } + if !ownerNames.isEmpty { + dataFrame.append(column: Column(name: "ownerName", contents: ownerNames)) + } + if !errors.isEmpty { + dataFrame.append(column: Column(name: "error", contents: errors)) + } + if !reasons.isEmpty { + dataFrame.append(column: Column(name: "reason", contents: reasons)) + } + if !recordTypes.isEmpty { + dataFrame.sort( + on: ColumnID("action", String.self), + ColumnID("recordType", String.self), + ColumnID("recordName", String.self) + ) + } else if !actions.isEmpty { + dataFrame.sort(on: ColumnID("action", String.self)) + } + var formattingOptions = FormattingOptions( + maximumLineWidth: 120, + maximumCellWidth: 80, + maximumRowCount: 50, + includesColumnTypes: false + ) + formattingOptions.includesRowAndColumnCounts = false + formattingOptions.includesRowIndices = false + return + dataFrame + .description(options: formattingOptions) + .replacing("\n", with: "\n ") + } + + switch event { + case .stateUpdate: + logger.debug("\(prefix) stateUpdate") + case .accountChange(let changeType): + switch changeType { + case .signIn(let currentUser): + logger.debug( + """ + \(prefix) signIn + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + case .signOut(let previousUser): + logger.debug( + """ + \(prefix) signOut + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + """ + ) + case .switchAccounts(let previousUser, let currentUser): + logger.debug( + """ + \(prefix) switchAccounts: + Previous user: \(previousUser.recordName).\(previousUser.zoneID.ownerName).\(previousUser.zoneID.zoneName) + Current user: \(currentUser.recordName).\(currentUser.zoneID.ownerName).\(currentUser.zoneID.zoneName) + """ + ) + @unknown default: + logger.debug("unknown") + } + case .fetchedDatabaseChanges(let modifications, let deletions): + for modification in modifications { + actions.append("✅ Modified") + zoneNames.append(modification.zoneName) + ownerNames.append(modification.ownerName) + if !deletions.isEmpty { + reasons.append("") + } + } + for (deletedZoneID, reason) in deletions { + actions.append("🗑️ Deleted") + zoneNames.append(deletedZoneID.zoneName) + ownerNames.append(deletedZoneID.ownerName) + reasons.append(reason.loggingDescription) + } + logger.debug( + """ + \(prefix) fetchedDatabaseChanges + \(tabularDescription) + """ + ) + case .fetchedRecordZoneChanges(let modifications, let deletions): + for modification in modifications { + actions.append("✅ Modified") + recordTypes.append(modification.recordType) + recordNames.append(modification.recordID.recordName) + } + for (deletedRecordID, deletedRecordType) in deletions { + actions.append("🗑️ Deleted") + recordTypes.append(deletedRecordType) + recordNames.append(deletedRecordID.recordName) + } + logger.debug( + """ + \(prefix) fetchedRecordZoneChanges + \(tabularDescription) + """ + ) + case .sentDatabaseChanges( + let savedZones, + let failedZoneSaves, + let deletedZoneIDs, + let failedZoneDeletes + ): + for savedZone in savedZones { + actions.append("✅ Saved") + zoneNames.append(savedZone.zoneID.zoneName) + ownerNames.append(savedZone.zoneID.ownerName) + if !failedZoneSaves.isEmpty || !failedZoneDeletes.isEmpty { + errors.append("") + } + } + for (failedSaveZone, error) in failedZoneSaves { + actions.append("🛑 Failed save") + zoneNames.append(failedSaveZone.zoneID.zoneName) + ownerNames.append(failedSaveZone.zoneID.ownerName) + errors.append(error.code.loggingDescription) + } + for deletedZoneID in deletedZoneIDs { + actions.append("🗑️ Deleted") + zoneNames.append(deletedZoneID.zoneName) + ownerNames.append(deletedZoneID.ownerName) + if !failedZoneSaves.isEmpty || !failedZoneDeletes.isEmpty { + errors.append("") + } + } + for (failedDeleteZoneID, error) in failedZoneDeletes { + actions.append("🛑 Failed delete") + zoneNames.append(failedDeleteZoneID.zoneName) + ownerNames.append(failedDeleteZoneID.ownerName) + errors.append(error.code.loggingDescription) + } + logger.debug( + """ + \(prefix) sentDatabaseChanges + \(tabularDescription) + """ + ) + case .sentRecordZoneChanges( + let savedRecords, + let failedRecordSaves, + let deletedRecordIDs, + let failedRecordDeletes + ): + for savedRecord in savedRecords { + actions.append("✅ Saved") + recordTypes.append(savedRecord.recordType) + recordNames.append(savedRecord.recordID.recordName) + if !failedRecordSaves.isEmpty || !failedRecordDeletes.isEmpty { + errors.append("") + } + } + for (failedRecord, error) in failedRecordSaves { + actions.append("🛑 Save failed") + recordTypes.append(failedRecord.recordType) + recordNames.append(failedRecord.recordID.recordName) + errors.append("\(error.code.loggingDescription) (\(error.errorCode))") + } + for deletedRecordID in deletedRecordIDs { + actions.append("🗑️ Deleted") + recordTypes.append("") + recordNames.append(deletedRecordID.recordName) + if !failedRecordSaves.isEmpty || !failedRecordDeletes.isEmpty { + errors.append("") + } + } + for (failedDeleteRecordID, error) in failedRecordDeletes { + actions.append("🛑 Delete failed") + recordTypes.append("") + recordNames.append(failedDeleteRecordID.recordName) + errors.append("\(error.code.loggingDescription) (\(error.errorCode))") + } + logger.debug( + """ + \(prefix) sentRecordZoneChanges + \(tabularDescription) + """ + ) + case .willFetchChanges: + logger.debug("\(prefix) willFetchChanges") + case .willFetchRecordZoneChanges(let zoneID): + logger.debug("\(prefix) willFetchRecordZoneChanges: \(zoneID.zoneName)") + case .didFetchRecordZoneChanges(let zoneID, let error): + let error = (error?.code.loggingDescription).map { "\n ❌ \($0)" } ?? "" + logger.debug( + """ + \(prefix) willFetchRecordZoneChanges + ✅ Zone: \(zoneID.zoneName):\(zoneID.ownerName)\(error) + """ + ) + case .didFetchChanges: + logger.debug("\(prefix) didFetchChanges") + case .willSendChanges: + logger.debug("\(prefix) willSendChanges") + case .didSendChanges: + logger.debug("\(prefix) didSendChanges") + @unknown default: + logger.warning("\(prefix) ⚠️ unknown event: \(event.description)") + } + } + + public func debug(_ message: String) { + logger.debug("\(message)") + } + } + + extension CKError.Code { + fileprivate var loggingDescription: String { + switch self { + case .internalError: "internalError" + case .partialFailure: "partialFailure" + case .networkUnavailable: "networkUnavailable" + case .networkFailure: "networkFailure" + case .badContainer: "badContainer" + case .serviceUnavailable: "serviceUnavailable" + case .requestRateLimited: "requestRateLimited" + case .missingEntitlement: "missingEntitlement" + case .notAuthenticated: "notAuthenticated" + case .permissionFailure: "permissionFailure" + case .unknownItem: "unknownItem" + case .invalidArguments: "invalidArguments" + case .resultsTruncated: "resultsTruncated" + case .serverRecordChanged: "serverRecordChanged" + case .serverRejectedRequest: "serverRejectedRequest" + case .assetFileNotFound: "assetFileNotFound" + case .assetFileModified: "assetFileModified" + case .incompatibleVersion: "incompatibleVersion" + case .constraintViolation: "constraintViolation" + case .operationCancelled: "operationCancelled" + case .changeTokenExpired: "changeTokenExpired" + case .batchRequestFailed: "batchRequestFailed" + case .zoneBusy: "zoneBusy" + case .badDatabase: "badDatabase" + case .quotaExceeded: "quotaExceeded" + case .zoneNotFound: "zoneNotFound" + case .limitExceeded: "limitExceeded" + case .userDeletedZone: "userDeletedZone" + case .tooManyParticipants: "tooManyParticipants" + case .alreadyShared: "alreadyShared" + case .referenceViolation: "referenceViolation" + case .managedAccountRestricted: "managedAccountRestricted" + case .participantMayNeedVerification: "participantMayNeedVerification" + case .serverResponseLost: "serverResponseLost" + case .assetNotAvailable: "assetNotAvailable" + case .accountTemporarilyUnavailable: "accountTemporarilyUnavailable" + #if canImport(FoundationModels) + case .participantAlreadyInvited: "participantAlreadyInvited" + #endif + @unknown default: "(unknown error)" + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKDatabase.DatabaseChange.Deletion.Reason { + fileprivate var loggingDescription: String { + switch self { + case .deleted: "deleted" + case .purged: "purged" + case .encryptedDataReset: "encryptedDataReset" + @unknown default: "(unknown reason: \(self))" + } + } + } +#endif \ No newline at end of file diff --git a/Sources/SQLiteData/CloudKit/Loggers/CompositeLogger.swift b/Sources/SQLiteData/CloudKit/Loggers/CompositeLogger.swift new file mode 100644 index 00000000..ce15fdcc --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Loggers/CompositeLogger.swift @@ -0,0 +1,41 @@ +#if canImport(CloudKit) + import CloudKit + + /// A logger that forwards events to multiple underlying loggers. + /// + /// This allows you to use multiple logging backends simultaneously, + /// such as logging to both Apple's Console.app and a third-party + /// crash reporting service. + /// + /// Example: + /// ```swift + /// let logger = CompositeLogger(loggers: [ + /// AppleLoggerAdapter(), + /// MySentryLogger(), + /// MyDataDogLogger() + /// ]) + /// ``` + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct CompositeLogger: SyncEngineLogger { + private let loggers: [any SyncEngineLogger] + + /// Creates a new composite logger with the specified loggers. + /// + /// - Parameter loggers: An array of loggers to forward events to. + public init(loggers: [any SyncEngineLogger]) { + self.loggers = loggers + } + + public func log(_ event: SyncEngine.Event, databaseScope: String) { + for logger in loggers { + logger.log(event, databaseScope: databaseScope) + } + } + + public func debug(_ message: String) { + for logger in loggers { + logger.debug(message) + } + } + } +#endif \ No newline at end of file diff --git a/Sources/SQLiteData/CloudKit/Loggers/DisabledLogger.swift b/Sources/SQLiteData/CloudKit/Loggers/DisabledLogger.swift new file mode 100644 index 00000000..5a4b4044 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Loggers/DisabledLogger.swift @@ -0,0 +1,21 @@ +#if canImport(CloudKit) + import CloudKit + + /// A no-op logger implementation that discards all log messages. + /// + /// This logger is used when logging is disabled, providing a lightweight + /// implementation that doesn't perform any actual logging operations. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct DisabledLogger: SyncEngineLogger { + /// Creates a new disabled logger instance. + public init() {} + + public func log(_ event: SyncEngine.Event, databaseScope: String) { + // No-op: logging is disabled + } + + public func debug(_ message: String) { + // No-op: logging is disabled + } + } +#endif \ No newline at end of file diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift b/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift similarity index 97% rename from Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift rename to Sources/SQLiteData/CloudKit/SyncEngine.Event.swift index 230aac02..762ade4c 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngine.Event.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift @@ -3,7 +3,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { - package enum Event: CustomStringConvertible, Sendable { + public enum Event: CustomStringConvertible, Sendable { case stateUpdate(stateSerialization: CKSyncEngine.State.Serialization) case accountChange(changeType: CKSyncEngine.Event.AccountChange.ChangeType) case fetchedDatabaseChanges( @@ -82,7 +82,7 @@ } } - package var description: String { + public var description: String { switch self { case .stateUpdate: "stateUpdate" case .accountChange: "accountChange" diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 2953f724..f95e2a21 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -3,7 +3,6 @@ import ConcurrencyExtras import Dependencies import OrderedCollections - import OSLog import Observation import StructuredQueriesCore import SwiftData @@ -19,7 +18,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final class SyncEngine: Observable, Sendable { package let userDatabase: UserDatabase - package let logger: Logger + package let logger: any SyncEngineLogger package let metadatabase: any DatabaseWriter package let tables: [any SynchronizableTable] package let privateTables: [any SynchronizableTable] @@ -73,7 +72,7 @@ /// - defaultZone: The zone for all records to be stored in. /// - startImmediately: Determines if the sync engine starts right away or requires an /// explicit call to ``start()``. By default this argument is `true`. - /// - logger: The logger used to log events in the sync engine. By default a `.disabled` + /// - logger: The logger used to log events in the sync engine. By default a disabled /// logger is used, which means logs are not printed. public convenience init< each T1: PrimaryKeyedTable & _SendableMetatype, @@ -85,8 +84,8 @@ containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), startImmediately: Bool = DependencyValues._current.context == .live, - logger: Logger = isTesting - ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") + logger: any SyncEngineLogger = isTesting + ? DisabledLogger() : AppleLoggerAdapter(subsystem: "SQLiteData", category: "CloudKit") ) throws where repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, @@ -202,7 +201,7 @@ SyncEngine ) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol), userDatabase: UserDatabase, - logger: Logger, + logger: any SyncEngineLogger, tables: [any SynchronizableTable], privateTables: [any SynchronizableTable] = [] ) throws { @@ -835,7 +834,7 @@ package func handleEvent(_ event: Event, syncEngine: any SyncEngineProtocol) async { #if DEBUG - logger.log(event, syncEngine: syncEngine) + logger.log(event, databaseScope: syncEngine.database.databaseScope.label) #endif switch event { @@ -943,13 +942,12 @@ defer { let state = state.withValue(\.self) if let tabularDescription = state.tabularDescription { - logger.debug( - """ + let message = """ SQLiteData (\(syncEngine.database.databaseScope.label).db) \ nextRecordZoneChangeBatch: \(reason) \(tabularDescription) """ - ) + logger.debug(message) } } #endif @@ -2246,6 +2244,17 @@ } #if DEBUG + extension CKDatabase.Scope { + var label: String { + switch self { + case .public: "global" + case .private: "private" + case .shared: "shared" + @unknown default: "unknown" + } + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private struct NextRecordZoneChangeBatchLoggingState { var events: [String] = [] diff --git a/Sources/SQLiteData/CloudKit/SyncEngineLogger.swift b/Sources/SQLiteData/CloudKit/SyncEngineLogger.swift new file mode 100644 index 00000000..100257bf --- /dev/null +++ b/Sources/SQLiteData/CloudKit/SyncEngineLogger.swift @@ -0,0 +1,23 @@ +#if canImport(CloudKit) + import CloudKit + + /// A protocol that defines logging requirements for CloudKit sync operations. + /// + /// This protocol allows for injectable logging implementations, enabling + /// custom logging backends while maintaining compatibility with the existing + /// Apple Logger implementation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public protocol SyncEngineLogger: Sendable { + /// Logs a CloudKit sync event. + /// + /// - Parameters: + /// - event: The sync engine event to log. + /// - databaseScope: The database scope label (e.g., "private", "shared", "global"). + func log(_ event: SyncEngine.Event, databaseScope: String) + + /// Logs a debug message. + /// + /// - Parameter message: The debug message to log. + func debug(_ message: String) + } +#endif \ No newline at end of file diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 2cbf557f..419c5c2b 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -4,7 +4,6 @@ import OrderedCollections import SQLiteData import SnapshotTesting import Testing -import os @Suite( .snapshots(record: .missing), @@ -209,7 +208,7 @@ extension SyncEngine { ) }, userDatabase: userDatabase, - logger: Logger(.disabled), + logger: DisabledLogger(), tables: tables, privateTables: privateTables )