From 924207c5f98d8cd9f46c42fcd0b1175d0d10dc5c Mon Sep 17 00:00:00 2001 From: Simon Duchastel <163092709+simond-stripe@users.noreply.github.com> Date: Thu, 9 Jan 2025 13:28:05 -0800 Subject: [PATCH] [Connect] Add analytics client and spec (#9780) * Add analytics client and spec * Add javadoc * Add common params * Add merchant id hook * Init analytics service * WIP * cleanup * Fix order of operations for initialization * Apply init changes to account onboarding * connect/src/main/java/com/stripe/android/connect/ * suppress lint check * Remove test emission * Update to singleton architecture * add eof for lint * Remove unneeded val * remove extra line * Add test, remove out-of-date comment * Update doc string * Add tests * Fix lint * Abstract service --- .../connect/EmbeddedComponentManager.kt | 27 +++ .../analytics/ComponentAnalyticsService.kt | 41 ++++ .../analytics/ConnectAnalyticsEvent.kt | 212 ++++++++++++++++++ .../analytics/ConnectAnalyticsService.kt | 62 +++++ .../webview/StripeConnectWebViewContainer.kt | 4 + ...StripeConnectWebViewContainerController.kt | 10 + .../ComponentAnalyticsServiceTest.kt | 124 ++++++++++ ...peConnectWebViewContainerControllerTest.kt | 9 + 8 files changed, 489 insertions(+) create mode 100644 connect/src/main/java/com/stripe/android/connect/analytics/ComponentAnalyticsService.kt create mode 100644 connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsEvent.kt create mode 100644 connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsService.kt create mode 100644 connect/src/test/java/com/stripe/android/connect/analytics/ComponentAnalyticsServiceTest.kt diff --git a/connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt b/connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt index 5b8a43d2fc3..74d7e1335c6 100644 --- a/connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt +++ b/connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt @@ -13,6 +13,9 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RestrictTo import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat.checkSelfPermission +import com.stripe.android.connect.analytics.ComponentAnalyticsService +import com.stripe.android.connect.analytics.ConnectAnalyticsService +import com.stripe.android.connect.analytics.DefaultConnectAnalyticsService import com.stripe.android.connect.appearance.Appearance import com.stripe.android.connect.appearance.fonts.CustomFontSource import com.stripe.android.connect.util.findActivity @@ -169,6 +172,22 @@ class EmbeddedComponentManager( return permissionsFlow.first() } + internal fun getComponentAnalyticsService(component: StripeEmbeddedComponent): ComponentAnalyticsService { + val analyticsService = checkNotNull(connectAnalyticsService) { + "ConnectAnalyticsService is not initialized" + } + val publishableKeyToLog = if (configuration.publishableKey.startsWith("uk_")) { + null // don't log "uk_" keys + } else { + configuration.publishableKey + } + return ComponentAnalyticsService( + analyticsService = analyticsService, + component = component, + publishableKey = publishableKeyToLog, + ) + } + // Configuration @PrivateBetaConnectSDK @@ -180,6 +199,8 @@ class EmbeddedComponentManager( @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) companion object { + private var connectAnalyticsService: ConnectAnalyticsService? = null + @VisibleForTesting internal val permissionsFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) private val launcherMap = mutableMapOf>() @@ -194,6 +215,12 @@ class EmbeddedComponentManager( fun onActivityCreate(activity: ComponentActivity) { val application = activity.application + if (connectAnalyticsService == null) { + connectAnalyticsService = DefaultConnectAnalyticsService( + application = application, + ) + } + application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityDestroyed(destroyedActivity: Activity) { // ensure we remove the activity and its launcher from our map, and unregister diff --git a/connect/src/main/java/com/stripe/android/connect/analytics/ComponentAnalyticsService.kt b/connect/src/main/java/com/stripe/android/connect/analytics/ComponentAnalyticsService.kt new file mode 100644 index 00000000000..9979612eb2d --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/analytics/ComponentAnalyticsService.kt @@ -0,0 +1,41 @@ +package com.stripe.android.connect.analytics + +import com.stripe.android.connect.StripeEmbeddedComponent +import java.util.UUID + +/** + * Service for logging [ConnectAnalyticsEvent] for the Connect SDK. Also keeps track + * of shared parameters to pass alongside events. + * There should be one instance of ComponentAnalyticsService per instantiation of [StripeEmbeddedComponent]. + */ +internal class ComponentAnalyticsService( + private val analyticsService: ConnectAnalyticsService, + private val component: StripeEmbeddedComponent, + private val publishableKey: String?, // can be null in cases where it should not be logged +) { + internal var merchantId: String? = null + private var componentUUID = UUID.randomUUID() + + /** + * Log an analytic [event]. + */ + fun track(event: ConnectAnalyticsEvent) { + val params = buildMap { + // add common params + put("merchantId", merchantId) + put("component", component.componentName) + put("componentInstance", componentUUID.toString()) + put("publishableKey", publishableKey) + + // event-specific params should be included in both the top-level and event_metadata + // blob so that we can use them in prometheus alerts (which are only available for + // top-level events). + if (event.params.isNotEmpty()) { + putAll(event.params) + put("event_metadata", event.params) + } + } + + analyticsService.track(event.eventName, params) + } +} diff --git a/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsEvent.kt b/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsEvent.kt new file mode 100644 index 00000000000..5e820aa7511 --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsEvent.kt @@ -0,0 +1,212 @@ +package com.stripe.android.connect.analytics + +/** + * Analytics event for the Connect SDK. One subclass per unique analytics event is expected. + */ +internal sealed class ConnectAnalyticsEvent( + val eventName: String, + val params: Map = mapOf(), +) { + /** + * A component was instantiated via create{ComponentType}. + * + * The delta between this and component.viewed could tell us if apps are instantiating + * components but never rendering them on screen. + */ + data object ComponentCreated : ConnectAnalyticsEvent("component.created") + + /** + * The component is viewed on screen (viewDidAppear lifecycle event on iOS) + */ + data class ComponentViewed( + val pageViewId: String? + ) : ConnectAnalyticsEvent( + "component.viewed", + mapOf("page_view_id" to pageViewId) + ) + + /** + * The web page finished loading (didFinish navigation on iOS). + * + * Note: This should happen before component_loaded, so we won't yet have a page_view_id. + */ + data class WebPageLoaded( + val timeToLoad: Double + ) : ConnectAnalyticsEvent( + "component.web.page_loaded", + mapOf("time_to_load" to timeToLoad.toString()) + ) + + /** + * The component is successfully loaded within the web view. Triggered from componentDidLoad + * message handler from the web view. + */ + data class WebComponentLoaded( + val pageViewId: String, + val timeToLoad: Double, + val perceivedTimeToLoad: Double + ) : ConnectAnalyticsEvent( + "component.web.component_loaded", + mapOf( + "page_view_id" to pageViewId, + "time_to_load" to timeToLoad.toString(), + "perceived_time_to_load" to perceivedTimeToLoad.toString() + ) + ) + + /** + * The SDK receives a non-200 status code loading the web view, other than "Internet connectivity" errors. + * + * Intent is to alert if the URL the mobile client expects is suddenly unreachable. + * The web view should always return a 200, even when displaying an error state. + */ + data class WebErrorPageLoad( + val status: Int?, + val error: String?, + val url: String + ) : ConnectAnalyticsEvent( + "component.web.error.page_load", + mapOf( + "status" to status?.toString(), + "error" to error, + "url" to url + ) + ) + + /** + * If the web view sends an onLoadError that can't be deserialized by the SDK. + */ + data class WebWarnErrorUnexpectedLoad( + val errorType: String, + val pageViewId: String? + ) : ConnectAnalyticsEvent( + "component.web.warnerror.unexpected_load_error_type", + mapOf( + "error_type" to errorType, + "page_view_id" to pageViewId + ) + ) + + /** + * If the web view calls onSetterFunctionCalled with a setter argument the SDK doesn't know how to handle. + * + * Note: It's expected to get this warning when web adds support for new setter functions not handled + * in older SDK versions. But logging it could help us debug issues where we expect the SDK to handle + * something it isn't. + */ + data class WebWarnUnrecognizedSetter( + val setter: String, + val pageViewId: String? + ) : ConnectAnalyticsEvent( + "component.web.warn.unrecognized_setter_function", + mapOf( + "setter" to setter, + "page_view_id" to pageViewId + ) + ) + + /** + * An error occurred deserializing the JSON payload from a web message. + */ + data class WebErrorDeserializeMessage( + val message: String, + val error: String, + val errorDescription: String?, + val pageViewId: String? + ) : ConnectAnalyticsEvent( + "component.web.error.deserialize_message", + mapOf( + "message" to message, + "error" to error, + "error_description" to errorDescription, + "page_view_id" to pageViewId + ) + ) + + /** + * A web view was opened when openWebView was called. + */ + data class AuthenticatedWebOpened( + val pageViewId: String?, + val authenticatedViewId: String + ) : ConnectAnalyticsEvent( + "component.authenticated_web.opened", + mapOf( + "page_view_id" to pageViewId, + "authenticated_view_id" to authenticatedViewId + ) + ) + + /** + * The web view successfully redirected back to the app. + */ + data class AuthenticatedWebRedirected( + val pageViewId: String?, + val authenticatedViewId: String + ) : ConnectAnalyticsEvent( + "component.authenticated_web.redirected", + mapOf( + "page_view_id" to pageViewId, + "authenticated_view_id" to authenticatedViewId + ) + ) + + /** + * The user closed the web view before getting redirected back to the app. + */ + data class AuthenticatedWebCanceled( + val pageViewId: String?, + val viewId: String + ) : ConnectAnalyticsEvent( + "component.authenticated_web.canceled", + mapOf( + "page_view_id" to pageViewId, + "view_id" to viewId + ) + ) + + /** + * The web view threw an error and was not successfully redirected back to the app. + */ + data class AuthenticatedWebError( + val error: String, + val pageViewId: String?, + val viewId: String + ) : ConnectAnalyticsEvent( + "component.authenticated_web.error", + mapOf( + "error" to error, + "page_view_id" to pageViewId, + "view_id" to viewId + ) + ) + + /** + * The web page navigated somewhere other than the component wrapper URL + * (e.g. https://connect-js.stripe.com/v1.0/ios-webview.html) + */ + data class WebErrorUnexpectedNavigation( + val url: String + ) : ConnectAnalyticsEvent( + "component.web.error.unexpected_navigation", + mapOf("url" to url) + ) + + /** + * Catch-all event for unexpected client-side errors. + */ + data class ClientError( + val domain: String, + val code: Int, + val file: String, + val line: Int + ) : ConnectAnalyticsEvent( + "client_error", + mapOf( + "domain" to domain, + "code" to code.toString(), + "file" to file, + "line" to line.toString() + ) + ) +} diff --git a/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsService.kt b/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsService.kt new file mode 100644 index 00000000000..75619ed4a0b --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsService.kt @@ -0,0 +1,62 @@ +package com.stripe.android.connect.analytics + +import android.app.Application +import com.stripe.android.core.BuildConfig +import com.stripe.android.core.Logger +import com.stripe.android.core.networking.AnalyticsRequestV2Factory +import com.stripe.android.core.networking.DefaultAnalyticsRequestV2Executor +import com.stripe.android.core.networking.DefaultStripeNetworkClient +import com.stripe.android.core.networking.RealAnalyticsRequestV2Storage +import com.stripe.android.core.utils.RealIsWorkManagerAvailable +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +/** + * Analytics service configured for Connect SDK. + * Consumers should prefer [ComponentAnalyticsService] instead as this service is very simple. + */ +internal interface ConnectAnalyticsService { + fun track(eventName: String, params: Map) +} + +internal class DefaultConnectAnalyticsService(application: Application) : ConnectAnalyticsService { + private val analyticsRequestStorage = RealAnalyticsRequestV2Storage(application) + private val logger = Logger.getInstance(enableLogging = BuildConfig.DEBUG) + private val networkClient = DefaultStripeNetworkClient() + private val isWorkerAvailable = RealIsWorkManagerAvailable( + isEnabledForMerchant = { true } + ) + + private val requestExecutor = DefaultAnalyticsRequestV2Executor( + application = application, + networkClient = networkClient, + logger = logger, + storage = analyticsRequestStorage, + isWorkManagerAvailable = isWorkerAvailable, + ) + + private val requestFactory = AnalyticsRequestV2Factory( + context = application, + clientId = CLIENT_ID, + origin = ORIGIN, + ) + + @OptIn(DelicateCoroutinesApi::class) + override fun track(eventName: String, params: Map) { + GlobalScope.launch(Dispatchers.IO) { + val request = requestFactory.createRequest( + eventName = eventName, + additionalParams = params, + includeSDKParams = true, + ) + requestExecutor.enqueue(request) + } + } + + internal companion object { + const val CLIENT_ID = "mobile_connect_sdk" + const val ORIGIN = "stripe-connect-android" + } +} diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt index 9c53d5f24e7..5623ecdbacc 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt @@ -196,8 +196,10 @@ internal class StripeConnectWebViewContainerImpl( } } } + val analyticsService = embeddedComponentManager.getComponentAnalyticsService(embeddedComponent) this.controller = StripeConnectWebViewContainerController( view = this, + analyticsService = analyticsService, embeddedComponentManager = embeddedComponentManager, embeddedComponent = embeddedComponent, listener = listener, @@ -373,6 +375,8 @@ internal class StripeConnectWebViewContainerImpl( fun accountSessionClaimed(message: String) { val accountSessionClaimedMessage = ConnectJson.decodeFromString(message) logger.debug("Account session claimed: $accountSessionClaimedMessage") + + controller?.onMerchantIdChanged(accountSessionClaimedMessage.merchantId) } @JavascriptInterface diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt index 29bcdbdaff3..64dde85ca79 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt @@ -14,6 +14,7 @@ import com.stripe.android.connect.EmbeddedComponentManager import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.StripeEmbeddedComponent import com.stripe.android.connect.StripeEmbeddedComponentListener +import com.stripe.android.connect.analytics.ComponentAnalyticsService import com.stripe.android.connect.webview.serialization.ConnectInstanceJs import com.stripe.android.connect.webview.serialization.SetOnLoadError import com.stripe.android.connect.webview.serialization.SetOnLoaderStart @@ -27,9 +28,11 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@Suppress("TooManyFunctions") @OptIn(PrivateBetaConnectSDK::class) internal class StripeConnectWebViewContainerController( private val view: StripeConnectWebViewContainerInternal, + private val analyticsService: ComponentAnalyticsService, private val embeddedComponentManager: EmbeddedComponentManager, private val embeddedComponent: StripeEmbeddedComponent, private val listener: Listener?, @@ -89,6 +92,13 @@ internal class StripeConnectWebViewContainerController>() + verify(analyticsService).track(any(), mapCaptor.capture()) + val params = mapCaptor.firstValue + + assertEquals("merchantId123", params["merchantId"]) + assertEquals(StripeEmbeddedComponent.PAYOUTS.componentName, params["component"]) + assertContains(params, "componentInstance") + assertEquals("publishableKey123", params["publishableKey"]) + } + + @Test + fun `track event emits still emits null common params`() { + val componentAnalyticsService = ComponentAnalyticsService( + analyticsService = analyticsService, + component = StripeEmbeddedComponent.PAYOUTS, + publishableKey = null, + ) + + componentAnalyticsService.track(ConnectAnalyticsEvent.ComponentCreated) + val mapCaptor = argumentCaptor>() + verify(analyticsService).track(any(), mapCaptor.capture()) + val params = mapCaptor.firstValue + + assertContains(params, "merchantId") + assertNull(params["merchantId"]) + assertContains(params, "publishableKey") + assertNull(params["publishableKey"]) + } + + @Test + fun `track event re-uses UUID`() { + val componentAnalyticsService = ComponentAnalyticsService( + analyticsService = analyticsService, + component = StripeEmbeddedComponent.PAYOUTS, + publishableKey = null, + ) + + componentAnalyticsService.track(ConnectAnalyticsEvent.ComponentCreated) + componentAnalyticsService.track(ConnectAnalyticsEvent.ComponentViewed(pageViewId = null)) + val mapCaptor = argumentCaptor>() + verify(analyticsService, times(2)).track(any(), mapCaptor.capture()) + + val emittedParams = mapCaptor.allValues + val expectedUUID = emittedParams.first()["componentInstance"] + emittedParams.map { params -> + assertEquals(expectedUUID, params["componentInstance"]) + } + } + + @Test + fun `track event emits event with metadata nested and at the root level`() { + val componentAnalyticsService = ComponentAnalyticsService( + analyticsService, + StripeEmbeddedComponent.PAYOUTS, + "publishableKey123" + ) + + componentAnalyticsService.track( + ConnectAnalyticsEvent.WebComponentLoaded( + pageViewId = "pageViewId123", + timeToLoad = 100.0, + perceivedTimeToLoad = 50.0, + ) + ) + val mapCaptor = argumentCaptor>() + verify(analyticsService).track(any(), mapCaptor.capture()) + val params = mapCaptor.firstValue + + val expectedMetadata = mapOf( + "page_view_id" to "pageViewId123", + "time_to_load" to "100.0", + "perceived_time_to_load" to "50.0", + ) + assertContains(params, "event_metadata") + assertEquals(expectedMetadata, params["event_metadata"]) + assertEquals("pageViewId123", params["page_view_id"]) + assertEquals("100.0", params["time_to_load"]) + assertEquals("50.0", params["perceived_time_to_load"]) + } + + @Test + fun `track event does not emit metadata when none exists`() { + val componentAnalyticsService = ComponentAnalyticsService( + analyticsService, + StripeEmbeddedComponent.PAYOUTS, + "publishableKey123" + ) + + componentAnalyticsService.track(ConnectAnalyticsEvent.ComponentCreated) // no metadata + val mapCaptor = argumentCaptor>() + verify(analyticsService).track(any(), mapCaptor.capture()) + val params = mapCaptor.firstValue + + assertFalse(params.containsKey("event_metadata")) + } +} diff --git a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt index 08243a603e6..0cdc54a2604 100644 --- a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt +++ b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt @@ -14,6 +14,7 @@ import com.stripe.android.connect.EmbeddedComponentManager import com.stripe.android.connect.PayoutsListener import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.StripeEmbeddedComponent +import com.stripe.android.connect.analytics.ComponentAnalyticsService import com.stripe.android.connect.appearance.Appearance import com.stripe.android.connect.appearance.Colors import com.stripe.android.connect.webview.serialization.SetOnLoadError @@ -51,6 +52,7 @@ class StripeConnectWebViewContainerControllerTest { private val mockContext: Context = mock() private val mockPermissionRequest: PermissionRequest = mock() private val view: StripeConnectWebViewContainerInternal = mock() + private val analyticsService: ComponentAnalyticsService = mock() private val embeddedComponentManager: EmbeddedComponentManager = mock() private val embeddedComponent: StripeEmbeddedComponent = StripeEmbeddedComponent.PAYOUTS @@ -74,6 +76,7 @@ class StripeConnectWebViewContainerControllerTest { controller = StripeConnectWebViewContainerController( view = view, + analyticsService = analyticsService, embeddedComponentManager = embeddedComponentManager, embeddedComponent = embeddedComponent, listener = listener, @@ -272,4 +275,10 @@ class StripeConnectWebViewContainerControllerTest { verify(mockPermissionRequest).deny() } + + @Test + fun `onMerchantIdChanged updates analytics service`() { + controller.onMerchantIdChanged("merchant_id") + verify(analyticsService).merchantId = "merchant_id" + } }