-
Notifications
You must be signed in to change notification settings - Fork 662
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] Add analytics client and spec #9780
Changes from 16 commits
531b194
3ac75eb
c0589c0
9e55604
cc0cf23
cb2e154
10d6434
45388a1
fb92f91
10b2386
a70c58c
cee4006
03b3776
1be17a0
8502807
ce8f402
162d768
071a83f
7192b33
f7a5842
79689e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add unit tests for this? At least verify the params are being added. |
||
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
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,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. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This docstring is inaccurate.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's inaccurate about this docstring? Is it specifically mentioning AnalyticsRequestV2, since that's the type we log but not the type the service exposes? I think it's still useful to recommend consumers to ComponentAnalyticsService (which is what should be used in other parts of the SDK anyways), @lng-stripe let me know what you think of the updated docstring. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, I might've misread it. I thought it was saying Still, I'm not a fan of referencing downstream dependencies since it's not clean and adds maintenance cost. |
||||||||||
*/ | ||||||||||
internal class ConnectAnalyticsService(application: Application) { | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally this would be abstracted for testability. Imagine unit testing |
||||||||||
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" | ||||||||||
} | ||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently unused. Can you add sending at least
ComponentCreated
to prove it works?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I already did this (see here) but backed out the change once I proved it works because I want all the emissions to be added in #9873.