Skip to content
Open
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 @@ -249,6 +249,7 @@ ShopifyCheckoutKit.configure {
$0.backgroundColor = .systemBackground
$0.closeButtonTintColor = nil
$0.logLevel = .debug
$0.telemetry.enabled = false
}
```

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

Expand Down
170 changes: 149 additions & 21 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 @@ -202,6 +203,15 @@ final class PreloadCache {
}

func keepAliveDidFail() {
entry?.view.telemetryRecorder.recordError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels strange to me that the telemetry class is bound to the checkout view. What's the reason for that? Can't it be a global instance?


Also a small nit: I find the name telemetryRecorder a bit awkward. Can we make it telemetry instead?

Suggested change
entry?.view.telemetryRecorder.recordError(
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."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Comment on lines +354 to +356

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not totally following why we have a telemetry instance for each "entrypoint" - @tiagocandido can you help me understand this?

}
.on(CheckoutProtocol.ready) { _ in
ReadyResult(checkout: nil, credential: nil, ucp: .success(), upgrade: nil, continueURL: nil, messages: nil)
Expand Down Expand Up @@ -460,6 +480,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 @@ -560,6 +583,8 @@ class CheckoutWebView: WKWebView {
checkoutRequest = request
didRetryCheckoutNavigation = false
hasHandledTerminalFailure = false
navigationRetryReason = nil
didCancelNavigationForHTTPError = false
checkoutNavigation = load(request)
}

Expand Down Expand Up @@ -702,6 +727,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 All @@ -715,15 +745,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.
telemetryRecorder.recordProtocolDecodeError(
.init(method: .init(method: "ec.error"), failureType: .params)
)
}
failure = CheckoutError.sdk(message: "Embedded checkout sent an invalid terminal error.")
}

let wasBackgroundedPreload = isPreloadBackgrounded
Expand All @@ -734,6 +772,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 @@ -845,6 +893,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 @@ -861,17 +921,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 @@ -887,14 +955,22 @@ extension CheckoutWebView: WKNavigationDelegate {
}

didRetryCheckoutNavigation = true
let retryReason = CheckoutTelemetry.retryReason(for: nsError)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way we could bake this into the recordNavigationRetry method? It feels like we're exposing an internal concern here, by having the telemetry class compute the reason and then feed it back into the class via another method

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 @@ -903,14 +979,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 @@ -920,16 +995,31 @@ 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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will clear the navigationRetryReason used to calculate isRetry below right?

Maybe we can add some tests around all the paths of isRetry - I think I see a few assertions that its false, but none on when its true

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good catch — the reset ran before the is_retry read, so it was always false there. Fixed by capturing the flag first, and added the missing true-path tests: one on the retry-failure flow, one for a crash mid-retry. The not_attempted branch reporting false is intentional though — that failure is the original load, not a retry.

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: wasRetry
)
)
recordNavigationDuration(result: .failure)
OSLogger.shared.error("Web content process terminated - url:\(LogSafeURL.string(webView.url))")
viewDelegate?.checkoutViewDidFailWithError(
error: CheckoutError.webContentProcessTerminated(
Expand All @@ -939,11 +1029,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 @@ -976,11 +1071,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 @@ -991,6 +1102,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()
Comment on lines +26 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How come this is public and configurable? I would expect this to be internal


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
}
}
Comment on lines +102 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems right, but giving the ability to swap out the telemetry client feels wrong

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