@trainerday/analytics-client
Version:
A lightweight JavaScript analytics client library with offline support for Hybrid and Progressive Web Apps
774 lines (636 loc) • 24.8 kB
JavaScript
/*!
* mixpanel-lite.js - v@version@
* Lightweight version of mixpanel-js with offline support
* https://github.com/john-doherty/mixpanel-lite
* @author John Doherty <www.johndoherty.info>
* @license MIT
*/
(function (window, document) {
'use strict';
if (!window.localStorage) {
console.warn('mixpanel-lite: localStorage not supported');
return;
}
if (!window.Promise) {
console.warn('mixpanel-lite: Promise not supported (try adding a polyfill)');
return;
}
var _trackingUrl = 'https://api.mixpanel.com/track?ip=1&verbose=1&data=';
var _engageUrl = 'https://api.mixpanel.com/engage?ip=1&verbose=1&data=';
var _token = null;
var _debugging = false;
var _doNotTrack = (String(navigator.doNotTrack || '0') === '1');
// holds a copy of current request properties
var _properties = {};
/**
* Attempt to get the network connection type from w3c NetworkInformation or cordova-plugin-network-information
* @returns {string} connection type or empty
*/
function getConnectionType() {
var connection = (navigator.connection || navigator.mozConnection || navigator.webkitConnection || {});
return connection.effectiveType || connection.type || '';
}
/**
* clear current identity
* @returns {void}
*/
function reset() {
init(_token);
if (_debugging) {
console.log('mixpanel.reset()');
}
}
/**
* Track an event.
* @param {String} eventName The name of the event. This can be anything the user does - 'Button Click', 'Sign Up', 'Item Purchased', etc.
* @param {Object} [data] A set of properties to include with the event you're sending. These describe the user who did the event or details about the event itself.
* @returns {void}
*/
function track(eventName, data) {
if (!_token) {
console.warn('mixpanel.track: You must call mixpanel.init(token) first');
return;
}
if (!eventName || eventName === '') {
console.warn('mixpanel.track: Invalid eventName');
return;
}
if (data && typeof data !== 'object') {
console.warn('mixpanel.track: Data param must be an object');
return;
}
if (_doNotTrack) {
console.warn('mixpanel.track: user does not want to be tracked');
return;
}
var eventData = {
event: eventName,
properties: cloneObject(_properties)
};
// add custom event data
Object.keys(data || {}).forEach(function (key) {
eventData.properties[key] = data[key];
});
// add unique insert id (A random 16 character string of alphanumeric characters that is unique to an event.)
eventData.properties.$insert_id = Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10);
// epoch time in seconds
eventData.properties.time = Math.round((new Date()).getTime() / 1000);
// remove empty properties
Object.keys(eventData.properties).forEach(function (key) {
if (eventData.properties[key] === null || eventData.properties[key] === '') {
delete eventData.properties[key];
}
});
// save the event
transactions.add(eventData);
// attempt to send
send();
if (_debugging) {
if (data) {
console.log('mixpanel.track(\'' + eventName + '\',' + JSON.stringify(data || {}) + ')');
}
else {
console.log('mixpanel.track(\'' + eventName + '\')');
}
console.dir(eventData);
}
}
/**
* Identify a user with a unique ID instead of a Mixpanel randomly generated distinct_id.
* If the method is never called, unique visitors will be identified by a UUID generated the first time they visit the site.
* @param {String} [id] A string that uniquely identifies a user. If not provided, the distinct_id from localStorage is used.
* @returns {void}
*/
function identify(id) {
if (!id || id.trim() === '') {
console.warn('mixpanel.identify: Invalid id');
return;
}
if (_debugging) {
console.log('mixpanel.identify(\'' + id + '\')');
}
// send identity request with old distinct id
track('$identify', {
$anon_distinct_id: _properties.distinct_id,
distinct_id: id,
$user_id: id
});
// change values for future requests
_properties.distinct_id = id;
_properties.$user_id = id;
}
/**
* Register a set of super properties, which are included with all events
* @param {object} data - JSON key/value pair
* @returns {void}
*/
function register(data) {
// add custom event data
Object.keys(data || {}).forEach(function (key) {
// only add properties if they contain a value
if (data[key] !== null && data[key] !== '') {
_properties[key] = data[key];
}
// otherwise remove them (allows unset)
else {
delete _properties[key];
}
});
}
/**
* set properties on an user record in engage
* @param {object} data - properties to set
* @returns {void}
*/
function setPeople(data) {
if (!data || typeof data !== 'object') {
console.warn('mixpanel.setPeople: Invalid data param, must be an object');
return;
}
if (_doNotTrack) {
console.warn('mixpanel.track: user does not want to be tracked');
return;
}
var eventData = {
$token: _token,
$distinct_id: _properties.distinct_id,
$set: {}
};
// add custom event data
Object.keys(data || {}).forEach(function (key) {
eventData.$set[key] = data[key];
});
// remove empty properties
Object.keys(eventData.$set).forEach(function (key) {
if (eventData.$set[key] === null || eventData.$set[key] === '') {
delete eventData.$set[key];
}
});
// save the event
transactions.add(eventData);
// attempt to send
send();
if (_debugging) {
console.log('mixpanel.people.set(' + JSON.stringify(data || {}) + ')');
console.dir(eventData);
}
}
/**
* Sends pending events to Mixpanel API
* @returns {void}
*/
function send() {
// get all items that have not yet been sent
var items = transactions.all().filter(function (item) {
return !item.__completed;
});
// convert each pending transaction into a request promise
var requests = items.map(function (item) {
// we have to return a function to execute in sequence, otherwise they'll execute immediately
return function () {
// depending on the update type, change the API URL (hacky)
var url = (item.$set) ? _engageUrl : _trackingUrl;
// encode the data so it can be sent via a HTTP GET (avoids preflight headers)
var dataToSend = base64Encode(JSON.stringify(item));
// generate mixpanel URL (add timestamp to make it unique)
url += encodeURIComponent(dataToSend) + '&_=' + new Date().getTime();
// mark item as not complete, in case it fails
item.__completed = false;
// execute the request
return httpGet(url).then(function () {
// mark item as completed
item.__completed = true;
}).catch(function () {
// temporary fix (ref https://github.com/john-doherty/mixpanel-lite/issues/5)
item.__completed = true;
});
};
});
// execute requests in order, if any fail, stop executing as we need transactions to be in order
promisesInSequence(requests).then(function () {
// remove completed requests
var incompleteRequests = items.filter(function (item) {
return !item.__completed;
});
// save incomplete requests for next time
transactions.reset(incompleteRequests);
});
}
/* #region Helpers */
/**
* Clones a JSON object
* @param {object} obj - object to clone
* @returns {object} cloned object
*/
function cloneObject(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* Executes an array of function that return a promise in sequence
* @param {Array} promises - array of functions that return a promise
* @returns {Promise} executes .then if all resolve otherwise .catch
*/
function promisesInSequence(promises) {
var result = Promise.resolve();
promises.forEach(function (promise) {
result = result.then(promise);
});
return result;
}
/**
* local storage helper for saving transactions in order
*/
var transactions = {
_key: 'mixpanel-lite',
// returns a list of all transactions or empty array
all: function () {
return JSON.parse(localStorage.getItem(transactions._key) || '[]');
},
// adds an item to the transaction log
add: function (data) {
// get existing transactions
var existing = transactions.all();
// get the last item inserted
var lastItem = existing.slice(-1)[0] || {};
// if the new item is not an exact match of the last item, add it
if (!isEqual(data, lastItem, ['properties.time', 'properties.$insert_id'])) {
// add latest to end of stack
existing.push(data);
// save changes
localStorage.setItem(transactions._key, JSON.stringify(existing));
}
},
// clears any pending transactions
clear: function () {
localStorage.setItem(transactions._key, JSON.stringify([]));
},
// replaces all transactions with new items
reset: function (items) {
localStorage.setItem(transactions._key, JSON.stringify(items || []));
}
};
/**
* Checks if two objects are equal
* @param {object} obj1 - first object to compare
* @param {object} obj2 - second object to compare
* @param {Array} excludeKeys - keys to exclude from comparison
* @returns {boolean} true if equal otherwise false
*/
function isEqual(obj1, obj2, excludeKeys) {
obj1 = JSON.parse(JSON.stringify(obj1 || {}));
obj2 = JSON.parse(JSON.stringify(obj2 || {}));
excludeKeys = excludeKeys || [];
for (var i = 0, l = excludeKeys.length; i < l; i++) {
deletePropertyByPath(obj1, excludeKeys[i]);
deletePropertyByPath(obj2, excludeKeys[i]);
}
return (JSON.stringify(obj1) === JSON.stringify(obj2));
}
/**
* Removes nested keys from an object
* @param {object} obj - object to modify
* @param {*} path - key to remove, can be nested
* @returns {object} modified object
*/
function deletePropertyByPath(obj, path) {
if (!obj || !path) return;
if (typeof path === 'string') path = path.split('.');
for (var i = 0; i < path.length - 1; i++) {
obj = obj[path[i]];
if (typeof obj === 'undefined') {
return;
}
}
delete obj[path.pop()];
}
/**
* Gets the device type iPad, iPhone etc
* @returns {string} device name
*/
function getDevice() {
var ua = navigator.userAgent;
if (/Windows Phone/i.test(ua) || /WPDesktop/.test(ua)) return 'Windows Phone';
if (/iPad/.test(ua)) return 'iPad';
if (/iPod/.test(ua)) return 'iPod Touch';
if (/iPhone/.test(ua)) return 'iPhone';
if (/(BlackBerry|PlayBook|BB10)/i.test(ua)) return 'BlackBerry';
if (/Android/.test(ua)) return 'Android';
return '';
}
/**
* Gets the Operating System
* @returns {string} os name
*/
function getOS() {
var ua = navigator.userAgent;
if (/Windows/i.test(ua)) return (/Phone/.test(ua) || /WPDesktop/.test(ua)) ? 'Windows Phone' : 'Windows';
if (/(iPhone|iPad|iPod)/.test(ua)) return 'iOS';
if (/Android/.test(ua)) return 'Android';
if (/(BlackBerry|PlayBook|BB10)/i.test(ua)) return 'BlackBerry';
if (/Mac/i.test(ua)) return 'Mac OS X';
if (/Linux/.test(ua)) return 'Linux';
if (/CrOS/.test(ua)) return 'Chrome OS';
return '';
}
/**
* This function detects which browser is running this script.
* The order of the checks are important since many user agents
* include key words used in later checks.
* @returns {string} browser name
*/
function getBrowser() {
var ua = navigator.userAgent;
var vendor = navigator.vendor || ''; // vendor is undefined for at least IE9
var opera = window.opera;
// internal helper
function includes(str, needle) {
return str.indexOf(needle) !== -1;
}
if (opera || includes(ua, ' OPR/')) return (includes(ua, 'Mini')) ? 'Opera Mini' : 'Opera';
if (/(BlackBerry|PlayBook|BB10)/i.test(ua)) return 'BlackBerry';
if (includes(ua, 'IEMobile') || includes(ua, 'WPDesktop')) return 'Internet Explorer Mobile';
if (includes(ua, 'Edge')) return 'Microsoft Edge';
if (includes(ua, 'FBIOS')) return 'Facebook Mobile';
if (includes(ua, 'Chrome')) return 'Chrome';
if (includes(ua, 'CriOS')) return 'Chrome iOS';
if (includes(ua, 'UCWEB') || includes(ua, 'UCBrowser')) return 'UC Browser';
if (includes(ua, 'FxiOS')) return 'Firefox iOS';
if (includes(vendor, 'Apple')) return (includes(ua, 'Mobile')) ? 'Mobile Safari' : 'Safari';
if (includes(ua, 'Android')) return 'Android Mobile';
if (includes(ua, 'Konqueror')) return 'Konqueror';
if (includes(ua, 'Firefox')) return 'Firefox';
if (includes(ua, 'MSIE') || includes(ua, 'Trident/')) return 'Internet Explorer';
if (includes(ua, 'Gecko')) return 'Mozilla';
return '';
}
/**
* This function detects which browser version is running this script,
* parsing major and minor version (e.g., 42.1). User agent strings from:
* http://www.useragentstring.com/pages/useragentstring.php
* @returns {number} browser version
*/
function getBrowserVersion() {
var browser = getBrowser();
var ua = navigator.userAgent;
var versionRegexs = {
'Internet Explorer Mobile': /rv:(\d+(\.\d+)?)/,
'Microsoft Edge': /Edge\/(\d+(\.\d+)?)/,
'Chrome': /Chrome\/(\d+(\.\d+)?)/,
'Chrome iOS': /CriOS\/(\d+(\.\d+)?)/,
'UC Browser': /(UCBrowser|UCWEB)\/(\d+(\.\d+)?)/,
'Safari': /Version\/(\d+(\.\d+)?)/,
'Mobile Safari': /Version\/(\d+(\.\d+)?)/,
'Opera': /(Opera|OPR)\/(\d+(\.\d+)?)/,
'Firefox': /Firefox\/(\d+(\.\d+)?)/,
'Firefox iOS': /FxiOS\/(\d+(\.\d+)?)/,
'Konqueror': /Konqueror:(\d+(\.\d+)?)/,
'BlackBerry': /BlackBerry (\d+(\.\d+)?)/,
'Android Mobile': /android\s(\d+(\.\d+)?)/,
'Internet Explorer': /(rv:|MSIE )(\d+(\.\d+)?)/,
'Mozilla': /rv:(\d+(\.\d+)?)/
};
var regex = versionRegexs[browser];
if (regex === undefined) {
return null;
}
var matches = ua.match(regex);
if (!matches) {
return null;
}
return parseFloat(matches[matches.length - 2]);
}
/**
* Gets the referring domain
* @returns {string} domain or empty string
*/
function getReferringDomain() {
var split = String(document.referrer || '').split('/');
if (split.length >= 3) {
return split[2];
}
return '';
}
/**
* Executes a HTTP GET request within a promise
* @param {string} url - url to get
* @returns {Promise} executes .then if successful otherwise .catch
*/
function httpGet(url) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
resolve({
url: url,
status: 200,
body: xhr.responseText || ''
});
}
else {
reject({
url: url,
status: xhr.status,
body: xhr.responseText || ''
});
}
}
};
xhr.send();
});
}
/**
* Generates a new distinct_id (code lifted from Mixpanel js)
* @returns {string} new UUID (example 16ee796360f641-0900a3aecd5282-3963720f-13c680-16ee7963610a3a)
*/
function getNewUUID() {
// Time/ticks information
// 1*new Date() is a cross browser version of Date.now()
var T = function () {
var d = 1 * new Date(),
i = 0;
// this while loop figures how many browser ticks go by
// before 1*new Date() returns a new number, ie the amount
// of ticks that go by per millisecond
while (d == 1 * new Date()) {
i++;
}
return d.toString(16) + i.toString(16);
};
// Math.Random entropy
var R = function () {
return Math.random().toString(16).replace('.', '');
};
// User agent entropy
// This function takes the user agent string, and then xors
// together each sequence of 8 bytes. This produces a final
// sequence of 8 bytes which it returns as hex.
var UA = function () {
var ua = navigator.userAgent,
i, ch, buffer = [],
ret = 0;
function xor(result, byte_array) {
var j, tmp = 0;
for (j = 0; j < byte_array.length; j++) {
tmp |= (buffer[j] << j * 8);
}
return result ^ tmp;
}
for (i = 0; i < ua.length; i++) {
ch = ua.charCodeAt(i);
buffer.unshift(ch & 0xFF);
if (buffer.length >= 4) {
ret = xor(ret, buffer);
buffer = [];
}
}
if (buffer.length > 0) {
ret = xor(ret, buffer);
}
return ret.toString(16);
};
var se = (screen.height * screen.width).toString(16);
return (T() + '-' + R() + '-' + UA() + '-' + se + '-' + T());
}
/**
* Base 64 encodes a string using btoa
* @param {string} str - string to encode
* @returns {string} containing padded base64 value
*/
function base64Encode(str) {
return window.btoa(unescape(encodeURIComponent(str)));
}
/* #endregion */
// no operation interface, exposes method that do nothing
var mutedInterface = {
init: function (token, ops) {
console.log('mixpanel.track(\'' + token + '\',' + JSON.stringify(ops || {}) + ')');
},
track: function (eventName, data) {
console.log('mixpanel.track(\'' + eventName + '\',' + JSON.stringify(data || {}) + ')');
},
register: function(data) {
console.log('mixpanel.register(' + JSON.stringify(data || {}) + ')');
},
reset: function () {
console.log('mixpanel.reset()');
},
identify: function (id) {
console.log('mixpanel.identify(\'' + id + '\')');
},
people: {
set: function (data) {
console.log('mixpanel.people.set(' + JSON.stringify(data || {}) + ')');
}
},
mute: mute,
unmute: unmute,
muted: true
};
// operational interface, exposes methods that talk to mixpanel
var unmutedInterface = {
init: init,
track: track,
register: register,
reset: reset,
identify: identify,
people: {
set: setPeople
},
mute: mute,
unmute: unmute,
muted: false
};
/**
* Mutes mixpanel by overriding public methods with empty functions
* @returns {void}
*/
function mute() {
window.mixpanel = mutedInterface;
}
/**
* Restores mixpanel function after a call to mixpanel.mute allowing data to be sent to mixpanel
* @returns {void}
*/
function unmute() {
window.mixpanel = unmutedInterface;
}
// expose mixpanel methods by default
window.mixpanel = unmutedInterface;
/**
* Sets up in memory properties to be sent with each request
* @param {string} token - mixpanel token
* @param {object} opts - options { debug: true }
* @returns {void}
*/
function init(token, opts) {
opts = opts || {};
if (opts.mute === true) {
window.mixpanel = mutedInterface;
}
if (opts.trackingUrl && opts.trackingUrl !== '') {
_trackingUrl = opts.trackingUrl;
}
if (opts.engageUrl && opts.engageUrl !== '') {
_engageUrl = opts.engageUrl;
}
token = String(token || '');
if (token === '') {
console.warn('mixpanel.init: invalid token');
return;
}
_token = token;
_debugging = (opts.debug === true);
var uuid = getNewUUID();
// params -> https://help.mixpanel.com/hc/en-us/articles/115004613766-Default-Properties-Collected-by-Mixpanel
_properties = {
token: token,
$os: getOS(),
$browser: getBrowser(),
$browser_version: getBrowserVersion(),
$device: getDevice(),
$screen_height: screen.height,
$screen_width: screen.width,
$referrer: document.referrer,
$referring_domain: getReferringDomain(),
distinct_id: uuid,
$device_id: uuid,
mp_lib: 'mixpanel-lite',
$lib_version: '0.0.0'
};
// only track page URLs
if (String(window.location.protocol).indexOf('http') === 0) {
_properties.$current_url = window.location.href;
}
if (window.device) {
if (window.device.manufacturer) {
_properties.$manufacturer = window.device.manufacturer;
}
if (window.device.model) {
_properties.$model = window.device.model;
}
if (window.device.version) {
_properties.$os_version = window.device.version;
}
}
// attempt to resolve connection type
_properties.connectionType = getConnectionType();
// listen for connection change events (only available in w3c implementation)
var connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection && connection.addEventListener) {
// update connection when it changes
connection.addEventListener('change', function () {
_properties.connectionType = getConnectionType();
});
}
if (_debugging) {
console.log('mixpanel.init(\'' + _token + '\')');
}
}
// if we are running in cordova use ondeviceready otherwise onload
window.addEventListener((window.cordova) ? 'deviceready' : 'load', send, { passive: true });
// always send pending request when the connection comes back online
window.addEventListener('online', send, { passive: true });
})(this, document);