Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-20 14:15:57 +02:00
parent 9e948f7703
commit 147625bda1
40 changed files with 254 additions and 131 deletions

View file

@ -1,20 +1,20 @@
package com.tangem.tap.common.analytics
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.core.analytics.api.ErrorEventLogger
import com.tangem.core.analytics.api.ExceptionLogger
import com.tangem.core.analytics.api.EventLogger
import timber.log.Timber
class AnalyticsEventsLogger(
private val name: String,
private val jsonConverter: MoshiJsonConverter,
) : EventLogger, ErrorEventLogger {
) : EventLogger, ExceptionLogger {
override fun logEvent(event: String, params: Map<String, String>) {
Timber.d(jsonConverter.prettyPrint(PrintEventModel(name, event, params)))
}
override fun logErrorEvent(error: Throwable, params: Map<String, String>) {
override fun logException(error: Throwable, params: Map<String, String>) {
Timber.e(error, jsonConverter.prettyPrint(PrintEventModel(name, "error", params)))
}
}

View file

@ -6,7 +6,6 @@ internal sealed class Push(event: String) : AnalyticsEvent(
category = "Push",
event = event,
params = emptyMap(),
error = null,
) {
data object PushNotificationOpened : Push(event = "Push Notification Opened")

View file

@ -9,8 +9,7 @@ sealed class Settings(
category: String = "Settings",
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent(category, event, params, error) {
) : AnalyticsEvent(category, event, params) {
class ScreenOpened : Settings(event = "Settings Screen Opened")
class ButtonStartWalletConnectSession : Settings(event = "Button - Start Wallet Connect Session")
@ -19,16 +18,14 @@ sealed class Settings(
sealed class CardSettings(
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : Settings("Settings / Card Settings", event, params, error) {
) : Settings("Settings / Card Settings", event, params) {
class ButtonFactoryReset : CardSettings("Button - Factory Reset")
class FactoryResetFinished(cardsCount: Int? = null, error: Throwable? = null) : CardSettings(
class FactoryResetFinished(cardsCount: Int? = null) : CardSettings(
event = "Factory Reset Finished",
params = buildMap {
cardsCount?.let { put("Cards Count", "$it") }
},
error = error,
)
class FactoryResetCanceled(cardsCount: Int) : CardSettings(
@ -44,10 +41,9 @@ sealed class Settings(
params = mapOf("Type" to type.value),
)
class SecurityModeChanged(mode: AnalyticsParam.SecurityMode, error: Throwable? = null) : CardSettings(
class SecurityModeChanged(mode: AnalyticsParam.SecurityMode) : CardSettings(
event = "Security Mode Changed",
params = mapOf("Mode" to mode.value),
error = error,
)
class AccessCodeRecoveryChanged(status: AnalyticsParam.AccessCodeRecoveryStatus) : CardSettings(

View file

@ -8,8 +8,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent
sealed class SignIn(
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent("Sign In", event, params, error) {
) : AnalyticsEvent("Sign In", event, params) {
class ScreenOpened : SignIn(event = "Sign In Screen Opened")

View file

@ -10,8 +10,7 @@ sealed class Token(
category: String,
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent(category, event, params, error) {
) : AnalyticsEvent(category, event, params) {
sealed class Receive(
event: String,

View file

@ -9,8 +9,7 @@ import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.O
internal sealed class WalletConnect(
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent("Wallet Connect", event, params, error) {
) : AnalyticsEvent("Wallet Connect", event, params) {
class ScreenOpened : WalletConnect(event = "WC Screen Opened")
class NewSessionInitiated(source: SourceType) : WalletConnect(

View file

@ -1,11 +1,13 @@
package com.tangem.tap.common.analytics.handlers.amplitude
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
class AmplitudeAnalyticsHandler(
private val client: AmplitudeAnalyticsClient,
) : AnalyticsHandler {
) : AnalyticsHandler, AnalyticsErrorHandler {
override fun id(): String = ID
@ -13,6 +15,10 @@ class AmplitudeAnalyticsHandler(
client.logEvent(eventId, params)
}
override fun sendErrorEvent(event: AnalyticsEvent) {
send(event.id, event.params)
}
companion object {
const val ID = "Amplitude"
}

View file

@ -1,16 +1,18 @@
package com.tangem.tap.common.analytics.handlers.firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.core.analytics.api.ErrorEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.converters.AnalyticsErrorConverter
import com.tangem.tap.common.analytics.events.Shop
class FirebaseAnalyticsHandler(
private val client: FirebaseAnalyticsClient,
) : AnalyticsHandler, ErrorEventHandler {
) : AnalyticsHandler, AnalyticsErrorHandler, AnalyticsExceptionHandler {
private val errorConverter = AnalyticsErrorConverter()
override fun id(): String = ID
@ -18,30 +20,17 @@ class FirebaseAnalyticsHandler(
client.logEvent(eventId, params)
}
override fun send(event: AnalyticsEvent) {
val error = event.error
when {
error != null -> {
val errorConverter = AnalyticsErrorConverter()
if (!errorConverter.canBeHandled(error)) return
override fun sendException(event: ExceptionAnalyticsEvent) {
if (!errorConverter.canBeHandled(event.exception)) return
val errorParams = errorConverter.convert(error).toMutableMap()
errorParams["Category"] = event.category
errorParams["Event"] = event.event
errorParams.putAll(event.params)
send(error, errorParams)
}
event is Shop.Purchased -> {
send(FirebaseAnalytics.Event.PURCHASE, event.params)
}
else -> {
super.send(event)
}
}
val errorParams = errorConverter.convert(event.exception).toMutableMap()
errorParams.putAll(event.params)
client.logException(event.exception, event.params)
}
override fun send(error: Throwable, params: Map<String, String>) {
client.logErrorEvent(error, params)
override fun sendErrorEvent(event: AnalyticsEvent) {
send(event)
}
companion object {

View file

@ -4,14 +4,15 @@ import android.os.Bundle
import androidx.core.os.bundleOf
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.crashlytics.recordException
import com.google.firebase.ktx.Firebase
import com.tangem.core.analytics.api.ErrorEventLogger
import com.tangem.core.analytics.api.ExceptionLogger
import com.tangem.core.analytics.api.EventLogger
/**
[REDACTED_AUTHOR]
*/
interface FirebaseAnalyticsClient : EventLogger, ErrorEventLogger
interface FirebaseAnalyticsClient : EventLogger, ExceptionLogger
internal class FirebaseClient : FirebaseAnalyticsClient {
@ -27,11 +28,12 @@ internal class FirebaseClient : FirebaseAnalyticsClient {
)
}
override fun logErrorEvent(error: Throwable, params: Map<String, String>) {
eventConverter.convertEventParams(params)
.forEach { fbCrashlytics.setCustomKey(it.key, it.value) }
fbCrashlytics.recordException(error)
// TODO [REDACTED_TASK_KEY] fix recordException usages in the app
override fun logException(error: Throwable, params: Map<String, String>) {
fbCrashlytics.recordException(error) {
eventConverter.convertEventParams(params)
.forEach { key(it.key, it.value) }
}
}
private fun Map<String, String>.toBundle(): Bundle = bundleOf(*this.toList().toTypedArray())

View file

@ -16,7 +16,7 @@ internal class FirebaseLogClient(
logger.logEvent(event, params)
}
override fun logErrorEvent(error: Throwable, params: Map<String, String>) {
logger.logErrorEvent(error, params)
override fun logException(error: Throwable, params: Map<String, String>) {
logger.logException(error, params)
}
}

View file

@ -10,7 +10,8 @@ import com.tangem.common.authentication.AuthenticationManager
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.core.Config
import com.tangem.common.services.secure.SecureStorage
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.crypto.bip39.Wordlist
import com.tangem.data.card.sdk.CardSdkOwner
import com.tangem.data.card.sdk.CardSdkProvider
@ -33,7 +34,7 @@ import javax.inject.Singleton
*/
@Singleton
internal class DefaultCardSdkProvider @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val dispatchers: CoroutineDispatcherProvider,
private val cardSdkFeatureToggles: CardSdkFeatureToggles,
) : CardSdkProvider, CardSdkOwner {
@ -48,7 +49,12 @@ internal class DefaultCardSdkProvider @Inject constructor(
override fun register(activity: FragmentActivity) = runBlocking(dispatchers.mainImmediate) {
if (activity.isDestroyed || activity.isFinishing || activity.isChangingConfigurations) {
val message = "Tangem SDK owner registration skipped: activity is destroyed or finishing"
analyticsEventHandler.send(TangemSdkWarningEvent(message))
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = IllegalStateException(message),
params = errorParams,
),
)
Log.info { message }
return@runBlocking
}
@ -66,14 +72,24 @@ internal class DefaultCardSdkProvider @Inject constructor(
private fun tryToRegisterWithForegroundActivity(): TangemSdk = runBlocking(dispatchers.mainImmediate) {
val warning = "Tangem SDK holder is null, trying to recreate it with foreground activity"
analyticsEventHandler.send(TangemSdkWarningEvent(warning))
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = IllegalStateException(warning),
params = errorParams,
),
)
Log.warning { warning }
val activity = foregroundActivityObserver.foregroundActivity
if (activity == null) {
val error = "Tangem SDK holder is null and foreground activity is null"
analyticsEventHandler.send(TangemSdkWarningEvent(error))
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = IllegalStateException(error),
params = errorParams,
),
)
Log.error { error }
error(error)
}
@ -84,7 +100,12 @@ internal class DefaultCardSdkProvider @Inject constructor(
if (sdk == null) {
val error = "Tangem SDK is null after re-registering with foreground activity"
analyticsEventHandler.send(TangemSdkWarningEvent(error))
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = IllegalStateException(error),
params = errorParams,
),
)
Log.error { error }
error(error)
}
@ -174,5 +195,10 @@ internal class DefaultCardSdkProvider @Inject constructor(
),
),
)
val errorParams = mapOf(
"Category" to "Tangem SDK",
"Event" to "Warning",
)
}
}

View file

@ -1,9 +0,0 @@
package com.tangem.tap.data
import com.tangem.core.analytics.models.AnalyticsEvent
internal class TangemSdkWarningEvent(message: String) : AnalyticsEvent(
category = "Tangem SDK",
event = "Warning",
error = IllegalStateException(message),
)

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.routing
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.tap.routing.ProxyAppRouter
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.tap.routing.configurator.MutableAppRouterConfig
@ -17,8 +18,15 @@ internal object AppRouterModule {
@Provides
@Singleton
fun provideAppRouter(config: AppRouterConfig, dispatchers: CoroutineDispatcherProvider): AppRouter =
ProxyAppRouter(config, dispatchers)
fun provideAppRouter(
config: AppRouterConfig,
dispatchers: CoroutineDispatcherProvider,
analyticsExceptionHandler: AnalyticsExceptionHandler,
): AppRouter = ProxyAppRouter(
config = config,
dispatchers = dispatchers,
analyticsExceptionHandler = analyticsExceptionHandler,
)
@Provides
@Singleton

View file

@ -7,6 +7,7 @@ import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.domain.card.ScanCardException
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.StateDialog
@ -34,7 +35,12 @@ internal object UseCaseScanProcessor {
ifLeft = {
val error = scanCardExceptionConverter.convertBack(it)
Analytics.send(Basic.ScanError(error))
Analytics.sendException(
ExceptionAnalyticsEvent(
exception = error,
params = mapOf("Event" to "Scan"),
),
)
CompletionResult.Failure(error)
},
ifRight = { CompletionResult.Success(it) },
@ -97,7 +103,12 @@ internal object UseCaseScanProcessor {
-> {
val error = scanCardExceptionConverter.convertBack(exception)
Analytics.send(Basic.ScanError(error))
Analytics.sendException(
ExceptionAnalyticsEvent(
exception = error,
params = mapOf("Event" to "Scan"),
),
)
onFailure(error)
}
}

View file

@ -14,7 +14,7 @@ import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.services.secure.SecureStorage
import com.tangem.common.usersCode.UserCodeRepository
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.res.getStringSafe
import com.tangem.crypto.bip39.DefaultMnemonic
import com.tangem.crypto.hdWallet.DerivationPath
@ -224,7 +224,12 @@ internal class DefaultTangemSdkManager(
private fun sendScanResultsToAnalytics(result: CompletionResult<ScanResponse>) {
if (result is CompletionResult.Failure) {
(result.error as? TangemSdkError)?.let { error ->
Analytics.send(Basic.ScanError(error))
Analytics.sendException(
ExceptionAnalyticsEvent(
exception = error,
params = mapOf("Event" to "Scan"),
),
)
}
}
}

View file

@ -4,7 +4,9 @@ import androidx.compose.runtime.Stable
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.domain.common.util.cardTypesResolver
@ -30,6 +32,8 @@ internal class SecurityModeModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val tangemSdkManager: TangemSdkManager,
private val cardSettingsInteractor: CardSettingsInteractor,
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : Model() {
private val scannedScanResponse = cardSettingsInteractor.scannedScanResponse.value
@ -85,14 +89,19 @@ internal class SecurityModeModel @Inject constructor(
val paramValue = AnalyticsParam.SecurityMode.from(selectedOption)
when (result) {
is CompletionResult.Success -> {
Analytics.send(Settings.CardSettings.SecurityModeChanged(paramValue))
analyticsEventHandler.send(Settings.CardSettings.SecurityModeChanged(paramValue))
store.dispatchNavigationAction(AppRouter::pop)
}
is CompletionResult.Failure -> {
val error = result.error
if (error is TangemSdkError && error !is TangemSdkError.UserCancelled) {
Analytics.send(Settings.CardSettings.SecurityModeChanged(paramValue, error))
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = error,
params = mapOf("Event" to "Security Mode Changed"),
),
)
}
}
else -> Unit

View file

@ -1,7 +1,6 @@
package com.tangem.tap.features.onboarding.products.wallet.redux
import android.net.Uri
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.core.TangemSdkError
@ -12,6 +11,7 @@ import com.tangem.common.routing.AppRouter
import com.tangem.common.services.Result
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.toWrappedList
@ -454,8 +454,6 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
store.dispatchOnMain(BackupAction.AddBackupCard.Success(result.data))
}
is CompletionResult.Failure -> {
val crashlytics = FirebaseCrashlytics.getInstance()
when (val error = result.error) {
is TangemSdkError.CardVerificationFailed -> {
Analytics.send(
@ -492,7 +490,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
GlobalAction.ShowDialog(BackupDialog.AttestationFailed),
)
}
else -> crashlytics.recordException(error)
else -> Analytics.sendException(ExceptionAnalyticsEvent(error))
}
}
}

View file

@ -1,8 +1,9 @@
package com.tangem.tap.routing
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -15,6 +16,7 @@ import kotlin.reflect.KClass
internal class ProxyAppRouter(
private val config: AppRouterConfig,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : AppRouter {
private val routerScope: CoroutineScope
@ -76,7 +78,7 @@ internal class ProxyAppRouter(
override fun defaultCompletionHandler(isSuccess: Boolean, errorMessage: String) {
if (!isSuccess) {
FirebaseCrashlytics.getInstance().recordException(RuntimeException(errorMessage))
analyticsExceptionHandler.sendException(ExceptionAnalyticsEvent(RuntimeException(errorMessage)))
Timber.w(errorMessage)
with(receiver = config.snackbarHandler ?: return) {

View file

@ -7,7 +7,6 @@ open class AnalyticsEvent(
val category: String,
val event: String,
var params: Map<String, String> = mapOf(),
val error: Throwable? = null,
) {
val id: String = "[$category] $event"

View file

@ -3,8 +3,7 @@ package com.tangem.core.analytics.models
sealed class Basic(
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent("Basic", event, params, error) {
) : AnalyticsEvent("Basic", event, params) {
class CardWasScanned(
source: AnalyticsParam.ScreensSources,
@ -73,11 +72,6 @@ sealed class Basic(
}
}
class ScanError(error: Throwable) : Basic(
event = "Scan",
error = error,
)
class ButtonSupport(source: AnalyticsParam.ScreensSources) : Basic(
event = "Request Support",
params = mapOf(

View file

@ -0,0 +1,8 @@
package com.tangem.core.analytics.models
class ExceptionAnalyticsEvent(
val exception: Throwable,
// val category: String,
// val event: String,
var params: Map<String, String> = mapOf(),
)

View file

@ -2,6 +2,7 @@ package com.tangem.core.analytics
import com.tangem.core.analytics.api.*
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
@ -16,7 +17,9 @@ interface GlobalAnalyticsEventHandler :
AnalyticsEventHandler,
AnalyticsHandlerHolder,
AnalyticsFilterHolder,
ParamsInterceptorHolder
ParamsInterceptorHolder,
AnalyticsErrorHandler,
AnalyticsExceptionHandler
object Analytics : GlobalAnalyticsEventHandler {
@ -72,6 +75,25 @@ object Analytics : GlobalAnalyticsEventHandler {
}
}
override fun sendErrorEvent(event: AnalyticsEvent) {
analyticsScope.launch {
event.params = applyParamsInterceptors(event)
analyticsMutex.withLock {
analyticsHandlers.filterIsInstance<AnalyticsErrorHandler>()
.forEach { handler -> handler.sendErrorEvent(event) }
}
}
}
override fun sendException(event: ExceptionAnalyticsEvent) {
analyticsScope.launch {
analyticsMutex.withLock {
analyticsHandlers.filterIsInstance<AnalyticsExceptionHandler>()
.forEach { it.sendException(event) }
}
}
}
private suspend fun applyParamsInterceptors(event: AnalyticsEvent): MutableMap<String, String> {
val interceptedParams = event.params.toMutableMap()
analyticsMutex.withLock {

View file

@ -8,5 +8,9 @@ interface EventLogger {
}
interface ErrorEventLogger {
fun logErrorEvent(error: Throwable, params: Map<String, String> = emptyMap())
fun logErrorEvent(event: String, params: Map<String, String> = emptyMap())
}
interface ExceptionLogger {
fun logException(error: Throwable, params: Map<String, String> = emptyMap())
}

View file

@ -1,15 +1,25 @@
package com.tangem.core.analytics.api
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
/**
[REDACTED_AUTHOR]
* Sends usual analytics events
*/
interface AnalyticsEventHandler {
fun send(event: AnalyticsEvent)
}
interface AnalyticsErrorHandler {
fun sendErrorEvent(event: AnalyticsEvent)
}
interface AnalyticsExceptionHandler {
fun sendException(event: ExceptionAnalyticsEvent)
}
interface AnalyticsHandler : AnalyticsEventHandler {
fun id(): String
fun send(eventId: String, params: Map<String, String> = emptyMap())
@ -19,10 +29,6 @@ interface AnalyticsHandler : AnalyticsEventHandler {
}
}
interface ErrorEventHandler {
fun send(error: Throwable, params: Map<String, String> = emptyMap())
}
interface AnalyticsHandlerHolder {
fun addHandler(name: String, handler: AnalyticsHandler)
fun removeHandler(name: String): AnalyticsHandler?

View file

@ -1,7 +1,9 @@
package com.tangem.core.analytics.di
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.api.ParamsInterceptorHolder
import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.domain.analytics.repository.AnalyticsRepository
@ -21,6 +23,18 @@ internal object AnalyticsModule {
return Analytics // todo replace after refactoring calling Analytics in whole project
}
@Singleton
@Provides
fun provideAnalyticsErrorHandler(): AnalyticsErrorHandler {
return Analytics
}
@Singleton
@Provides
fun provideAnalyticsExceptionHandler(): AnalyticsExceptionHandler {
return Analytics
}
@Singleton
@Provides
fun provideParamsInterceptorHolder(): ParamsInterceptorHolder {

View file

@ -9,7 +9,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
class TokenExchangeAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Token", event, params, null) {
) : AnalyticsEvent("Token", event, params) {
class CexTxStatusOpened(token: String) : TokenScreenAnalyticsEvent(
event = "Swap Status Opened",

View file

@ -8,7 +8,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
class TokenOnrampAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Onramp", event, params, null) {
) : AnalyticsEvent("Onramp", event, params) {
class OnrampStatusOpened(tokenSymbol: String, provider: String, fiatCurrency: String) : TokenScreenAnalyticsEvent(
event = "Onramp Status Opened",

View file

@ -6,7 +6,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
sealed class TokenReceiveAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Token / Receive", event, params, null) {
) : AnalyticsEvent("Token / Receive", event, params) {
class ReceiveScreenOpened(token: String) : TokenReceiveAnalyticsEvent(
event = "Receive Screen Opened",

View file

@ -13,8 +13,7 @@ import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
sealed class TokenScreenAnalyticsEvent(
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent("Token", event, params, error) {
) : AnalyticsEvent("Token", event, params) {
/** Legacy event. It has a unique category, but it also is sent on TokenScreen */
class DetailsScreenOpened(token: String) : AnalyticsEvent(

View file

@ -1,11 +1,12 @@
package com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.model
import androidx.compose.runtime.Stable
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -48,6 +49,7 @@ class MultiWalletBackupModel @Inject constructor(
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val tangemSdkManager: TangemSdkManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val uiMessageSender: UiMessageSender,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val cardRepository: CardRepository,
@ -241,7 +243,7 @@ class MultiWalletBackupModel @Inject constructor(
)
}
}
else -> FirebaseCrashlytics.getInstance().recordException(result.error)
else -> analyticsExceptionHandler.sendException(ExceptionAnalyticsEvent(result.error))
}
}
}

View file

@ -117,6 +117,7 @@ sealed class SwapEvents(
"Error Code" to errorCode.toString(),
),
)
// TODO parameters
// region Promo activity
data class ChangellyActivity(

View file

@ -6,8 +6,7 @@ internal sealed class Settings(
category: String = "Settings",
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent(category, event, params, error) {
) : AnalyticsEvent(category, event, params) {
data object ButtonCreateBackup : Settings(event = "Button - Create Backup")

View file

@ -10,8 +10,7 @@ sealed class WalletScreenAnalyticsEvent {
sealed class Basic(
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : AnalyticsEvent(category = "Basic", event = event, params = params, error = error) {
) : AnalyticsEvent(category = "Basic", event = event, params = params) {
class WalletToppedUp(userWalletId: UserWalletId, walletType: AnalyticsParam.WalletType) :
Basic(

View file

@ -47,7 +47,7 @@ coil = "2.1.0"
compose-shimmer = "1.0.3"
coroutine = "1.7.2" # 1.8+ is not compatible with tangem-sdk
desugarJdkLibs = "1.1.5"
firebase = "33.7.0"
firebase = "33.10.0"
googleMaterialComponent = "1.6.1"
googlePlayReview = "2.0.1"
googlePlayReviewKtx = "2.0.1"

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.core.datasource)
implementation(projects.core.configToggles)
implementation(projects.core.utils)
implementation(projects.core.analytics)
// endregion
// region AndroidX libraries

View file

@ -1,6 +1,8 @@
package com.tangem.blockchainsdk.di
import com.tangem.blockchainsdk.providers.BlockchainProvidersResponseMerger
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import dagger.Module
import dagger.Provides
@ -17,4 +19,12 @@ internal object ExcludedBlockchainsModule {
fun bindExcludedBlockchains(excludedBlockchainsManager: ExcludedBlockchainsManager): ExcludedBlockchains {
return ExcludedBlockchains(excludedBlockchainsManager)
}
@Provides
@Singleton
fun bindBlockchainProvidersResponseMerger(
analyticsExceptionHandler: AnalyticsExceptionHandler,
): BlockchainProvidersResponseMerger {
return BlockchainProvidersResponseMerger(analyticsExceptionHandler)
}
}

View file

@ -22,6 +22,7 @@ import javax.inject.Singleton
internal class BlockchainProvidersResponseLoader @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val blockchainProvidersStorage: BlockchainProvidersStorage,
private val blockchainProvidersResponseMerger: BlockchainProvidersResponseMerger,
private val dispatchers: CoroutineDispatcherProvider,
) {
@ -31,7 +32,7 @@ internal class BlockchainProvidersResponseLoader @Inject constructor(
return loadRemote().fold(
onSuccess = { remoteResponse ->
BlockchainProvidersResponseMerger.merge(
blockchainProvidersResponseMerger.merge(
local = localResponse,
remote = remoteResponse,
)

View file

@ -1,19 +1,21 @@
package com.tangem.blockchainsdk.providers
import androidx.core.util.PatternsCompat
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.blockchainsdk.BlockchainProvidersResponse
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.datasource.local.config.providers.models.ProviderModel
import timber.log.Timber
import javax.inject.Inject
/**
* Merger of [BlockchainProvidersResponse]
*
[REDACTED_AUTHOR]
*/
internal object BlockchainProvidersResponseMerger {
private val firebaseCrashlytics by lazy(FirebaseCrashlytics::getInstance)
internal class BlockchainProvidersResponseMerger @Inject internal constructor(
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) {
private val forbiddenSchemes = listOf("wss://")
@ -41,7 +43,7 @@ internal object BlockchainProvidersResponseMerger {
val missingBlockchains = result.keys - remote.keys
val blockchainsWithoutProviders = remote.filterValues { it.isEmpty() }.keys
recordException(missingBlockchains = missingBlockchains + blockchainsWithoutProviders)
logException(missingBlockchains = missingBlockchains + blockchainsWithoutProviders)
}
return result.guaranteeUrlsEndWithSlash()
@ -74,15 +76,19 @@ internal object BlockchainProvidersResponseMerger {
return PatternsCompat.WEB_URL.matcher(inputUrl).matches()
}
private fun recordException(missingBlockchains: Set<String>) {
private fun logException(missingBlockchains: Set<String>) {
val exception = IllegalStateException(
"Remote config does not contain required blockchains or providers information: " +
missingBlockchains.joinToString(),
"Remote config does not contain some blockchains or providers information",
)
Timber.e(exception)
firebaseCrashlytics.recordException(exception)
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = exception,
params = mapOf("Missing blockchains" to missingBlockchains.joinToString()),
),
)
}
/*

View file

@ -4,6 +4,8 @@ import com.google.common.truth.Truth
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.blockchainsdk.providers.BlockchainProvidersResponseMergerTest.Companion.localResponse
import com.tangem.blockchainsdk.providers.BlockchainProvidersResponseMergerTest.Companion.remoteResponse
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.config.providers.BlockchainProvidersStorage
import com.tangem.datasource.local.config.providers.models.ProviderModel
@ -20,10 +22,16 @@ internal class BlockchainProvidersResponseLoaderTest {
private val tangemTechApi = mockk<TangemTechApi>()
private val blockchainProvidersStorage = mockk<BlockchainProvidersStorage>()
private val analyticsExceptionHandler = object : AnalyticsExceptionHandler {
override fun sendException(event: ExceptionAnalyticsEvent) {
FirebaseCrashlytics.getInstance().recordException(event.exception)
}
}
private val loader = BlockchainProvidersResponseLoader(
tangemTechApi = tangemTechApi,
blockchainProvidersStorage = blockchainProvidersStorage,
blockchainProvidersResponseMerger = BlockchainProvidersResponseMerger(analyticsExceptionHandler),
dispatchers = TestingCoroutineDispatcherProvider(),
)

View file

@ -2,7 +2,10 @@ package com.tangem.blockchainsdk.providers
import com.google.common.truth.Truth
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.crashlytics.recordException
import com.tangem.blockchainsdk.BlockchainProvidersResponse
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.datasource.local.config.providers.models.ProviderModel
import io.mockk.*
import org.junit.Before
@ -13,6 +16,14 @@ import org.junit.Test
*/
internal class BlockchainProvidersResponseMergerTest {
private val blockchainProvidersResponseMerger = BlockchainProvidersResponseMerger(
object : AnalyticsExceptionHandler {
override fun sendException(event: ExceptionAnalyticsEvent) {
FirebaseCrashlytics.getInstance().recordException(event.exception)
}
},
)
@Before
fun setup() {
mockkStatic(FirebaseCrashlytics::class)
@ -25,7 +36,7 @@ internal class BlockchainProvidersResponseMergerTest {
fun test_if_both_configs_are_empty() {
val expected = emptyMap<String, List<ProviderModel>>()
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = emptyMap(),
remote = emptyMap(),
)
@ -37,7 +48,7 @@ internal class BlockchainProvidersResponseMergerTest {
fun test_if_local_config_is_empty() {
val expected = remoteResponse
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = emptyMap(),
remote = remoteResponse,
)
@ -49,7 +60,7 @@ internal class BlockchainProvidersResponseMergerTest {
fun test_if_remote_config_is_empty() {
val expected = localResponse
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = localResponse,
remote = emptyMap(),
)
@ -61,7 +72,7 @@ internal class BlockchainProvidersResponseMergerTest {
fun test_if_both_configs_are_not_empty() {
val expected = remoteResponse
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = localResponse,
remote = remoteResponse,
)
@ -73,7 +84,7 @@ internal class BlockchainProvidersResponseMergerTest {
fun test_if_configs_are_equal() {
val expected = remoteResponse
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = remoteResponse,
remote = remoteResponse,
)
@ -88,7 +99,7 @@ internal class BlockchainProvidersResponseMergerTest {
val expected = localResponseWithEth + remoteResponse
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = localResponseWithEth,
remote = remoteResponse,
)
@ -103,7 +114,7 @@ internal class BlockchainProvidersResponseMergerTest {
val eth = "ethereum" to emptyList<ProviderModel>()
val remoteWithEth = remoteResponse + eth
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = localResponse,
remote = remoteWithEth,
)
@ -121,7 +132,7 @@ internal class BlockchainProvidersResponseMergerTest {
*/
val expected = remoteResponse
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = localResponse,
remote = remoteWithoutLocal,
)
@ -137,7 +148,7 @@ internal class BlockchainProvidersResponseMergerTest {
val expected = remoteResponse + ("ethereum" to listOf(nowNodesProvider))
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = localResponse,
remote = remoteWithEth,
)
@ -152,7 +163,7 @@ internal class BlockchainProvidersResponseMergerTest {
val expected = remoteResponse
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = localResponse,
remote = remoteWithEth,
)
@ -170,7 +181,7 @@ internal class BlockchainProvidersResponseMergerTest {
val expected = remoteResponse + eth.addSlash() + kaspa.addSlash()
val actual = BlockchainProvidersResponseMerger.merge(
val actual = blockchainProvidersResponseMerger.merge(
local = localWithKaspa,
remote = remoteWithEth,
)