Skip to content

Instrument Swift checkout failures - #675

Open
tiagocandido wants to merge 1 commit into
telemetry/metrics-foundationfrom
telemetry/swift-integration
Open

Instrument Swift checkout failures#675
tiagocandido wants to merge 1 commit into
telemetry/metrics-foundationfrom
telemetry/swift-integration

Conversation

@tiagocandido

@tiagocandido tiagocandido commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Record Swift checkout errors, protocol decoding failures, navigation retry outcomes, and monotonic navigation duration
  • Add a default-on telemetry setting with an explicit runtime opt-out that discards buffered measurements
  • Prevent duplicate HTTP/navigation error metrics and align retry funnel semantics (started, failed, not_attempted)
  • Share one telemetry client across entry points: per-entry-point recorders stamp the product attribute (accelerated_checkouts vs checkout_kit) onto each measurement, and the platform reports react-native-swift when embedded in React Native
  • Report is_retry correctly when the web content process terminates during a retried navigation
  • Resolve the decode-error recorder at event time so a runtime opt-out/re-enable cannot pin a stale recorder
  • Record a params decode failure for terminal ec.error payloads that have a valid envelope but undecodable params
  • Lazily initialize the recorder and keep production transport out of unit tests
  • Guard recorder creation against a concurrent runtime opt-out
  • Document the bounded diagnostic data and explicit opt-out

Validation

  • Swift formatting
  • Full Swift unit test suite
  • Swift API compatibility checks

@github-actions github-actions Bot added the #gsd:50662 Rebase Checkout Kit on UCP label Aug 18, 2026
@tiagocandido
tiagocandido force-pushed the telemetry/metrics-foundation branch from 8c0ff67 to e36641b Compare August 19, 2026 13:02
@tiagocandido
tiagocandido force-pushed the telemetry/swift-integration branch from 0bdb099 to da2ae5b Compare August 19, 2026 13:02
@tiagocandido
tiagocandido force-pushed the telemetry/metrics-foundation branch from e36641b to ffd7c4d Compare August 24, 2026 11:17
@tiagocandido
tiagocandido force-pushed the telemetry/swift-integration branch from da2ae5b to 0611c9e Compare August 24, 2026 11:17

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@bitrise

bitrise Bot commented Aug 25, 2026

Copy link
Copy Markdown

Install this build

Open Tophat, select your target device, then click Install. Links open on the Mac running Tophat.

SDK Install
Swift Install with Tophat

Checkout Kit E2E results

Status Suite Target Platform OS version tag Device
swift-ios swift ios latest iPhone 15
iOS 27 Beta

Comment thread platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift Outdated
let client = CheckoutKitTelemetry(
configuration: .init(
sdkVersion: MetaData.version,
product: entryPoint == .acceleratedCheckouts ? .acceleratedCheckouts : .checkoutKit,

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.

Does product just become a piece of metadata in the metrics?
Just wondering if theres a benefit to having N clients per entrypoint or if it makes sense to just share 1
thinking to any future products we add, does it make sense to keep many permutations inside the telemetry library, or could each extra library just attach a dict to identify themselves/attach info they want

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.

Went with your suggestion — one shared client now. product was already a per-datapoint attribute on the wire, so the client takes it per measurement and recorder(for:) just stamps the entry point's product. On the dict idea, I'd rather not: arbitrary attributes would break the closed-cardinality contract, so a new product stays an enum case.

@tiagocandido
tiagocandido force-pushed the telemetry/metrics-foundation branch from ffd7c4d to 0084629 Compare August 27, 2026 12:44
@tiagocandido
tiagocandido force-pushed the telemetry/swift-integration branch from 0611c9e to 4478383 Compare August 27, 2026 12:44
@tiagocandido
tiagocandido force-pushed the telemetry/metrics-foundation branch from 0084629 to 9e37951 Compare August 28, 2026 09:50
@tiagocandido
tiagocandido force-pushed the telemetry/swift-integration branch from 4478383 to 3eb6a64 Compare August 28, 2026 09:50

@markmur markmur left a comment

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 is looking good to me. I just have a couple of questions around:

  • Ownership - feels unusual to me that the telemetry client belongs to the CheckoutWebView, rather than be a global.
  • Having an instance of the telemetry client per entrypoint
  • Offering the ability to swap out the configured telemetry client. I don't think we want this. The most we want to offer at the moment is the ability to turn it on or off.

}

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(

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.

It is global — the view property is just a computed accessor onto the one shared client, scoped to the view's entry point (the view is the only thing that knows it). Renamed to telemetry as suggested.

Comment on lines +354 to +356
CheckoutTelemetry.recorder(for: entryPoint).recordProtocolDecodeError(
.init(method: .init(method: method), failureType: .params)
)

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?

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.

Not an instance per entry point — recorder(for:) hands out a stateless struct that stamps the right product on each measurement before forwarding to the single shared client. The wrapper exists so accelerated checkouts and regular checkout split on the product dimension without a second client.

}

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

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.

Good call — added TelemetryNavigationRetryMetric(error:result:) so the mapping lives with the metric, and the call site stores retry.reason from the metric it just recorded. The view still has to remember the reason, since the later .failed event and the is_retry flags must report the same one across delegate callbacks.

Comment on lines +26 to +27
/// Controls anonymous diagnostic metrics sent by Checkout Kit.
public var telemetry = Configuration.Telemetry()

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

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.

It's public deliberately — this is the consumer opt-out (configuration.telemetry.enabled = false), documented in the README, and it's what the React Native telemetry: boolean config maps onto. Making it internal would remove the opt-out.

Comment on lines +102 to +107
extension Configuration {
public struct Telemetry: Sendable {
/// Set to `false` to prevent Checkout Kit from recording or sending diagnostic metrics.
public var enabled: Bool = true
}
}

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

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.

Nothing here is swappable — enabled is the struct's only public member and it has no public initializer, so flipping the Bool is the only thing a consumer can do. No client or recorder type is public; the single injection seam is an internal test-only override. Same shape as preloading.enabled a few lines up.

platform: telemetryPlatform()
)
)
client.start()

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.

Seems like we could have it start implicitly in the telemetry constructor

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.

Deliberate: constructing and exporting are separate states in all three languages. Web relies on it directly — the element creates the client lazily but only arms the 60s export timer on connectedCallback, with a test pinning that disabled-before-connection never starts — and the foundations tests build clients without live timers. Starting in the constructor would collapse that state, so I'd keep the one explicit start() here.

@tiagocandido
tiagocandido force-pushed the telemetry/swift-integration branch from 3eb6a64 to 5a27e7c Compare August 28, 2026 13:29
@tiagocandido

Copy link
Copy Markdown
Contributor Author

Thanks Mark — on the three themes:

  • Ownership: the client is global — one shared instance behind a lock in Telemetry.swift, created lazily and shut down on opt-out. What lives on the view is only a computed accessor (renamed telemetry per your nit) that scopes the global client to the view's entry point.
  • Per entry point: there's no client per entry point — recorder(for:) returns a stateless wrapper that stamps product (checkout_kit vs accelerated_checkouts) on each measurement; the timer/buffer/backoff state is the single shared client.
  • Swapping: there's no way to swap the client. The whole public surface is configuration.telemetry.enabled: Bool — exactly on/off, same shape as preloading.enabled. The only injection point is an internal test-only override.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

#gsd:50662 Rebase Checkout Kit on UCP

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants