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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions platforms/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ ShopifyCheckoutKit.configure {
$0.backgroundColor = .systemBackground
$0.closeButtonTintColor = nil
$0.logLevel = .debug
$0.telemetry.enabled = false
}
```

Expand All @@ -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`.

Expand Down
142 changes: 128 additions & 14 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#if !COCOAPODS
import CheckoutKitTelemetry
import EmbeddedCheckoutProtocol
#endif
import SafariServices
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -544,6 +564,8 @@ class CheckoutWebView: WKWebView {
checkoutRequest = request
didRetryCheckoutNavigation = false
hasHandledTerminalFailure = false
navigationRetryReason = nil
didCancelNavigationForHTTPError = false
checkoutNavigation = load(request)
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)")

Expand All @@ -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
}
Expand All @@ -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
}

Expand All @@ -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()
Expand All @@ -915,16 +979,28 @@ extension CheckoutWebView: WKNavigationDelegate {
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
guard !hasHandledTerminalFailure else { return }
hasHandledTerminalFailure = true
timer = nil
resetProvisionalNavigationRetryState()
let wasBackgroundedPreload = isPreloadBackgrounded
handleCachedViewFailure(
.webContentUnavailable,
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(
Expand All @@ -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
}
Expand Down Expand Up @@ -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))."
Expand All @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading