diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java new file mode 100644 index 00000000..fc470bd5 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java @@ -0,0 +1,120 @@ +package com.parsely.parselyandroid; + +import java.util.Calendar; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; +import java.util.Timer; +import java.util.TimerTask; + +/** + * Engagement manager for article and video engagement. + *

+ * Implemented to handle its own queuing of future executions to accomplish + * two things: + *

+ * 1. Flushing any engaged time before canceling. + * 2. Progressive backoff for long engagements to save data. + */ +class EngagementManager { + + private final ParselyTracker parselyTracker; + public Map baseEvent; + private boolean started; + private final Timer parentTimer; + private TimerTask waitingTimerTask; + private long latestDelayMillis, totalTime; + private Calendar startTime; + private final UpdateEngagementIntervalCalculator intervalCalculator; + + public EngagementManager( + ParselyTracker parselyTracker, + Timer parentTimer, + long intervalMillis, + Map baseEvent, + UpdateEngagementIntervalCalculator intervalCalculator + ) { + this.parselyTracker = parselyTracker; + this.baseEvent = baseEvent; + this.parentTimer = parentTimer; + this.intervalCalculator = intervalCalculator; + latestDelayMillis = intervalMillis; + totalTime = 0; + startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + } + + public boolean isRunning() { + return started; + } + + public void start() { + scheduleNextExecution(latestDelayMillis); + started = true; + startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + } + + public void stop() { + waitingTimerTask.cancel(); + started = false; + } + + public boolean isSameVideo(String url, String urlRef, ParselyVideoMetadata metadata) { + Map baseMetadata = (Map) baseEvent.get("metadata"); + return (baseEvent.get("url").equals(url) && + baseEvent.get("urlref").equals(urlRef) && + baseMetadata.get("link").equals(metadata.link) && + (int) (baseMetadata.get("duration")) == metadata.durationSeconds); + } + + private void scheduleNextExecution(long delay) { + TimerTask task = new TimerTask() { + public void run() { + doEnqueue(scheduledExecutionTime()); + latestDelayMillis = intervalCalculator.updateLatestInterval(startTime); + scheduleNextExecution(latestDelayMillis); + } + + public boolean cancel() { + boolean output = super.cancel(); + // Only enqueue when we actually canceled something. If output is false then + // this has already been canceled. + if (output) { + doEnqueue(scheduledExecutionTime()); + } + return output; + } + }; + latestDelayMillis = delay; + parentTimer.schedule(task, delay); + waitingTimerTask = task; + } + + private void doEnqueue(long scheduledExecutionTime) { + // Create a copy of the base event to enqueue + Map event = new HashMap<>(baseEvent); + ParselyTracker.PLog(String.format("Enqueuing %s event.", event.get("action"))); + + // Update `ts` for the event since it's happening right now. + Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + @SuppressWarnings("unchecked") + Map baseEventData = (Map) event.get("data"); + assert baseEventData != null; + Map data = new HashMap<>(baseEventData); + data.put("ts", now.getTimeInMillis()); + event.put("data", data); + + // Adjust inc by execution time in case we're late or early. + long executionDiff = (System.currentTimeMillis() - scheduledExecutionTime); + long inc = (latestDelayMillis + executionDiff); + totalTime += inc; + event.put("inc", inc / 1000); + event.put("tt", totalTime); + + parselyTracker.enqueueEvent(event); + } + + + public double getIntervalMillis() { + return latestDelayMillis; + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 47e848c2..49feabaa 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -38,12 +38,10 @@ import java.io.ObjectOutputStream; import java.io.StringWriter; import java.util.ArrayList; -import java.util.Calendar; import java.util.Formatter; import java.util.HashMap; import java.util.HashSet; import java.util.Map; -import java.util.TimeZone; import java.util.Timer; import java.util.TimerTask; import java.util.UUID; @@ -74,6 +72,7 @@ public class ParselyTracker { private String lastPageviewUuid = null; @NonNull private final EventsBuilder eventsBuilder; + @NonNull final UpdateEngagementIntervalCalculator intervalCalculator = new UpdateEngagementIntervalCalculator(); /** * Create a new ParselyTracker instance. @@ -176,7 +175,7 @@ public double getVideoEngagementInterval() { * @return Whether the engagement tracker is running. */ public boolean engagementIsActive() { - return engagementManager != null && engagementManager.started; + return engagementManager != null && engagementManager.isRunning(); } /** @@ -185,7 +184,7 @@ public boolean engagementIsActive() { * @return Whether video tracking is active. */ public boolean videoIsActive() { - return videoEngagementManager != null && videoEngagementManager.started; + return videoEngagementManager != null && videoEngagementManager.isRunning(); } /** @@ -289,7 +288,7 @@ public void startEngagement( // Start a new EngagementTask Map event = eventsBuilder.buildEvent(url, urlRef, "heartbeat", null, extraData, lastPageviewUuid); - engagementManager = new EngagementManager(timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event); + engagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator); engagementManager.start(); } @@ -365,7 +364,7 @@ public void trackPlay( // Start a new engagement manager for the video. @NonNull final Map hbEvent = eventsBuilder.buildEvent(url, urlRef, "vheartbeat", videoMetadata, extraData, uuid); // TODO: Can we remove some metadata fields from this request? - videoEngagementManager = new EngagementManager(timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent); + videoEngagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator); videoEngagementManager.start(); } @@ -416,7 +415,7 @@ public void resetVideo() { * * @param event The event Map to enqueue. */ - private void enqueueEvent(Map event) { + void enqueueEvent(Map event) { // Push it onto the queue eventQueue.add(event); new QueueManager().execute(); @@ -722,118 +721,4 @@ private void flushEvents() { new FlushQueue().execute(); } - /** - * Engagement manager for article and video engagement. - *

- * Implemented to handle its own queuing of future executions to accomplish - * two things: - *

- * 1. Flushing any engaged time before canceling. - * 2. Progressive backoff for long engagements to save data. - */ - private class EngagementManager { - - public Map baseEvent; - private boolean started; - private final Timer parentTimer; - private TimerTask waitingTimerTask; - private long latestDelayMillis, totalTime; - private Calendar startTime; - - private static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; - private static final long OFFSET_MATCHING_BASE_INTERVAL = 35; - private static final double BACKOFF_PROPORTION = 0.3; - - - public EngagementManager(Timer parentTimer, long intervalMillis, Map baseEvent) { - this.baseEvent = baseEvent; - this.parentTimer = parentTimer; - latestDelayMillis = intervalMillis; - totalTime = 0; - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - } - - public boolean isRunning() { - return started; - } - - public void start() { - scheduleNextExecution(latestDelayMillis); - started = true; - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - } - - public void stop() { - waitingTimerTask.cancel(); - started = false; - } - - public boolean isSameVideo(String url, String urlRef, ParselyVideoMetadata metadata) { - Map baseMetadata = (Map) baseEvent.get("metadata"); - return (baseEvent.get("url").equals(url) && - baseEvent.get("urlref").equals(urlRef) && - baseMetadata.get("link").equals(metadata.link) && - (int) (baseMetadata.get("duration")) == metadata.durationSeconds); - } - - private void scheduleNextExecution(long delay) { - TimerTask task = new TimerTask() { - public void run() { - doEnqueue(scheduledExecutionTime()); - updateLatestInterval(); - scheduleNextExecution(latestDelayMillis); - } - - public boolean cancel() { - boolean output = super.cancel(); - // Only enqueue when we actually canceled something. If output is false then - // this has already been canceled. - if (output) { - doEnqueue(scheduledExecutionTime()); - } - return output; - } - }; - latestDelayMillis = delay; - parentTimer.schedule(task, delay); - waitingTimerTask = task; - } - - private void doEnqueue(long scheduledExecutionTime) { - // Create a copy of the base event to enqueue - Map event = new HashMap<>(baseEvent); - PLog(String.format("Enqueuing %s event.", event.get("action"))); - - // Update `ts` for the event since it's happening right now. - Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - @SuppressWarnings("unchecked") - Map baseEventData = (Map) event.get("data"); - assert baseEventData != null; - Map data = new HashMap<>((Map) baseEventData); - data.put("ts", now.getTimeInMillis()); - event.put("data", data); - - // Adjust inc by execution time in case we're late or early. - long executionDiff = (System.currentTimeMillis() - scheduledExecutionTime); - long inc = (latestDelayMillis + executionDiff); - totalTime += inc; - event.put("inc", inc / 1000); - event.put("tt", totalTime); - - enqueueEvent(event); - } - - private void updateLatestInterval() { - Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - long totalTrackedTime = (now.getTime().getTime() - startTime.getTime().getTime()) / 1000; - double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; - double newInterval = totalWithOffset * BACKOFF_PROPORTION; - long clampedNewInterval = (long)Math.min(MAX_TIME_BETWEEN_HEARTBEATS, newInterval); - latestDelayMillis = clampedNewInterval * 1000; - } - - public double getIntervalMillis() { - return latestDelayMillis; - } - } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java new file mode 100644 index 00000000..d85afb1a --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java @@ -0,0 +1,22 @@ +package com.parsely.parselyandroid; + +import androidx.annotation.NonNull; + +import java.util.Calendar; +import java.util.TimeZone; + +class UpdateEngagementIntervalCalculator { + + private static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; + private static final long OFFSET_MATCHING_BASE_INTERVAL = 35; + private static final double BACKOFF_PROPORTION = 0.3; + + long updateLatestInterval(@NonNull final Calendar startTime) { + Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + long totalTrackedTime = (now.getTime().getTime() - startTime.getTime().getTime()) / 1000; + double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; + double newInterval = totalWithOffset * BACKOFF_PROPORTION; + long clampedNewInterval = (long) Math.min(MAX_TIME_BETWEEN_HEARTBEATS, newInterval); + return clampedNewInterval * 1000; + } +} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt new file mode 100644 index 00000000..19732f3b --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -0,0 +1,151 @@ +package com.parsely.parselyandroid + +import androidx.test.core.app.ApplicationProvider +import java.util.Calendar +import java.util.TimeZone +import java.util.Timer +import org.assertj.core.api.AbstractLongAssert +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.assertj.core.api.Assertions.withinPercentage +import org.assertj.core.api.MapAssert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +private typealias Event = MutableMap + +@RunWith(RobolectricTestRunner::class) +internal class EngagementManagerTest { + + private lateinit var sut: EngagementManager + private val tracker = FakeTracker() + private val parentTimer = Timer() + private val baseEvent: Event = mutableMapOf( + "action" to "heartbeat", + "data" to testData + ) + + @Before + fun setUp() { + sut = EngagementManager( + tracker, + parentTimer, + DEFAULT_INTERVAL_MILLIS, + baseEvent, + FakeIntervalCalculator() + ) + } + + @Test + fun `when starting manager, then record the correct event after interval millis`() { + // when + sut.start() + sleep(DEFAULT_INTERVAL_MILLIS) + val timestamp = now - THREAD_SLEEPING_THRESHOLD + + // then + assertThat(tracker.events[0]).isCorrectEvent( + // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS + withTotalTime = { isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) }, + // Ideally: timestamp should be equal to System.currentTimeMillis() at the time of recording the event + withTimestamp = { isCloseTo(timestamp, within(20L)) } + ) + } + + @Test + fun `when starting manager, then schedule task each interval period`() { + sut.start() + + sleep(DEFAULT_INTERVAL_MILLIS) + val firstTimestamp = now - THREAD_SLEEPING_THRESHOLD + + sleep(DEFAULT_INTERVAL_MILLIS) + val secondTimestamp = now - 2 * THREAD_SLEEPING_THRESHOLD + + sleep(DEFAULT_INTERVAL_MILLIS) + val thirdTimestamp = now - 3 * THREAD_SLEEPING_THRESHOLD + + sleep(THREAD_SLEEPING_THRESHOLD) + + val firstEvent = tracker.events[0] + assertThat(firstEvent).isCorrectEvent( + // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS + withTotalTime = { isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) }, + // Ideally: timestamp should be equal to `now` at the time of recording the event + withTimestamp = { isCloseTo(firstTimestamp, within(20L)) } + ) + val secondEvent = tracker.events[1] + assertThat(secondEvent).isCorrectEvent( + // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS * 2 + withTotalTime = { isCloseTo(DEFAULT_INTERVAL_MILLIS * 2, withinPercentage(10)) }, + // Ideally: timestamp should be equal to `now` at the time of recording the event + withTimestamp = { isCloseTo(secondTimestamp, within(20L)) } + ) + val thirdEvent = tracker.events[2] + assertThat(thirdEvent).isCorrectEvent( + // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS * 3 + withTotalTime = { isCloseTo(DEFAULT_INTERVAL_MILLIS * 3, withinPercentage(10)) }, + // Ideally: timestamp should be equal to `now` at the time of recording the event + withTimestamp = { isCloseTo(thirdTimestamp, within(20L)) } + ) + } + + private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) + + private fun MapAssert.isCorrectEvent( + withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, + withTimestamp: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, + ): MapAssert { + return containsEntry("action", "heartbeat") + // Incremental will be always 0 because the interval is lower than 1s + .containsEntry("inc", 0L) + .hasEntrySatisfying("tt") { totalTime -> + totalTime as Long + assertThat(totalTime).withTotalTime() + } + .hasEntrySatisfying("data") { data -> + @Suppress("UNCHECKED_CAST") + data as Map + assertThat(data).hasEntrySatisfying("ts") { timestamp -> + timestamp as Long + assertThat(timestamp).withTimestamp() + }.containsAllEntriesOf(testData.minus("ts")) + } + } + + private val now: Long + get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis + + class FakeTracker : ParselyTracker( + "", + 0, + ApplicationProvider.getApplicationContext() + ) { + val events = mutableListOf() + + override fun enqueueEvent(event: Event) { + events += event + } + } + + class FakeIntervalCalculator : UpdateEngagementIntervalCalculator() { + override fun updateLatestInterval(startTime: Calendar): Long { + return DEFAULT_INTERVAL_MILLIS + } + } + + private companion object { + const val DEFAULT_INTERVAL_MILLIS = 100L + // Additional time to wait to ensure that the timer has fired + const val THREAD_SLEEPING_THRESHOLD = 50L + val testData = mutableMapOf( + "os" to "android", + "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", + "os_version" to "34", + "manufacturer" to "Google", + "ts" to 123L + ) + } +}