Skip to content

Commit 2807e94

Browse files
authored
JS Offline tracking (matomo-org#15970)
* JS Offline tracking * minor tweaks * add some tests * add some tests * apply review feedback
1 parent 06d4385 commit 2807e94

File tree

4 files changed

+231
-1
lines changed

4 files changed

+231
-1
lines changed

core/Tracker/Request.php

+11-1
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ public function getParam($name)
398398
// some visitor attributes can be overwritten
399399
'cip' => array('', 'string'),
400400
'cdt' => array('', 'string'),
401+
'cdo' => array('', 'int'),
401402
'cid' => array('', 'string'),
402403
'uid' => array('', 'string'),
403404

@@ -484,11 +485,16 @@ public function setCurrentTimestamp($timestamp)
484485

485486
protected function getCustomTimestamp()
486487
{
487-
if (!$this->hasParam('cdt')) {
488+
if (!$this->hasParam('cdt') && !$this->hasParam('cdo')) {
488489
return false;
489490
}
490491

491492
$cdt = $this->getParam('cdt');
493+
$cdo = $this->getParam('cdo');
494+
495+
if (empty($cdt) && $cdo) {
496+
$cdt = $this->timestamp;
497+
}
492498

493499
if (empty($cdt)) {
494500
return false;
@@ -498,6 +504,10 @@ protected function getCustomTimestamp()
498504
$cdt = strtotime($cdt);
499505
}
500506

507+
if (!empty($cdo)) {
508+
$cdt = $cdt - abs($cdo);
509+
}
510+
501511
if (!$this->isTimestampValid($cdt, $this->timestamp)) {
502512
Common::printDebug(sprintf("Datetime %s is not valid", date("Y-m-d H:i:m", $cdt)));
503513
return false;

js/piwik.js

+7
Original file line numberDiff line numberDiff line change
@@ -6855,6 +6855,13 @@ if (typeof window.Matomo !== 'object') {
68556855

68566856
// initialize the Matomo singleton
68576857
addEventListener(windowAlias, 'beforeunload', beforeUnloadHandler, false);
6858+
addEventListener(windowAlias, 'online', function () {
6859+
if (isDefined(navigatorAlias.serviceWorker) && isDefined(navigatorAlias.serviceWorker.ready)) {
6860+
navigatorAlias.serviceWorker.ready.then(function(swRegistration) {
6861+
return swRegistration.sync.register('matomoSync');
6862+
});
6863+
}
6864+
}, false);
68586865

68596866
addEventListener(windowAlias,'message', function(e) {
68606867
if (!e || !e.origin) {

offline-service-worker.js

+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
var matomoAnalytics = {initialize: function (options) {
2+
if ('object' !== typeof options) {
3+
options = {};
4+
}
5+
6+
var maxLimitQueue = options.queueLimit || 50;
7+
var maxTimeLimit = options.timeLimit || (60 * 60 * 24); // in seconds...
8+
// same as configured in in tracking_requests_require_authentication_when_custom_timestamp_newer_than
9+
10+
function getQueue()
11+
{
12+
return new Promise(function(resolve, reject) {
13+
// do a thing, possibly async, then...
14+
15+
if (!indexedDB) {
16+
reject(new Error('No support for IndexedDB'));
17+
return;
18+
}
19+
var request = indexedDB.open("matomo", 1);
20+
21+
request.onerror = function() {
22+
console.error("Error", request.error);
23+
reject(new Error(request.error));
24+
};
25+
request.onupgradeneeded = function(event) {
26+
console.log('onupgradeneeded')
27+
var db = event.target.result;
28+
29+
if (!db.objectStoreNames.contains('requests')) {
30+
db.createObjectStore('requests', {autoIncrement : true, keyPath: 'id'});
31+
}
32+
33+
};
34+
request.onsuccess = function(event) {
35+
var db = event.target.result;
36+
let transaction = db.transaction("requests", "readwrite");
37+
let requests =transaction.objectStore("requests");
38+
resolve(requests);
39+
40+
41+
};
42+
});
43+
}
44+
45+
function syncQueue () {
46+
// check something in indexdb
47+
return getQueue().then(function (queue) {
48+
queue.openCursor().onsuccess = function(event) {
49+
var cursor = event.target.result;
50+
if (cursor && navigator.onLine) {
51+
cursor.continue();
52+
var queueId = cursor.value.id;
53+
54+
var secondsQueuedAgo = ((Date.now() - cursor.value.created) / 1000);
55+
secondsQueuedAgo = parseInt(secondsQueuedAgo, 10);
56+
if (secondsQueuedAgo > maxTimeLimit) {
57+
// too old
58+
getQueue().then(function (queue) {
59+
queue.delete(queueId);
60+
});
61+
return;
62+
}
63+
64+
console.log("Cursor " + cursor.key);
65+
66+
var init = {
67+
headers: cursor.value.headers,
68+
method: cursor.value.method,
69+
}
70+
if (cursor.value.body) {
71+
init.body = cursor.value.body;
72+
}
73+
74+
if (cursor.value.url.includes('?')) {
75+
cursor.value.url += '&cdo=' + secondsQueuedAgo;
76+
} else if (init.body) {
77+
// todo test if this actually works for bulk requests
78+
init.body = init.body.replace('&idsite=', '&cdo=' + secondsQueuedAgo + '&idsite=');
79+
}
80+
81+
fetch(cursor.value.url, init).then(function (response) {
82+
console.log('server response', response);
83+
if (response.status < 400) {
84+
getQueue().then(function (queue) {
85+
queue.delete(queueId);
86+
});
87+
}
88+
}).catch(function (error) {
89+
console.error('Send to Server failed:', error);
90+
throw error
91+
})
92+
}
93+
else {
94+
console.log("No more entries!");
95+
}
96+
};
97+
});
98+
}
99+
100+
function limitQueueIfNeeded(queue)
101+
{
102+
var countRequest = queue.count();
103+
countRequest.onsuccess = function(event) {
104+
if (event.result > maxLimitQueue) {
105+
// we delete only one at a time because of concurrency some other process might delete data too
106+
queue.openCursor().onsuccess = function(event) {
107+
var cursor = event.target.result;
108+
if (cursor) {
109+
queue.delete(cursor.value.id);
110+
limitQueueIfNeeded(queue);
111+
}
112+
}
113+
}
114+
}
115+
}
116+
117+
self.addEventListener('sync', function(event) {
118+
if (event.tag === 'matomoSync') {
119+
syncQueue();
120+
}
121+
});
122+
123+
self.addEventListener('fetch', function (event) {
124+
let isOnline = navigator.onLine;
125+
126+
let isTrackingRequest = (event.request.url.includes('/matomo.php')
127+
|| event.request.url.includes('/piwik.php'));
128+
let isTrackerRequest = event.request.url.endsWith('/matomo.js')
129+
|| event.request.url.endsWith('/piwik.js');
130+
131+
if (isTrackerRequest) {
132+
if (isOnline) {
133+
syncQueue();
134+
}
135+
caches.open('matomo').then(function(cache) {
136+
return cache.match(event.request).then(function (response) {
137+
return response || fetch(event.request).then(function(response) {
138+
cache.put(event.request, response.clone());
139+
return response;
140+
});
141+
});
142+
})
143+
} else if (isTrackingRequest && isOnline) {
144+
syncQueue();
145+
event.respondWith(fetch(event.request));
146+
} else if (isTrackingRequest && !isOnline) {
147+
148+
var headers = {};
149+
for (const [header, value] of event.request.headers) {
150+
headers[header] = value;
151+
}
152+
153+
let requestInfo = {
154+
url: event.request.url,
155+
referrer : event.request.referrer,
156+
method : event.request.method,
157+
referrerPolicy : event.request.referrerPolicy,
158+
headers : headers,
159+
created: Date.now()
160+
};
161+
event.request.text().then(function (postData) {
162+
requestInfo.body = postData;
163+
164+
getQueue().then(function (queue) {
165+
queue.add(requestInfo);
166+
limitQueueIfNeeded(queue);
167+
168+
return queue;
169+
});
170+
});
171+
172+
}
173+
});
174+
}
175+
};

tests/PHPUnit/Unit/Tracker/RequestTest.php

+38
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,44 @@ public function test_getCurrentTimestamp_ShouldReturnTheCurrentTimestamp_IfTimes
4848
$this->assertSame($this->time, $request->getCurrentTimestamp());
4949
}
5050

51+
public function test_getCurrentTimestamp_ShouldReturnTheCurrentTimestamp_IfRelativeOffsetIsUsed()
52+
{
53+
$request = $this->buildRequest(array('cdo' => '10'));
54+
$this->assertSame($this->time - 10, $request->getCurrentTimestamp());
55+
}
56+
57+
public function test_getCurrentTimestamp_ShouldReturnTheCurrentTimestamp_IfRelativeOffsetIsUsedIsTooMuchInPastShouldReturnFalseWhenNotAuthenticated()
58+
{
59+
$this->expectException(\Exception::class);
60+
$this->expectExceptionMessage('Custom timestamp is 99990 seconds old, requires &token_auth');
61+
$request = $this->buildRequest(array('cdo' => '99990'));
62+
$this->assertSame($this->time - 10, $request->getCurrentTimestamp());
63+
}
64+
65+
public function test_getCurrentTimestamp_CanUseRelativeOffsetAndCustomTimestamp()
66+
{
67+
$time = time() - 20;
68+
$request = $this->buildRequest(array('cdo' => '10', 'cdt' => $time));
69+
$request->setCurrentTimestamp(time());
70+
$this->assertSame($time - 10, $request->getCurrentTimestamp());
71+
}
72+
73+
public function test_getCurrentTimestamp_CanUseNegativeRelativeOffsetAndCustomTimestamp()
74+
{
75+
$time = time() - 20;
76+
$request = $this->buildRequest(array('cdo' => '-10', 'cdt' => $time));
77+
$request->setCurrentTimestamp(time());
78+
$this->assertSame($time - 10, $request->getCurrentTimestamp());
79+
}
80+
81+
public function test_getCurrentTimestamp_WithCustomTimestamp()
82+
{
83+
$time = time() - 20;
84+
$request = $this->buildRequest(array('cdt' => $time));
85+
$request->setCurrentTimestamp(time());
86+
$this->assertEquals($time, $request->getCurrentTimestamp());
87+
}
88+
5189
public function test_isEmptyRequest_ShouldReturnTrue_InCaseNoParamsSet()
5290
{
5391
$request = $this->buildRequest(array());

0 commit comments

Comments
 (0)