Skip to content

Commit ca714a7

Browse files
authored
Merge pull request #95 from Parsely/engagement_manager_coroutines
Engagement manager to coroutines
2 parents 62cfa12 + d282f8b commit ca714a7

File tree

8 files changed

+328
-195
lines changed

8 files changed

+328
-195
lines changed

parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import okhttp3.mockwebserver.MockResponse
2828
import okhttp3.mockwebserver.MockWebServer
2929
import okhttp3.mockwebserver.RecordedRequest
3030
import org.assertj.core.api.Assertions.assertThat
31+
import org.assertj.core.api.Assertions.within
3132
import org.junit.Test
3233
import org.junit.runner.RunWith
3334

@@ -190,6 +191,96 @@ class FunctionalTests {
190191
}
191192
}
192193

194+
/**
195+
* In this scenario consumer app starts an engagement session and after 27150 ms,
196+
* it stops the session.
197+
*
198+
* Intervals:
199+
* With current implementation of `HeartbeatIntervalCalculator`, the next intervals are:
200+
* - 10500ms for the first interval
201+
* - 13650ms for the second interval
202+
*
203+
* So after ~27,2s we should observe
204+
* - 2 `heartbeat` events from `startEngagement` + 1 `heartbeat` event caused by `stopEngagement` which is triggered during engagement interval
205+
*
206+
* Time off-differences in assertions are acceptable, because it's a time-sensitive test
207+
*/
208+
@Test
209+
fun engagementManagerTest() {
210+
val engagementUrl = "engagementUrl"
211+
var startTimestamp = Duration.ZERO
212+
val firstInterval = 10500.milliseconds
213+
val secondInterval = 13650.milliseconds
214+
val pauseInterval = 3.seconds
215+
ActivityScenario.launch(SampleActivity::class.java).use { scenario ->
216+
// given
217+
scenario.onActivity { activity: Activity ->
218+
beforeEach(activity)
219+
server.enqueue(MockResponse().setResponseCode(200))
220+
parselyTracker = initializeTracker(activity, flushInterval = 30.seconds)
221+
222+
// when
223+
startTimestamp = System.currentTimeMillis().milliseconds
224+
parselyTracker.startEngagement(engagementUrl, null)
225+
}
226+
227+
Thread.sleep((firstInterval + secondInterval + pauseInterval).inWholeMilliseconds)
228+
parselyTracker.stopEngagement()
229+
230+
// then
231+
val request = server.takeRequest(35, TimeUnit.SECONDS)!!.toMap()["events"]!!
232+
233+
assertThat(
234+
request.sortedBy { it.data.timestamp }
235+
.filter { it.action == "heartbeat" }
236+
).hasSize(3)
237+
.satisfies({
238+
val firstEvent = it[0]
239+
val secondEvent = it[1]
240+
val thirdEvent = it[2]
241+
242+
assertThat(firstEvent.data.timestamp).isCloseTo(
243+
(startTimestamp + firstInterval).inWholeMilliseconds,
244+
within(1.seconds.inWholeMilliseconds)
245+
)
246+
assertThat(firstEvent.totalTime).isCloseTo(
247+
firstInterval.inWholeMilliseconds,
248+
within(100L)
249+
)
250+
assertThat(firstEvent.incremental).isCloseTo(
251+
firstInterval.inWholeSeconds,
252+
within(1L)
253+
)
254+
255+
assertThat(secondEvent.data.timestamp).isCloseTo(
256+
(startTimestamp + firstInterval + secondInterval).inWholeMilliseconds,
257+
within(1.seconds.inWholeMilliseconds)
258+
)
259+
assertThat(secondEvent.totalTime).isCloseTo(
260+
(firstInterval + secondInterval).inWholeMilliseconds,
261+
within(100L)
262+
)
263+
assertThat(secondEvent.incremental).isCloseTo(
264+
secondInterval.inWholeSeconds,
265+
within(1L)
266+
)
267+
268+
assertThat(thirdEvent.data.timestamp).isCloseTo(
269+
(startTimestamp + firstInterval + secondInterval + pauseInterval).inWholeMilliseconds,
270+
within(1.seconds.inWholeMilliseconds)
271+
)
272+
assertThat(thirdEvent.totalTime).isCloseTo(
273+
(firstInterval + secondInterval + pauseInterval).inWholeMilliseconds,
274+
within(100L)
275+
)
276+
assertThat(thirdEvent.incremental).isCloseTo(
277+
(pauseInterval).inWholeSeconds,
278+
within(1L)
279+
)
280+
})
281+
}
282+
}
283+
193284
private fun RecordedRequest.toMap(): Map<String, List<Event>> {
194285
val listType: TypeReference<Map<String, List<Event>>> =
195286
object : TypeReference<Map<String, List<Event>>>() {}
@@ -200,6 +291,15 @@ class FunctionalTests {
200291
@JsonIgnoreProperties(ignoreUnknown = true)
201292
data class Event(
202293
@JsonProperty("idsite") var idsite: String,
294+
@JsonProperty("action") var action: String,
295+
@JsonProperty("data") var data: ExtraData,
296+
@JsonProperty("tt") var totalTime: Long,
297+
@JsonProperty("inc") var incremental: Long,
298+
)
299+
300+
@JsonIgnoreProperties(ignoreUnknown = true)
301+
data class ExtraData(
302+
@JsonProperty("ts") var timestamp: Long,
203303
)
204304

205305
private val locallyStoredEvents

parsely/src/main/java/com/parsely/parselyandroid/Clock.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ package com.parsely.parselyandroid
22

33
import java.util.Calendar
44
import java.util.TimeZone
5+
import kotlin.time.Duration
56
import kotlin.time.Duration.Companion.milliseconds
67

78
open class Clock {
8-
open val now
9+
open val now: Duration
910
get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis.milliseconds
1011
}

parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java

Lines changed: 0 additions & 120 deletions
This file was deleted.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.parsely.parselyandroid
2+
3+
import kotlin.time.Duration
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.Job
6+
import kotlinx.coroutines.delay
7+
import kotlinx.coroutines.isActive
8+
import kotlinx.coroutines.launch
9+
10+
/**
11+
* Engagement manager for article and video engagement.
12+
*
13+
*
14+
* Implemented to handle its own queuing of future executions to accomplish
15+
* two things:
16+
*
17+
*
18+
* 1. Flushing any engaged time before canceling.
19+
* 2. Progressive backoff for long engagements to save data.
20+
*/
21+
internal class EngagementManager(
22+
private val parselyTracker: ParselyTracker,
23+
private var latestDelayMillis: Long,
24+
private val baseEvent: Map<String, Any>,
25+
private val intervalCalculator: HeartbeatIntervalCalculator,
26+
private val coroutineScope: CoroutineScope,
27+
private val clock: Clock,
28+
) {
29+
private var job: Job? = null
30+
private var totalTime: Long = 0
31+
private var nextScheduledExecution: Long = 0
32+
33+
val isRunning: Boolean
34+
get() = job?.isActive ?: false
35+
36+
fun start() {
37+
val startTime = clock.now
38+
job = coroutineScope.launch {
39+
while (isActive) {
40+
latestDelayMillis = intervalCalculator.calculate(startTime)
41+
nextScheduledExecution = clock.now.inWholeMilliseconds + latestDelayMillis
42+
delay(latestDelayMillis)
43+
doEnqueue(clock.now.inWholeMilliseconds)
44+
}
45+
}
46+
}
47+
48+
fun stop() {
49+
job?.let {
50+
it.cancel()
51+
doEnqueue(nextScheduledExecution)
52+
}
53+
}
54+
55+
fun isSameVideo(url: String, urlRef: String, metadata: ParselyVideoMetadata): Boolean {
56+
val baseMetadata = baseEvent["metadata"] as Map<String, Any>?
57+
return baseEvent["url"] == url && baseEvent["urlref"] == urlRef && baseMetadata!!["link"] == metadata.link && baseMetadata["duration"] as Int == metadata.durationSeconds
58+
}
59+
60+
private fun doEnqueue(scheduledExecutionTime: Long) {
61+
// Create a copy of the base event to enqueue
62+
val event: MutableMap<String, Any> = HashMap(
63+
baseEvent
64+
)
65+
ParselyTracker.PLog(String.format("Enqueuing %s event.", event["action"]))
66+
67+
// Update `ts` for the event since it's happening right now.
68+
val baseEventData = (event["data"] as Map<String, Any>?)!!
69+
val data: MutableMap<String, Any> = HashMap(baseEventData)
70+
data["ts"] = clock.now.inWholeMilliseconds
71+
event["data"] = data
72+
73+
// Adjust inc by execution time in case we're late or early.
74+
val executionDiff = clock.now.inWholeMilliseconds - scheduledExecutionTime
75+
val inc = latestDelayMillis + executionDiff
76+
totalTime += inc
77+
event["inc"] = inc / 1000
78+
event["tt"] = totalTime
79+
parselyTracker.enqueueEvent(event)
80+
}
81+
82+
val intervalMillis: Double
83+
get() = latestDelayMillis.toDouble()
84+
}

parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
package com.parsely.parselyandroid
22

3-
import java.util.Calendar
4-
import kotlin.time.Duration.Companion.hours
5-
import kotlin.time.Duration.Companion.milliseconds
3+
import kotlin.time.Duration
64
import kotlin.time.Duration.Companion.minutes
75
import kotlin.time.Duration.Companion.seconds
86

97
internal open class HeartbeatIntervalCalculator(private val clock: Clock) {
108

11-
open fun calculate(startTime: Calendar): Long {
12-
val startTimeDuration = startTime.time.time.milliseconds
9+
open fun calculate(startTime: Duration): Long {
1310
val nowDuration = clock.now
1411

15-
val totalTrackedTime = nowDuration - startTimeDuration
12+
val totalTrackedTime = nowDuration - startTime
1613
val totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL
1714
val newInterval = totalWithOffset * BACKOFF_PROPORTION
1815
val clampedNewInterval = minOf(MAX_TIME_BETWEEN_HEARTBEATS, newInterval)

0 commit comments

Comments
 (0)