Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Connect] Emit analytic events #9873

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ 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.appearance.Appearance
import com.stripe.android.connect.appearance.fonts.CustomFontSource
import com.stripe.android.connect.util.findActivity
Expand Down Expand Up @@ -169,6 +171,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 +198,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 +214,12 @@ class EmbeddedComponentManager(
fun onActivityCreate(activity: ComponentActivity) {
val application = activity.application

if (connectAnalyticsService == null) {
connectAnalyticsService = ConnectAnalyticsService(
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,208 @@
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: Long
) : 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: Long,
val perceivedTimeToLoad: Long
) : 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/android_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 error: String,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 I talked with @mludowise-stripe and since this is a catch-all error for capturing unexpected issues, we agreed the schema doesn't need to match between iOS and Android. I picked a schema that makes sense for Android independent of what iOS has chosen.

val errorMessage: String? = null,
) : ConnectAnalyticsEvent(
"client_error",
mapOf(
"error" to error,
"errorMessage" to errorMessage,
)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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.AnalyticsRequestV2
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

/**
* Service for logging [AnalyticsRequestV2] for the Connect SDK.
* This service is very simple. Consumers should prefer [ComponentAnalyticsService] instead,
* which uses this service internally.
*/
internal class ConnectAnalyticsService(application: Application) {
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)
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
@@ -0,0 +1,23 @@
package com.stripe.android.connect.util

/**
* [Clock] interface to be used to provide compatible `Clock` functionality,
* and one day be replaced by `java.time.Clock` when all consumers support > SDK 26.
*
* Also useful for mocking in tests.
*/
internal interface Clock {

/**
* Return the current system time in milliseconds
*/
fun millis(): Long
}

/**
* A [Clock] that depends on Android APIs. To be replaced by java.time.Clock when all consumers
* support > SDK 26.
*/
internal class AndroidClock : Clock {
override fun millis(): Long = System.currentTimeMillis()
}
Loading
Loading