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

Merged
merged 13 commits into from
Feb 4, 2025
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
lng-stripe marked this conversation as resolved.
Show resolved Hide resolved
) : 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
lng-stripe marked this conversation as resolved.
Show resolved Hide resolved
) : 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,
lng-stripe marked this conversation as resolved.
Show resolved Hide resolved
)
)
}
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",
lng-stripe marked this conversation as resolved.
Show resolved Hide resolved
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,
lng-stripe marked this conversation as resolved.
Show resolved Hide resolved
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",
lng-stripe marked this conversation as resolved.
Show resolved Hide resolved
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) {
lng-stripe marked this conversation as resolved.
Show resolved Hide resolved
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"
lng-stripe marked this conversation as resolved.
Show resolved Hide resolved
)
)
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