diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b0089..3a4b565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 1.4.0 (unreleased) + +* Added the ability to log PowerSync sync network requests. + +```swift +try await database.connect( + connector: Connector(), + options: ConnectOptions( + clientConfiguration: SyncClientConfiguration( + requestLogger: SyncRequestLoggerConfiguration( + requestLevel: .headers + ) { message in + // Handle Network request logs here + print(message) + } + ) + ) +) + +``` ## 1.3.1 * Update SQLite to 3.50.3. diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index 0246f7f..4737c52 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -70,7 +70,18 @@ class SystemManager { func connect() async { do { - try await db.connect(connector: connector) + try await db.connect( + connector: connector, + options: ConnectOptions( + clientConfiguration: SyncClientConfiguration( + requestLogger: SyncRequestLoggerConfiguration( + requestLevel: .headers + ) { message in + self.db.logger.debug(message, tag: "SyncRequest") + } + ) + ) + ) try await attachments?.startSync() } catch { print("Unexpected error: \(error.localizedDescription)") // Catches any other error diff --git a/Package.swift b/Package.swift index 9596dc4..e07deee 100644 --- a/Package.swift +++ b/Package.swift @@ -31,8 +31,8 @@ if let kotlinSdkPath = localKotlinSdkOverride { // Not using a local build, so download from releases conditionalTargets.append(.binaryTarget( name: "PowerSyncKotlin", - url: "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.3.1/PowersyncKotlinRelease.zip", - checksum: "b01b72cbf88a2e7b9b67efce966799493fc48d4523b5989d8c645ed182880975" + url: "https://github.com/powersync-ja/powersync-kotlin/releases/download/v1.4.0/PowersyncKotlinRelease.zip", + checksum: "e800db216fc1c9722e66873deb4f925530267db6dbd5e2114dd845cc62c28cd9" )) } diff --git a/Sources/PowerSync/Kotlin/KotlinNetworkLogger.swift b/Sources/PowerSync/Kotlin/KotlinNetworkLogger.swift new file mode 100644 index 0000000..767a550 --- /dev/null +++ b/Sources/PowerSync/Kotlin/KotlinNetworkLogger.swift @@ -0,0 +1,29 @@ +import PowerSyncKotlin + +extension SyncRequestLogLevel { + func toKotlin() -> SwiftSyncRequestLogLevel { + switch self { + case .all: + return SwiftSyncRequestLogLevel.all + case .headers: + return SwiftSyncRequestLogLevel.headers + case .body: + return SwiftSyncRequestLogLevel.body + case .info: + return SwiftSyncRequestLogLevel.info + case .none: + return SwiftSyncRequestLogLevel.none + } + } +} + +extension SyncRequestLoggerConfiguration { + func toKotlinConfig() -> SwiftRequestLoggerConfig { + return SwiftRequestLoggerConfig( + logLevel: self.requestLevel.toKotlin(), + log: { [log] message in + log(message) + } + ) + } +} diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index ec5ea39..d78b370 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -59,7 +59,8 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { params: resolvedOptions.params.mapValues { $0.toKotlinMap() }, options: createSyncOptions( newClient: resolvedOptions.newClientImplementation, - userAgent: "PowerSync Swift SDK" + userAgent: "PowerSync Swift SDK", + loggingConfig: resolvedOptions.clientConfiguration?.requestLogger?.toKotlinConfig() ) ) } diff --git a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift index 68c9bd4..0edde56 100644 --- a/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/Protocol/PowerSyncDatabaseProtocol.swift @@ -1,5 +1,25 @@ import Foundation +/// Configuration for the sync client used to connect to the PowerSync service. +/// +/// Provides options to customize network behavior and logging for PowerSync +/// HTTP requests and responses. +public struct SyncClientConfiguration { + /// Optional configuration for logging PowerSync HTTP requests. + /// + /// When provided, network requests will be logged according to the + /// specified `SyncRequestLoggerConfiguration`. Set to `nil` to disable request logging entirely. + /// + /// - SeeAlso: `SyncRequestLoggerConfiguration` for configuration options + public let requestLogger: SyncRequestLoggerConfiguration? + + /// Creates a new sync client configuration. + /// - Parameter requestLogger: Optional network logger configuration + public init(requestLogger: SyncRequestLoggerConfiguration? = nil) { + self.requestLogger = requestLogger + } +} + /// Options for configuring a PowerSync connection. /// /// Provides optional parameters to customize sync behavior such as throttling and retry policies. @@ -42,21 +62,36 @@ public struct ConnectOptions { @_spi(PowerSyncExperimental) public var newClientImplementation: Bool + /// Configuration for the sync client used for PowerSync requests. + /// + /// Provides options to customize network behavior including logging of HTTP + /// requests and responses. When `nil`, default HTTP client settings are used + /// with no network logging. + /// + /// Set this to configure network logging or other HTTP client behaviors + /// specific to PowerSync operations. + /// + /// - SeeAlso: `SyncClientConfiguration` for available configuration options + public var clientConfiguration: SyncClientConfiguration? + /// Initializes a `ConnectOptions` instance with optional values. /// /// - Parameters: /// - crudThrottle: TimeInterval between CRUD operations in milliseconds. Defaults to `1` second. /// - retryDelay: Delay TimeInterval between retry attempts in milliseconds. Defaults to `5` seconds. /// - params: Custom sync parameters to send to the server. Defaults to an empty dictionary. + /// - clientConfiguration: Configuration for the HTTP client used to connect to PowerSync. public init( crudThrottle: TimeInterval = 1, retryDelay: TimeInterval = 5, - params: JsonParam = [:] + params: JsonParam = [:], + clientConfiguration: SyncClientConfiguration? = nil ) { self.crudThrottle = crudThrottle self.retryDelay = retryDelay self.params = params self.newClientImplementation = false + self.clientConfiguration = clientConfiguration } /// Initializes a ``ConnectOptions`` instance with optional values, including experimental options. @@ -65,12 +100,14 @@ public struct ConnectOptions { crudThrottle: TimeInterval = 1, retryDelay: TimeInterval = 5, params: JsonParam = [:], - newClientImplementation: Bool = false + newClientImplementation: Bool = false, + clientConfiguration: SyncClientConfiguration? = nil ) { self.crudThrottle = crudThrottle self.retryDelay = retryDelay self.params = params self.newClientImplementation = newClientImplementation + self.clientConfiguration = clientConfiguration } } @@ -91,7 +128,6 @@ public protocol PowerSyncDatabaseProtocol: Queries { /// Wait for the first sync to occur func waitForFirstSync() async throws - /// Replace the schema with a new version. This is for advanced use cases - typically the schema /// should just be specified once in the constructor. /// @@ -179,7 +215,7 @@ public protocol PowerSyncDatabaseProtocol: Queries { /// The database can still be queried after this is called, but the tables /// would be empty. /// - /// - Parameter clearLocal: Set to false to preserve data in local-only tables. + /// - Parameter clearLocal: Set to false to preserve data in local-only tables. Defaults to `true`. func disconnectAndClear(clearLocal: Bool) async throws /// Close the database, releasing resources. @@ -229,8 +265,8 @@ public extension PowerSyncDatabaseProtocol { ) } - func disconnectAndClear(clearLocal: Bool = true) async throws { - try await self.disconnectAndClear(clearLocal: clearLocal) + func disconnectAndClear() async throws { + try await disconnectAndClear(clearLocal: true) } func getCrudBatch(limit: Int32 = 100) async throws -> CrudBatch? { diff --git a/Sources/PowerSync/Protocol/SyncRequestLogger.swift b/Sources/PowerSync/Protocol/SyncRequestLogger.swift new file mode 100644 index 0000000..46b31ad --- /dev/null +++ b/Sources/PowerSync/Protocol/SyncRequestLogger.swift @@ -0,0 +1,83 @@ +/// Level of logs to expose to a `SyncRequestLogger` handler. +/// +/// Controls the verbosity of network logging for PowerSync HTTP requests. +/// The log level is configured once during initialization and determines +/// which network events will be logged throughout the session. +public enum SyncRequestLogLevel { + /// Log all network activity including headers, body, and info + case all + /// Log only request/response headers + case headers + /// Log only request/response body content + case body + /// Log basic informational messages about requests + case info + /// Disable all network logging + case none +} + +/// Configuration for PowerSync HTTP request logging. +/// +/// This configuration is set once during initialization and used throughout +/// the PowerSync session. The `requestLevel` determines which network events +/// are logged. +/// +/// - Note: The request level cannot be changed after initialization. A new call to `PowerSyncDatabase.connect` is required to change the level. +public struct SyncRequestLoggerConfiguration { + /// The request logging level that determines which network events are logged. + /// Set once during initialization and used throughout the session. + public let requestLevel: SyncRequestLogLevel + + private let logHandler: (_ message: String) -> Void + + /// Creates a new network logger configuration. + /// - Parameters: + /// - requestLevel: The `SyncRequestLogLevel` to use for filtering log messages + /// - logHandler: A closure which handles log messages + public init( + requestLevel: SyncRequestLogLevel, + logHandler: @escaping (_ message: String) -> Void) + { + self.requestLevel = requestLevel + self.logHandler = logHandler + } + + public func log(_ message: String) { + logHandler(message) + } + + /// Creates a new network logger configuration using a `LoggerProtocol` instance. + /// + /// This initializer allows integration with an existing logging framework by adapting + /// a `LoggerProtocol` to conform to `SyncRequestLogger`. The specified `logSeverity` + /// controls the severity level at which log messages are recorded. An optional `logTag` + /// may be used to help categorize logs. + /// + /// - Parameters: + /// - requestLevel: The `SyncRequestLogLevel` to use for filtering which network events are logged. + /// - logger: An object conforming to `LoggerProtocol` that will receive log messages. + /// - logSeverity: The severity level to use for all log messages (defaults to `.debug`). + /// - logTag: An optional tag to include with each log message, for use by the logging backend. + public init( + requestLevel: SyncRequestLogLevel, + logger: LoggerProtocol, + logSeverity: LogSeverity = .debug, + logTag: String? = nil) + { + self.requestLevel = requestLevel + self.logHandler = { message in + switch logSeverity { + case .debug: + logger.debug(message, tag: logTag) + case .info: + logger.info(message, tag: logTag) + case .warning: + logger.warning(message, tag: logTag) + case .error: + logger.error(message, tag: logTag) + case .fault: + logger.fault(message, tag: logTag) + } + } + } +} diff --git a/Tests/PowerSyncTests/ConnectTests.swift b/Tests/PowerSyncTests/ConnectTests.swift index 3df8894..3c80cb4 100644 --- a/Tests/PowerSyncTests/ConnectTests.swift +++ b/Tests/PowerSyncTests/ConnectTests.swift @@ -88,4 +88,47 @@ final class ConnectTests: XCTestCase { await fulfillment(of: [expectation], timeout: 5) watchTask.cancel() } + + func testSyncHTTPLogs() async throws { + let expectation = XCTestExpectation( + description: "Should log a request to the PowerSync endpoint" + ) + + let fakeUrl = "https://fakepowersyncinstance.fakepowersync.local" + + class TestConnector: PowerSyncBackendConnector { + let url: String + + init(url: String) { + self.url = url + } + + override func fetchCredentials() async throws -> PowerSyncCredentials? { + PowerSyncCredentials( + endpoint: url, + token: "123" + ) + } + } + + try await database.connect( + connector: TestConnector(url: fakeUrl), + options: ConnectOptions( + clientConfiguration: SyncClientConfiguration( + requestLogger: SyncRequestLoggerConfiguration( + requestLevel: .all + ) { message in + // We want to see a request to the specified instance + if message.contains(fakeUrl) { + expectation.fulfill() + } + } + ) + ) + ) + + await fulfillment(of: [expectation], timeout: 5) + + try await database.disconnectAndClear() + } }