debian-mirror-gitlab/snowplow-javascript-tracker/src/js/out_queue.js
2020-04-13 14:06:41 +05:30

370 lines
12 KiB
JavaScript

/*
* JavaScript tracker for Snowplow: out_queue.js
*
* Significant portions copyright 2010 Anthon Pang. Remainder copyright
* 2012-2014 Snowplow Analytics Ltd. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* * Neither the name of Anthon Pang nor Snowplow Analytics Ltd nor the
* names of their contributors may be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
;(function() {
var
mapValues = require('lodash/mapValues'),
isString = require('lodash/isString'),
map = require('lodash/map'),
localStorageAccessible = require('./lib/detectors').localStorageAccessible,
helpers = require('./lib/helpers'),
object = typeof exports !== 'undefined' ? exports : this; // For eventual node.js environment support
/**
* Object handling sending events to a collector.
* Instantiated once per tracker instance.
*
* @param string functionName The Snowplow function name (used to generate the localStorage key)
* @param string namespace The tracker instance's namespace (used to generate the localStorage key)
* @param object mutSnowplowState Gives the pageUnloadGuard a reference to the outbound queue
* so it can unload the page when all queues are empty
* @param boolean useLocalStorage Whether to use localStorage at all
* @param string eventMethod if null will use 'beacon' otherwise can be set to 'post', 'get', or 'beacon' to force.
* @param int bufferSize How many events to batch in localStorage before sending them all.
* Only applies when sending POST requests and when localStorage is available.
* @param int maxPostBytes Maximum combined size in bytes of the event JSONs in a POST request
* @param string postPath The path where events are to be posted
* @return object OutQueueManager instance
*/
object.OutQueueManager = function (functionName, namespace, mutSnowplowState, useLocalStorage, eventMethod, postPath, bufferSize, maxPostBytes) {
var queueName,
executingQueue = false,
configCollectorUrl,
outQueue;
//Force to lower case if its a string
eventMethod = eventMethod.toLowerCase ? eventMethod.toLowerCase() : eventMethod;
// Use the Beacon API if eventMethod is set null, true, or 'beacon'.
var isBeaconRequested = (eventMethod === null) || (eventMethod === true) || (eventMethod === "beacon") || (eventMethod === "true");
// Fall back to POST or GET for browsers which don't support Beacon API
var isBeaconAvailable = Boolean(isBeaconRequested && navigator && navigator.sendBeacon);
var useBeacon = (isBeaconAvailable && isBeaconRequested);
// Use GET if specified
var isGetRequested = (eventMethod === "get");
// Use POST if specified
var isPostRequested = (eventMethod === "post");
// usePost acts like a catch all for POST methods - Beacon or XHR
var usePost = (isPostRequested || useBeacon) && !isGetRequested;
// Don't use POST for browsers which don't support CORS XMLHttpRequests (e.g. IE <= 9)
usePost = usePost && Boolean(window.XMLHttpRequest && ('withCredentials' in new XMLHttpRequest()));
// Resolve all options and capabilities and decide path
var path = usePost ? postPath : '/i';
bufferSize = (localStorageAccessible() && useLocalStorage && usePost && bufferSize) || 1;
// Different queue names for GET and POST since they are stored differently
queueName = ['snowplowOutQueue', functionName, namespace, usePost ? 'post2' : 'get'].join('_');
if (useLocalStorage) {
// Catch any JSON parse errors or localStorage that might be thrown
try {
// TODO: backward compatibility with the old version of the queue for POST requests
outQueue = JSON.parse(localStorage.getItem(queueName));
}
catch(e) {}
}
// Initialize to and empty array if we didn't get anything out of localStorage
if (!Array.isArray(outQueue)) {
outQueue = [];
}
// Used by pageUnloadGuard
mutSnowplowState.outQueues.push(outQueue);
if (usePost && bufferSize > 1) {
mutSnowplowState.bufferFlushers.push(function () {
if (!executingQueue) {
executeQueue();
}
});
}
/*
* Convert a dictionary to a querystring
* The context field is the last in the querystring
*/
function getQuerystring(request) {
var querystring = '?',
lowPriorityKeys = {'co': true, 'cx': true},
firstPair = true;
for (var key in request) {
if (request.hasOwnProperty(key) && !(lowPriorityKeys.hasOwnProperty(key))) {
if (!firstPair) {
querystring += '&';
} else {
firstPair = false;
}
querystring += encodeURIComponent(key) + '=' + encodeURIComponent(request[key]);
}
}
for (var contextKey in lowPriorityKeys) {
if (request.hasOwnProperty(contextKey) && lowPriorityKeys.hasOwnProperty(contextKey)) {
querystring += '&' + contextKey + '=' + encodeURIComponent(request[contextKey]);
}
}
return querystring;
}
/*
* Convert numeric fields to strings to match payload_data schema
*/
function getBody(request) {
var cleanedRequest = mapValues(request, function (v) {
return v.toString();
});
return {
evt: cleanedRequest,
bytes: getUTF8Length(JSON.stringify(cleanedRequest))
};
}
/**
* Count the number of bytes a string will occupy when UTF-8 encoded
* Taken from http://stackoverflow.com/questions/2848462/count-bytes-in-textarea-using-javascript/
*
* @param string s
* @return number Length of s in bytes when UTF-8 encoded
*/
function getUTF8Length(s) {
var len = 0;
for (var i = 0; i < s.length; i++) {
var code = s.charCodeAt(i);
if (code <= 0x7f) {
len += 1;
} else if (code <= 0x7ff) {
len += 2;
} else if (code >= 0xd800 && code <= 0xdfff) {
// Surrogate pair: These take 4 bytes in UTF-8 and 2 chars in UCS-2
// (Assume next char is the other [valid] half and just skip it)
len += 4; i++;
} else if (code < 0xffff) {
len += 3;
} else {
len += 4;
}
}
return len;
}
/*
* Queue an image beacon for submission to the collector.
* If we're not processing the queue, we'll start.
*/
function enqueueRequest (request, url) {
configCollectorUrl = url + path;
if (usePost) {
var body = getBody(request);
if (body.bytes >= maxPostBytes) {
helpers.warn("Event of size " + body.bytes + " is too long - the maximum size is " + maxPostBytes);
var xhr = initializeXMLHttpRequest(configCollectorUrl);
xhr.send(encloseInPayloadDataEnvelope(attachStmToEvent([body.evt])));
return;
} else {
outQueue.push(body);
}
} else {
outQueue.push(getQuerystring(request));
}
var savedToLocalStorage = false;
if (useLocalStorage) {
savedToLocalStorage = helpers.attemptWriteLocalStorage(queueName, JSON.stringify(outQueue));
}
if (!executingQueue && (!savedToLocalStorage || outQueue.length >= bufferSize)) {
executeQueue();
}
}
/*
* Run through the queue of image beacons, sending them one at a time.
* Stops processing when we run out of queued requests, or we get an error.
*/
function executeQueue () {
// Failsafe in case there is some way for a bad value like "null" to end up in the outQueue
while (outQueue.length && typeof outQueue[0] !== 'string' && typeof outQueue[0] !== 'object') {
outQueue.shift();
}
if (outQueue.length < 1) {
executingQueue = false;
return;
}
// Let's check that we have a Url to ping
if (!isString(configCollectorUrl)) {
throw "No Snowplow collector configured, cannot track";
}
executingQueue = true;
var nextRequest = outQueue[0];
if (usePost) {
var xhr = initializeXMLHttpRequest(configCollectorUrl);
// Time out POST requests after 5 seconds
var xhrTimeout = setTimeout(function () {
xhr.abort();
executingQueue = false;
}, 5000);
function chooseHowManyToExecute(q) {
var numberToSend = 0;
var byteCount = 0;
while (numberToSend < q.length) {
byteCount += q[numberToSend].bytes;
if (byteCount >= maxPostBytes) {
break;
} else {
numberToSend += 1;
}
}
return numberToSend;
}
// Keep track of number of events to delete from queue
var numberToSend = chooseHowManyToExecute(outQueue);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 400) {
for (var deleteCount = 0; deleteCount < numberToSend; deleteCount++) {
outQueue.shift();
}
if (useLocalStorage) {
helpers.attemptWriteLocalStorage(queueName, JSON.stringify(outQueue));
}
clearTimeout(xhrTimeout);
executeQueue();
} else if (xhr.readyState === 4 && xhr.status >= 400) {
clearTimeout(xhrTimeout);
executingQueue = false;
}
};
var batch = map(outQueue.slice(0, numberToSend), function (x) {
return x.evt;
});
if (batch.length > 0) {
var beaconStatus;
if (useBeacon) {
const headers = { type: 'application/json' };
const blob = new Blob([encloseInPayloadDataEnvelope(attachStmToEvent(batch))], headers);
beaconStatus = navigator.sendBeacon(configCollectorUrl, blob);
}
if (!useBeacon || !beaconStatus) {
xhr.send(encloseInPayloadDataEnvelope(attachStmToEvent(batch)));
}
}
} else {
var image = new Image(1, 1);
image.onload = function () {
outQueue.shift();
if (useLocalStorage) {
helpers.attemptWriteLocalStorage(queueName, JSON.stringify(outQueue));
}
executeQueue();
};
image.onerror = function () {
executingQueue = false;
};
image.src = configCollectorUrl + nextRequest.replace('?', '?stm=' + new Date().getTime() + '&');
}
}
/**
* Open an XMLHttpRequest for a given endpoint with the correct credentials and header
*
* @param string url The destination URL
* @return object The XMLHttpRequest
*/
function initializeXMLHttpRequest(url) {
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
return xhr;
}
/**
* Enclose an array of events in a self-describing payload_data JSON string
*
* @param array events Batch of events
* @return string payload_data self-describing JSON
*/
function encloseInPayloadDataEnvelope(events) {
return JSON.stringify({
schema: 'iglu:com.snowplowanalytics.snowplow/payload_data/jsonschema/1-0-4',
data: events
});
}
/**
* Attaches the STM field to outbound POST events.
*
* @param events the events to attach the STM to
*/
function attachStmToEvent(events) {
var stm = new Date().getTime().toString();
for (var i = 0; i < events.length; i++) {
events[i]['stm'] = stm;
}
return events
}
return {
enqueueRequest: enqueueRequest,
executeQueue: executeQueue
};
};
}());