Skip to content

Commit

Permalink
chore(sdk): swap out sharedpreferences (#11)
Browse files Browse the repository at this point in the history
Introduces the EventPipeline, in which events are collected and persisted asynchronously (with relation the main thread), and batched for upload.

Closes:
https://topsort.atlassian.net/browse/API-797
https://topsort.atlassian.net/browse/API-800

PR: #11
  • Loading branch information
anonvt authored Aug 9, 2024
1 parent 5764c74 commit 23abd65
Show file tree
Hide file tree
Showing 14 changed files with 737 additions and 22 deletions.
5 changes: 4 additions & 1 deletion TopsortAnalytics/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,17 @@ dependencies {
implementation 'androidx.core:core-ktx:1.13.1'

//Work Manager
implementation "androidx.work:work-runtime-ktx:2.9.0"
implementation 'androidx.work:work-runtime-ktx:2.9.0'
implementation 'androidx.datastore:datastore-preferences:1.1.1'

//JodaTime
implementation group: 'joda-time', name: 'joda-time', version: '2.12.5'

testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20200518'
testImplementation 'org.assertj:assertj-core:3.26.0'
androidTestImplementation 'org.assertj:assertj-core:3.26.0'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation 'androidx.work:work-testing:2.9.0'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package com.topsort.analytics

import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.impl.utils.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import com.topsort.analytics.core.Logger
import com.topsort.analytics.model.Click
import com.topsort.analytics.model.ClickEvent
import com.topsort.analytics.model.Event
import com.topsort.analytics.model.Impression
import com.topsort.analytics.model.ImpressionEvent
import com.topsort.analytics.model.Purchase
import com.topsort.analytics.model.PurchaseEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONArray
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class EventPipelineTest {

private lateinit var workManager: WorkManager

@Before
fun setup() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext

val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setExecutor(SynchronousExecutor())
.build()

// Initialize WorkManager for instrumentation tests.
WorkManagerTestInitHelper.initializeTestWorkManager(appContext, config)

workManager = WorkManager.getInstance(appContext)

EventPipeline.setup(appContext)
Logger.log.clear()
}

@Test
fun impressions_are_batched() {
val impressions1 = listOf(
getImpressionPromoted(),
getImpressionOrganic()
)
val impressions2 = listOf(
getImpressionPromoted(),
getImpressionOrganic()
)

runBlocking {
EventPipeline.clear()
val job1 = EventPipeline.storeImpression(
ImpressionEvent(impressions1),
shouldFlush = false
)
val job2 = EventPipeline.storeImpression(
ImpressionEvent(impressions2),
shouldFlush = false
)

// Make sure they're persisted
job1.join()
job2.join()

val storedStr = EventPipeline.readImpressions()
val storedDeserialized = Impression.Factory.fromJsonArray(JSONArray("[$storedStr]"))

assertThat(storedDeserialized).containsExactlyInAnyOrderElementsOf(impressions1 + impressions2)
}
}

@Test
fun clicks_are_batched() {
val clicks1 = listOf(
getClickPromoted(),
getClickOrganic(),
)
val clicks2 = listOf(
getClickPromoted(),
getClickOrganic(),
)

runBlocking {
EventPipeline.clear()
val job1 = EventPipeline.storeClick(
ClickEvent(clicks1),
shouldFlush = false
)
val job2 = EventPipeline.storeClick(
ClickEvent(clicks2),
shouldFlush = false
)

// Make sure they're persisted
job1.join()
job2.join()

val storedStr = EventPipeline.readClicks()
val storedDeserialized = Click.Factory.fromJsonArray(JSONArray("[$storedStr]"))

assertThat(storedDeserialized).containsExactlyInAnyOrderElementsOf(clicks1 + clicks2)
}
}

@Test
fun purchases_are_batched() {
val purchases1 = listOf(
getRandomPurchase(),
getRandomPurchase(),
)
val purchases2 = listOf(
getRandomPurchase(),
getRandomPurchase(),
)

runBlocking {
EventPipeline.clear()
val job1 = EventPipeline.storePurchase(
PurchaseEvent(purchases1),
shouldFlush = false
)
val job2 = EventPipeline.storePurchase(
PurchaseEvent(purchases2),
shouldFlush = false
)

// Make sure they're persisted
job1.join()
job2.join()

val storedStr = EventPipeline.readPurchases()
val storedDeserialized = Purchase.fromJsonArray(JSONArray("[$storedStr]"))

assertThat(storedDeserialized).containsExactlyInAnyOrderElementsOf(purchases1 + purchases2)
}
}

@Test
fun aggregate_joins_Events() {
val impressions = listOf(
getImpressionPromoted(),
getImpressionOrganic()
)

val clicks = listOf(
getClickPromoted(),
getClickOrganic(),
)

val purchases = listOf(
getRandomPurchase(),
getRandomPurchase(),
)

runBlocking {
EventPipeline.clear()
val aggregated = Event(
impressions = impressions,
clicks = clicks,
purchases = purchases
)
val job1 = EventPipeline.storeImpression(
ImpressionEvent(impressions),
shouldFlush = false
)
val job2 = EventPipeline.storeClick(
ClickEvent(clicks),
shouldFlush = false
)
val job3 = EventPipeline.storePurchase(
PurchaseEvent(purchases),
shouldFlush = false
)

// Make sure they're persisted
job1.join()
job2.join()
job3.join()

val storedEvent = EventPipeline.aggregateEvents()

assertThat(aggregated).isEqualTo(storedEvent)
}
}

@Test
fun events_are_uploaded() {
val impressions = listOf(
getImpressionPromoted(),
getImpressionOrganic()
)

val aggregated = Event(
impressions = impressions,
)

runBlocking {
EventPipeline.clear()

EventPipeline.storeImpression(ImpressionEvent(impressions))
.join()

// Wait for the upload work to be done
while (
workManager.getWorkInfos(WorkQuery.fromUniqueWorkNames(UPLOAD_SIGNAL))
.get().first().state != WorkInfo.State.SUCCEEDED
) {
yield()
}

}

assertThat(Logger.log).contains("uploading: ${aggregated.toJsonObject()}")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.topsort.analytics

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.topsort.analytics.model.Click
import com.topsort.analytics.model.Impression
import com.topsort.analytics.model.Purchase
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
internal class JsonTestAndroid {

@Test
fun json_click_serialization() {
val clicks = listOf(
getClickPromoted(),
getClickOrganic()
)

for(click in clicks) {
val serialized = click.toJsonObject().toString()
val deserialized = Click.Factory.fromJsonObject(JSONObject(serialized))

assertThat(click).isNotSameAs(deserialized)
assertThat(click).isEqualTo(deserialized)
}
}

@Test
fun json_impression_serialization() {
val impressions = listOf(
getImpressionPromoted(),
getImpressionOrganic()
)

for(impression in impressions) {
val serialized = impression.toJsonObject().toString()
val deserialized = Impression.Factory.fromJsonObject(JSONObject(serialized))

assertThat(impression).isNotSameAs(deserialized)
assertThat(impression).isEqualTo(deserialized)
}
}

@Test
fun json_purchase_serialization() {
val purchase = getRandomPurchase()
val serialized = purchase.toJsonObject().toString()
val deserialized = Purchase.fromJsonObject(JSONObject(serialized))

assertThat(purchase).isNotSameAs(deserialized)
assertThat(purchase).isEqualTo(deserialized)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.topsort.analytics

import com.topsort.analytics.core.eventNow
import com.topsort.analytics.core.randomId
import com.topsort.analytics.model.Click
import com.topsort.analytics.model.Entity
import com.topsort.analytics.model.EntityType
import com.topsort.analytics.model.Impression
import com.topsort.analytics.model.Placement
import com.topsort.analytics.model.Purchase
import com.topsort.analytics.model.PurchasedItem

fun getClickPromoted() : Click {
return Click.Factory.buildPromoted(
placement = getTestPlacement(),
occurredAt = eventNow(),
opaqueUserId = randomId("oId_"),
id = randomId("mktId_"),
resolvedBidId = randomId("resolvedBid_"),
additionalAttribution = "{\"additional\":\"attribution click\"}",
)
}

fun getClickOrganic() : Click {
return Click.Factory.buildOrganic(
placement = getTestPlacement(),
entity = Entity(
type = EntityType.Product,
id = randomId("product_"),
),
occurredAt = eventNow(),
opaqueUserId = randomId("oId_"),
id = randomId("mktId_"),
additionalAttribution = "{\"additional\":\"attribution click\"}",
)
}

fun getImpressionPromoted() : Impression {
return Impression.Factory.buildPromoted (
placement = getTestPlacement(),
occurredAt = eventNow(),
opaqueUserId = randomId("oId_"),
id = randomId("mktId_"),
resolvedBidId = randomId("resolvedBid_"),
additionalAttribution = "{\"additional\":\"attribution impression\"}",
)
}

fun getImpressionOrganic() : Impression {
return Impression.Factory.buildOrganic (
placement = getTestPlacement(),
entity = Entity(
type = EntityType.Product,
id = randomId("product_"),
),
occurredAt = eventNow(),
opaqueUserId = randomId("oId_"),
id = randomId("mktId_"),
additionalAttribution = "{\"additional\":\"attribution impression\"}",
)
}

fun getRandomPurchase() : Purchase {
return Purchase(
opaqueUserId = randomId("oId_"),
occurredAt = eventNow(),
items = listOf(
PurchasedItem(
productId = randomId("p_"),
quantity = 1,
unitPrice = 100,
resolvedBidId = randomId("resolvedBid_"),
)
),
id = randomId("orderId_"),
)
}

private fun getTestPlacement() : Placement {
return Placement(
path = "test",
position = 2,
page = 1,
pageSize = 20,
productId = randomId(),
categoryIds = listOf("cat1", "cat2"),
searchQuery = "search query",
location = "gibraltar",
)
}
Loading

0 comments on commit 23abd65

Please sign in to comment.