diff --git a/Example/PaymentSheet Example/PaymentSheetUITest/EmbeddedUITest.swift b/Example/PaymentSheet Example/PaymentSheetUITest/EmbeddedUITest.swift index 42ff4d461db..1e482dd3ad0 100644 --- a/Example/PaymentSheet Example/PaymentSheetUITest/EmbeddedUITest.swift +++ b/Example/PaymentSheet Example/PaymentSheetUITest/EmbeddedUITest.swift @@ -73,7 +73,7 @@ class EmbeddedUITests: PaymentSheetUITestCase { let aliPayAnalytics = analyticsLog.compactMap({ $0[string: "event"] }) XCTAssertEqual( aliPayAnalytics, - ["mc_load_started", "link.account_lookup.complete", "mc_load_succeeded", "mc_carousel_payment_method_tapped"] + ["mc_embedded_update_started", "mc_load_started", "link.account_lookup.complete", "mc_load_succeeded", "mc_embedded_update_finished", "mc_carousel_payment_method_tapped"] ) // ...and *updating* to a SetupIntent... @@ -122,9 +122,7 @@ class EmbeddedUITests: PaymentSheetUITestCase { let klarnaAnalytics = analyticsLog.compactMap({ $0[string: "event"] }) XCTAssertEqual( klarnaAnalytics, - ["mc_load_started", "link.account_lookup.complete", "mc_load_succeeded", "mc_carousel_payment_method_tapped", - "mc_form_shown", "mc_form_completed", "mc_confirm_button_tapped", - ] + ["mc_embedded_update_started", "mc_load_started", "link.account_lookup.complete", "mc_load_succeeded", "mc_embedded_update_finished", "mc_carousel_payment_method_tapped", "mc_form_shown", "mc_form_completed", "mc_confirm_button_tapped"] ) // ...switching back to payment should keep Klarna selected diff --git a/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift b/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift index baf099dddc3..b0e786d581d 100644 --- a/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift +++ b/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift @@ -77,7 +77,14 @@ import Foundation // MARK: - Embedded Payment Element init case mcInitEmbedded = "mc_embedded_init" - + + // MARK: - Embedded Payment Element confirm + case mcConfirmEmbedded = "mc_embedded_confirm" + + // MARK: - Embedded Payment Element update + case mcUpdateStartedEmbedded = "mc_embedded_update_started" + case mcUpdateFinishedEmbedded = "mc_embedded_update_finished" + // MARK: - PaymentSheet Show case mcShowCustomNewPM = "mc_custom_sheet_newpm_show" case mcShowCustomSavedPM = "mc_custom_sheet_savedpm_show" diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Analytics/PaymentSheetAnalyticsHelper.swift b/StripePaymentSheet/StripePaymentSheet/Source/Analytics/PaymentSheetAnalyticsHelper.swift index 295d26a74ca..c227ab83ad5 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Analytics/PaymentSheetAnalyticsHelper.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Analytics/PaymentSheetAnalyticsHelper.swift @@ -24,6 +24,17 @@ final class PaymentSheetAnalyticsHelper { case flowController case complete case embedded + + var analyticsValue: String { + switch self { + case .flowController: + return "flowcontroller" + case .complete: + return "paymentsheet" + case .embedded: + return "embedded" + } + } } init( @@ -70,7 +81,7 @@ final class PaymentSheetAnalyticsHelper { func logLoadStarted() { loadingStartDate = Date() - log(event: .paymentSheetLoadStarted) + log(event: .paymentSheetLoadStarted, params: ["integration_shape": integrationShape.analyticsValue]) } func logLoadFailed(error: Error) { @@ -82,7 +93,8 @@ final class PaymentSheetAnalyticsHelper { log( event: .paymentSheetLoadFailed, duration: duration, - error: error + error: error, + params: ["integration_shape": integrationShape.analyticsValue] ) } @@ -114,6 +126,7 @@ final class PaymentSheetAnalyticsHelper { "selected_lpm": defaultPaymentMethodAnalyticsValue, "intent_type": intent.analyticsValue, "ordered_lpms": orderedPaymentMethodTypes.map({ $0.identifier }).joined(separator: ","), + "integration_shape": integrationShape.analyticsValue ] let linkEnabled: Bool = PaymentSheet.isLinkEnabled(elementsSession: elementsSession, configuration: configuration) if linkEnabled { @@ -124,6 +137,7 @@ final class PaymentSheetAnalyticsHelper { guard let loadingStartDate else { return 0 } return Date().timeIntervalSince(loadingStartDate) }() + log( event: .paymentSheetLoadSucceeded, duration: duration, @@ -320,6 +334,25 @@ final class PaymentSheetAnalyticsHelper { linkUI: paymentOption.linkUIAnalyticsValue ) } + + func logEmbeddedUpdateStarted() { + stpAssert(integrationShape == .embedded, "This function should only be used with embedded integration") + log(event: .mcUpdateStartedEmbedded) + } + + func logEmbeddedUpdateFinished(result: EmbeddedPaymentElement.UpdateResult, duration: TimeInterval) { + stpAssert(integrationShape == .embedded, "This function should only be used with embedded integration") + + let error: Error? = { + switch result { + case .failed(let error): + return error + default: + return nil + } + }() + log(event: .mcUpdateFinishedEmbedded, duration: duration, error: error, params: ["status": result.analyticValue]) + } func log( event: STPAnalyticEvent, @@ -431,3 +464,16 @@ extension PaymentElementConfiguration { return payload } } + +extension EmbeddedPaymentElement.UpdateResult { + var analyticValue: String { + switch self { + case .succeeded: + return "succeeded" + case .canceled: + return "canceled" + case .failed(_): + return "failed" + } + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift index 9e5b34fa8e1..ac7b8f293e7 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift @@ -104,9 +104,13 @@ public final class EmbeddedPaymentElement { public func update( intentConfiguration: IntentConfiguration ) async -> UpdateResult { + let startTime = Date() + analyticsHelper.logEmbeddedUpdateStarted() // Do not process any update calls if we have already successfully confirmed an intent guard !hasConfirmedIntent else { - return .failed(error: PaymentSheetError.embeddedPaymentElementAlreadyConfirmedIntent) + let result: EmbeddedPaymentElement.UpdateResult = .failed(error: PaymentSheetError.embeddedPaymentElementAlreadyConfirmedIntent) + analyticsHelper.logEmbeddedUpdateFinished(result: result, duration: Date().timeIntervalSince(startTime)) + return result } embeddedPaymentMethodsView.isUserInteractionEnabled = false @@ -184,6 +188,7 @@ public final class EmbeddedPaymentElement { self.latestUpdateTask = currentUpdateTask let updateResult = await currentUpdateTask.value embeddedPaymentMethodsView.isUserInteractionEnabled = true + analyticsHelper.logEmbeddedUpdateFinished(result: updateResult, duration: Date().timeIntervalSince(startTime)) return updateResult } @@ -192,6 +197,7 @@ public final class EmbeddedPaymentElement { /// - Note: This method presents authentication screens on the instance's `presentingViewController` property. /// - Note: This method requires that the last call to `update` succeeded. If the last `update` call failed, this call will fail. If this method is called while a call to `update` is in progress, it waits until the `update` call completes. public func confirm() async -> EmbeddedPaymentElementResult { + analyticsHelper.log(event: .mcConfirmEmbedded) guard let presentingViewController else { let errorMessage = "Presenting view controller is nil. Please set EmbeddedPaymentElement.presentingViewController." stpAssertionFailure(errorMessage) diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift index c3584474b0c..293de0e41cb 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift @@ -93,8 +93,8 @@ class EmbeddedPaymentElementTest: XCTestCase { // Sanity check that the analytics... let analytics = STPAnalyticsClient.sharedClient._testLogHistory - let loadStartedEvents = analytics.filter { $0["event"] as? String == "mc_load_started" } - let loadSucceededEvents = analytics.filter { $0["event"] as? String == "mc_load_succeeded" } + let loadStartedEvents = analytics.filter { $0["event"] as? String == "mc_load_started" && $0["integration_shape"] as? String == "embedded" } + let loadSucceededEvents = analytics.filter { $0["event"] as? String == "mc_load_succeeded" && $0["integration_shape"] as? String == "embedded" } // ...have the expected # of start and succeeded events... XCTAssertEqual(loadStartedEvents.count, 3) XCTAssertEqual(loadSucceededEvents.count, 3) @@ -257,6 +257,12 @@ class EmbeddedPaymentElementTest: XCTestCase { case .canceled: XCTFail("Expected confirm to succeed, but it was canceled") } + + // Check our confirm analytics + let analytics = STPAnalyticsClient.sharedClient._testLogHistory + let confirmEvents = analytics.filter { $0["event"] as? String == "mc_embedded_confirm" } + // ...have the expected # of confirm events... + XCTAssertEqual(confirmEvents.count, 1) } func testConfirmWithInvalidCard() async throws { diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetAnalyticsHelperTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetAnalyticsHelperTest.swift index 80154cbfe57..b2df51d34f5 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetAnalyticsHelperTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetAnalyticsHelperTest.swift @@ -112,33 +112,63 @@ final class PaymentSheetAnalyticsHelperTest: XCTestCase { } func testLogLoadFailed() { - let sut = PaymentSheetAnalyticsHelper(integrationShape: .complete, configuration: PaymentSheet.Configuration(), analyticsClient: analyticsClient) - // Load started -> failed - sut.logLoadStarted() - sut.logLoadFailed(error: NSError(domain: "domain", code: 1)) - XCTAssertEqual(analyticsClient._testLogHistory[0]["event"] as? String, "mc_load_started") - XCTAssertEqual(analyticsClient._testLogHistory[1]["event"] as? String, "mc_load_failed") - XCTAssertLessThan(analyticsClient._testLogHistory[1]["duration"] as! Double, 1.0) + let integrationShapes: [(PaymentSheetAnalyticsHelper.IntegrationShape, String)] = [ + (.complete, "paymentsheet"), + (.embedded, "embedded"), + (.flowController, "flowcontroller") + ] + + for (shape, shapeString) in integrationShapes { + let sut = PaymentSheetAnalyticsHelper(integrationShape: shape, configuration: PaymentSheet.Configuration(), analyticsClient: analyticsClient) + + // Reset the analytics client for each iteration + analyticsClient._testLogHistory.removeAll() + + // Load started -> failed + sut.logLoadStarted() + sut.logLoadFailed(error: NSError(domain: "domain", code: 1)) + + XCTAssertEqual(analyticsClient._testLogHistory[0]["event"] as? String, "mc_load_started") + XCTAssertEqual(analyticsClient._testLogHistory[0]["integration_shape"] as? String, shapeString) + XCTAssertEqual(analyticsClient._testLogHistory[1]["event"] as? String, "mc_load_failed") + XCTAssertLessThan(analyticsClient._testLogHistory[1]["duration"] as! Double, 1.0) + XCTAssertEqual(analyticsClient._testLogHistory[1]["integration_shape"] as? String, shapeString) + } } func testLogLoadSucceeded() { - let sut = PaymentSheetAnalyticsHelper(integrationShape: .complete, configuration: PaymentSheet.Configuration(), analyticsClient: analyticsClient) - // Load started -> succeeded - sut.logLoadStarted() - sut.logLoadSucceeded( - intent: ._testValue(), - elementsSession: ._testCardValue(), - defaultPaymentMethod: .applePay, - orderedPaymentMethodTypes: [.stripe(.card), .external(._testPayPalValue())] - ) - XCTAssertEqual(analyticsClient._testLogHistory[0]["event"] as? String, "mc_load_started") - - let loadSucceededPayload = analyticsClient._testLogHistory[1] - XCTAssertEqual(loadSucceededPayload["event"] as? String, "mc_load_succeeded") - XCTAssertLessThan(loadSucceededPayload["duration"] as! Double, 1.0) - XCTAssertEqual(loadSucceededPayload["selected_lpm"] as? String, "apple_pay") - XCTAssertEqual(loadSucceededPayload["intent_type"] as? String, "payment_intent") - XCTAssertEqual(loadSucceededPayload["ordered_lpms"] as? String, "card,external_paypal") + let integrationShapes: [(PaymentSheetAnalyticsHelper.IntegrationShape, String)] = [ + (.complete, "paymentsheet"), + (.embedded, "embedded"), + (.flowController, "flowcontroller") + ] + + for (shape, shapeString) in integrationShapes { + let sut = PaymentSheetAnalyticsHelper(integrationShape: shape, configuration: PaymentSheet.Configuration(), analyticsClient: analyticsClient) + + // Reset the analytics client for each iteration + analyticsClient._testLogHistory.removeAll() + + // Load started -> succeeded + sut.logLoadStarted() + sut.logLoadSucceeded( + intent: ._testValue(), + elementsSession: ._testCardValue(), + defaultPaymentMethod: .applePay, + orderedPaymentMethodTypes: [.stripe(.card), .external(._testPayPalValue())] + ) + + XCTAssertEqual(analyticsClient._testLogHistory[0]["event"] as? String, "mc_load_started") + XCTAssertEqual(analyticsClient._testLogHistory[0]["integration_shape"] as? String, shapeString) + + let loadSucceededPayload = analyticsClient._testLogHistory[1] + XCTAssertEqual(loadSucceededPayload["event"] as? String, "mc_load_succeeded") + XCTAssertLessThan(loadSucceededPayload["duration"] as! Double, 1.0) + XCTAssertEqual(loadSucceededPayload["selected_lpm"] as? String, "apple_pay") + XCTAssertEqual(loadSucceededPayload["intent_type"] as? String, "payment_intent") + XCTAssertEqual(loadSucceededPayload["ordered_lpms"] as? String, "card,external_paypal") + XCTAssertEqual(loadSucceededPayload["integration_shape"] as? String, shapeString) + } } func testLogShow() { @@ -369,6 +399,42 @@ final class PaymentSheetAnalyticsHelperTest: XCTestCase { ) XCTAssertEqual(analyticsClient._testLogHistory.last!["link_context"] as? String, "link_card_brand") } + + func testLogEmbeddedUpdate() { + let sut = PaymentSheetAnalyticsHelper(integrationShape: .embedded, configuration: PaymentSheet.Configuration(), analyticsClient: analyticsClient) + let testDuration: TimeInterval = 10.5 + + // Test update started + sut.logEmbeddedUpdateStarted() + XCTAssertEqual(analyticsClient._testLogHistory.last!["event"] as? String, "mc_embedded_update_started") + + // Test successful update + sut.logEmbeddedUpdateFinished(result: .succeeded, duration: testDuration) + XCTAssertEqual(analyticsClient._testLogHistory.last!["event"] as? String, "mc_embedded_update_finished") + XCTAssertEqual(analyticsClient._testLogHistory.last!["status"] as? String, "succeeded") + XCTAssertEqual(analyticsClient._testLogHistory.last!["duration"] as? TimeInterval, testDuration) + XCTAssertNil(analyticsClient._testLogHistory.last!["error_type"]) + XCTAssertNil(analyticsClient._testLogHistory.last!["error_code"]) + + // Test failed update + sut.logEmbeddedUpdateStarted() + let error = NSError(domain: "test", code: 123) + sut.logEmbeddedUpdateFinished(result: .failed(error: error), duration: testDuration) + XCTAssertEqual(analyticsClient._testLogHistory.last!["event"] as? String, "mc_embedded_update_finished") + XCTAssertEqual(analyticsClient._testLogHistory.last!["status"] as? String, "failed") + XCTAssertEqual(analyticsClient._testLogHistory.last!["duration"] as? TimeInterval, testDuration) + XCTAssertEqual(analyticsClient._testLogHistory.last!["error_type"] as? String, "test") + XCTAssertEqual(analyticsClient._testLogHistory.last!["error_code"] as? String, "123") + + // Test canceled update + sut.logEmbeddedUpdateStarted() + sut.logEmbeddedUpdateFinished(result: .canceled, duration: testDuration) + XCTAssertEqual(analyticsClient._testLogHistory.last!["event"] as? String, "mc_embedded_update_finished") + XCTAssertEqual(analyticsClient._testLogHistory.last!["status"] as? String, "canceled") + XCTAssertEqual(analyticsClient._testLogHistory.last!["duration"] as? TimeInterval, testDuration) + XCTAssertNil(analyticsClient._testLogHistory.last!["error_type"]) + XCTAssertNil(analyticsClient._testLogHistory.last!["error_code"]) + } // MARK: - Helpers