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 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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
Copy link
Contributor

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)?

Copy link

@mludowise-stripe mludowise-stripe Jan 15, 2025

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 Android Clock API? Maybe we could store it milliseconds using Long and then convert it inside mapOf to keep it easy to construct?

) : ConnectAnalyticsEvent(
"component.web.page_loaded",
mapOf("time_to_load" to timeToLoad.toString())
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here. Even better, could you add @param docs for all the events? It's not obvious what many of the params mean, nor their shape.

Choose a reason for hiding this comment

The 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(
Expand Down Expand Up @@ -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
Expand All @@ -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,
Copy link
Contributor 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.

Copy link

@mludowise-stripe mludowise-stripe Jan 15, 2025

Choose a reason for hiding this comment

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

  1. We need to ensure that we never log any PII or sensitive data. On iOS, we explicitly don't log the error's message since we can't control its contents (ex: json deserialization errors can contain the raw json which could have user-entered data like SSN or name). If we have full control over the error message on Android, then we can log it, but maybe we can add a comment that explicitly instructs adopters to ensure no PII or sensitive data is included.

    Similarly, since error can be an arbitrary string, maybe we should add a similar comment or change this to error_name or error_code.

  2. Is error meant to be enough information to uniquely identify the callsite the error is coming from?

    On iOS, we don't control the domain+code (it could be an iOS system error), so we use the file+line number to uniquely identify the call-site. On Android, if error is meant to be unique and something we can directly control, then should we make this an enum (e.g. AnalyticClientErrorCode or something)? Okay to leave it as a string, but maybe we add a comment stating that it should be uniquely identifiable so it can be used to trace the call site in code.

Copy link

@mludowise-stripe mludowise-stripe Jan 15, 2025

Choose a reason for hiding this comment

The 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,
Copy link
Contributor

Choose a reason for hiding this comment

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

Convention seems to be snake_case

Suggested change
"errorMessage" to errorMessage,
"error_message" to errorMessage,

Choose a reason for hiding this comment

The 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
Expand Up @@ -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
Expand Down Expand Up @@ -202,6 +203,7 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>(
this.controller = StripeConnectWebViewContainerController(
view = this,
analyticsService = analyticsService,
clock = AndroidClock(),
embeddedComponentManager = embeddedComponentManager,
embeddedComponent = embeddedComponent,
listener = listener,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems redundant. The event name is already component.web.error.deserialize_message. We should replace error with function and value $webFunctionName, similar to event component.web.warn.unrecognized_setter_function.

errorMessage = e.message,
)
null
}
}

private fun WebView.evaluateSdkJs(function: String, payload: JsonObject) {
val command = "$ANDROID_JS_INTERFACE.$function($payload)"
post {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?,
Expand All @@ -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())

Expand All @@ -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))
}

/**
Expand Down Expand Up @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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 component.web.error.unexpected_navigation -- why isn't that necessary here?

Copy link

@mludowise-stripe mludowise-stripe Jan 15, 2025

Choose a reason for hiding this comment

The 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:
https://docs.google.com/document/d/1nzHGofoclzij4nP5n5qgSpYLia-93heStXvt7vOfrkQ/edit?tab=t.0#bookmark=id.t7fuyluvgru2

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
*/
Expand All @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO error_code would be better than a verbose error.

Choose a reason for hiding this comment

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

➕ Related to my comment above, I think giving it a name like error_code means it's less likely we'd inadvertently include an error message that contained PII.

errorMessage = "Received pop-up for allow-listed host: $url"
Copy link

@mludowise-stripe mludowise-stripe Jan 15, 2025

Choose a reason for hiding this comment

The 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)
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

view.updateConnectInstance(appearance)
}
}
Expand Down Expand Up @@ -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"

Choose a reason for hiding this comment

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

Is it possible that request.resources may contain PII? If so, we shouldn't include it in the analytic.

)
)
logger.warning(
"($loggerTag) Denying permission - ${request.resources.joinToString()} are not supported"
"($loggerTag) Denying permission - '${request.resources.joinToString()}' are not supported"
)
}
return
Expand Down Expand Up @@ -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,
)
)
}

/**
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ import com.stripe.android.connect.util.getContrastingColor
@OptIn(PrivateBetaConnectSDK::class)
internal data class StripeConnectWebViewContainerState(
/**
* True if we received the 'pageDidLoad' message.
* Non-null if we received the 'pageDidLoad' message,
* null otherwise.
*/
val receivedPageDidLoad: Boolean = false,
val pageViewId: String? = null,

/**
* The time the webview began loading, in milliseconds from midnight, January 1, 1970 UTC.
*/
val didBeginLoadingMillis: Long? = null,

/**
* True if we received the 'setOnLoaderStart' message.
Expand Down
Loading
Loading