Skip to content

Commit

Permalink
Merge pull request #82 from Parsely/engagement_manager_tests
Browse files Browse the repository at this point in the history
  • Loading branch information
wzieba authored Oct 26, 2023
2 parents b2882f5 + 98dfeaa commit 1caf06f
Show file tree
Hide file tree
Showing 4 changed files with 299 additions and 121 deletions.
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Implemented to handle its own queuing of future executions to accomplish
* two things:
* <p>
* 1. Flushing any engaged time before canceling.
* 2. Progressive backoff for long engagements to save data.
*/
class EngagementManager {

private final ParselyTracker parselyTracker;
public Map<String, Object> 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<String, Object> 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<String, Object> baseMetadata = (Map<String, Object>) 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<String, Object> 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<String, Object> baseEventData = (Map<String, Object>) event.get("data");
assert baseEventData != null;
Map<String, Object> 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;
}
}
127 changes: 6 additions & 121 deletions parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}

/**
Expand All @@ -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();
}

/**
Expand Down Expand Up @@ -289,7 +288,7 @@ public void startEngagement(

// Start a new EngagementTask
Map<String, Object> 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();
}

Expand Down Expand Up @@ -365,7 +364,7 @@ public void trackPlay(
// Start a new engagement manager for the video.
@NonNull final Map<String, Object> 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();
}

Expand Down Expand Up @@ -416,7 +415,7 @@ public void resetVideo() {
*
* @param event The event Map to enqueue.
*/
private void enqueueEvent(Map<String, Object> event) {
void enqueueEvent(Map<String, Object> event) {
// Push it onto the queue
eventQueue.add(event);
new QueueManager().execute();
Expand Down Expand Up @@ -722,118 +721,4 @@ private void flushEvents() {
new FlushQueue().execute();
}

/**
* Engagement manager for article and video engagement.
* <p>
* Implemented to handle its own queuing of future executions to accomplish
* two things:
* <p>
* 1. Flushing any engaged time before canceling.
* 2. Progressive backoff for long engagements to save data.
*/
private class EngagementManager {

public Map<String, Object> 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<String, Object> 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<String, Object> baseMetadata = (Map<String, Object>) 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<String, Object> 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<String, Object> baseEventData = (Map<String, Object>) event.get("data");
assert baseEventData != null;
Map<String, Object> data = new HashMap<>((Map<String, Object>) 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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 1caf06f

Please sign in to comment.