Skip to content

[Woo POS] Tracking preparaion #11537

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 30, 2024
1,723 changes: 866 additions & 857 deletions WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class AnalyticsTracker private constructor(
private var username: String? = null
private var anonymousID: String? = null

private val analyticsEventsToTrack = Channel<Pair<AnalyticsEvent, Map<String, *>>>(capacity = BUFFERED)
private val analyticsEventsToTrack = Channel<Pair<IAnalyticsEvent, Map<String, *>>>(capacity = BUFFERED)

init {
appCoroutineScope.launch {
Expand Down Expand Up @@ -82,11 +82,11 @@ class AnalyticsTracker private constructor(
return uuid
}

private fun track(stat: AnalyticsEvent, properties: Map<String, *>) {
private fun track(stat: IAnalyticsEvent, properties: Map<String, *>) {
analyticsEventsToTrack.trySend(Pair(stat, properties))
}

private fun doTrack(stat: AnalyticsEvent, properties: Map<String, *>) {
private fun doTrack(stat: IAnalyticsEvent, properties: Map<String, *>) {
if (tracksClient == null) {
return
}
Expand All @@ -102,7 +102,8 @@ class AnalyticsTracker private constructor(
}

val propertiesJson = JSONObject(properties.buildFinalProperties(stat.siteless))
tracksClient?.track(EVENTS_PREFIX + eventName, propertiesJson, user, userType)
val eventPrefix = if (stat.isPosEvent) POS_EVENTS_PREFIX else EVENTS_PREFIX
tracksClient?.track(eventPrefix + eventName, propertiesJson, user, userType)

if (propertiesJson.length() > 0) {
WooLog.i(T.UTILS, "\uD83D\uDD35 Tracked: $eventName, Properties: $propertiesJson")
Expand Down Expand Up @@ -166,6 +167,7 @@ class AnalyticsTracker private constructor(

private const val TRACKS_ANON_ID = "nosara_tracks_anon_id"
private const val EVENTS_PREFIX = "woocommerceandroid_"
private const val POS_EVENTS_PREFIX = "woocommerceandroid_pos_"
private const val KEY_SITE_URL = "site_url"

const val IS_DEBUG = "is_debug"
Expand Down Expand Up @@ -688,7 +690,7 @@ class AnalyticsTracker private constructor(
sendUsageStats = prefs.getBoolean(PREFKEY_SEND_USAGE_STATS, true)
}

fun track(stat: AnalyticsEvent, properties: Map<String, *> = emptyMap<String, String>()) {
fun track(stat: IAnalyticsEvent, properties: Map<String, *> = emptyMap<String, String>()) {
if (instance == null && BuildConfig.DEBUG && !PackageUtils.isTesting()) {
error("event $stat was tracked before AnalyticsTracker was initialized.")
}
Expand All @@ -704,7 +706,7 @@ class AnalyticsTracker private constructor(
* @param errorType The type of error.
* @param errorDescription The error text or other description.
*/
fun track(stat: AnalyticsEvent, errorContext: String?, errorType: String?, errorDescription: String?) {
fun track(stat: IAnalyticsEvent, errorContext: String?, errorType: String?, errorDescription: String?) {
track(stat, mapOf(), errorContext, errorType, errorDescription)
}

Expand All @@ -717,7 +719,7 @@ class AnalyticsTracker private constructor(
* @param errorDescription The error text or other description.
*/
fun track(
stat: AnalyticsEvent,
stat: IAnalyticsEvent,
properties: Map<String, Any>,
errorContext: String?,
errorType: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ open class AnalyticsTrackerWrapper @Inject constructor() {
}
}

fun track(stat: AnalyticsEvent, properties: Map<String, *> = emptyMap<String, Any>()) {
fun track(stat: IAnalyticsEvent, properties: Map<String, *> = emptyMap<String, Any>()) {
AnalyticsTracker.track(stat, properties)
}

Expand All @@ -42,7 +42,7 @@ open class AnalyticsTrackerWrapper @Inject constructor() {
* @param errorType The type of error.
* @param errorDescription The error text or other description.
*/
fun track(stat: AnalyticsEvent, errorContext: String?, errorType: String?, errorDescription: String?) {
fun track(stat: IAnalyticsEvent, errorContext: String?, errorType: String?, errorDescription: String?) {
AnalyticsTracker.track(stat, errorContext, errorType, errorDescription)
}

Expand All @@ -55,7 +55,7 @@ open class AnalyticsTrackerWrapper @Inject constructor() {
* @param errorDescription The error text or other description.
*/
fun track(
stat: AnalyticsEvent,
stat: IAnalyticsEvent,
properties: Map<String, Any>,
errorContext: String?,
errorType: String?,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
package com.woocommerce.android.ui.woopos.checkout

import androidx.lifecycle.SavedStateHandle
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalytics
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker
import com.woocommerce.android.viewmodel.ScopedViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class WooPosCheckoutViewModel @Inject constructor(
private val analyticsTracker: WooPosAnalyticsTracker,
savedStateHandle: SavedStateHandle,
) : ScopedViewModel(savedStateHandle)
) : ScopedViewModel(savedStateHandle) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@backwardstruck @samiuelson

Shell we actually reuse ScopedViewModel? That couples us to the main code but gives some reduction in the complexity of the POS. wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The understanding I have is that we no longer have to worry as much about keeping things decoupled. Rather we want what is faster, as long as it's still correct. Then we would commit to spending the additional time later, after this stage of the POS is done, to go back and decouple. So it should be fine to keep it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@backwardstruck It's quite interesting, but I understand it differently. Even though we've chosen not to use modularization, we still need to build it in a decoupled way. This means we are incorporating the unfavorable aspects of both approaches, but that's my understanding 🤔

@samiuelson what's your take on this? =)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the real question is if we want to reuse the Event class (for navigation), and the event triggering mechanism, the ScopedViewModel introduces. Other parts like the CoroutineScope interface, are not useful, IMO. Unless we realize we need it, I'd suggest getting rid of another layer of inheritance and extending the ViewModel class directly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also dislike the idea of ScopedViewModel "being" a CoroutineScope itself – it's not very helpful and makes code harder to debug and read.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samiuelson @backwardstruck

I create an issue on getting rid ScopedViewModel and the events from the main app. We should probably take this one sooner while we still have 1-2 places where it is used

#11622

init {
launch {
analyticsTracker.track(WooPosAnalytics.Event.Test)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's for testing. Let's remove that before merging

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice:

2024-05-21 16:04:02.385  8173-8684  WooCommerce-UTILS       com.woocommerce.android.dev          I  🔵 Tracked: woo_pos_test_event, Properties: {"blog_id":227901557,"is_wpcom_store":true,"was_ecommerce_trial":true,"plan_product_slug":"business-bundle","store_id":"79059489-918b-4632-9fee-465239092a2e","is_debug":true,"site_url":"https:\/\/woo-totally-selfless-bear.wpcomstaging.com","cached_woo_core_version":"8.9.1"}

}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.woocommerce.android.ui.woopos.util.analytics

import com.woocommerce.android.analytics.IAnalyticsEvent
import kotlin.reflect.KClass

sealed class WooPosAnalytics : IAnalyticsEvent {
override val siteless: Boolean = false
override val isPosEvent: Boolean = true

private val _properties: MutableMap<String, String> = mutableMapOf()
val properties: Map<String, String> get() = _properties.toMap()

fun addProperties(additionalProperties: Map<String, String>) {
_properties.putAll(additionalProperties)
}

sealed class Error : WooPosAnalytics() {
abstract val errorContext: KClass<Any>
abstract val errorType: String?
abstract val errorDescription: String?

data class Test(
override val errorContext: KClass<Any>,
override val errorType: String?,
override val errorDescription: String?,
) : Error() {
override val name: String = "WOO_POS_TEST_ERROR"
}
}

sealed class Event : WooPosAnalytics() {
data object Test : Event() {
override val name: String = "WOO_POS_TEST_EVENT"
}
}
}

internal fun IAnalyticsEvent.addProperties(additionalProperties: Map<String, String>) {
when (this) {
is WooPosAnalytics -> addProperties(additionalProperties)
else -> error("Cannot add properties to non-WooPosAnalytics event")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.woocommerce.android.ui.woopos.util.analytics

import javax.inject.Inject

class WooPosAnalyticsCommonPropertiesProvider @Inject constructor() {
val commonProperties: Map<String, String> = mapOf()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.woocommerce.android.ui.woopos.util.analytics

import com.woocommerce.android.analytics.AnalyticsTrackerWrapper
import com.woocommerce.android.analytics.IAnalyticsEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject

class WooPosAnalyticsTracker @Inject constructor(
private val analyticsTrackerWrapper: AnalyticsTrackerWrapper,
private val commonPropertiesProvider: WooPosAnalyticsCommonPropertiesProvider
) {
suspend fun track(analytics: IAnalyticsEvent) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kidinov sorry for chiming here again, just one question here for my learning, since you worked on making the tracking done on a background thread before (#11293), what's the usefulness of making this suspendable?

And one suggestion, if you don't mind: if this has to be suspendable, I think a more appropriate dispatcher to use would be Dispatchers.Default, Dispatchers.IO is destined more for blocking IO operations, which is not the case here, WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @hichamboushaba 👋

sorry for chiming here again, just one question here for my learning, since you worked on making the tracking done on a background thread before (#11293), what's the usefulness of making this suspendable?

I believe we should build things based on the API, not the current implementation. Eventually, if all goes well we may use another implementation of the tracking itself, which may or may not be implemented in a blocking manner, so having this suspended in the first place is a good idea in my mind

I think a more appropriate dispatcher to use would be Dispatchers.Default, Dispatchers.IO is destined more for blocking IO operations, which is not the case here

Tracking it's a network call - why Default?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually, if all goes well we may use another implementation of the tracking itself, which may or may not be implemented in a blocking manner, so having this suspended in the first place is a good idea in my mind

I would have agreed with this if the call was going to an external dependency, but in this case, it goes to our AnalyticsTracker class, which internally keeps a queue and handles the events, then these events are also emitted to another queue in Tracks library (which is also our internal library), I feel like we are overengineering this part 🙂, and with the complexity, we increase the change of any other issues:

  1. Ordering (that we discussed on the other PR), I think it would be a major issue here given the parallelism involved with using Dispatchers.IO.
  2. Non-accurate timestamps.
  3. Other stuff that we didn't think of.

Tracking it's a network call - why Default?

I don't think we can consider Tracking as a network call, we are just saving the items to an in-memory queue, the networking part is handled using a dedicate Thread in the Tracks library.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Further discussion on Slack.

withContext(Dispatchers.IO) {
analytics.addProperties(commonPropertiesProvider.commonProperties)
when (analytics) {
is WooPosAnalytics.Event -> {
analyticsTrackerWrapper.track(
analytics,
analytics.properties
)
}

is WooPosAnalytics.Error -> {
analyticsTrackerWrapper.track(
analytics,
analytics.properties,
analytics.errorContext.simpleName,
analytics.errorType,
analytics.errorDescription
)
}

else -> error("Unknown analytics event type: $analytics")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.woocommerce.android.ui.woopos.util.analytics

import com.woocommerce.android.analytics.AnalyticsTrackerWrapper
import com.woocommerce.android.analytics.IAnalyticsEvent
import kotlinx.coroutines.test.runTest
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import kotlin.test.Test
import kotlin.test.assertFails

class WooPosAnalyticsTrackerTest {
private val analyticsTrackerWrapper: AnalyticsTrackerWrapper = mock()
private val commonPropertiesProvider: WooPosAnalyticsCommonPropertiesProvider = mock()

val tracker = WooPosAnalyticsTracker(
analyticsTrackerWrapper,
commonPropertiesProvider,
)

@Test
fun `given an event, when track is called, then it should track the event via wrapper`() = runTest {
// GIVEN
val event = WooPosAnalytics.Event.Test

// WHEN
tracker.track(event)

// THEN
verify(analyticsTrackerWrapper).track(
event,
event.properties
)
}

@Test
fun `given an err, when track is called, then it should track the error via wrapper`() = runTest {
// GIVEN
val error = WooPosAnalytics.Error.Test(
errorContext = Any::class,
errorType = "test",
errorDescription = "test",
)

// WHEN
tracker.track(error)

// THEN
verify(analyticsTrackerWrapper).track(
error,
error.properties,
error.errorContext.simpleName,
error.errorType,
error.errorDescription
)
}

@Test
fun `given an event and common properties, when track is called, then it should track the event with common properties`() = runTest {
// GIVEN
val event = WooPosAnalytics.Event.Test
val commonProperties = mapOf("test" to "test")
whenever(commonPropertiesProvider.commonProperties).thenReturn(commonProperties)

// WHEN
tracker.track(event)

// THEN
verify(analyticsTrackerWrapper).track(
event,
event.properties + commonProperties
)
}

@Test
fun `given an error and common properties, when track is called, then it should track the event with common properties`() = runTest {
// GIVEN
val error = WooPosAnalytics.Error.Test(
errorContext = Any::class,
errorType = "test",
errorDescription = "test",
)
val commonProperties = mapOf("test" to "test")
whenever(commonPropertiesProvider.commonProperties).thenReturn(commonProperties)

// WHEN
tracker.track(error)

// THEN
verify(analyticsTrackerWrapper).track(
error,
error.properties + commonProperties,
error.errorContext.simpleName,
error.errorType,
error.errorDescription
)
}

@Test
fun `given an non woopos event, when track is called, then it throw an exception`() = runTest {
// GIVEN
val event = object : IAnalyticsEvent {
override val name: String = "test"
override val siteless: Boolean = false
override val isPosEvent: Boolean = false
}

// WHEN && THEN
assertFails { tracker.track(event) }
}
}
Loading