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
18 changes: 15 additions & 3 deletions StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,21 @@ import UIKit

// MARK: Internal/private properties
@_spi(STP) public var apiURL: URL! = URL(string: APIBaseURL)
@_spi(STP) public var urlSession = URLSession(
configuration: StripeAPIConfiguration.sharedUrlSessionConfiguration
)

private static let metricsDelegate = URLSessionMetricsDelegate()

@_spi(STP) public var urlSession: URLSession = {
return URLSession(
configuration: StripeAPIConfiguration.sharedUrlSessionConfiguration,
delegate: metricsDelegate,
delegateQueue: nil
)
}()

/// Access the metrics delegate for capturing network performance data
@_spi(STP) public static var networkMetricsDelegate: URLSessionMetricsDelegate {
return metricsDelegate
}

/// A set of beta headers to add to Stripe API requests e.g. `Set(["alipay_beta=v1"])`.
@_spi(STP) public var betas: Set<String> = []
Expand Down
152 changes: 152 additions & 0 deletions StripeCore/StripeCore/Source/Helpers/URLSessionMetricsDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//
// URLSessionMetricsDelegate.swift
// StripeCore
//
// Created to measure network performance metrics
//

import Foundation

/// Network performance metrics for a single request
@_spi(STP) public struct NetworkMetrics {
public let url: String
public let totalTime: TimeInterval
public let dnsLookupTime: TimeInterval?
public let connectionTime: TimeInterval?
public let tlsHandshakeTime: TimeInterval?
public let requestTime: TimeInterval?
public let serverProcessingTime: TimeInterval?
public let responseDownloadTime: TimeInterval?
public let isConnectionReused: Bool
public let networkProtocol: String?

/// Convert to analytics payload
public func analyticsPayload(prefix: String = "") -> [String: Any] {
var payload: [String: Any] = [:]

// Convert all times to milliseconds and round to whole numbers
payload["\(prefix)total_time_ms"] = Int(totalTime * 1000)
if let dnsTime = dnsLookupTime {
payload["\(prefix)dns_time_ms"] = Int(dnsTime * 1000)
}
if let connTime = connectionTime {
payload["\(prefix)connection_time_ms"] = Int(connTime * 1000)
}
if let tlsTime = tlsHandshakeTime {
payload["\(prefix)tls_time_ms"] = Int(tlsTime * 1000)
}
if let serverTime = serverProcessingTime {
payload["\(prefix)server_time_ms"] = Int(serverTime * 1000)
}

payload["\(prefix)connection_reused"] = isConnectionReused
payload["\(prefix)protocol"] = networkProtocol

return payload
}
}

/// A delegate that captures detailed network timing metrics for debugging performance issues
@_spi(STP) public class URLSessionMetricsDelegate: NSObject, URLSessionTaskDelegate {

/// Callback for when metrics are collected
public var metricsHandler: ((NetworkMetrics) -> Void)?

/// Storage for the most recent metrics (for Elements/Sessions call)
@_spi(STP) public private(set) var lastElementsSessionMetrics: NetworkMetrics?

public override init() {
super.init()
}

public func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
let structuredMetrics = extractMetrics(from: metrics)

// Store if this is an Elements/Sessions call
if let url = task.currentRequest?.url?.absoluteString,
url.contains("/v1/elements/sessions") {
lastElementsSessionMetrics = structuredMetrics
}

// Call handler if set
if let structuredMetrics = structuredMetrics {
metricsHandler?(structuredMetrics)
}
}

private func extractMetrics(from metrics: URLSessionTaskMetrics) -> NetworkMetrics? {
guard let transactionMetrics = metrics.transactionMetrics.first,
let fetchStart = transactionMetrics.fetchStartDate,
let responseEnd = transactionMetrics.responseEndDate else {
return nil
}

let totalTime = responseEnd.timeIntervalSince(fetchStart)

// DNS lookup time
let dnsLookupTime: TimeInterval? = {
guard let start = transactionMetrics.domainLookupStartDate,
let end = transactionMetrics.domainLookupEndDate else {
return nil
}
return end.timeIntervalSince(start)
}()

// Connection time (total)
let connectionTime: TimeInterval? = {
guard let start = transactionMetrics.connectStartDate,
let end = transactionMetrics.connectEndDate else {
return nil
}
return end.timeIntervalSince(start)
}()

// TLS handshake time
let tlsHandshakeTime: TimeInterval? = {
guard let start = transactionMetrics.secureConnectionStartDate,
let end = transactionMetrics.connectEndDate else {
return nil
}
return end.timeIntervalSince(start)
}()

// Request time
let requestTime: TimeInterval? = {
guard let start = transactionMetrics.requestStartDate,
let end = transactionMetrics.requestEndDate else {
return nil
}
return end.timeIntervalSince(start)
}()

// Server processing time (TTFB)
let serverProcessingTime: TimeInterval? = {
guard let requestEnd = transactionMetrics.requestEndDate,
let responseStart = transactionMetrics.responseStartDate else {
return nil
}
return responseStart.timeIntervalSince(requestEnd)
}()

// Response download time
let responseDownloadTime: TimeInterval? = {
guard let start = transactionMetrics.responseStartDate else {
return nil
}
return responseEnd.timeIntervalSince(start)
}()

return NetworkMetrics(
url: metrics.transactionMetrics.first?.request.url?.absoluteString ?? "unknown",
totalTime: totalTime,
dnsLookupTime: dnsLookupTime,
connectionTime: connectionTime,
tlsHandshakeTime: tlsHandshakeTime,
requestTime: requestTime,
serverProcessingTime: serverProcessingTime,
responseDownloadTime: responseDownloadTime,
isConnectionReused: transactionMetrics.isReusedConnection,
networkProtocol: transactionMetrics.networkProtocolName
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ final class PaymentSheetAnalyticsHelper {
intent: Intent,
elementsSession: STPElementsSession,
defaultPaymentMethod: SavedPaymentOptionsViewController.Selection?,
orderedPaymentMethodTypes: [PaymentSheet.PaymentMethodType]
orderedPaymentMethodTypes: [PaymentSheet.PaymentMethodType],
networkMetrics: NetworkMetrics?
) {
stpAssert(loadingStartDate != nil)
self.intent = intent
Expand Down Expand Up @@ -167,6 +168,12 @@ final class PaymentSheetAnalyticsHelper {
params["link_disabled_reasons"] = PaymentSheet.linkDisabledReasons(elementsSession: elementsSession, configuration: configuration).analyticsValue
params["link_signup_disabled_reasons"] = PaymentSheet.linkSignupDisabledReasons(elementsSession: elementsSession, configuration: configuration).analyticsValue

// Add network metrics if available and enabled by backend flag
if let metrics = networkMetrics,
elementsSession.shouldSendNetworkAnalytics {
params.mergeAssertingOnOverwrites(metrics.analyticsPayload(prefix: "elements_session_"))
}

log(
event: .paymentSheetLoadSucceeded,
duration: duration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,10 @@ extension STPElementsSession {
var shouldAttestOnConfirmation: Bool {
flags["elements_mobile_attest_on_intent_confirmation"] == true
}

var shouldSendNetworkAnalytics: Bool {
flags["mpe_send_network_analytics"] == true
}
}

extension STPElementsSession {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,8 @@ extension PaymentSheet {
intent: viewController.loadResult.intent,
elementsSession: viewController.loadResult.elementsSession,
savedPaymentMethods: viewController.savedPaymentMethods, // Note: not using load result!
paymentMethodTypes: viewController.loadResult.paymentMethodTypes
paymentMethodTypes: viewController.loadResult.paymentMethodTypes,
elementsSessionNetworkMetrics: viewController.loadResult.elementsSessionNetworkMetrics
)
self.viewController = Self.makeViewController(
configuration: self.configuration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ final class PaymentSheetLoader {
let savedPaymentMethods: [STPPaymentMethod]
/// The payment method types that should be shown (i.e. filtered)
let paymentMethodTypes: [PaymentSheet.PaymentMethodType]
/// Network metrics for the Elements/Sessions call (if available)
let elementsSessionNetworkMetrics: NetworkMetrics?
}

enum IntegrationShape {
Expand Down Expand Up @@ -177,11 +179,16 @@ final class PaymentSheetLoader {
guard !paymentMethodTypes.isEmpty else {
throw PaymentSheetError.noPaymentMethodTypesAvailable(intentPaymentMethods: elementsSession.orderedPaymentMethodTypes)
}

// Capture network metrics for Elements/Sessions call
let networkMetrics = STPAPIClient.networkMetricsDelegate.lastElementsSessionMetrics

analyticsHelper.logLoadSucceeded(
intent: intent,
elementsSession: elementsSession,
defaultPaymentMethod: paymentOptionsViewModels.stp_boundSafeObject(at: defaultSelectedIndex),
orderedPaymentMethodTypes: paymentMethodTypes
orderedPaymentMethodTypes: paymentMethodTypes,
networkMetrics: networkMetrics
)
if integrationShape.shouldStartCheckoutMeasurementOnLoad {
analyticsHelper.startTimeMeasurement(.checkout)
Expand All @@ -195,8 +202,10 @@ final class PaymentSheetLoader {
intent: intent,
elementsSession: elementsSession,
savedPaymentMethods: filteredSavedPaymentMethods,
paymentMethodTypes: paymentMethodTypes
paymentMethodTypes: paymentMethodTypes,
elementsSessionNetworkMetrics: networkMetrics
)

completion(.success(loadResult))
} catch {
analyticsHelper.logLoadFailed(error: error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ fileprivate extension PaymentSheet.FlowController {
let elementsSession = STPElementsSession(allResponseFields: [:], sessionID: "", configID: "", orderedPaymentMethodTypes: [], orderedPaymentMethodTypesAndWallets: ["card", "link", "apple_pay"], unactivatedPaymentMethodTypes: [], countryCode: nil, merchantCountryCode: nil, merchantLogoUrl: nil, linkSettings: nil, experimentsData: nil, flags: [:], paymentMethodSpecs: nil, cardBrandChoice: nil, isApplePayEnabled: true, externalPaymentMethods: [], customPaymentMethods: [], passiveCaptchaData: nil, customer: nil)
let intentConfig = PaymentSheet.IntentConfiguration(mode: .payment(amount: 10, currency: "USD", setupFutureUsage: nil, captureMethod: .automatic, paymentMethodOptions: nil)) { _, _ in return "" }
let intent = Intent.deferredIntent(intentConfig: intentConfig)
let loadResult = PaymentSheetLoader.LoadResult(intent: intent, elementsSession: elementsSession, savedPaymentMethods: [], paymentMethodTypes: [])
let loadResult = PaymentSheetLoader.LoadResult(intent: intent, elementsSession: elementsSession, savedPaymentMethods: [], paymentMethodTypes: [], elementsSessionNetworkMetrics: nil)
let analyticsHelper = PaymentSheetAnalyticsHelper(integrationShape: .complete, configuration: psConfig)
return PaymentSheet.FlowController(configuration: psConfig, loadResult: loadResult, analyticsHelper: analyticsHelper)
}
Expand Down
Loading