diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 60136f0a7..20482ad7a 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -249,6 +249,7 @@ ShopifyCheckoutKit.configure { $0.backgroundColor = .systemBackground $0.closeButtonTintColor = nil $0.logLevel = .debug + $0.telemetry.enabled = false } ``` @@ -262,6 +263,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 c9961b8a0..7899945fa 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 @@ -202,6 +203,15 @@ final class PreloadCache { } func keepAliveDidFail() { + entry?.view.telemetry.recordError( + .init( + category: .navigation, + stage: .load, + code: .connectionLost, + retryable: false, + isRetry: false + ) + ) evict(with: .failed( reason: .webContentUnavailable, message: "Preload keep-alive failed." @@ -291,10 +301,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 @@ -331,9 +345,15 @@ 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 { [entryPoint] method, error, params in OSLogger.shared.error("Failed to decode \(method) payload: \(error)") OSLogger.shared.debug("Raw \(method) params: \(String(bytes: params, encoding: .utf8) ?? "")") + // Resolve the recorder per event; snapshotting it in the capture + // list would pin whichever client existed when this closure was + // first built, outliving telemetry disable/re-enable. + CheckoutTelemetry.recorder(for: entryPoint).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) @@ -461,6 +481,14 @@ class CheckoutWebView: WKWebView { var loadedCheckoutURL: URL? private var entryPoint: MetaData.EntryPoint? + /// A product-scoped view onto the single global telemetry client, resolved + /// on every access so runtime opt-out and re-enable are always honored. + /// The view supplies only its entry point; all buffering, export, and + /// lifecycle state lives in the shared client. + var telemetry: any CheckoutTelemetryRecording { + CheckoutTelemetry.recorder(for: entryPoint) + } + // MARK: Initializers convenience init(frame: CGRect = .zero, entryPoint: MetaData.EntryPoint? = nil) { @@ -560,6 +588,8 @@ class CheckoutWebView: WKWebView { checkoutRequest = request didRetryCheckoutNavigation = false hasHandledTerminalFailure = false + navigationRetryReason = nil + didCancelNavigationForHTTPError = false checkoutNavigation = load(request) } @@ -702,6 +732,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 { + telemetry.recordProtocolDecodeError( + .init(method: .init(method: "ec.error"), failureType: .envelope) + ) + } Task { @MainActor in let composedClient = ComposedCheckoutCommunicationClient( merchant: client, @@ -715,15 +750,23 @@ extension CheckoutWebView: WKScriptMessageHandler { // `ec.error` denotes a terminal session error. Message severity selects the public // lifecycle code, but does not keep the embedded session alive. - let failure = if !malformedEnvelope, - let notification = try? JSONDecoder().decode( - TerminalErrorNotification.self, - from: Data(body.utf8) - ) + let failure: CheckoutError + if !malformedEnvelope, + let notification = try? JSONDecoder().decode( + TerminalErrorNotification.self, + from: Data(body.utf8) + ) { - CheckoutError.terminalProtocol(error: notification.params.error) + failure = CheckoutError.terminalProtocol(error: notification.params.error) } else { - CheckoutError.sdk(message: "Embedded checkout sent an invalid terminal error.") + if !malformedEnvelope { + // Valid envelope with undecodable params; the envelope case + // was already recorded before this method was called. + telemetry.recordProtocolDecodeError( + .init(method: .init(method: "ec.error"), failureType: .params) + ) + } + failure = CheckoutError.sdk(message: "Embedded checkout sent an invalid terminal error.") } let wasBackgroundedPreload = isPreloadBackgrounded @@ -734,6 +777,16 @@ extension CheckoutWebView: WKScriptMessageHandler { hasHandledTerminalFailure = true guard !wasBackgroundedPreload else { return } + telemetry.recordError( + .init( + category: .protocol, + stage: .message, + code: .unknown, + retryable: false, + isRetry: navigationRetryReason != nil + ) + ) + recordNavigationDuration(result: .failure) viewDelegate?.checkoutViewDidFailWithError(error: failure) } } @@ -845,6 +898,18 @@ extension CheckoutWebView: WKNavigationDelegate { .httpError(statusCode: statusCode), message: "HTTP response returned status code \(statusCode)." ) + let isServerError = statusCode >= 500 + telemetry.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)") @@ -861,17 +926,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 } @@ -890,11 +963,17 @@ extension CheckoutWebView: WKNavigationDelegate { OSLogger.shared.warn("Retrying checkout navigation - domain:\(nsError.domain) code:\(nsError.code) url:\(url)") guard let retryNavigation = load(checkoutRequest) else { + telemetry.recordNavigationRetry(.init(error: nsError, result: .notAttempted)) OSLogger.shared.error("Checkout navigation retry failed to start - domain:\(nsError.domain) code:\(nsError.code) url:\(url)") failNavigation(with: error) return } + let retry = TelemetryNavigationRetryMetric(error: nsError, result: .started) + telemetry.recordNavigationRetry(retry) + // Remember the reason so the later `.failed` event and `is_retry` + // flags report the same one across delegate callbacks. + navigationRetryReason = retry.reason checkoutNavigation = retryNavigation } @@ -903,14 +982,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() @@ -920,7 +998,9 @@ extension CheckoutWebView: WKNavigationDelegate { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { guard !hasHandledTerminalFailure else { return } hasHandledTerminalFailure = true - timer = nil + // Capture before the reset below so a crash during a retried + // navigation still reports is_retry. + let wasRetry = navigationRetryReason != nil resetProvisionalNavigationRetryState() let wasBackgroundedPreload = isPreloadBackgrounded handleCachedViewFailure( @@ -928,8 +1008,21 @@ extension CheckoutWebView: WKNavigationDelegate { message: "Web content process terminated." ) - guard !wasBackgroundedPreload else { return } + guard !wasBackgroundedPreload else { + navigationStartedAt = nil + return + } + telemetry.recordError( + .init( + category: .renderProcess, + stage: .presentation, + code: .unknown, + retryable: false, + isRetry: wasRetry + ) + ) + recordNavigationDuration(result: .failure) OSLogger.shared.error("Web content process terminated - url:\(LogSafeURL.string(webView.url))") viewDelegate?.checkoutViewDidFailWithError( error: CheckoutError.webContentProcessTerminated( @@ -939,11 +1032,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 } @@ -976,11 +1074,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 { + telemetry.recordNavigationRetry( + .init(reason: navigationRetryReason, result: .failed) + ) + } + telemetry.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))." @@ -991,6 +1105,23 @@ extension CheckoutWebView: WKNavigationDelegate { viewDelegate?.checkoutViewDidFailWithError(error: failure) } + private func recordNavigationDuration(result: TelemetryNavigationDurationResult) { + guard let startTime = navigationStartedAt else { return } + navigationStartedAt = nil + didRecordInitialNavigationDuration = true + telemetry.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 9f16aa806..09a818947 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..8490b3883 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Telemetry.swift @@ -0,0 +1,174 @@ +#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 client: (any CheckoutTelemetryClient)? + var recorderOverride: (any CheckoutTelemetryRecording)? +} + +/// Stamps an entry point's product onto every measurement before forwarding to +/// the shared client, so one client instance serves every entry point. +private struct ProductScopedRecorder: CheckoutTelemetryRecording { + let product: TelemetryProduct + let base: any CheckoutTelemetryRecording + + func recordError(_ metric: TelemetryErrorMetric) { + base.recordError( + .init( + category: metric.category, + stage: metric.stage, + code: metric.code, + retryable: metric.retryable, + isRetry: metric.isRetry, + product: product + ) + ) + } + + func recordProtocolDecodeError(_ metric: TelemetryProtocolDecodeErrorMetric) { + base.recordProtocolDecodeError( + .init(method: metric.method, failureType: metric.failureType, product: product) + ) + } + + func recordNavigationRetry(_ metric: TelemetryNavigationRetryMetric) { + base.recordNavigationRetry( + .init(reason: metric.reason, result: metric.result, product: product) + ) + } + + func recordNavigationDuration(_ metric: TelemetryNavigationDurationMetric) { + base.recordNavigationDuration( + .init( + milliseconds: metric.milliseconds, + result: metric.result, + preloaded: metric.preloaded, + product: product + ) + ) + } +} + +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 base: (any CheckoutTelemetryRecording)? + lockedCheckoutTelemetry.update { state in + if let recorderOverride = state.recorderOverride { + base = recorderOverride + return + } + if let client = state.client { + base = client + 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, + platform: telemetryPlatform() + ) + ) + client.start() + state.client = client + base = client + } + guard let base else { return noOpCheckoutTelemetryRecorder } + return ProductScopedRecorder( + product: entryPoint == .acceleratedCheckouts ? .acceleratedCheckouts : .checkoutKit, + base: base + ) + } + + static func disable() { + var client: (any CheckoutTelemetryClient)? + lockedCheckoutTelemetry.update { state in + client = state.client + state.client = nil + } + if let client { + 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 + } + } + + fileprivate 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 + } + } +} + +extension TelemetryNavigationRetryMetric { + /// Derives the bounded retry reason from the navigation error, so call + /// sites record retries without mapping errors to reasons themselves. + init(error: NSError, result: TelemetryNavigationRetryResult) { + self.init(reason: CheckoutTelemetry.retryReason(for: error), result: result) + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 2dc64fe9c..9a08e56f1 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:") @@ -636,6 +656,52 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertNil(error.underlyingError) } + func testWebContentProcessTerminationDuringRetryRecordsIsRetry() throws { + view.load(checkout: url) + let initialNavigation = try XCTUnwrap(view.checkoutNavigation) + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil) + view.webView(view, didFailProvisionalNavigation: initialNavigation, withError: error) + XCTAssertEqual(telemetryRecorder.navigationRetries.map(\.result), [.started]) + + view.webViewWebContentProcessDidTerminate(view) + + let metric = try XCTUnwrap(telemetryRecorder.errors.last(where: { $0.category == .renderProcess })) + XCTAssertTrue(metric.isRetry) + } + + @MainActor + func testDecodeErrorUsesRecorderInstalledAtEventTime() async throws { + _ = view.defaultsClient // Builds the decode-error closure under the setUp recorder. + let lateRecorder = MockCheckoutTelemetryRecorder() + CheckoutTelemetry.overrideRecorderForTesting(lateRecorder) + + let body = #"{"jsonrpc":"2.0","method":"ec.complete","params":{"unexpected":true}}"# + view.userContentController(WKUserContentController(), didReceive: MockScriptMessage(body: body)) + + for _ in 0 ..< 100 where lateRecorder.decodeErrors.isEmpty { + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertEqual(lateRecorder.decodeErrors.map(\.failureType), [.params]) + XCTAssertTrue( + telemetryRecorder.decodeErrors.isEmpty, + "Decode errors must reach the recorder installed at event time, not capture time" + ) + } + + @MainActor + func testTerminalErrorWithMalformedParamsRecordsParamsDecodeError() async throws { + let body = #"{"jsonrpc":"2.0","method":"ec.error","params":{"bogus":true}}"# + view.userContentController(WKUserContentController(), didReceive: MockScriptMessage(body: body)) + + for _ in 0 ..< 100 where telemetryRecorder.decodeErrors.isEmpty { + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertEqual(telemetryRecorder.decodeErrors.map(\.failureType), [.params]) + XCTAssertEqual(telemetryRecorder.decodeErrors.first?.method.rawValue, "ec.error") + } + func testWebViewDoesNotEmitDidFailForCancelledRedirect() throws { let url = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) let view = CheckoutWebView.for(checkout: url) @@ -664,6 +730,10 @@ 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]) + let finalError = try XCTUnwrap(telemetryRecorder.errors.last(where: { $0.category == .navigation })) + XCTAssertTrue(finalError.isRetry, "A failure on the retried navigation must report is_retry") } func testWebViewFailsWhenRetryLoadDoesNotReturnNavigation() throws { @@ -680,6 +750,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 { @@ -1156,6 +1228,42 @@ class CheckoutWebViewTests: XCTestCase { await fulfillment(of: [failed], timeout: 2.0) XCTAssertEqual(mockDelegate.failureCount, 1) + // Stray HTTP records can arrive from neighboring tests' in-flight + // real navigations; count only this scenario's category. + XCTAssertEqual(telemetryRecorder.errors.filter { $0.category == .protocol }.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 @@ -1370,6 +1478,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 bc9627cbb..2f50ffc2c 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() } @@ -25,6 +26,7 @@ class PreloadCacheTests: XCTestCase { CheckoutWebView.invalidate() ShopifyCheckoutKit.configuration.preloading.enabled = true ShopifyCheckoutKit.configuration.allowedMessageOrigins = [] + CheckoutTelemetry.overrideRecorderForTesting(nil) try await super.tearDown() } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index 6f46ad6a7..cdd09e3b6 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() } @@ -153,6 +158,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 { @@ -268,3 +279,22 @@ 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..86cab7afa --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/TelemetryConfigurationTests.swift @@ -0,0 +1,80 @@ +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) + } + + func testRecorderStampsProductPerEntryPoint() { + ShopifyCheckoutKit.configuration.telemetry.enabled = true + + CheckoutTelemetry.recorder(for: nil).recordError( + .init(category: .http, stage: .load, code: .server, retryable: true) + ) + CheckoutTelemetry.recorder(for: .acceleratedCheckouts).recordError( + .init(category: .http, stage: .load, code: .server, retryable: true) + ) + + XCTAssertEqual(recorder.products, [.checkoutKit, .acceleratedCheckouts]) + } +} + +private final class RecordingCheckoutTelemetryRecorder: CheckoutTelemetryRecording, @unchecked Sendable { + private(set) var errorCount = 0 + private(set) var products: [TelemetryProduct?] = [] + + func recordError(_ metric: TelemetryErrorMetric) { + errorCount += 1 + products.append(metric.product) + } + + 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..7c3b2c2db 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", + "isInternal": true, + "declAttributes": [ + "Final" + ], + "isExternal": 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