From da2ae5bac04fdf210a186cf6515e697c47868a91 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Wed, 19 Aug 2026 15:00:34 +0200 Subject: [PATCH] Instrument Swift checkout failures --- platforms/swift/README.md | 9 + .../ShopifyCheckoutKit/CheckoutWebView.swift | 142 ++++++++++- .../ShopifyCheckoutKit/Configuration.swift | 10 + .../ShopifyCheckoutKit.swift | 4 + .../ShopifyCheckoutKit/Telemetry.swift | 129 ++++++++++ .../CheckoutWebViewTests.swift | 93 +++++++ .../PreloadCacheTests.swift | 2 + .../PreloadObservabilityTests.swift | 29 +++ .../ShopifyCheckoutKitTests.swift | 10 + .../TelemetryConfigurationTests.swift | 65 +++++ platforms/swift/api/ShopifyCheckoutKit.json | 239 ++++++++++++++++++ 11 files changed, 718 insertions(+), 14 deletions(-) create mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/Telemetry.swift create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/TelemetryConfigurationTests.swift diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 081a6ab24..ec1e11e5a 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -247,6 +247,7 @@ ShopifyCheckoutKit.configure { $0.backgroundColor = .systemBackground $0.closeButtonTintColor = nil $0.logLevel = .debug + $0.telemetry.enabled = false } ``` @@ -260,6 +261,14 @@ ShopifyCheckoutKit.configure { | `logLevel` | `.warn` | SDK logging verbosity. Threshold-ordered `.debug` → `.warn` → `.error` → `.none`; use `.debug` during integration. | | `preloading.enabled` | `true` | Enables best-effort checkout preloading before presentation. | | `allowedMessageOrigins` | `[]` | Origins trusted to send incoming checkout messages. Empty trusts every origin (open by default). See [Incoming message origin validation](#incoming-message-origin-validation). | +| `telemetry.enabled` | `true` | Sends anonymous diagnostic metrics to Shopify. Set to `false` to opt out. | + +Checkout Kit reports bounded counts for checkout errors, protocol decoding +failures, and navigation retries, plus navigation duration histograms. These +diagnostics never include checkout URLs, message payloads, buyer data, or +checkout, order, customer, or shop identifiers. Disabling telemetry stops new +collection and discards measurements that have not already been handed to the +operating system for delivery. To localize the title, add `shopify_checkout_kit_title` to your app's `Localizable.xcstrings`. diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index d537654f7..5c14d7aa9 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -1,4 +1,5 @@ #if !COCOAPODS + import CheckoutKitTelemetry import EmbeddedCheckoutProtocol #endif import SafariServices @@ -208,6 +209,15 @@ final class PreloadCache { } func keepAliveDidFail() { + entry?.view.telemetryRecorder.recordError( + .init( + category: .navigation, + stage: .load, + code: .connectionLost, + retryable: false, + isRetry: false + ) + ) evict(with: .failed( reason: .webContentUnavailable, message: "Preload keep-alive failed." @@ -296,10 +306,14 @@ class CheckoutWebView: WKWebView { private static let purposeHeader = "Shopify-Purpose" private static let prefetchPurpose = "prefetch" - var timer: Date? + private let navigationClock: () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + private var navigationStartedAt: TimeInterval? + private var didRecordInitialNavigationDuration = false private(set) var checkoutNavigation: WKNavigation? private var didRetryCheckoutNavigation = false + private var navigationRetryReason: TelemetryNavigationRetryReason? + private var didCancelNavigationForHTTPError = false private var checkoutRequest: URLRequest? var checkoutBridge: CheckoutBridgeProtocol.Type = CheckoutBridge.self @@ -336,9 +350,12 @@ class CheckoutWebView: WKWebView { /// in-app browser surface, and routes non-web URLs through `externalURLHandler` /// (consumers may still override via their own client). lazy var defaultsClient: CheckoutProtocol.Client = .init() - .onDecodeError { method, error, params in + .onDecodeError { [telemetryRecorder] method, error, params in OSLogger.shared.error("Failed to decode \(method) payload: \(error)") OSLogger.shared.debug("Raw \(method) params: \(String(bytes: params, encoding: .utf8) ?? "")") + telemetryRecorder.recordProtocolDecodeError( + .init(method: .init(method: method), failureType: .params) + ) } .on(CheckoutProtocol.ready) { _ in ReadyResult(checkout: nil, credential: nil, ucp: .success(), upgrade: nil, continueURL: nil, messages: nil) @@ -446,6 +463,9 @@ class CheckoutWebView: WKWebView { /// cart-url origin for incoming message validation. var loadedCheckoutURL: URL? private var entryPoint: MetaData.EntryPoint? + var telemetryRecorder: any CheckoutTelemetryRecording { + CheckoutTelemetry.recorder(for: entryPoint) + } // MARK: Initializers @@ -544,6 +564,8 @@ class CheckoutWebView: WKWebView { checkoutRequest = request didRetryCheckoutNavigation = false hasHandledTerminalFailure = false + navigationRetryReason = nil + didCancelNavigationForHTTPError = false checkoutNavigation = load(request) } @@ -663,6 +685,11 @@ extension CheckoutWebView: WKScriptMessageHandler { /// unrecoverable error message selects the stable lifecycle code; no qualifying message maps to /// `.unknown`. Malformed terminal payloads map to `.sdkError`. private func handleTerminalProtocolError(_ body: String, malformedEnvelope: Bool = false) { + if malformedEnvelope { + telemetryRecorder.recordProtocolDecodeError( + .init(method: .init(method: "ec.error"), failureType: .envelope) + ) + } Task { @MainActor in let composedClient = ComposedCheckoutCommunicationClient( merchant: client, @@ -695,6 +722,16 @@ extension CheckoutWebView: WKScriptMessageHandler { hasHandledTerminalFailure = true guard !wasBackgroundedPreload else { return } + telemetryRecorder.recordError( + .init( + category: .protocol, + stage: .message, + code: .unknown, + retryable: false, + isRetry: navigationRetryReason != nil + ) + ) + recordNavigationDuration(result: .failure) viewDelegate?.checkoutViewDidFailWithError(error: failure) } } @@ -840,6 +877,18 @@ extension CheckoutWebView: WKNavigationDelegate { .httpError(statusCode: statusCode), message: "HTTP response returned status code \(statusCode)." ) + let isServerError = statusCode >= 500 + telemetryRecorder.recordError( + .init( + category: .http, + stage: .load, + code: isServerError ? .server : .client, + retryable: isServerError, + isRetry: navigationRetryReason != nil + ) + ) + recordNavigationDuration(result: .failure) + didCancelNavigationForHTTPError = true OSLogger.shared.debug("Handling response for URL: \(LogSafeURL.string(response.url)), status code: \(statusCode)") @@ -856,17 +905,25 @@ extension CheckoutWebView: WKNavigationDelegate { func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { let url = LogSafeURL.string(webView.url) OSLogger.shared.info("Started provisional navigation - url:\(url)") - timer = Date() + if navigationStartedAt == nil, !didRecordInitialNavigationDuration { + navigationStartedAt = navigationClock() + } + didCancelNavigationForHTTPError = false viewDelegate?.checkoutViewDidStartNavigation() } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - timer = nil - let nsError = error as NSError let url = LogSafeURL.string(webView.url) + if didCancelNavigationForHTTPError { + didCancelNavigationForHTTPError = false + OSLogger.shared.debug("Ignoring provisional navigation cancelled by HTTP response policy - url:\(url)") + return + } + if isCancelledNavigationError(nsError) { + navigationStartedAt = nil OSLogger.shared.debug("Ignoring cancelled provisional navigation - url:\(url)") return } @@ -882,14 +939,22 @@ extension CheckoutWebView: WKNavigationDelegate { } didRetryCheckoutNavigation = true + let retryReason = CheckoutTelemetry.retryReason(for: nsError) OSLogger.shared.warn("Retrying checkout navigation - domain:\(nsError.domain) code:\(nsError.code) url:\(url)") guard let retryNavigation = load(checkoutRequest) else { + telemetryRecorder.recordNavigationRetry( + .init(reason: retryReason, result: .notAttempted) + ) OSLogger.shared.error("Checkout navigation retry failed to start - domain:\(nsError.domain) code:\(nsError.code) url:\(url)") failNavigation(with: error) return } + telemetryRecorder.recordNavigationRetry( + .init(reason: retryReason, result: .started) + ) + navigationRetryReason = retryReason checkoutNavigation = retryNavigation } @@ -898,14 +963,13 @@ extension CheckoutWebView: WKNavigationDelegate { viewDelegate?.checkoutViewDidFinishNavigation() - if let startTime = timer { - let endTime = Date() - let diff = endTime.timeIntervalSince(startTime) + if let startTime = navigationStartedAt { + let diff = milliseconds(from: startTime) / 1000 let message = "Loaded checkout in \(String(format: "%.2f", diff))s" ShopifyCheckoutKit.configuration.logger.log(message) } - timer = nil + recordNavigationDuration(result: .success) if navigation === checkoutNavigation { resetProvisionalNavigationRetryState() @@ -915,7 +979,6 @@ extension CheckoutWebView: WKNavigationDelegate { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { guard !hasHandledTerminalFailure else { return } hasHandledTerminalFailure = true - timer = nil resetProvisionalNavigationRetryState() let wasBackgroundedPreload = isPreloadBackgrounded handleCachedViewFailure( @@ -923,8 +986,21 @@ extension CheckoutWebView: WKNavigationDelegate { message: "Web content process terminated." ) - guard !wasBackgroundedPreload else { return } + guard !wasBackgroundedPreload else { + navigationStartedAt = nil + return + } + telemetryRecorder.recordError( + .init( + category: .renderProcess, + stage: .presentation, + code: .unknown, + retryable: false, + isRetry: navigationRetryReason != nil + ) + ) + recordNavigationDuration(result: .failure) OSLogger.shared.error("Web content process terminated - url:\(LogSafeURL.string(webView.url))") viewDelegate?.checkoutViewDidFailWithError( error: CheckoutError.webContentProcessTerminated( @@ -934,11 +1010,16 @@ extension CheckoutWebView: WKNavigationDelegate { } func webView(_ webView: WKWebView, didFail _: WKNavigation!, withError error: Error) { - timer = nil - let nsError = error as NSError + if didCancelNavigationForHTTPError { + didCancelNavigationForHTTPError = false + OSLogger.shared.debug("Ignoring committed navigation cancelled by HTTP response policy") + return + } + if isCancelledNavigationError(nsError) { + navigationStartedAt = nil OSLogger.shared.debug("Ignoring cancelled committed navigation - code:NSURLErrorCancelled") return } @@ -971,11 +1052,27 @@ extension CheckoutWebView: WKNavigationDelegate { checkoutRequest = nil checkoutNavigation = nil didRetryCheckoutNavigation = false + navigationRetryReason = nil } private func failNavigation(with error: Error) { - resetProvisionalNavigationRetryState() let nsError = error as NSError + if let navigationRetryReason { + telemetryRecorder.recordNavigationRetry( + .init(reason: navigationRetryReason, result: .failed) + ) + } + telemetryRecorder.recordError( + .init( + category: .navigation, + stage: .load, + code: CheckoutTelemetry.errorCode(for: nsError), + retryable: isRetryableProvisionalNavigationError(nsError), + isRetry: navigationRetryReason != nil + ) + ) + recordNavigationDuration(result: .failure) + resetProvisionalNavigationRetryState() handleCachedViewFailure( .navigationFailed, message: "Navigation failed (error code: \(nsError.code))." @@ -986,6 +1083,23 @@ extension CheckoutWebView: WKNavigationDelegate { viewDelegate?.checkoutViewDidFailWithError(error: failure) } + private func recordNavigationDuration(result: TelemetryNavigationDurationResult) { + guard let startTime = navigationStartedAt else { return } + navigationStartedAt = nil + didRecordInitialNavigationDuration = true + telemetryRecorder.recordNavigationDuration( + .init( + milliseconds: milliseconds(from: startTime), + result: result, + preloaded: isPreloadRequest + ) + ) + } + + private func milliseconds(from startTime: TimeInterval) -> Double { + return (navigationClock() - startTime) * 1000 + } + private func isCheckout(url: URL?) -> Bool { return self.url == url } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift index 594b16796..19becdc57 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift @@ -23,6 +23,9 @@ public struct Configuration: Sendable { public var preloading = Configuration.Preloading() + /// Controls anonymous diagnostic metrics sent by Checkout Kit. + public var telemetry = Configuration.Telemetry() + public var tintColor: UIColor = .init(red: 0.09, green: 0.45, blue: 0.69, alpha: 1.00) @available(*, renamed: "tintColor", message: "spinnerColor has been superseded by tintColor") @@ -95,3 +98,10 @@ extension Configuration { public var enabled: Bool = true } } + +extension Configuration { + public struct Telemetry: Sendable { + /// Set to `false` to prevent Checkout Kit from recording or sending diagnostic metrics. + public var enabled: Bool = true + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index a3eae5bfe..892270c59 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -34,6 +34,10 @@ public func configure(_ block: (inout Configuration) -> Void) { private func applyConfigurationChange(configuration: Configuration, previousConfiguration: Configuration) { OSLogger.shared.logLevel = configuration.logLevel + if previousConfiguration.telemetry.enabled, !configuration.telemetry.enabled { + CheckoutTelemetry.disable() + } + if configuration.preloading.enabled != previousConfiguration.preloading.enabled { Task { @MainActor in invalidate() diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/Telemetry.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Telemetry.swift new file mode 100644 index 000000000..b4f40c453 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Telemetry.swift @@ -0,0 +1,129 @@ +#if !COCOAPODS + import CheckoutKitTelemetry +#endif +import Foundation + +protocol CheckoutTelemetryRecording: Sendable { + func recordError(_ metric: TelemetryErrorMetric) + func recordProtocolDecodeError(_ metric: TelemetryProtocolDecodeErrorMetric) + func recordNavigationRetry(_ metric: TelemetryNavigationRetryMetric) + func recordNavigationDuration(_ metric: TelemetryNavigationDurationMetric) +} + +private protocol CheckoutTelemetryClient: CheckoutTelemetryRecording { + func start() + func shutdown(discardPending: Bool) async -> Bool +} + +extension CheckoutKitTelemetry: CheckoutTelemetryClient {} + +private struct NoOpCheckoutTelemetryRecorder: CheckoutTelemetryRecording { + func recordError(_: TelemetryErrorMetric) {} + func recordProtocolDecodeError(_: TelemetryProtocolDecodeErrorMetric) {} + func recordNavigationRetry(_: TelemetryNavigationRetryMetric) {} + func recordNavigationDuration(_: TelemetryNavigationDurationMetric) {} +} + +private struct CheckoutTelemetryState { + var checkoutKitClient: (any CheckoutTelemetryClient)? + var acceleratedCheckoutsClient: (any CheckoutTelemetryClient)? + var recorderOverride: (any CheckoutTelemetryRecording)? +} + +private let noOpCheckoutTelemetryRecorder = NoOpCheckoutTelemetryRecorder() +private let lockedCheckoutTelemetry = LockedValue(CheckoutTelemetryState()) + +enum CheckoutTelemetry { + static var recorder: any CheckoutTelemetryRecording { + recorder(for: nil) + } + + static func recorder(for entryPoint: MetaData.EntryPoint?) -> any CheckoutTelemetryRecording { + guard ShopifyCheckoutKit.configuration.telemetry.enabled else { + return noOpCheckoutTelemetryRecorder + } + + var recorder: (any CheckoutTelemetryRecording)? + lockedCheckoutTelemetry.update { state in + if let recorderOverride = state.recorderOverride { + recorder = recorderOverride + return + } + let existingClient = switch entryPoint { + case .acceleratedCheckouts: state.acceleratedCheckoutsClient + case nil: state.checkoutKitClient + } + if let existingClient { + recorder = existingClient + return + } + + // Re-check under the lock so a concurrent disable() cannot race a + // client creation that would keep exporting after opt-out. + guard ShopifyCheckoutKit.configuration.telemetry.enabled else { + return + } + + let client = CheckoutKitTelemetry( + configuration: .init( + sdkVersion: MetaData.version, + product: entryPoint == .acceleratedCheckouts ? .acceleratedCheckouts : .checkoutKit, + platform: telemetryPlatform() + ) + ) + client.start() + switch entryPoint { + case .acceleratedCheckouts: state.acceleratedCheckoutsClient = client + case nil: state.checkoutKitClient = client + } + recorder = client + } + return recorder ?? noOpCheckoutTelemetryRecorder + } + + static func disable() { + var clients: [any CheckoutTelemetryClient] = [] + lockedCheckoutTelemetry.update { state in + clients = [state.checkoutKitClient, state.acceleratedCheckoutsClient].compactMap { $0 } + state.checkoutKitClient = nil + state.acceleratedCheckoutsClient = nil + } + clients.forEach { client in + Task { _ = await client.shutdown(discardPending: true) } + } + } + + static func overrideRecorderForTesting(_ recorder: (any CheckoutTelemetryRecording)?) { + lockedCheckoutTelemetry.update { state in + state.recorderOverride = recorder + } + } + + private static func telemetryPlatform() -> TelemetryPlatform { + ShopifyCheckoutKit.configuration.platform?.identifier == "ReactNative" + ? .reactNativeSwift + : .swift + } + + static func errorCode(for error: NSError) -> TelemetryErrorCode { + guard error.domain == NSURLErrorDomain else { return .unknown } + switch error.code { + case NSURLErrorCancelled: return .cancelled + case NSURLErrorTimedOut: return .timeout + case NSURLErrorNetworkConnectionLost: return .connectionLost + case NSURLErrorCannotConnectToHost: return .cannotConnect + case NSURLErrorDNSLookupFailed: return .dns + default: return .unknown + } + } + + static func retryReason(for error: NSError) -> TelemetryNavigationRetryReason { + switch errorCode(for: error) { + case .timeout: return .timeout + case .connectionLost: return .connectionLost + case .cannotConnect: return .cannotConnect + case .dns: return .dns + default: return .unknown + } + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index b56d44f98..e7391b426 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -1,3 +1,4 @@ +import CheckoutKitTelemetry import EmbeddedCheckoutProtocol @testable import ShopifyCheckoutKit import WebKit @@ -7,10 +8,13 @@ import XCTest class CheckoutWebViewTests: XCTestCase { private var view: CheckoutWebView! private var mockDelegate: MockCheckoutWebViewDelegate! + private var telemetryRecorder: MockCheckoutTelemetryRecorder! private var url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! override func setUp() async throws { try await super.setUp() + telemetryRecorder = MockCheckoutTelemetryRecorder() + CheckoutTelemetry.overrideRecorderForTesting(telemetryRecorder) ShopifyCheckoutKit.configuration.preloading.enabled = true CheckoutWebView.invalidate() view = CheckoutWebView.for(checkout: url) @@ -26,6 +30,7 @@ class CheckoutWebViewTests: XCTestCase { view.viewDelegate = nil CheckoutWebView.invalidate() ShopifyCheckoutKit.configuration.preloading.enabled = true + CheckoutTelemetry.overrideRecorderForTesting(nil) try await super.tearDown() } @@ -34,6 +39,21 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(view.configuration.allowsInlineMediaPlayback) } + func testRecordsHTTPFailureWithoutResponseData() throws { + view.load(checkout: url) + let link = try XCTUnwrap(view.url) + let response = try XCTUnwrap(HTTPURLResponse(url: link, statusCode: 503, httpVersion: nil, headerFields: nil)) + + _ = view.handleResponse(response) + + XCTAssertEqual(telemetryRecorder.errors.count, 1) + XCTAssertEqual(telemetryRecorder.errors[0].category, .http) + XCTAssertEqual(telemetryRecorder.errors[0].stage, .load) + XCTAssertEqual(telemetryRecorder.errors[0].code, .server) + XCTAssertTrue(telemetryRecorder.errors[0].retryable) + XCTAssertFalse(telemetryRecorder.errors[0].isRetry) + } + func testImplementsWKNavigationDelegatePolicySelectors() { let navigationActionSelector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:") let navigationResponseSelector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:") @@ -571,6 +591,8 @@ class CheckoutWebViewTests: XCTestCase { view.webView(view, didFailProvisionalNavigation: retryNavigation, withError: error) wait(for: [didFailWithErrorExpectation], timeout: 5) + XCTAssertEqual(telemetryRecorder.navigationRetries.map(\.result), [.started, .failed]) + XCTAssertEqual(telemetryRecorder.navigationRetries.map(\.reason), [.timeout, .timeout]) } func testWebViewFailsWhenRetryLoadDoesNotReturnNavigation() throws { @@ -587,6 +609,8 @@ class CheckoutWebViewTests: XCTestCase { retryView.webView(retryView, didFailProvisionalNavigation: initialNavigation, withError: error) wait(for: [didFailWithErrorExpectation], timeout: 5) + XCTAssertEqual(telemetryRecorder.navigationRetries.map(\.result), [.notAttempted]) + XCTAssertEqual(telemetryRecorder.navigationRetries.map(\.reason), [.timeout]) } func testWebViewDoesNotRetryCancelledProvisionalNavigation() throws { @@ -1063,6 +1087,40 @@ class CheckoutWebViewTests: XCTestCase { await fulfillment(of: [failed], timeout: 2.0) XCTAssertEqual(mockDelegate.failureCount, 1) + XCTAssertEqual(telemetryRecorder.errors.count, 1) + } + + func testHTTPPolicyCancellationDoesNotRecordDuplicateNavigationError() throws { + view.load(checkout: url) + let navigation = try XCTUnwrap(view.checkoutNavigation) + let link = try XCTUnwrap(view.url) + let response = try XCTUnwrap(HTTPURLResponse(url: link, statusCode: 500, httpVersion: nil, headerFields: nil)) + + XCTAssertEqual(view.handleResponse(response), .cancel) + view.webView( + view, + didFailProvisionalNavigation: navigation, + withError: NSError( + domain: WKError.errorDomain, + code: 102 + ) + ) + + XCTAssertEqual(telemetryRecorder.errors.count, 1) + XCTAssertEqual(telemetryRecorder.errors.first?.category, .http) + } + + func testNavigationDurationRecordsSuccessOnce() throws { + view.load(checkout: url) + let navigation = try XCTUnwrap(view.checkoutNavigation) + view.webView(view, didStartProvisionalNavigation: navigation) + + view.webView(view, didFinish: navigation) + view.webView(view, didFinish: navigation) + + XCTAssertEqual(telemetryRecorder.navigationDurations.count, 1) + XCTAssertEqual(telemetryRecorder.navigationDurations.first?.result, .success) + XCTAssertGreaterThanOrEqual(telemetryRecorder.navigationDurations.first?.milliseconds ?? -1, 0) } // MARK: - Incoming message origin validation @@ -1267,6 +1325,41 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) } + + func testNavigationDurationIgnoresSubsequentMainFrameNavigation() throws { + view.load(checkout: url) + let navigation = try XCTUnwrap(view.checkoutNavigation) + view.webView(view, didStartProvisionalNavigation: navigation) + view.webView(view, didFinish: navigation) + + view.webView(view, didStartProvisionalNavigation: navigation) + view.webView(view, didFinish: navigation) + + XCTAssertEqual(telemetryRecorder.navigationDurations.count, 1) + } +} + +private final class MockCheckoutTelemetryRecorder: CheckoutTelemetryRecording, @unchecked Sendable { + private(set) var errors: [TelemetryErrorMetric] = [] + private(set) var decodeErrors: [TelemetryProtocolDecodeErrorMetric] = [] + private(set) var navigationRetries: [TelemetryNavigationRetryMetric] = [] + private(set) var navigationDurations: [TelemetryNavigationDurationMetric] = [] + + func recordError(_ metric: TelemetryErrorMetric) { + errors.append(metric) + } + + func recordProtocolDecodeError(_ metric: TelemetryProtocolDecodeErrorMetric) { + decodeErrors.append(metric) + } + + func recordNavigationRetry(_ metric: TelemetryNavigationRetryMetric) { + navigationRetries.append(metric) + } + + func recordNavigationDuration(_ metric: TelemetryNavigationDurationMetric) { + navigationDurations.append(metric) + } } private actor RecordingBridgeClient: CheckoutCommunicationProtocol { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift index f14e9ea5a..09ce78f5e 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift @@ -17,6 +17,7 @@ class PreloadCacheTests: XCTestCase { override func setUp() async throws { try await super.setUp() + CheckoutTelemetry.overrideRecorderForTesting(NoOpTestTelemetryRecorder()) ShopifyCheckoutKit.configuration.preloading.enabled = true CheckoutWebView.invalidate() } @@ -24,6 +25,7 @@ class PreloadCacheTests: XCTestCase { override func tearDown() async throws { CheckoutWebView.invalidate() ShopifyCheckoutKit.configuration.preloading.enabled = true + CheckoutTelemetry.overrideRecorderForTesting(nil) try await super.tearDown() } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index 6f800bb67..eeb08f0a6 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -1,3 +1,4 @@ +import CheckoutKitTelemetry import Combine import EmbeddedCheckoutProtocol @testable import ShopifyCheckoutKit @@ -7,9 +8,12 @@ import XCTest @MainActor class PreloadObservabilityTests: XCTestCase { private var url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! + private var telemetryRecorder: PreloadTelemetryRecorder! override func setUp() async throws { try await super.setUp() + telemetryRecorder = PreloadTelemetryRecorder() + CheckoutTelemetry.overrideRecorderForTesting(telemetryRecorder) ShopifyCheckoutKit.configuration.preloading.enabled = true CheckoutWebView.invalidate() } @@ -17,6 +21,7 @@ class PreloadObservabilityTests: XCTestCase { override func tearDown() async throws { CheckoutWebView.invalidate() ShopifyCheckoutKit.configuration.preloading.enabled = true + CheckoutTelemetry.overrideRecorderForTesting(nil) try await super.tearDown() } @@ -139,6 +144,12 @@ class PreloadObservabilityTests: XCTestCase { .failed(reason: .webContentUnavailable, message: "Preload keep-alive failed.") ) } + XCTAssertEqual(telemetryRecorder.errors.count, 1) + XCTAssertEqual(telemetryRecorder.errors.first?.category, .navigation) + XCTAssertEqual(telemetryRecorder.errors.first?.stage, .load) + XCTAssertEqual(telemetryRecorder.errors.first?.code, .connectionLost) + XCTAssertEqual(telemetryRecorder.errors.first?.retryable, false) + XCTAssertEqual(telemetryRecorder.errors.first?.isRetry, false) } func testHTTPErrorTransitionsToFailed() throws { @@ -254,3 +265,21 @@ class PreloadObservabilityTests: XCTestCase { } } } + +final class PreloadTelemetryRecorder: CheckoutTelemetryRecording, @unchecked Sendable { + private(set) var errors: [TelemetryErrorMetric] = [] + + func recordError(_ metric: TelemetryErrorMetric) { + errors.append(metric) + } + func recordProtocolDecodeError(_: TelemetryProtocolDecodeErrorMetric) {} + func recordNavigationRetry(_: TelemetryNavigationRetryMetric) {} + func recordNavigationDuration(_: TelemetryNavigationDurationMetric) {} +} + +struct NoOpTestTelemetryRecorder: CheckoutTelemetryRecording { + func recordError(_: TelemetryErrorMetric) {} + func recordProtocolDecodeError(_: TelemetryProtocolDecodeErrorMetric) {} + func recordNavigationRetry(_: TelemetryNavigationRetryMetric) {} + func recordNavigationDuration(_: TelemetryNavigationDurationMetric) {} +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift index 5e826f875..7ca982eb6 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift @@ -50,6 +50,16 @@ class ShopifyCheckoutKitTests: XCTestCase { ) } + func test_configuration_telemetryDefaultsToEnabled() { + XCTAssertTrue(Configuration().telemetry.enabled) + } + + func test_configuration_canDisableTelemetry() { + ShopifyCheckoutKit.configuration.telemetry.enabled = false + + XCTAssertFalse(ShopifyCheckoutKit.configuration.telemetry.enabled) + } + func test_configuration_onLogLevelChange_usesExistingInstance() { let originalLogger = OSLogger.shared let originalLogLevel = OSLogger.shared.logLevel diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/TelemetryConfigurationTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/TelemetryConfigurationTests.swift new file mode 100644 index 000000000..5174f98c4 --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/TelemetryConfigurationTests.swift @@ -0,0 +1,65 @@ +import CheckoutKitTelemetry +@testable import ShopifyCheckoutKit +import XCTest + +@MainActor +final class TelemetryConfigurationTests: XCTestCase { + private var originalConfiguration: Configuration! + private var recorder: RecordingCheckoutTelemetryRecorder! + + override func setUp() async throws { + try await super.setUp() + originalConfiguration = ShopifyCheckoutKit.configuration + recorder = RecordingCheckoutTelemetryRecorder() + CheckoutTelemetry.overrideRecorderForTesting(recorder) + } + + override func tearDown() async throws { + ShopifyCheckoutKit.configuration = originalConfiguration + CheckoutTelemetry.overrideRecorderForTesting(nil) + try await super.tearDown() + } + + func testDisabledTelemetryDoesNotForwardMetrics() { + ShopifyCheckoutKit.configuration.telemetry.enabled = false + + CheckoutTelemetry.recorder.recordError( + .init(category: .http, stage: .load, code: .server, retryable: true) + ) + + XCTAssertEqual(recorder.errorCount, 0) + } + + func testEnabledTelemetryForwardsMetrics() { + ShopifyCheckoutKit.configuration.telemetry.enabled = true + + CheckoutTelemetry.recorder.recordError( + .init(category: .http, stage: .load, code: .server, retryable: true) + ) + + XCTAssertEqual(recorder.errorCount, 1) + } + + func testReenabledTelemetryUsesInstalledRecorder() { + ShopifyCheckoutKit.configuration.telemetry.enabled = false + ShopifyCheckoutKit.configuration.telemetry.enabled = true + + CheckoutTelemetry.recorder.recordError( + .init(category: .http, stage: .load, code: .server, retryable: true) + ) + + XCTAssertEqual(recorder.errorCount, 1) + } +} + +private final class RecordingCheckoutTelemetryRecorder: CheckoutTelemetryRecording, @unchecked Sendable { + private(set) var errorCount = 0 + + func recordError(_: TelemetryErrorMetric) { + errorCount += 1 + } + + func recordProtocolDecodeError(_: TelemetryProtocolDecodeErrorMetric) {} + func recordNavigationRetry(_: TelemetryNavigationRetryMetric) {} + func recordNavigationDuration(_: TelemetryNavigationDurationMetric) {} +} diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index a3c1fd074..0c02feca9 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -4,6 +4,13 @@ "name": "ShopifyCheckoutKit", "printedName": "ShopifyCheckoutKit", "children": [ + { + "kind": "Import", + "name": "CheckoutKitTelemetry", + "printedName": "CheckoutKitTelemetry", + "declKind": "Import", + "moduleName": "ShopifyCheckoutKit" + }, { "kind": "Import", "name": "Combine", @@ -3896,6 +3903,79 @@ } ] }, + { + "kind": "Var", + "name": "telemetry", + "printedName": "telemetry", + "children": [ + { + "kind": "TypeNominal", + "name": "Telemetry", + "printedName": "ShopifyCheckoutKit.Configuration.Telemetry", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9TelemetryV" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9telemetryAC9TelemetryVvp", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV9telemetryAC9TelemetryVvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Telemetry", + "printedName": "ShopifyCheckoutKit.Configuration.Telemetry", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9TelemetryV" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9telemetryAC9TelemetryVvg", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV9telemetryAC9TelemetryVvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Telemetry", + "printedName": "ShopifyCheckoutKit.Configuration.Telemetry", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9TelemetryV" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9telemetryAC9TelemetryVvs", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV9telemetryAC9TelemetryVvs", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "set" + } + ] + }, { "kind": "Var", "name": "tintColor", @@ -5527,6 +5607,121 @@ "mangledName": "$ss9EscapableP" } ] + }, + { + "kind": "TypeDecl", + "name": "Telemetry", + "printedName": "Telemetry", + "children": [ + { + "kind": "Var", + "name": "enabled", + "printedName": "enabled", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9TelemetryV7enabledSbvp", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV9TelemetryV7enabledSbvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9TelemetryV7enabledSbvg", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV9TelemetryV7enabledSbvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9TelemetryV7enabledSbvs", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV9TelemetryV7enabledSbvs", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "set" + } + ] + } + ], + "declKind": "Struct", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV9TelemetryV", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV9TelemetryV", + "moduleName": "ShopifyCheckoutKit", + "isFromExtension": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] } ], "declKind": "Struct", @@ -8314,6 +8509,50 @@ "mangledName": "$s18ShopifyCheckoutKit0B21CommunicationProtocolP" } ] + }, + { + "kind": "TypeDecl", + "name": "CheckoutKitTelemetry", + "printedName": "CheckoutKitTelemetry", + "declKind": "Class", + "usr": "s:20CheckoutKitTelemetryAAC", + "mangledName": "$s20CheckoutKitTelemetryAAC", + "moduleName": "CheckoutKitTelemetry", + "declAttributes": [ + "Final" + ], + "isExternal": true, + "hasMissingDesignatedInitializers": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] } ], "json_format_version": 8