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