Skip to content

Commit

Permalink
Embedded GA metrics (#4507)
Browse files Browse the repository at this point in the history
## Summary
- Distinguish between embedded in load events
- Add analytics for update API calls
- Track confirm calls

## Motivation
- Embedded GA

## Testing
- Updating existing tests
- New unit tests

## Changelog
N/A
  • Loading branch information
porter-stripe authored Jan 31, 2025
1 parent e6d488d commit e16dd1f
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -82,7 +93,8 @@ final class PaymentSheetAnalyticsHelper {
log(
event: .paymentSheetLoadFailed,
duration: duration,
error: error
error: error,
params: ["integration_shape": integrationShape.analyticsValue]
)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -124,6 +137,7 @@ final class PaymentSheetAnalyticsHelper {
guard let loadingStartDate else { return 0 }
return Date().timeIntervalSince(loadingStartDate)
}()

log(
event: .paymentSheetLoadSucceeded,
duration: duration,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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

Expand Down

0 comments on commit e16dd1f

Please sign in to comment.