diff --git a/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift b/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift index 1f57054e779a..6de8d6ef7485 100644 --- a/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift +++ b/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift @@ -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 = [] diff --git a/StripeCore/StripeCore/Source/Helpers/URLSessionMetricsDelegate.swift b/StripeCore/StripeCore/Source/Helpers/URLSessionMetricsDelegate.swift new file mode 100644 index 000000000000..78cf3e0b06a5 --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/URLSessionMetricsDelegate.swift @@ -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 + ) + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Analytics/PaymentSheetAnalyticsHelper.swift b/StripePaymentSheet/StripePaymentSheet/Source/Analytics/PaymentSheetAnalyticsHelper.swift index 1230a220e484..f4a12df12a6a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Analytics/PaymentSheetAnalyticsHelper.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Analytics/PaymentSheetAnalyticsHelper.swift @@ -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 @@ -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, diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/STPElementsSession.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/STPElementsSession.swift index 01c1c907ec15..4cfaa05d1310 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/STPElementsSession.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/STPElementsSession.swift @@ -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 { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFlowController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFlowController.swift index 5f7e0c19d036..d2ccb7c486ba 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFlowController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFlowController.swift @@ -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, diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift index 88f6da1e2635..9da1a0dc1900 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift @@ -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 { @@ -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) @@ -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) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/WalletButtonsView/WalletButtonsView.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/WalletButtonsView/WalletButtonsView.swift index 13712b2d2331..e1d978372842 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/WalletButtonsView/WalletButtonsView.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/WalletButtonsView/WalletButtonsView.swift @@ -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) }