Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-21 11:24:36 +02:00
parent 3b269e6f6a
commit c40326c1c9
12 changed files with 128 additions and 58 deletions

View file

@ -8,6 +8,7 @@ import coil.ImageLoaderFactory
import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.tangem.Log
import com.tangem.TangemSdkLogger
import com.tangem.blockchain.common.ExceptionHandler
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
@ -55,6 +56,7 @@ import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler
import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler
import com.tangem.tap.common.images.createCoilImageLoader
@ -206,6 +208,9 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
private val uiMessageSender: UiMessageSender
get() = entryPoint.getUiMessageSender()
private val blockchainExceptionHandler: BlockchainExceptionHandler
get() = entryPoint.getBlockchainExceptionHandler()
private val workerFactory: HiltWorkerFactory
get() = entryPoint.getWorkerFactory()
@ -219,6 +224,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
private val userWalletBuilderFactory: UserWalletBuilder.Factory
get() = entryPoint.getUserWalletBuilderFactory()
// endregion
override fun onCreate() {
@ -264,7 +270,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
}
loadNativeLibraries()
// ExceptionHandler.append(blockchainExceptionHandler) // TODO [REDACTED_TASK_KEY] Send only to Firebase
ExceptionHandler.append(blockchainExceptionHandler)
if (LogConfig.network.blockchainSdkNetwork) {
BlockchainSdkRetrofitBuilder.interceptors = listOf(
createNetworkLoggingInterceptor(),

View file

@ -1,15 +1,15 @@
package com.tangem.tap.common.analytics.handlers
import com.tangem.blockchain.common.ExceptionHandlerOutput
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.tap.common.analytics.events.BlockchainExceptionEvent
import javax.inject.Inject
class BlockchainExceptionHandler @Inject constructor(
private val analyticsHandler: AnalyticsEventHandler,
private val analyticsErrorHandler: AnalyticsErrorHandler,
) : ExceptionHandlerOutput {
override fun handleApiSwitch(currentHost: String, nextHost: String, message: String) {
analyticsHandler.send(
analyticsErrorHandler.sendErrorEvent(
BlockchainExceptionEvent(
selectedHost = nextHost,
exceptionHost = currentHost,

View file

@ -1,16 +1,18 @@
package com.tangem.datasource.api.common.response
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import retrofit2.Call
import retrofit2.CallAdapter
import java.lang.reflect.Type
internal class ApiResponseCallAdapter(
private val resultType: Type,
private val analyticsErrorHandler: AnalyticsErrorHandler,
) : CallAdapter<Type, Call<ApiResponse<Type>>> {
override fun responseType(): Type = resultType
override fun adapt(call: Call<Type>): Call<ApiResponse<Type>> {
return ApiResponseCallDelegate(call)
return ApiResponseCallDelegate(call, analyticsErrorHandler)
}
}

View file

@ -1,12 +1,15 @@
package com.tangem.datasource.api.common.response
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory() {
class ApiResponseCallAdapterFactory private constructor(
private val analyticsErrorHandler: AnalyticsErrorHandler,
) : CallAdapter.Factory() {
override fun get(returnType: Type, annotations: Array<out Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
if (getRawType(returnType) != Call::class.java) {
@ -19,11 +22,11 @@ class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory(
}
val resultType = getParameterUpperBound(0, callType as ParameterizedType)
return ApiResponseCallAdapter(resultType)
return ApiResponseCallAdapter(resultType, analyticsErrorHandler)
}
companion object {
fun create() = ApiResponseCallAdapterFactory()
fun create(analyticsErrorHandler: AnalyticsErrorHandler) = ApiResponseCallAdapterFactory(analyticsErrorHandler)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.api.common.response
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import okhttp3.Request
import okio.Timeout
import retrofit2.Call
@ -9,6 +10,7 @@ import timber.log.Timber
internal class ApiResponseCallDelegate<T : Any>(
private val wrappedCall: Call<T>,
private val analyticsErrorHandler: AnalyticsErrorHandler,
) : Call<ApiResponse<T>> {
override fun enqueue(callback: Callback<ApiResponse<T>>) {
@ -16,7 +18,7 @@ internal class ApiResponseCallDelegate<T : Any>(
}
override fun execute(): Response<ApiResponse<T>> = throw NotImplementedError()
override fun clone(): Call<ApiResponse<T>> = ApiResponseCallDelegate(wrappedCall.clone())
override fun clone(): Call<ApiResponse<T>> = ApiResponseCallDelegate(wrappedCall.clone(), analyticsErrorHandler)
override fun request(): Request = wrappedCall.request()
override fun timeout(): Timeout = wrappedCall.timeout()
override fun isExecuted(): Boolean = wrappedCall.isExecuted
@ -30,7 +32,7 @@ internal class ApiResponseCallDelegate<T : Any>(
) : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val safeResponse = response.toSafeApiResponse()
val safeResponse = response.toSafeApiResponse(analyticsErrorHandler)
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}

View file

@ -19,51 +19,51 @@ sealed class ApiResponseError : Exception() {
) : ApiResponseError() {
// region Error Codes
enum class Code(val code: Int) {
enum class Code(val numericCode: Int) {
// 4xx Server Errors
BAD_REQUEST(code = 400),
UNAUTHORIZED(code = 401),
PAYMENT_REQUIRED(code = 402),
FORBIDDEN(code = 403),
NOT_FOUND(code = 404),
METHOD_NOT_ALLOWED(code = 405),
NOT_ACCEPTABLE(code = 406),
PROXY_AUTHENTICATION_REQUIRED(code = 407),
REQUEST_TIMEOUT(code = 408),
CONFLICT(code = 409),
GONE(code = 410),
LENGTH_REQUIRED(code = 411),
PRECONDITION_FAILED(code = 412),
PAYLOAD_TOO_LARGE(code = 413),
URI_TOO_LONG(code = 414),
UNSUPPORTED_MEDIA_TYPE(code = 415),
RANGE_NOT_SATISFIABLE(code = 416),
EXPECTATION_FAILED(code = 417),
IM_A_TEAPOT(code = 418), // Not an error, but an April Fools' joke from RFC 2324
UNPROCESSABLE_ENTITY(code = 422),
LOCKED(code = 423),
FAILED_DEPENDENCY(code = 424),
TOO_EARLY(code = 425),
UPGRADE_REQUIRED(code = 426),
PRECONDITION_REQUIRED(code = 428),
TOO_MANY_REQUESTS(code = 429),
REQUEST_HEADER_FIELDS_TOO_LARGE(code = 431),
UNAVAILABLE_FOR_LEGAL_REASONS(code = 451),
BAD_REQUEST(numericCode = 400),
UNAUTHORIZED(numericCode = 401),
PAYMENT_REQUIRED(numericCode = 402),
FORBIDDEN(numericCode = 403),
NOT_FOUND(numericCode = 404),
METHOD_NOT_ALLOWED(numericCode = 405),
NOT_ACCEPTABLE(numericCode = 406),
PROXY_AUTHENTICATION_REQUIRED(numericCode = 407),
REQUEST_TIMEOUT(numericCode = 408),
CONFLICT(numericCode = 409),
GONE(numericCode = 410),
LENGTH_REQUIRED(numericCode = 411),
PRECONDITION_FAILED(numericCode = 412),
PAYLOAD_TOO_LARGE(numericCode = 413),
URI_TOO_LONG(numericCode = 414),
UNSUPPORTED_MEDIA_TYPE(numericCode = 415),
RANGE_NOT_SATISFIABLE(numericCode = 416),
EXPECTATION_FAILED(numericCode = 417),
IM_A_TEAPOT(numericCode = 418), // Not an error, but an April Fools' joke from RFC 2324
UNPROCESSABLE_ENTITY(numericCode = 422),
LOCKED(numericCode = 423),
FAILED_DEPENDENCY(numericCode = 424),
TOO_EARLY(numericCode = 425),
UPGRADE_REQUIRED(numericCode = 426),
PRECONDITION_REQUIRED(numericCode = 428),
TOO_MANY_REQUESTS(numericCode = 429),
REQUEST_HEADER_FIELDS_TOO_LARGE(numericCode = 431),
UNAVAILABLE_FOR_LEGAL_REASONS(numericCode = 451),
// 5xx Server Errors
INTERNAL_SERVER_ERROR(code = 500),
NOT_IMPLEMENTED(code = 501),
BAD_GATEWAY(code = 502),
SERVICE_UNAVAILABLE(code = 503),
GATEWAY_TIMEOUT(code = 504),
HTTP_VERSION_NOT_SUPPORTED(code = 505),
VARIANT_ALSO_NEGOTIATES(code = 506),
INSUFFICIENT_STORAGE(code = 507),
LOOP_DETECTED(code = 508),
NOT_EXTENDED(code = 510),
NETWORK_AUTHENTICATION_REQUIRED(code = 511),
INTERNAL_SERVER_ERROR(numericCode = 500),
NOT_IMPLEMENTED(numericCode = 501),
BAD_GATEWAY(numericCode = 502),
SERVICE_UNAVAILABLE(numericCode = 503),
GATEWAY_TIMEOUT(numericCode = 504),
HTTP_VERSION_NOT_SUPPORTED(numericCode = 505),
VARIANT_ALSO_NEGOTIATES(numericCode = 506),
INSUFFICIENT_STORAGE(numericCode = 507),
LOOP_DETECTED(numericCode = 508),
NOT_EXTENDED(numericCode = 510),
NETWORK_AUTHENTICATION_REQUIRED(numericCode = 511),
;
override fun toString(): String = "$code - $name"
override fun toString(): String = "$numericCode - $name"
companion object {
val values = values()

View file

@ -1,5 +1,7 @@
package com.tangem.datasource.api.common.response
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.api.common.response.analytics.ApiErrorEvent
import kotlinx.coroutines.TimeoutCancellationException
import retrofit2.Response
import timber.log.Timber
@ -9,18 +11,20 @@ import java.net.UnknownHostException
import java.util.concurrent.TimeoutException
import javax.net.ssl.SSLHandshakeException
internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
internal fun <T : Any> Response<T>.toSafeApiResponse(analyticsErrorHandler: AnalyticsErrorHandler): ApiResponse<T> {
val body = body()
return if (isSuccessful && body != null) {
apiSuccess(body)
} else {
val code = ApiResponseError.HttpException.Code.values
.firstOrNull { it.code == code() }
.firstOrNull { it.numericCode == code() }
val e = try {
if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))
} else {
sendHttpError(code, analyticsErrorHandler)
ApiResponseError.HttpException(code, message(), errorBody()?.string())
}
} catch (e: Exception) {
@ -32,6 +36,21 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
}
}
private fun <T : Any> Response<T>.sendHttpError(
code: ApiResponseError.HttpException.Code,
analyticsErrorHandler: AnalyticsErrorHandler,
) {
val fullRequestUrl = raw().request.url.toUrl()
val shortUrl = fullRequestUrl.authority + fullRequestUrl.path
analyticsErrorHandler.sendErrorEvent(
ApiErrorEvent(
endpoint = shortUrl,
code = code.numericCode,
message = errorBody()?.string().orEmpty(),
),
)
}
internal fun Throwable.toApiError(): ApiResponseError = when (this) {
is ConnectException,
is UnknownHostException,

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.api.common.response.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
internal data class ApiErrorEvent(
val endpoint: String,
val code: Int,
val message: String,
) : AnalyticsEvent(
category = "Tangem API Service",
event = "Exception",
params = mapOf(
"Endpoint" to endpoint,
"Code" to code.toString(),
"Message" to message,
),
)

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.di
import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
@ -61,6 +62,7 @@ internal object NetworkModule {
fun provideExpressApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): TangemExpressApi {
@ -69,6 +71,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
@ -83,6 +86,7 @@ internal object NetworkModule {
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
apiConfigsManager: ApiConfigsManager,
analyticsErrorHandler: AnalyticsErrorHandler,
appLogsStore: AppLogsStore,
): StakeKitApi {
return createApi(
@ -90,6 +94,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
timeouts = Timeouts(
callTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS,
connectTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS,
@ -109,6 +114,7 @@ internal object NetworkModule {
fun provideOnrampApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): OnrampApi {
@ -117,6 +123,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
@ -130,6 +137,7 @@ internal object NetworkModule {
fun provideTangemTechApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
): TangemTechApi {
return createApi(
@ -137,6 +145,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = { applyTimeoutAnnotations() },
)
}
@ -147,6 +156,7 @@ internal object NetworkModule {
fun provideTangemTechApiV2(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
appVersionProvider: AppVersionProvider,
): TangemTechApiV2 {
return provideTangemTechApiInternal(
@ -154,6 +164,7 @@ internal object NetworkModule {
context = context,
appVersionProvider = appVersionProvider,
baseUrl = PROD_V2_TANGEM_TECH_BASE_URL,
analyticsErrorHandler = analyticsErrorHandler,
)
}
@ -162,6 +173,7 @@ internal object NetworkModule {
fun provideTangemTechMarketsApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
): TangemTechMarketsApi {
return createApi(
@ -169,6 +181,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
this.callTimeout(TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.connectTimeout(TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
@ -183,6 +196,7 @@ internal object NetworkModule {
fun provideTangemVisaAuthApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): TangemVisaAuthApi {
@ -191,6 +205,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
@ -204,6 +219,7 @@ internal object NetworkModule {
fun provideTangemVisaApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): TangemVisaApi {
@ -212,6 +228,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
@ -226,6 +243,7 @@ internal object NetworkModule {
context: Context,
appVersionProvider: AppVersionProvider,
baseUrl: String,
analyticsErrorHandler: AnalyticsErrorHandler,
timeouts: Timeouts = Timeouts(),
requestHeaders: List<RequestHeader> = listOf(AppVersionPlatformHeaders(appVersionProvider)),
): T {
@ -257,7 +275,7 @@ internal object NetworkModule {
return Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create(analyticsErrorHandler))
.baseUrl(baseUrl)
.client(client)
.build()
@ -269,6 +287,7 @@ internal object NetworkModule {
moshi: Moshi,
context: Context,
apiConfigsManager: ApiConfigsManager,
analyticsErrorHandler: AnalyticsErrorHandler,
timeouts: Timeouts = Timeouts(),
clientBuilder: OkHttpClient.Builder.() -> OkHttpClient.Builder = { this },
): T {
@ -276,7 +295,7 @@ internal object NetworkModule {
return Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create(analyticsErrorHandler))
.baseUrl(environmentConfig.baseUrl)
.client(
OkHttpClient.Builder()

View file

@ -335,7 +335,7 @@ internal class DefaultMarketsTokenRepository(
): T {
return when (error) {
is ApiResponseError.HttpException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Http, error.code.code, error.message.orEmpty())
createEvent(MarketsDataAnalyticsEvent.Type.Http, error.code.numericCode, error.message.orEmpty())
}
is ApiResponseError.TimeoutException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Timeout, null, error.message.orEmpty())

View file

@ -131,7 +131,7 @@ internal class DefaultHotCryptoRepository(
analyticsEventHandler.send(
event = MainScreenAnalyticsEvent.HotTokenError(
errorCode = (it as? ApiResponseError.HttpException)?.code?.code?.toString().orEmpty(),
errorCode = (it as? ApiResponseError.HttpException)?.code?.numericCode?.toString().orEmpty(),
),
)
}

View file

@ -9,6 +9,7 @@ import arrow.core.getOrElse
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
@ -82,6 +83,7 @@ internal class SwapModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val blockchainInteractor: BlockchainInteractor,
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsErrorEventHandler: AnalyticsErrorHandler,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
@ -483,7 +485,7 @@ internal class SwapModel @Inject constructor(
val receiveToken = dataState.toCryptoCurrency?.currency?.let {
"${it.network.backendId}:${it.symbol}"
}
analyticsEventHandler.send(
analyticsErrorEventHandler.sendErrorEvent(
SwapEvents.NoticeProviderError(
sendToken = "${initialCurrencyFrom.network.backendId}:${initialCurrencyFrom.symbol}",
receiveToken = receiveToken ?: "",