-
Notifications
You must be signed in to change notification settings - Fork 663
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
base: master
Are you sure you want to change the base?
Changes from all commits
93602fe
dd7a73f
1ac718d
24c6c7f
f1b428e
6844553
2920d50
f421523
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 | ||||
---|---|---|---|---|---|---|
|
@@ -31,7 +31,7 @@ internal sealed class ConnectAnalyticsEvent( | |||||
* Note: This should happen before component_loaded, so we won't yet have a page_view_id. | ||||||
*/ | ||||||
data class WebPageLoaded( | ||||||
val timeToLoad: Double | ||||||
val timeToLoad: Long | ||||||
) : ConnectAnalyticsEvent( | ||||||
"component.web.page_loaded", | ||||||
mapOf("time_to_load" to timeToLoad.toString()) | ||||||
|
@@ -43,8 +43,8 @@ internal sealed class ConnectAnalyticsEvent( | |||||
*/ | ||||||
data class WebComponentLoaded( | ||||||
val pageViewId: String, | ||||||
val timeToLoad: Double, | ||||||
val perceivedTimeToLoad: Double | ||||||
val timeToLoad: Long, | ||||||
val perceivedTimeToLoad: Long | ||||||
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. Same here. Even better, could you add 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. ➕ I have comments documenting these in the Analytic Events section of our scoping doc. I think you could copy these over verbatim (that's what I did on iOS) |
||||||
) : ConnectAnalyticsEvent( | ||||||
"component.web.component_loaded", | ||||||
mapOf( | ||||||
|
@@ -183,7 +183,7 @@ internal sealed class ConnectAnalyticsEvent( | |||||
|
||||||
/** | ||||||
* The web page navigated somewhere other than the component wrapper URL | ||||||
* (e.g. https://connect-js.stripe.com/v1.0/ios-webview.html) | ||||||
* (e.g. https://connect-js.stripe.com/v1.0/android_webview.html) | ||||||
*/ | ||||||
data class WebErrorUnexpectedNavigation( | ||||||
val url: String | ||||||
|
@@ -196,17 +196,13 @@ internal sealed class ConnectAnalyticsEvent( | |||||
* Catch-all event for unexpected client-side errors. | ||||||
*/ | ||||||
data class ClientError( | ||||||
val domain: String, | ||||||
val code: Int, | ||||||
val file: String, | ||||||
val line: Int | ||||||
val error: String, | ||||||
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. 👀 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. 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.
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. To add more context: we plan to setup per-platform alerts for these these generic client errors. So adding a uniquely identifiable error_code could also be useful if we ever wanted to configure those alerts to be more fine-grained, but not a requirement. |
||||||
val errorMessage: String? = null, | ||||||
) : ConnectAnalyticsEvent( | ||||||
"client_error", | ||||||
mapOf( | ||||||
"domain" to domain, | ||||||
"code" to code.toString(), | ||||||
"file" to file, | ||||||
"line" to line.toString() | ||||||
"error" to error, | ||||||
"errorMessage" to errorMessage, | ||||||
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. Convention seems to be snake_case
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. ➕ |
||||||
) | ||||||
) | ||||||
} |
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() | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -33,6 +33,7 @@ import com.stripe.android.connect.StripeEmbeddedComponentListener | |
import com.stripe.android.connect.appearance.Appearance | ||
import com.stripe.android.connect.databinding.StripeConnectWebviewBinding | ||
import com.stripe.android.connect.toJsonObject | ||
import com.stripe.android.connect.util.AndroidClock | ||
import com.stripe.android.connect.webview.serialization.AccountSessionClaimedMessage | ||
import com.stripe.android.connect.webview.serialization.ConnectInstanceJs | ||
import com.stripe.android.connect.webview.serialization.ConnectJson | ||
|
@@ -202,6 +203,7 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>( | |
this.controller = StripeConnectWebViewContainerController( | ||
view = this, | ||
analyticsService = analyticsService, | ||
clock = AndroidClock(), | ||
embeddedComponentManager = embeddedComponentManager, | ||
embeddedComponent = embeddedComponent, | ||
listener = listener, | ||
|
@@ -266,7 +268,11 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>( | |
@VisibleForTesting | ||
internal inner class StripeConnectWebViewClient : WebViewClient() { | ||
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { | ||
controller?.onPageStarted() | ||
controller?.onPageStarted(url) | ||
} | ||
|
||
override fun onPageFinished(view: WebView?, url: String?) { | ||
controller?.onPageFinished() | ||
} | ||
|
||
override fun onReceivedHttpError( | ||
|
@@ -371,29 +377,41 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>( | |
|
||
@JavascriptInterface | ||
fun onSetterFunctionCalled(message: String) { | ||
val parsed = ConnectJson.decodeFromString<SetterFunctionCalledMessage>(message) | ||
val parsed = tryDeserializeWebMessage<SetterFunctionCalledMessage>( | ||
webFunctionName = "onSetterFunctionCalled", | ||
message = message, | ||
) ?: return | ||
logger.debug("Setter function called: $parsed") | ||
|
||
controller?.onReceivedSetterFunctionCalled(parsed) | ||
} | ||
|
||
@JavascriptInterface | ||
fun openSecureWebView(message: String) { | ||
val secureWebViewData = ConnectJson.decodeFromString<SecureWebViewMessage>(message) | ||
val secureWebViewData = tryDeserializeWebMessage<SecureWebViewMessage>( | ||
webFunctionName = "openSecureWebView", | ||
message = message, | ||
) | ||
logger.debug("Open secure web view with data: $secureWebViewData") | ||
} | ||
|
||
@JavascriptInterface | ||
fun pageDidLoad(message: String) { | ||
val pageLoadMessage = ConnectJson.decodeFromString<PageLoadMessage>(message) | ||
val pageLoadMessage = tryDeserializeWebMessage<PageLoadMessage>( | ||
webFunctionName = "pageDidLoad", | ||
message = message, | ||
) ?: return | ||
logger.debug("Page did load: $pageLoadMessage") | ||
|
||
controller?.onReceivedPageDidLoad() | ||
controller?.onReceivedPageDidLoad(pageLoadMessage.pageViewId) | ||
} | ||
|
||
@JavascriptInterface | ||
fun accountSessionClaimed(message: String) { | ||
val accountSessionClaimedMessage = ConnectJson.decodeFromString<AccountSessionClaimedMessage>(message) | ||
val accountSessionClaimedMessage = tryDeserializeWebMessage<AccountSessionClaimedMessage>( | ||
webFunctionName = "accountSessionClaimed", | ||
message = message, | ||
) ?: return | ||
logger.debug("Account session claimed: $accountSessionClaimedMessage") | ||
|
||
controller?.onMerchantIdChanged(accountSessionClaimedMessage.merchantId) | ||
|
@@ -407,6 +425,22 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>( | |
} | ||
} | ||
|
||
private inline fun <reified T> tryDeserializeWebMessage( | ||
webFunctionName: String, | ||
message: String, | ||
): T? { | ||
return try { | ||
ConnectJson.decodeFromString<T>(message) | ||
} catch (e: IllegalArgumentException) { | ||
controller?.onErrorDeserializingWebMessage( | ||
webMessage = message, | ||
error = "Unable to deserialize message from $webFunctionName", | ||
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 seems redundant. The event name is already |
||
errorMessage = e.message, | ||
) | ||
null | ||
} | ||
} | ||
|
||
private fun WebView.evaluateSdkJs(function: String, payload: JsonObject) { | ||
val command = "$ANDROID_JS_INTERFACE.$function($payload)" | ||
post { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,10 +18,13 @@ 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.analytics.ConnectAnalyticsEvent | ||
import com.stripe.android.connect.util.Clock | ||
import com.stripe.android.connect.webview.serialization.ConnectInstanceJs | ||
import com.stripe.android.connect.webview.serialization.SetOnLoadError | ||
import com.stripe.android.connect.webview.serialization.SetOnLoaderStart | ||
import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage | ||
import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage.UnknownValue | ||
import com.stripe.android.core.Logger | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.flow.MutableStateFlow | ||
|
@@ -36,6 +39,7 @@ import kotlinx.coroutines.withContext | |
internal class StripeConnectWebViewContainerController<Listener : StripeEmbeddedComponentListener>( | ||
private val view: StripeConnectWebViewContainerInternal, | ||
private val analyticsService: ComponentAnalyticsService, | ||
private val clock: Clock, | ||
private val embeddedComponentManager: EmbeddedComponentManager, | ||
private val embeddedComponent: StripeEmbeddedComponent, | ||
private val listener: Listener?, | ||
|
@@ -44,6 +48,10 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded | |
private val logger: Logger = Logger.getInstance(enableLogging = BuildConfig.DEBUG), | ||
) : DefaultLifecycleObserver { | ||
|
||
init { | ||
analyticsService.track(ConnectAnalyticsEvent.ComponentCreated) | ||
} | ||
|
||
private val loggerTag = javaClass.simpleName | ||
private val _stateFlow = MutableStateFlow(StripeConnectWebViewContainerState()) | ||
|
||
|
@@ -57,14 +65,39 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded | |
* Callback to invoke when the view is attached. | ||
*/ | ||
fun onViewAttached() { | ||
updateState { copy(didBeginLoadingMillis = clock.millis()) } | ||
view.loadUrl(embeddedComponentManager.getStripeURL(embeddedComponent)) | ||
|
||
analyticsService.track(ConnectAnalyticsEvent.ComponentViewed(stateFlow.value.pageViewId)) | ||
simond-stripe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
* Callback to invoke when the page started loading. | ||
*/ | ||
fun onPageStarted() { | ||
fun onPageStarted(url: String?) { | ||
updateState { copy(isNativeLoadingIndicatorVisible = !receivedSetOnLoaderStart) } | ||
|
||
if (url != null) { | ||
val pageLoadUrl = Uri.parse(url) | ||
val expectedUrl = Uri.parse(embeddedComponentManager.getStripeURL(embeddedComponent)) | ||
if ( | ||
pageLoadUrl.scheme != expectedUrl.scheme || | ||
pageLoadUrl.host != expectedUrl.host || | ||
pageLoadUrl.path != expectedUrl.path | ||
) { | ||
// expected URL doesn't match what we navigated to | ||
val sanitizedUrl = pageLoadUrl.buildUpon().clearQuery().fragment(null).build().toString() | ||
analyticsService.track(ConnectAnalyticsEvent.WebErrorUnexpectedNavigation(sanitizedUrl)) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Callback to invoke when the page finished loading. | ||
*/ | ||
fun onPageFinished() { | ||
val timeToLoad = clock.millis() - (stateFlow.value.didBeginLoadingMillis ?: 0) | ||
analyticsService.track(ConnectAnalyticsEvent.WebPageLoaded(timeToLoad)) | ||
} | ||
|
||
/** | ||
|
@@ -92,9 +125,31 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded | |
// don't send errors for requests that aren't for the main page load | ||
if (isMainPageLoad) { | ||
listener?.onLoadError(RuntimeException(errorString)) // TODO - wrap error better | ||
analyticsService.track( | ||
ConnectAnalyticsEvent.WebErrorPageLoad( | ||
status = httpStatusCode, | ||
error = errorMessage, | ||
url = requestUrl | ||
) | ||
) | ||
} | ||
} | ||
|
||
fun onErrorDeserializingWebMessage( | ||
webMessage: String, | ||
error: String, | ||
errorMessage: String?, | ||
) { | ||
analyticsService.track( | ||
ConnectAnalyticsEvent.WebErrorDeserializeMessage( | ||
message = webMessage, | ||
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. Any security/privacy risks of sending this arbitrary data to analytics? We're sanitizing the URL for 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. Yes, we do need to be sensitive of PII – please see my comment in the analytic event scoping doc: When I originally scoped this, I checked that the JS messages we currently send shouldn't contain PII, but I'm considering removing it for future-proofing in case we eventually use a JS message that does contain PII. So maybe we drop it? – I'm open to suggestions |
||
error = error, | ||
errorDescription = errorMessage, | ||
pageViewId = stateFlow.value.pageViewId, | ||
) | ||
) | ||
} | ||
|
||
/** | ||
* Callback whenever the merchant ID changes, such as in the | ||
*/ | ||
|
@@ -110,8 +165,13 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded | |
fun shouldOverrideUrlLoading(context: Context, request: WebResourceRequest): Boolean { | ||
val url = request.url | ||
return if (url.host?.lowercase() in ALLOWLISTED_HOSTS) { | ||
// TODO - add an analytic event here to track this unexpected behavior | ||
logger.warning("($loggerTag) Received pop-up for allow-listed host: $url") | ||
analyticsService.track( | ||
ConnectAnalyticsEvent.ClientError( | ||
error = "Unexpected pop-up", | ||
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. IMO 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. ➕ Related to my comment above, I think giving it a name like |
||
errorMessage = "Received pop-up for allow-listed host: $url" | ||
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. I think we should only log the URL's host in the message, rather than the entire URL. It's possible the URL could contain sensitive data (e.g. if we get a bug in FinancialConnections integration, it could be a bank auth redirect and contain an oauth token as a query param). |
||
) | ||
) | ||
false // Allow the request to propagate so we open URL in WebView, but this is not an expected operation | ||
} else if ( | ||
url.scheme.equals("https", ignoreCase = true) || url.scheme.equals("http", ignoreCase = true) | ||
|
@@ -139,7 +199,7 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded | |
embeddedComponentManager.appearanceFlow | ||
.collectLatest { appearance -> | ||
updateState { copy(appearance = appearance) } | ||
if (stateFlow.value.receivedPageDidLoad) { | ||
if (stateFlow.value.pageViewId != null) { | ||
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. 👍 |
||
view.updateConnectInstance(appearance) | ||
} | ||
} | ||
|
@@ -177,9 +237,14 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded | |
if (permissionsRequested.isEmpty()) { // all calls to PermissionRequest must be on the main thread | ||
withContext(Dispatchers.Main) { | ||
request.deny() // no supported permissions were requested, so reject the request | ||
// TODO - add an analytic event here to track this unexpected behavior | ||
analyticsService.track( | ||
ConnectAnalyticsEvent.ClientError( | ||
error = "Unexpected permissions request", | ||
errorMessage = "Unexpected permissions '${request.resources.joinToString()}' requested" | ||
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. Is it possible that |
||
) | ||
) | ||
logger.warning( | ||
"($loggerTag) Denying permission - ${request.resources.joinToString()} are not supported" | ||
"($loggerTag) Denying permission - '${request.resources.joinToString()}' are not supported" | ||
) | ||
} | ||
return | ||
|
@@ -215,9 +280,20 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded | |
/** | ||
* Callback to invoke upon receiving 'pageDidLoad' message. | ||
*/ | ||
fun onReceivedPageDidLoad() { | ||
fun onReceivedPageDidLoad(pageViewId: String) { | ||
view.updateConnectInstance(embeddedComponentManager.appearanceFlow.value) | ||
updateState { copy(receivedPageDidLoad = true) } | ||
updateState { copy(pageViewId = pageViewId) } | ||
|
||
// right now view onAttach and begin load happen at the same time, | ||
// so timeToLoad and perceivedTimeToLoad are the same value | ||
val timeToLoad = clock.millis() - (stateFlow.value.didBeginLoadingMillis ?: 0) | ||
analyticsService.track( | ||
ConnectAnalyticsEvent.WebComponentLoaded( | ||
pageViewId = pageViewId, | ||
timeToLoad = timeToLoad, | ||
perceivedTimeToLoad = timeToLoad, | ||
) | ||
) | ||
} | ||
|
||
/** | ||
|
@@ -239,6 +315,14 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded | |
listener?.onLoadError(RuntimeException("${value.error.type}: ${value.error.message}")) | ||
} | ||
else -> { | ||
if (value is UnknownValue) { | ||
analyticsService.track( | ||
ConnectAnalyticsEvent.WebWarnUnrecognizedSetter( | ||
setter = message.setter, | ||
pageViewId = stateFlow.value.pageViewId | ||
) | ||
) | ||
} | ||
with(listenerDelegate) { | ||
listener?.delegate(message) | ||
} | ||
|
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.
Document the unit (ms, seconds)?
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.
On iOS, this is a
Double
and the unit is in seconds. I think we want it to be consistent across platforms:https://docs.google.com/document/d/1nzHGofoclzij4nP5n5qgSpYLia-93heStXvt7vOfrkQ/edit?tab=t.0#bookmark=id.1au4f34vo3wd
Was this changed to
Long
to be compatible with the AndroidClock
API? Maybe we could store it milliseconds usingLong
and then convert it insidemapOf
to keep it easy to construct?