Skip to content

Commit

Permalink
Adds attestation analytics events. (#9831)
Browse files Browse the repository at this point in the history
  • Loading branch information
carlosmuvi-stripe authored Jan 22, 2025
1 parent 61c18c7 commit 2b6caa8
Show file tree
Hide file tree
Showing 12 changed files with 164 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import com.stripe.android.financialconnections.FinancialConnectionsSheetViewEffe
import com.stripe.android.financialconnections.FinancialConnectionsSheetViewEffect.OpenAuthFlowWithUrl
import com.stripe.android.financialconnections.FinancialConnectionsSheetViewEffect.OpenNativeAuthFlow
import com.stripe.android.financialconnections.FinancialConnectionsSheetViewModel.Companion.QUERY_PARAM_PAYMENT_METHOD
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AttestationInitFailed
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AttestationInitSkipped
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker
import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.ErrorCode
import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.Metadata
Expand Down Expand Up @@ -119,11 +120,22 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor(
private fun initAuthFlow() {
viewModelScope.launch {
kotlin.runCatching {
val attestationInitialized = prepareStandardRequestManager()
getOrFetchSync(
val attestationInitResult = prepareStandardRequestManager()
val syncResponse = getOrFetchSync(
refetchCondition = Always,
attestationInitialized = attestationInitialized
attestationInitialized = attestationInitResult.initialized
)
val pane = syncResponse.manifest.nextPane
when (attestationInitResult) {
// We'll just emit failure events to reduce event emissions
AttestationInitResult.Success -> null
AttestationInitResult.Skipped -> AttestationInitSkipped(pane)
is AttestationInitResult.Failure -> AttestationInitFailed(
pane = pane,
error = attestationInitResult.error
)
}?.let(analyticsTracker::track)
syncResponse
}.onFailure {
finishWithResult(stateFlow.value, Failed(it))
}.onSuccess {
Expand All @@ -132,23 +144,16 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor(
}
}

private suspend fun prepareStandardRequestManager(): Boolean {
private suspend fun prepareStandardRequestManager(): AttestationInitResult {
// If previously within the application session an integrity check failed
// do not initialize the request manager and directly launch the web flow.
if (integrityVerdictManager.verdictFailed()) {
return false
}
val result = integrityRequestManager.prepare()
result.onFailure {
analyticsTracker.track(
FinancialConnectionsAnalyticsEvent.Error(
extraMessage = "Failed to warm up the IntegrityStandardRequestManager",
pane = Pane.CONSENT,
exception = it
)
)
return AttestationInitResult.Skipped
}
return result.isSuccess
return integrityRequestManager.prepare().fold(
onSuccess = { AttestationInitResult.Success },
onFailure = { AttestationInitResult.Failure(it) }
)
}

/**
Expand Down Expand Up @@ -582,6 +587,12 @@ internal class FinancialConnectionsSheetViewModel @Inject constructor(
}
}

private sealed class AttestationInitResult(val initialized: Boolean) {
data object Success : AttestationInitResult(initialized = true)
data object Skipped : AttestationInitResult(initialized = false)
data class Failure(val error: Throwable) : AttestationInitResult(initialized = false)
}

companion object {

val Factory = viewModelFactory {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.stripe.android.financialconnections.exception.FinancialConnectionsErr
import com.stripe.android.financialconnections.exception.WebAuthFlowFailedException
import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane
import com.stripe.android.financialconnections.utils.filterNotNullValues
import com.stripe.attestation.AttestationError

/**
* Event definitions for Financial Connections.
Expand Down Expand Up @@ -410,6 +411,48 @@ internal sealed class FinancialConnectionsAnalyticsEvent(
).filterNotNullValues()
)

class AttestationInitSkipped(pane: Pane) : FinancialConnectionsAnalyticsEvent(
name = "attestation.init_skipped",
mapOf(
"pane" to pane.analyticsValue,
).filterNotNullValues()
)

class AttestationInitFailed(
pane: Pane,
error: Throwable
) : FinancialConnectionsAnalyticsEvent(
name = "attestation.init_failed",
mapOf(
"pane" to pane.analyticsValue,
"error_reason" to if (error is AttestationError) error.errorType.name else "unknown"
).filterNotNullValues()
)

class AttestationRequestSucceeded(
pane: Pane,
endpoint: AttestationEndpoint
) : FinancialConnectionsAnalyticsEvent(
name = "attestation.request_token_succeeded",
mapOf(
"pane" to pane.analyticsValue,
"api" to endpoint.analyticsValue
).filterNotNullValues()
)

class AttestationRequestFailed(
pane: Pane,
endpoint: AttestationEndpoint,
error: Throwable,
) : FinancialConnectionsAnalyticsEvent(
name = "attestation.request_token_failed",
mapOf(
"pane" to pane.analyticsValue,
"api" to endpoint.analyticsValue,
"error_reason" to if (error is AttestationError) error.errorType.name else "unknown"
)
)

internal val Pane.analyticsValue
get() = when (this) {
// We want to log partner_auth regardless of the pane being shown full-screen or as a drawer.
Expand Down Expand Up @@ -443,6 +486,11 @@ internal sealed class FinancialConnectionsAnalyticsEvent(
result = 31 * result + eventName.hashCode()
return result
}

enum class AttestationEndpoint(val analyticsValue: String) {
LOOKUP("consumer_session_lookup"),
SIGNUP("link_sign_up"),
}
}

private const val EVENT_PREFIX = "linked_accounts"
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ internal class FinancialConnectionsAnalyticsTrackerImpl(
"is_stripe_direct" to manifest.isStripeDirect.toString(),
"single_account" to manifest.singleAccount.toString(),
"allow_manual_entry" to manifest.allowManualEntry.toString(),
"app_verification_enabled" to manifest.appVerificationEnabled.toString(),
"account_holder_id" to manifest.accountholderToken,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ package com.stripe.android.financialconnections.domain

import android.app.Application
import com.stripe.android.financialconnections.FinancialConnectionsSheet
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AttestationEndpoint
import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane
import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository
import com.stripe.android.model.ConsumerSessionLookup
import com.stripe.android.model.EmailSource
import com.stripe.attestation.IntegrityRequestManager
import javax.inject.Inject

internal class LookupAccount @Inject constructor(
private val application: Application,
private val integrityRequestManager: IntegrityRequestManager,
private val requestIntegrityToken: RequestIntegrityToken,
private val consumerSessionRepository: FinancialConnectionsConsumerSessionRepository,
val configuration: FinancialConnectionsSheet.Configuration,
) {
Expand All @@ -19,14 +20,16 @@ internal class LookupAccount @Inject constructor(
email: String,
emailSource: EmailSource,
verifiedFlow: Boolean,
sessionId: String
sessionId: String,
pane: Pane
): ConsumerSessionLookup {
return if (verifiedFlow) {
val token = requestIntegrityToken(pane = pane, endpoint = AttestationEndpoint.LOOKUP)
requireNotNull(
consumerSessionRepository.mobileLookupConsumerSession(
email = email.lowercase().trim(),
emailSource = emailSource,
verificationToken = integrityRequestManager.requestToken().getOrThrow(),
verificationToken = token,
appId = application.packageName,
sessionId = sessionId
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.stripe.android.financialconnections.domain

import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AttestationEndpoint
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AttestationRequestFailed
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AttestationRequestSucceeded
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker
import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest
import com.stripe.attestation.IntegrityRequestManager
import jakarta.inject.Inject

internal class RequestIntegrityToken @Inject constructor(
private val integrityRequestManager: IntegrityRequestManager,
private val analyticsTracker: FinancialConnectionsAnalyticsTracker
) {
suspend operator fun invoke(
endpoint: AttestationEndpoint,
pane: FinancialConnectionsSessionManifest.Pane
): String = integrityRequestManager.requestToken()
.onSuccess {
analyticsTracker.track(
AttestationRequestSucceeded(
pane = pane,
endpoint = endpoint
)
)
}
.onFailure {
analyticsTracker.track(
AttestationRequestFailed(
pane = pane,
endpoint = endpoint,
error = it
)
)
}
.getOrThrow()
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ internal class NetworkingLinkLoginWarmupViewModel @AssistedInject constructor(
eventTracker.track(Click("click.continue", PANE))
// Trigger a lookup call to ensure we cache a consumer session for posterior verification.
lookupAccount(
pane = PANE,
email = payload.email,
emailSource = EmailSource.CUSTOMER_OBJECT,
sessionId = payload.sessionId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.stripe.android.financialconnections.features.networkinglinksignup

import com.stripe.android.core.Logger
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AttestationEndpoint.SIGNUP
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click
import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker
import com.stripe.android.financialconnections.analytics.logError
Expand All @@ -10,6 +11,7 @@ import com.stripe.android.financialconnections.domain.GetCachedAccounts
import com.stripe.android.financialconnections.domain.GetOrFetchSync
import com.stripe.android.financialconnections.domain.GetOrFetchSync.RefetchCondition.Always
import com.stripe.android.financialconnections.domain.HandleError
import com.stripe.android.financialconnections.domain.RequestIntegrityToken
import com.stripe.android.financialconnections.domain.SaveAccountToLink
import com.stripe.android.financialconnections.features.common.isDataFlow
import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane
Expand All @@ -20,7 +22,6 @@ import com.stripe.android.financialconnections.navigation.Destination.Networking
import com.stripe.android.financialconnections.navigation.Destination.Success
import com.stripe.android.financialconnections.navigation.NavigationManager
import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository
import com.stripe.attestation.IntegrityRequestManager
import javax.inject.Inject
import javax.inject.Named

Expand All @@ -40,7 +41,7 @@ internal interface LinkSignupHandler {
internal class LinkSignupHandlerForInstantDebits @Inject constructor(
private val consumerRepository: FinancialConnectionsConsumerSessionRepository,
private val attachConsumerToLinkAccountSession: AttachConsumerToLinkAccountSession,
private val integrityRequestManager: IntegrityRequestManager,
private val requestIntegrityToken: RequestIntegrityToken,
private val getOrFetchSync: GetOrFetchSync,
private val navigationManager: NavigationManager,
@Named(APPLICATION_ID) private val applicationId: String,
Expand All @@ -54,7 +55,10 @@ internal class LinkSignupHandlerForInstantDebits @Inject constructor(

val manifest = getOrFetchSync().manifest
val signup = if (manifest.appVerificationEnabled) {
val token = integrityRequestManager.requestToken().getOrThrow()
val token = requestIntegrityToken(
endpoint = SIGNUP,
pane = LINK_LOGIN
)
consumerRepository.mobileSignUp(
email = state.validEmail!!,
phoneNumber = state.validPhone!!,
Expand Down Expand Up @@ -96,7 +100,7 @@ internal class LinkSignupHandlerForNetworking @Inject constructor(
private val consumerRepository: FinancialConnectionsConsumerSessionRepository,
private val getOrFetchSync: GetOrFetchSync,
private val getCachedAccounts: GetCachedAccounts,
private val integrityRequestManager: IntegrityRequestManager,
private val requestIntegrityToken: RequestIntegrityToken,
private val saveAccountToLink: SaveAccountToLink,
private val eventTracker: FinancialConnectionsAnalyticsTracker,
private val navigationManager: NavigationManager,
Expand All @@ -117,7 +121,10 @@ internal class LinkSignupHandlerForNetworking @Inject constructor(
// ** New signup flow on verified flows: 2 requests **
// 1. Mobile signup endpoint providing email + phone number
// 2. Separately call SaveAccountToLink with the newly created account.
val token = integrityRequestManager.requestToken().getOrThrow()
val token = requestIntegrityToken(
endpoint = SIGNUP,
pane = NETWORKING_LINK_SIGNUP_PANE
)
val signup = consumerRepository.mobileSignUp(
email = state.validEmail!!,
phoneNumber = state.validPhone!!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ internal class NetworkingLinkSignupViewModel @AssistedInject constructor(
delay(getLookupDelayMs(validEmail))
val payload = stateFlow.value.payload()
lookupAccount(
pane = pane,
email = validEmail,
emailSource = if (payload?.prefilledEmail == validEmail) CUSTOMER_OBJECT else USER_ACTION,
sessionId = payload?.sessionId ?: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ class NetworkingLinkLoginWarmupViewModelTest {
email = anyOrNull(),
emailSource = anyOrNull(),
verifiedFlow = anyOrNull(),
sessionId = anyOrNull()
sessionId = anyOrNull(),
pane = any()
)
).thenReturn(
ConsumerSessionLookup(
Expand All @@ -95,7 +96,7 @@ class NetworkingLinkLoginWarmupViewModelTest {
val viewModel = buildViewModel(NetworkingLinkLoginWarmupState())
viewModel.onContinueClick()

verify(lookupAccount).invoke(any(), any(), any(), any())
verify(lookupAccount).invoke(any(), any(), any(), any(), any())
navigationManager.assertNavigatedTo(
destination = Destination.NetworkingLinkVerification,
pane = Pane.NETWORKING_LINK_LOGIN_WARMUP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import com.stripe.android.financialconnections.ApiKeyFixtures.syncResponse
import com.stripe.android.financialconnections.domain.AttachConsumerToLinkAccountSession
import com.stripe.android.financialconnections.domain.GetOrFetchSync
import com.stripe.android.financialconnections.domain.HandleError
import com.stripe.android.financialconnections.domain.RequestIntegrityToken
import com.stripe.android.financialconnections.features.networkinglinksignup.NetworkingLinkSignupState.Payload
import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane
import com.stripe.android.financialconnections.navigation.NavigationManager
import com.stripe.android.financialconnections.presentation.Async
import com.stripe.android.financialconnections.repository.FinancialConnectionsConsumerSessionRepository
import com.stripe.android.financialconnections.utils.TestIntegrityRequestManager
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.mockito.kotlin.any
Expand All @@ -29,7 +29,7 @@ class LinkSignupHandlerForInstantDebitsTest {
private val consumerRepository = mock<FinancialConnectionsConsumerSessionRepository>()
private val getOrFetchSync = mock<GetOrFetchSync>()
private val attachConsumerToLinkAccountSession = mock<AttachConsumerToLinkAccountSession>()
private val integrityRequestManager = TestIntegrityRequestManager()
private val requestIntegrityToken = mock<RequestIntegrityToken>()
private val navigationManager = mock<NavigationManager>()
private val handleError = mock<HandleError>()

Expand All @@ -38,7 +38,7 @@ class LinkSignupHandlerForInstantDebitsTest {
handler = LinkSignupHandlerForInstantDebits(
consumerRepository,
attachConsumerToLinkAccountSession,
integrityRequestManager,
requestIntegrityToken,
getOrFetchSync,
navigationManager,
"applicationId",
Expand All @@ -61,14 +61,16 @@ class LinkSignupHandlerForInstantDebitsTest {

@Test
fun `performSignup should navigate to next pane on success`() = runTest {
val expectedToken = "token"
val expectedPane = Pane.INSTITUTION_PICKER
val testState = NetworkingLinkSignupState(
validEmail = "[email protected]",
validPhone = "+123456789",
isInstantDebits = true,
payload = Async.Success(validPayload)
)

val expectedPane = Pane.INSTITUTION_PICKER
whenever(requestIntegrityToken(anyOrNull(), anyOrNull())).thenReturn(expectedToken)
whenever(getOrFetchSync(anyOrNull(), anyOrNull())).thenReturn(
syncResponse(
sessionManifest().copy(
Expand Down
Loading

0 comments on commit 2b6caa8

Please sign in to comment.