Skip to content

Commit ddca8f1

Browse files
authored
Merge pull request #243 from reown-com/feat/pos_observability
feat: add events observability to POS
2 parents 438d6d2 + 98eceeb commit ddca8f1

File tree

13 files changed

+549
-17
lines changed

13 files changed

+549
-17
lines changed

.github/workflows/ci_assemble.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
- name: Assemble Debug Project
4747
env:
4848
WC_CLOUD_PROJECT_ID: ${{ secrets.WC_CLOUD_PROJECT_ID }}
49+
POS_PROJECT_ID: ${{ secrets.POS_PROJECT_ID }}
4950
NOTIFY_INTEGRATION_TESTS_PROJECT_ID: ${{ secrets.NOTIFY_INTEGRATION_TESTS_PROJECT_ID }}
5051
NOTIFY_INTEGRATION_TESTS_SECRET: ${{ secrets.NOTIFY_INTEGRATION_TESTS_SECRET }}
5152
# See if we can build out the list of sample project instead of manually adding them here

.github/workflows/ci_debug_sample.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ jobs:
1010
conf: [
1111
{ name: wallet, command: ":sample:wallet:assembleDebug :sample:wallet:appDistributionUploadDebug" },
1212
{ name: dapp, command: ":sample:dapp:assembleDebug :sample:dapp:appDistributionUploadDebug" },
13-
{ name: modal, command: ":sample:modal:assembleDebug :sample:modal:appDistributionUploadDebug" }
13+
{ name: modal, command: ":sample:modal:assembleDebug :sample:modal:appDistributionUploadDebug" },
14+
{ name: pos, command: ":sample:pos:assembleDebug" }
1415
]
1516
name: ${{ matrix.conf.name }}
1617
runs-on: ubuntu-latest
@@ -51,6 +52,7 @@ jobs:
5152
- name: Release sample - Debug
5253
env:
5354
WC_CLOUD_PROJECT_ID: ${{ secrets.WC_CLOUD_PROJECT_ID }}
55+
POS_PROJECT_ID: ${{ secrets.POS_PROJECT_ID }}
5456
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
5557
NOTIFY_INTEGRATION_TESTS_PROJECT_ID: ${{ secrets.NOTIFY_INTEGRATION_TESTS_PROJECT_ID }}
5658
NOTIFY_INTEGRATION_TESTS_SECRET: ${{ secrets.NOTIFY_INTEGRATION_TESTS_SECRET }}

.github/workflows/ci_internal_sample.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ jobs:
1919
conf: [
2020
{ name: wallet, command: ":sample:wallet:assembleInternal :sample:wallet:appDistributionUploadInternal" },
2121
{ name: dapp, command: ":sample:dapp:assembleInternal :sample:dapp:appDistributionUploadInternal" },
22-
{ name: modal, command: ":sample:modal:assembleInternal :sample:modal:appDistributionUploadInternal" }
22+
{ name: modal, command: ":sample:modal:assembleInternal :sample:modal:appDistributionUploadInternal" },
23+
{ name: pos, command: ":sample:pos:assembleInternal" }
2324
]
2425
name: ${{ matrix.conf.name }}
2526
runs-on: ubuntu-latest
@@ -60,6 +61,7 @@ jobs:
6061
- name: Release sample - Internal
6162
env:
6263
WC_CLOUD_PROJECT_ID: ${{ secrets.WC_CLOUD_PROJECT_ID }}
64+
POS_PROJECT_ID: ${{ secrets.POS_PROJECT_ID }}
6365
FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
6466
NOTIFY_INTEGRATION_TESTS_PROJECT_ID: ${{ secrets.NOTIFY_INTEGRATION_TESTS_PROJECT_ID }}
6567
NOTIFY_INTEGRATION_TESTS_SECRET: ${{ secrets.NOTIFY_INTEGRATION_TESTS_SECRET }}

product/pos/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,17 @@ android {
2626

2727
buildConfigField(type = "String", name = "SDK_VERSION", value = "\"${requireNotNull(extra.get(KEY_PUBLISH_VERSION))}\"")
2828
buildConfigField(type = "String", name = "CORE_API_BASE_URL", value = "\"https://api.pay.walletconnect.com\"")
29+
buildConfigField(type = "String", name = "PULSE_BASE_URL", value = "\"https://pulse.walletconnect.org\"")
30+
buildConfigField(type = "String", name = "POS_PROJECT_ID", value = "\"${System.getenv("POS_PROJECT_ID") ?: ""}\"")
2931
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3032
}
3133

3234
buildTypes {
35+
debug {
36+
buildConfigField(type = "String", name = "INGEST_BASE_URL", value = "\"https://ingest-staging.walletconnect.org/\"")
37+
}
3338
release {
39+
buildConfigField(type = "String", name = "INGEST_BASE_URL", value = "\"https://ingest.walletconnect.org/\"")
3440
isMinifyEnabled = false
3541
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
3642
}

product/pos/src/main/kotlin/com/walletconnect/pos/PosClient.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.walletconnect.pos
22

33
import com.walletconnect.pos.api.ApiClient
44
import com.walletconnect.pos.api.ApiResult
5+
import com.walletconnect.pos.api.ErrorTracker
6+
import com.walletconnect.pos.api.EventTracker
57
import com.walletconnect.pos.api.mapErrorCodeToPaymentError
68
import com.walletconnect.pos.api.mapStatusToPaymentEvent
79
import kotlinx.coroutines.CoroutineScope
@@ -17,6 +19,8 @@ import kotlinx.coroutines.launch
1719
object PosClient {
1820
private var delegate: POSDelegate? = null
1921
private var apiClient: ApiClient? = null
22+
private var eventTracker: EventTracker? = null
23+
private var errorTracker: ErrorTracker? = null
2024
private var scope: CoroutineScope? = null
2125
private var currentPollingJob: Job? = null
2226

@@ -33,8 +37,11 @@ object PosClient {
3337
check(merchantId.isNotBlank()) { "merchantId cannot be blank" }
3438
check(deviceId.isNotBlank()) { "deviceId cannot be blank" }
3539
cleanup()
36-
this.scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
37-
this.apiClient = ApiClient(apiKey, merchantId)
40+
val newScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
41+
this.scope = newScope
42+
this.eventTracker = EventTracker(merchantId, newScope)
43+
this.errorTracker = ErrorTracker(newScope)
44+
this.apiClient = ApiClient(apiKey, merchantId, eventTracker!!, errorTracker!!)
3845
}
3946

4047
/**
@@ -58,6 +65,8 @@ object PosClient {
5865
fun createPaymentIntent(amount: Pos.Amount, referenceId: String) {
5966
checkInitialized()
6067
currentPollingJob?.cancel()
68+
val valueMinor = amount.value.toLongOrNull() ?: 0L
69+
eventTracker?.trackWcPaySelected(referenceId, amount.unit, valueMinor)
6170
currentPollingJob = scope?.launch {
6271
apiClient!!.createPayment(referenceId, amount.unit, amount.value) { event ->
6372
emitEvent(event)
@@ -118,5 +127,7 @@ object PosClient {
118127
scope?.cancel()
119128
scope = null
120129
apiClient = null
130+
eventTracker = null
131+
errorTracker = null
121132
}
122133
}

product/pos/src/main/kotlin/com/walletconnect/pos/api/ApiClient.kt

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import java.util.concurrent.TimeUnit
1919
internal class ApiClient(
2020
private val apiKey: String,
2121
private val merchantId: String,
22+
private val eventTracker: EventTracker,
23+
private val errorTracker: ErrorTracker,
2224
baseUrl: String = BuildConfig.CORE_API_BASE_URL
2325
) {
2426
private val moshi = Moshi.Builder()
@@ -70,31 +72,45 @@ internal class ApiClient(
7072
return
7173
}
7274

73-
onEvent(
74-
Pos.PaymentEvent.PaymentCreated(
75-
uri = URI(data.gatewayUrl),
76-
amount = Pos.Amount(unit, value),
77-
paymentId = data.paymentId
78-
)
75+
val paymentCreatedEvent = Pos.PaymentEvent.PaymentCreated(
76+
uri = URI(data.gatewayUrl),
77+
amount = Pos.Amount(unit, value),
78+
paymentId = data.paymentId
7979
)
80+
val valueMinor = value.toLongOrNull() ?: 0L
81+
val context = PaymentContext(
82+
paymentUrl = data.gatewayUrl,
83+
unit = unit,
84+
valueMinor = valueMinor,
85+
referenceId = referenceId.ifBlank { null }
86+
)
87+
eventTracker.trackPaymentCreated(data.paymentId, context)
88+
onEvent(paymentCreatedEvent)
8089

81-
startPolling(data.paymentId, onEvent)
90+
startPolling(data.paymentId, context, onEvent)
8291
} else {
8392
val error = parseErrorResponse(response)
84-
onEvent(mapCreatePaymentError(error.code, error.message))
93+
val paymentError = mapCreatePaymentError(error.code, error.message)
94+
eventTracker.trackPaymentFailed(referenceId, null, paymentError)
95+
onEvent(paymentError)
8596
}
8697
} catch (e: CancellationException) {
8798
// Rethrow cancellation to properly propagate coroutine cancellation
8899
throw e
89100
} catch (e: IOException) {
90-
onEvent(Pos.PaymentEvent.PaymentError.CreatePaymentFailed("Network error: ${e.message}"))
101+
errorTracker.trackError(PulseErrorType.NETWORK_ERROR, e.message ?: "Network error", "createPayment")
102+
val paymentError = Pos.PaymentEvent.PaymentError.CreatePaymentFailed("Network error: ${e.message}")
103+
onEvent(paymentError)
91104
} catch (e: Exception) {
92-
onEvent(Pos.PaymentEvent.PaymentError.Undefined("Unexpected error: ${e.message}"))
105+
errorTracker.trackError(PulseErrorType.SDK_ERROR, e.message ?: "Unexpected error", "createPayment")
106+
val paymentError = Pos.PaymentEvent.PaymentError.Undefined("Unexpected error: ${e.message}")
107+
onEvent(paymentError)
93108
}
94109
}
95110

96111
private suspend fun startPolling(
97112
paymentId: String,
113+
context: PaymentContext,
98114
onEvent: (Pos.PaymentEvent) -> Unit
99115
) {
100116
var lastEmittedStatus: String? = null
@@ -106,7 +122,9 @@ internal class ApiClient(
106122

107123
if (data.status != lastEmittedStatus) {
108124
lastEmittedStatus = data.status
109-
onEvent(mapStatusToPaymentEvent(data.status, paymentId))
125+
val event = mapStatusToPaymentEvent(data.status, paymentId)
126+
trackPaymentStatusEvent(paymentId, context, data.status, event)
127+
onEvent(event)
110128
}
111129

112130
if (data.isFinal || data.pollInMs == null) {
@@ -117,13 +135,35 @@ internal class ApiClient(
117135
}
118136

119137
is ApiResult.Error -> {
120-
onEvent(mapErrorCodeToPaymentError(result.code, result.message))
138+
val paymentError = mapErrorCodeToPaymentError(result.code, result.message)
139+
if (!isSdkError(result.code)) {
140+
eventTracker.trackPaymentFailed(paymentId, context, paymentError)
141+
}
142+
onEvent(paymentError)
121143
break
122144
}
123145
}
124146
}
125147
}
126148

149+
private fun trackPaymentStatusEvent(
150+
paymentId: String,
151+
context: PaymentContext,
152+
status: String,
153+
event: Pos.PaymentEvent
154+
) {
155+
when (status) {
156+
PaymentStatus.REQUIRES_ACTION -> eventTracker.trackPaymentRequested(paymentId, context)
157+
PaymentStatus.PROCESSING -> eventTracker.trackPaymentProcessing(paymentId, context)
158+
PaymentStatus.SUCCEEDED -> eventTracker.trackPaymentCompleted(paymentId, context)
159+
PaymentStatus.EXPIRED, PaymentStatus.FAILED -> {
160+
if (event is Pos.PaymentEvent.PaymentError) {
161+
eventTracker.trackPaymentFailed(paymentId, context, event)
162+
}
163+
}
164+
}
165+
}
166+
127167
suspend fun getPaymentStatus(paymentId: String): ApiResult<GetPaymentStatusResponse> {
128168
return try {
129169
val response = payApi.getPaymentStatus(paymentId)
@@ -143,8 +183,10 @@ internal class ApiClient(
143183
// Rethrow cancellation to properly propagate coroutine cancellation
144184
throw e
145185
} catch (e: IOException) {
186+
errorTracker.trackError(PulseErrorType.NETWORK_ERROR, e.message ?: "Network error", "getPaymentStatus")
146187
ApiResult.Error(ErrorCodes.NETWORK_ERROR, e.message ?: "Network error")
147188
} catch (e: Exception) {
189+
errorTracker.trackError(PulseErrorType.SDK_ERROR, e.message ?: "Unexpected error", "getPaymentStatus")
148190
ApiResult.Error(ErrorCodes.PARSE_ERROR, e.message ?: "Unexpected error")
149191
}
150192
}
@@ -172,6 +214,7 @@ internal class ApiClient(
172214
message = response.message()
173215
)
174216
} catch (e: Exception) {
217+
errorTracker.trackError(PulseErrorType.PARSE_ERROR, e.message ?: "Failed to parse error response", "parseErrorResponse")
175218
ApiErrorDetails(
176219
code = "HTTP_${response.code()}",
177220
message = response.message()
@@ -185,6 +228,10 @@ internal class ApiClient(
185228
}
186229
}
187230

231+
private fun isSdkError(code: String): Boolean {
232+
return code == ErrorCodes.NETWORK_ERROR || code == ErrorCodes.PARSE_ERROR
233+
}
234+
188235
private fun String.ensureTrailingSlash(): String {
189236
return if (endsWith("/")) this else "$this/"
190237
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.walletconnect.pos.api
2+
3+
import com.squareup.moshi.Moshi
4+
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
5+
import com.walletconnect.pos.BuildConfig
6+
import kotlinx.coroutines.CoroutineExceptionHandler
7+
import kotlinx.coroutines.CoroutineScope
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.delay
10+
import kotlinx.coroutines.launch
11+
import okhttp3.OkHttpClient
12+
import okhttp3.logging.HttpLoggingInterceptor
13+
import retrofit2.Retrofit
14+
import retrofit2.converter.moshi.MoshiConverterFactory
15+
import java.util.UUID
16+
import java.util.concurrent.TimeUnit
17+
18+
internal class ErrorTracker(
19+
private val scope: CoroutineScope,
20+
baseUrl: String = BuildConfig.PULSE_BASE_URL
21+
) {
22+
private val moshi = Moshi.Builder()
23+
.addLast(KotlinJsonAdapterFactory())
24+
.build()
25+
26+
private val httpClient = OkHttpClient.Builder()
27+
.connectTimeout(15, TimeUnit.SECONDS)
28+
.readTimeout(15, TimeUnit.SECONDS)
29+
.writeTimeout(15, TimeUnit.SECONDS)
30+
.apply {
31+
if (BuildConfig.DEBUG) {
32+
addInterceptor(HttpLoggingInterceptor().apply {
33+
level = HttpLoggingInterceptor.Level.BODY
34+
})
35+
}
36+
}
37+
.build()
38+
39+
private val retrofit = Retrofit.Builder()
40+
.baseUrl(baseUrl.ensureTrailingSlash())
41+
.client(httpClient)
42+
.addConverterFactory(MoshiConverterFactory.create(moshi))
43+
.build()
44+
45+
private val pulseApi: PulseApi = retrofit.create(PulseApi::class.java)
46+
47+
private val silentExceptionHandler = CoroutineExceptionHandler { _, t -> println(t) }
48+
49+
companion object {
50+
private const val MAX_RETRIES = 3
51+
private val RETRY_DELAYS_MS = listOf(1000L, 3000L, 5000L)
52+
private const val SDK_TYPE = "pos"
53+
}
54+
55+
fun trackError(type: String, message: String, method: String) {
56+
try {
57+
val event = PulseEvent(
58+
eventId = UUID.randomUUID().toString(),
59+
timestamp = System.currentTimeMillis(),
60+
props = PulseProps(
61+
type = type,
62+
properties = PulseErrorProperties(
63+
message = message,
64+
method = method
65+
)
66+
)
67+
)
68+
69+
scope.launch(Dispatchers.IO + silentExceptionHandler) {
70+
sendWithRetry(event)
71+
}
72+
} catch (_: Exception) {
73+
// Silently ignore any synchronous errors
74+
// Error tracking should never affect the main flow
75+
}
76+
}
77+
78+
private suspend fun sendWithRetry(event: PulseEvent) {
79+
repeat(MAX_RETRIES) { attempt ->
80+
try {
81+
val response = pulseApi.sendEvent(
82+
sdkType = SDK_TYPE,
83+
sdkVersion = BuildConfig.SDK_VERSION,
84+
projectId = BuildConfig.POS_PROJECT_ID,
85+
body = event
86+
)
87+
if (response.isSuccessful) {
88+
return
89+
}
90+
} catch (_: Exception) {
91+
// Network or other error, retry silently
92+
}
93+
94+
if (attempt < MAX_RETRIES - 1) {
95+
delay(RETRY_DELAYS_MS[attempt])
96+
}
97+
}
98+
}
99+
100+
private fun String.ensureTrailingSlash(): String {
101+
return if (endsWith("/")) this else "$this/"
102+
}
103+
}

0 commit comments

Comments
 (0)