From e19fba814d1a7d7e09857563f5ac2236bfb5f39a Mon Sep 17 00:00:00 2001
From: diosmosis <benaka@piwik.pro>
Date: Wed, 10 Jun 2015 01:03:47 -0700
Subject: [PATCH] Add new JS tracking heartbeat feature which replaces old one.
 New one is more performant and executes much less code on user websites.

New piwik.js tests included as well as an additional utility to make writing future tests easier (use jquery to parse tracker requests that are returned from fetchTrackedRequests). However tests don't currently pass since qunit is not super helpful for testing async code.
---
 js/piwik.js                | 159 +++++++++++++++++++++++--------------
 tests/javascript/index.php |  98 ++++++++++++++++++++++-
 2 files changed, 194 insertions(+), 63 deletions(-)

diff --git a/js/piwik.js b/js/piwik.js
index 05af4dbab8..16701cf08c 100644
--- a/js/piwik.js
+++ b/js/piwik.js
@@ -2198,7 +2198,7 @@ if (typeof Piwik !== 'object') {
                 configMinimumVisitTime,
 
                 // Recurring heart beat after initial ping (in milliseconds)
-                configHeartBeatTimer,
+                configHeartBeatDelay,
 
                 // Disallow hash tags in URL
                 configDiscardHashTag,
@@ -2287,10 +2287,13 @@ if (typeof Piwik !== 'object') {
                 linkTrackingEnabled = false,
 
                 // Guard against installing the activity tracker more than once per Tracker instance
-                activityTrackingInstalled = false,
+                heartBeatSetUp = false,
 
-                // Last activity timestamp
-                lastActivityTime,
+                // Timestamp of last tracker request sent to Piwik
+                lastTrackerRequestTime,
+
+                // Handle to the current heart beat timeout
+                heartBeatTimeout,
 
                 // Internal state of the pseudo click handler
                 lastButton,
@@ -2516,6 +2519,9 @@ if (typeof Piwik !== 'object') {
              * Send request
              */
             function sendRequest(request, delay, callback) {
+                if (request.indexOf('ping=') === -1) {
+                    lastTrackerRequestTime = (new Date()).getTime();
+                }
 
                 if (!configDoNotTrack && request) {
                     makeSureThereIsAGapAfterFirstTrackingRequestToPreventMultipleVisitorCreation(function () {
@@ -2528,6 +2534,10 @@ if (typeof Piwik !== 'object') {
                         setExpireDateTime(delay);
                     });
                 }
+
+                if (configHeartBeatDelay) {
+                    heartBeatUp();
+                }
             }
 
             function canSendBulkRequest(requests)
@@ -2617,16 +2627,6 @@ if (typeof Piwik !== 'object') {
                 }
             }
 
-            /*
-             * Process all "activity" events.
-             * For performance, this function must have low overhead.
-             */
-            function activityHandler() {
-                var now = new Date();
-
-                lastActivityTime = now.getTime();
-            }
-
             /*
              * Generate a pseudo-unique ID to fingerprint this user
              * 16 hexits = 64 bits
@@ -3183,47 +3183,84 @@ if (typeof Piwik !== 'object') {
                 sendRequest(request, configTrackerPause);
 
                 // send ping
-                if (configMinimumVisitTime && configHeartBeatTimer && !activityTrackingInstalled) {
-                    activityTrackingInstalled = true;
-
-                    // add event handlers; cross-browser compatibility here varies significantly
-                    // @see http://quirksmode.org/dom/events
-                    addEventListener(documentAlias, 'click', activityHandler);
-                    addEventListener(documentAlias, 'mouseup', activityHandler);
-                    addEventListener(documentAlias, 'mousedown', activityHandler);
-                    addEventListener(documentAlias, 'mousemove', activityHandler);
-                    addEventListener(documentAlias, 'mousewheel', activityHandler);
-                    addEventListener(windowAlias, 'DOMMouseScroll', activityHandler);
-                    addEventListener(windowAlias, 'scroll', activityHandler);
-                    addEventListener(documentAlias, 'keypress', activityHandler);
-                    addEventListener(documentAlias, 'keydown', activityHandler);
-                    addEventListener(documentAlias, 'keyup', activityHandler);
-                    addEventListener(windowAlias, 'resize', activityHandler);
-                    addEventListener(windowAlias, 'focus', activityHandler);
-                    addEventListener(windowAlias, 'blur', activityHandler);
-
-                    // periodic check for activity
-                    lastActivityTime = now.getTime();
-                    setTimeout(function heartBeat() {
-                        var requestPing;
-                        now = new Date();
-
-                        // there was activity during the heart beat period;
-                        // on average, this is going to overstate the visitDuration by configHeartBeatTimer/2
-                        if ((lastActivityTime + configHeartBeatTimer) > now.getTime()) {
-                            // send ping if minimum visit time has elapsed
-                            if (configMinimumVisitTime < now.getTime()) {
-                                requestPing = getRequest('ping=1', customData, 'ping');
-
-                                sendRequest(requestPing, configTrackerPause);
-                            }
+                if (configHeartBeatDelay) {
+                    setUpHeartBeat();
+                }
+            }
 
-                            // resume heart beat
-                            setTimeout(heartBeat, configHeartBeatTimer);
-                        }
-                        // else heart beat cancelled due to inactivity
-                    }, configHeartBeatTimer);
+            /*
+             * Setup event handlers and timeout for initial heart beat.
+             */
+            function setUpHeartBeat() {
+                if (heartBeatSetUp) {
+                    return;
                 }
+
+                heartBeatSetUp = true;
+
+                addEventListener(windowAlias, 'focus', function () {
+                    // since it's possible for a user to come back to a tab after several hours or more, we try to send
+                    // a ping if the page is active. (after the ping is sent, the heart beat timeout will be set)
+                    if (heartBeatPingIfActivity()) {
+                        return;
+                    }
+
+                    heartBeatUp();
+                });
+                addEventListener(windowAlias, 'blur', function () {
+                    heartBeatDown();
+                });
+
+                heartBeatUp();
+            }
+
+            /*
+             * Sets up the heart beat timeout.
+             */
+            function heartBeatUp(delay) {
+                if (heartBeatTimeout) {
+                    return;
+                }
+
+                heartBeatTimeout = setTimeout(function heartBeat() {
+                    heartBeatTimeout = null;
+                    if (heartBeatPingIfActivity()) {
+                        return;
+                    }
+
+                    var now = new Date(),
+                        heartBeatDelay = configHeartBeatDelay - (now.getTime() - lastTrackerRequestTime);
+                    heartBeatDelay = Math.min(configHeartBeatDelay, heartBeatDelay); // sanity check
+                    heartBeatUp(heartBeatDelay)
+                }, delay || configHeartBeatDelay);
+            }
+
+            /*
+             * Removes the heart beat timeout.
+             */
+            function heartBeatDown() {
+                if (!heartBeatTimeout) {
+                    return;
+                }
+
+                clearTimeout(heartBeatTimeout);
+                heartBeatTimeout = null;
+            }
+
+            /*
+             * If there was user activity since the last check, and it's been configHeartBeatDelay seconds
+             * since the last tracker, send a ping request (the heartbeat timeout will be reset by sendRequest).
+             */
+            function heartBeatPingIfActivity() {
+                var now = new Date();
+                if (lastTrackerRequestTime + configHeartBeatDelay < now.getTime()) {
+                    var requestPing = getRequest('ping=1', null, 'ping');
+                    sendRequest(requestPing, configTrackerPause);
+
+                    return true;
+                }
+
+                return false;
             }
 
             /*
@@ -4962,14 +4999,18 @@ if (typeof Piwik !== 'object') {
                 /**
                  * Set heartbeat (in seconds)
                  *
-                 * @param int minimumVisitLength
-                 * @param int heartBeatDelay
+                 * @param int heartBeatDelay Defaults to 15.
                  */
-                setHeartBeatTimer: function (minimumVisitLength, heartBeatDelay) {
-                    var now = new Date();
+                setHeartBeatTimer: function (heartBeatDelay) {
+                    configHeartBeatDelay = (heartBeatDelay || 15) * 1000;
+                },
 
-                    configMinimumVisitTime = now.getTime() + minimumVisitLength * 1000;
-                    configHeartBeatTimer = heartBeatDelay * 1000;
+                /**
+                 * Clear heartbeat.
+                 */
+                clearHeartBeat: function () {
+                    heartBeatDown();
+                    configHeartBeatDelay = null;
                 },
 
                 /**
diff --git a/tests/javascript/index.php b/tests/javascript/index.php
index 4be71f1d79..f45bb06dac 100644
--- a/tests/javascript/index.php
+++ b/tests/javascript/index.php
@@ -18,6 +18,9 @@ function getToken() {
 function getContentToken() {
     return "<?php $token = md5(uniqid(mt_rand(), true)); echo $token; ?>";
 }
+function getHeartbeatToken() {
+    return "<?php $token = md5(uniqid(mt_rand(), true)); echo $token; ?>";
+}
 <?php
 $sqlite = false;
 if (file_exists("enable_sqlite")) {
@@ -231,7 +234,7 @@ function triggerEvent(element, type, buttonNumber) {
      }
  }
 
- function fetchTrackedRequests(token)
+ function fetchTrackedRequests(token, parse)
  {
      var xhr = window.XMLHttpRequest ? new window.XMLHttpRequest() :
          window.ActiveXObject ? new ActiveXObject("Microsoft.XMLHTTP") :
@@ -240,7 +243,18 @@ function triggerEvent(element, type, buttonNumber) {
      xhr.open("GET", "piwik.php?requests=" + token, false);
      xhr.send(null);
 
-     return xhr.responseText;
+     var response = xhr.responseText;
+     if (parse) {
+         var results = [];
+         $(response).filter('span').each(function (i) {
+             if (i != 0) {
+                 results.push($(this).text());
+             }
+         });
+         return results;
+     }
+
+     return response;
  }
 
  function dropCookie(cookieName, path, domain) {
@@ -3087,6 +3101,84 @@ if ($sqlite) {
         }, 5000);
     });
 
+    // heartbeat tests
+    test("trackingHeartBeat", function () {
+        expect(11);
+
+        var tokenBase = getHeartbeatToken();
+
+        var tracker = Piwik.getTracker();
+        tracker.setTrackerUrl("piwik.php");
+        tracker.setSiteId(1);
+        tracker.setHeartBeatTimer(1);
+
+        // test ping heart beat not set up until an initial request tracked
+        tracker.setCustomData('token', 1 + tokenBase);
+        wait(1200);
+
+        // test ping not sent on initial page load, and sent if inactive for N secs.
+        tracker.setCustomData('token', 2 + tokenBase);
+        tracker.trackPageView('whatever'); // normal request sent here
+        triggerEvent(document.body, 'focus');
+
+        wait(1200); // ping request sent after this
+
+        // test ping not sent after N secs, if tracking request sent in the mean time
+        tracker.setCustomData('token', 3 + tokenBase);
+
+        wait(200);
+        tracker.trackPageView('whatever2'); // normal request sent here
+        wait(200);
+
+        wait(1000); // ping request NOT sent here
+
+        // test ping sent N secs after second tracking request if inactive.
+        tracker.setCustomData('token', 4 + tokenBase);
+
+        wait(1000); // ping request sent here
+
+        // test ping not sent N secs after, if window blur event triggered (ie tab switch) and N secs pass.
+        tracker.setCustomData('token', 5 + tokenBase);
+        triggerEvent(document.body, 'blur');
+
+        wait(1000); // ping request not sent here
+
+        // test ping sent immediately if tab switched and more than N secs pass, then tab switched back
+        tracker.setCustomData('token', 6 + tokenBase);
+
+        triggerEvent(document.body, 'focus'); // ping request sent here
+
+        stop();
+        setTimeout(function() {
+            var token;
+
+            var requests = fetchTrackedRequests(token = 1 + tokenBase, true);
+            equal(requests.length, 0, "no requests sent before initial non-ping request sent");
+
+            requests = fetchTrackedRequests(token = 2 + tokenBase, true);
+            ok(/action_name=whatever/.test(requests[0]) && !(/ping=1/.test(requests[0])), "first request is page view not ping");
+            ok(/ping=1/.test(requests[1]), "second request is ping request");
+            equal(requests.length, 2, "only 2 requests sent for normal ping");
+
+            requests = fetchTrackedRequests(token = 3 + tokenBase, true);
+            ok(/action_name=whatever2/.test(requests[0]) && !(/ping=1/.test(requests[0])), "first request is page view not ping");
+            equal(requests.length, 1, "no ping request sent if other request sent in meantime");
+
+            requests = fetchTrackedRequests(token = 4 + tokenBase, true);
+            ok(/ping=1/.test(requests[0]), "ping request sent if no other activity and after heart beat");
+            equal(requests.length, 1, "only ping request sent if no other activity");
+
+            requests = fetchTrackedRequests(token = 5 + tokenBase, true);
+            equal(requests.length, 0, "no requests sent if window not in focus");
+
+            requests = fetchTrackedRequests(token = 6 + tokenBase, true);
+            ok(/ping=1/.test(requests[0]), "ping sent after window regains focus");
+            equal(requests.length, 1, "only one ping request sent after window regains focus");
+
+            start();
+        }, 6000);
+    });
+
     test("trackingContent", function() {
         expect(81);
 
@@ -3566,9 +3658,7 @@ if ($sqlite) {
 
             start();
         }, 4000);
-
     });
-
     <?php
 }
 ?>
-- 
GitLab