Skip to content

Commit

Permalink
[Connect] Add analytics client and spec (#9780)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
simond-stripe authored Jan 9, 2025
1 parent 0333f85 commit 924207c
Show file tree
Hide file tree
Showing 8 changed files with 489 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -180,6 +199,8 @@ class EmbeddedComponentManager(

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
companion object {
private var connectAnalyticsService: ConnectAnalyticsService? = null

@VisibleForTesting
internal val permissionsFlow: MutableSharedFlow<Boolean> = MutableSharedFlow(extraBufferCapacity = 1)
private val launcherMap = mutableMapOf<Activity, ActivityResultLauncher<String>>()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Any?> = 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()
)
)
}
Original file line number Diff line number Diff line change
@@ -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<String, Any?>)
}

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<String, Any?>) {
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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,10 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>(
}
}
}
val analyticsService = embeddedComponentManager.getComponentAnalyticsService(embeddedComponent)
this.controller = StripeConnectWebViewContainerController(
view = this,
analyticsService = analyticsService,
embeddedComponentManager = embeddedComponentManager,
embeddedComponent = embeddedComponent,
listener = listener,
Expand Down Expand Up @@ -373,6 +375,8 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>(
fun accountSessionClaimed(message: String) {
val accountSessionClaimedMessage = ConnectJson.decodeFromString<AccountSessionClaimedMessage>(message)
logger.debug("Account session claimed: $accountSessionClaimedMessage")

controller?.onMerchantIdChanged(accountSessionClaimedMessage.merchantId)
}

@JavascriptInterface
Expand Down
Loading

0 comments on commit 924207c

Please sign in to comment.