mixpanel-browser
Version:
The official Mixpanel JavaScript browser client library
1,336 lines (1,207 loc) • 96.6 kB
JavaScript
/* eslint camelcase: "off" */
import Config from './config';
import { MAX_RECORDING_MS, _, console, userAgent, document, navigator, slice, NOOP_FUNC, JSONStringify } from './utils';
import { isRecordingExpired } from './recorder/utils';
import { window } from './window';
import { Autocapture } from './autocapture';
import { FeatureFlagManager } from './flags';
import { FormTracker, LinkTracker } from './dom-trackers';
import { RequestBatcher } from './request-batcher';
import { MixpanelGroup } from './mixpanel-group';
import { MixpanelPeople } from './mixpanel-people';
import {
MixpanelPersistence,
PEOPLE_DISTINCT_ID_KEY,
ALIAS_ID_KEY
} from './mixpanel-persistence';
import {
optIn,
optOut,
hasOptedIn,
hasOptedOut,
clearOptInOut,
addOptOutCheckMixpanelLib
} from './gdpr-utils';
import { IDBStorageWrapper, RECORDING_REGISTRY_STORE_NAME } from './storage/indexed-db';
/*
* Mixpanel JS Library
*
* Copyright 2012, Mixpanel, Inc. All Rights Reserved
* http://mixpanel.com/
*
* Includes portions of Underscore.js
* http://documentcloud.github.com/underscore/
* (c) 2011 Jeremy Ashkenas, DocumentCloud Inc.
* Released under the MIT License.
*/
/*
SIMPLE STYLE GUIDE:
this.x === public function
this._x === internal - only use within this file
this.__x === private - only use within the class
Globals should be all caps
*/
var init_type; // MODULE or SNIPPET loader
// allow bundlers to specify how extra code (recorder bundle) should be loaded
// eslint-disable-next-line no-unused-vars
var load_extra_bundle = function(src, _onload) {
throw new Error(src + ' not available in this build.');
};
var mixpanel_master; // main mixpanel instance / object
var INIT_MODULE = 0;
var INIT_SNIPPET = 1;
var IDENTITY_FUNC = function(x) {return x;};
/** @const */ var PRIMARY_INSTANCE_NAME = 'mixpanel';
/** @const */ var PAYLOAD_TYPE_BASE64 = 'base64';
/** @const */ var PAYLOAD_TYPE_JSON = 'json';
/** @const */ var DEVICE_ID_PREFIX = '$device:';
/*
* Dynamic... constants? Is that an oxymoron?
*/
// http://hacks.mozilla.org/2009/07/cross-site-xmlhttprequest-with-cors/
// https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#withCredentials
var USE_XHR = (window.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest());
// IE<10 does not support cross-origin XHR's but script tags
// with defer won't block window.onload; ENQUEUE_REQUESTS
// should only be true for Opera<12
var ENQUEUE_REQUESTS = !USE_XHR && (userAgent.indexOf('MSIE') === -1) && (userAgent.indexOf('Mozilla') === -1);
// save reference to navigator.sendBeacon so it can be minified
var sendBeacon = null;
if (navigator['sendBeacon']) {
sendBeacon = function() {
// late reference to navigator.sendBeacon to allow patching/spying
return navigator['sendBeacon'].apply(navigator, arguments);
};
}
var DEFAULT_API_ROUTES = {
'track': 'track/',
'engage': 'engage/',
'groups': 'groups/',
'record': 'record/',
'flags': 'flags/'
};
/*
* Module-level globals
*/
var DEFAULT_CONFIG = {
'api_host': 'https://api-js.mixpanel.com',
'api_hosts': {},
'api_routes': DEFAULT_API_ROUTES,
'api_extra_query_params': {},
'api_method': 'POST',
'api_transport': 'XHR',
'api_payload_format': PAYLOAD_TYPE_BASE64,
'app_host': 'https://mixpanel.com',
'autocapture': false,
'cdn': 'https://cdn.mxpnl.com',
'cross_site_cookie': false,
'cross_subdomain_cookie': true,
'error_reporter': NOOP_FUNC,
'flags': false,
'persistence': 'cookie',
'persistence_name': '',
'cookie_domain': '',
'cookie_name': '',
'loaded': NOOP_FUNC,
'mp_loader': null,
'track_marketing': true,
'track_pageview': false,
'skip_first_touch_marketing': false,
'store_google': true,
'stop_utm_persistence': false,
'save_referrer': true,
'test': false,
'verbose': false,
'img': false,
'debug': false,
'track_links_timeout': 300,
'cookie_expiration': 365,
'upgrade': false,
'disable_persistence': false,
'disable_cookie': false,
'secure_cookie': false,
'ip': true,
'opt_out_tracking_by_default': false,
'opt_out_persistence_by_default': false,
'opt_out_tracking_persistence_type': 'localStorage',
'opt_out_tracking_cookie_prefix': null,
'property_blacklist': [],
'xhr_headers': {}, // { header: value, header2: value }
'ignore_dnt': false,
'batch_requests': true,
'batch_size': 50,
'batch_flush_interval_ms': 5000,
'batch_request_timeout_ms': 90000,
'batch_autostart': true,
'hooks': {},
'record_block_class': new RegExp('^(mp-block|fs-exclude|amp-block|rr-block|ph-no-capture)$'),
'record_block_selector': 'img, video',
'record_canvas': false,
'record_collect_fonts': false,
'record_heatmap_data': false,
'record_idle_timeout_ms': 30 * 60 * 1000, // 30 minutes
'record_mask_text_class': new RegExp('^(mp-mask|fs-mask|amp-mask|rr-mask|ph-mask)$'),
'record_mask_text_selector': '*',
'record_max_ms': MAX_RECORDING_MS,
'record_min_ms': 0,
'record_sessions_percent': 0,
'recorder_src': 'https://cdn.mxpnl.com/libs/mixpanel-recorder.min.js'
};
var DOM_LOADED = false;
/**
* Mixpanel Library Object
* @constructor
*/
var MixpanelLib = function() {};
/**
* create_mplib(token:string, config:object, name:string)
*
* This function is used by the init method of MixpanelLib objects
* as well as the main initializer at the end of the JSLib (that
* initializes document.mixpanel as well as any additional instances
* declared before this file has loaded).
*/
var create_mplib = function(token, config, name) {
var instance,
target = (name === PRIMARY_INSTANCE_NAME) ? mixpanel_master : mixpanel_master[name];
if (target && init_type === INIT_MODULE) {
instance = target;
} else {
if (target && !_.isArray(target)) {
console.error('You have already initialized ' + name);
return;
}
instance = new MixpanelLib();
}
instance._cached_groups = {}; // cache groups in a pool
instance._init(token, config, name);
instance['people'] = new MixpanelPeople();
instance['people']._init(instance);
if (!instance.get_config('skip_first_touch_marketing')) {
// We need null UTM params in the object because
// UTM parameters act as a tuple. If any UTM param
// is present, then we set all UTM params including
// empty ones together
var utm_params = _.info.campaignParams(null);
var initial_utm_params = {};
var has_utm = false;
_.each(utm_params, function(utm_value, utm_key) {
initial_utm_params['initial_' + utm_key] = utm_value;
if (utm_value) {
has_utm = true;
}
});
if (has_utm) {
instance['people'].set_once(initial_utm_params);
}
}
// if any instance on the page has debug = true, we set the
// global debug to be true
Config.DEBUG = Config.DEBUG || instance.get_config('debug');
// if target is not defined, we called init after the lib already
// loaded, so there won't be an array of things to execute
if (!_.isUndefined(target) && _.isArray(target)) {
// Crunch through the people queue first - we queue this data up &
// flush on identify, so it's better to do all these operations first
instance._execute_array.call(instance['people'], target['people']);
instance._execute_array(target);
}
return instance;
};
// Initialization methods
/**
* This function initializes a new instance of the Mixpanel tracking object.
* All new instances are added to the main mixpanel object as sub properties (such as
* mixpanel.library_name) and also returned by this function. To define a
* second instance on the page, you would call:
*
* mixpanel.init('new token', { your: 'config' }, 'library_name');
*
* and use it like so:
*
* mixpanel.library_name.track(...);
*
* @param {String} token Your Mixpanel API token
* @param {Object} [config] A dictionary of config options to override. <a href="https://github.com/mixpanel/mixpanel-js/blob/v2.46.0/src/mixpanel-core.js#L88-L127">See a list of default config options</a>.
* @param {String} [name] The name for the new mixpanel instance that you want created
*/
MixpanelLib.prototype.init = function (token, config, name) {
if (_.isUndefined(name)) {
this.report_error('You must name your new library: init(token, config, name)');
return;
}
if (name === PRIMARY_INSTANCE_NAME) {
this.report_error('You must initialize the main mixpanel object right after you include the Mixpanel js snippet');
return;
}
var instance = create_mplib(token, config, name);
mixpanel_master[name] = instance;
instance._loaded();
return instance;
};
// mixpanel._init(token:string, config:object, name:string)
//
// This function sets up the current instance of the mixpanel
// library. The difference between this method and the init(...)
// method is this one initializes the actual instance, whereas the
// init(...) method sets up a new library and calls _init on it.
//
MixpanelLib.prototype._init = function(token, config, name) {
config = config || {};
this['__loaded'] = true;
this['config'] = {};
var variable_features = {};
// default to JSON payload for standard mixpanel.com API hosts
if (!('api_payload_format' in config)) {
var api_host = config['api_host'] || DEFAULT_CONFIG['api_host'];
if (api_host.match(/\.mixpanel\.com/)) {
variable_features['api_payload_format'] = PAYLOAD_TYPE_JSON;
}
}
this.set_config(_.extend({}, DEFAULT_CONFIG, variable_features, config, {
'name': name,
'token': token,
'callback_fn': ((name === PRIMARY_INSTANCE_NAME) ? name : PRIMARY_INSTANCE_NAME + '.' + name) + '._jsc'
}));
this['_jsc'] = NOOP_FUNC;
this.__dom_loaded_queue = [];
this.__request_queue = [];
this.__disabled_events = [];
this._flags = {
'disable_all_events': false,
'identify_called': false
};
// set up request queueing/batching
this.request_batchers = {};
this._batch_requests = this.get_config('batch_requests');
if (this._batch_requests) {
if (!_.localStorage.is_supported(true) || !USE_XHR) {
this._batch_requests = false;
console.log('Turning off Mixpanel request-queueing; needs XHR and localStorage support');
_.each(this.get_batcher_configs(), function(batcher_config) {
console.log('Clearing batch queue ' + batcher_config.queue_key);
_.localStorage.remove(batcher_config.queue_key);
});
} else {
this.init_batchers();
if (sendBeacon && window.addEventListener) {
// Before page closes or hides (user tabs away etc), attempt to flush any events
// queued up via navigator.sendBeacon. Since sendBeacon doesn't report success/failure,
// events will not be removed from the persistent store; if the site is loaded again,
// the events will be flushed again on startup and deduplicated on the Mixpanel server
// side.
// There is no reliable way to capture only page close events, so we lean on the
// visibilitychange and pagehide events as recommended at
// https://developer.mozilla.org/en-US/docs/Web/API/Window/unload_event#usage_notes.
// These events fire when the user clicks away from the current page/tab, so will occur
// more frequently than page unload, but are the only mechanism currently for capturing
// this scenario somewhat reliably.
var flush_on_unload = _.bind(function() {
if (!this.request_batchers.events.stopped) {
this.request_batchers.events.flush({unloading: true});
}
}, this);
window.addEventListener('pagehide', function(ev) {
if (ev['persisted']) {
flush_on_unload();
}
});
window.addEventListener('visibilitychange', function() {
if (document['visibilityState'] === 'hidden') {
flush_on_unload();
}
});
}
}
}
this['persistence'] = this['cookie'] = new MixpanelPersistence(this['config']);
this.unpersisted_superprops = {};
this._gdpr_init();
var uuid = _.UUID();
if (!this.get_distinct_id()) {
// There is no need to set the distinct id
// or the device id if something was already stored
// in the persitence
this.register_once({
'distinct_id': DEVICE_ID_PREFIX + uuid,
'$device_id': uuid
}, '');
}
this.flags = new FeatureFlagManager({
getFullApiRoute: _.bind(function() {
return this.get_api_host('flags') + '/' + this.get_config('api_routes')['flags'];
}, this),
getConfigFunc: _.bind(this.get_config, this),
getPropertyFunc: _.bind(this.get_property, this),
trackingFunc: _.bind(this.track, this)
});
this.flags.init();
this['flags'] = this.flags;
this.autocapture = new Autocapture(this);
this.autocapture.init();
this._init_tab_id();
this._check_and_start_session_recording();
};
/**
* Assigns a unique UUID to this tab / window by leveraging sessionStorage.
* This is primarily used for session recording, where data must be isolated to the current tab.
*/
MixpanelLib.prototype._init_tab_id = function() {
if (_.sessionStorage.is_supported()) {
try {
var key_suffix = this.get_config('name') + '_' + this.get_config('token');
var tab_id_key = 'mp_tab_id_' + key_suffix;
// A flag is used to determine if sessionStorage is copied over and we need to generate a new tab ID.
// This enforces a unique ID in the cases like duplicated tab, window.open(...)
var should_generate_new_tab_id_key = 'mp_gen_new_tab_id_' + key_suffix;
if (_.sessionStorage.get(should_generate_new_tab_id_key) || !_.sessionStorage.get(tab_id_key)) {
_.sessionStorage.set(tab_id_key, '$tab-' + _.UUID());
}
_.sessionStorage.set(should_generate_new_tab_id_key, '1');
this.tab_id = _.sessionStorage.get(tab_id_key);
// Remove the flag when the tab is unloaded to indicate the stored tab ID can be reused. This event is not reliable to detect all page unloads,
// but reliable in cases where the user remains in the tab e.g. a refresh or href navigation.
// If the flag is absent, this indicates to the next SDK instance that we can reuse the stored tab_id.
window.addEventListener('beforeunload', function () {
_.sessionStorage.remove(should_generate_new_tab_id_key);
});
} catch(err) {
this.report_error('Error initializing tab id', err);
}
} else {
this.report_error('Session storage is not supported, cannot keep track of unique tab ID.');
}
};
MixpanelLib.prototype.get_tab_id = function () {
return this.tab_id || null;
};
MixpanelLib.prototype._should_load_recorder = function () {
var recording_registry_idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
var tab_id = this.get_tab_id();
return recording_registry_idb.init()
.then(function () {
return recording_registry_idb.getAll();
})
.then(function (recordings) {
for (var i = 0; i < recordings.length; i++) {
// if there are expired recordings in the registry, we should load the recorder to flush them
// if there's a recording for this tab id, we should load the recorder to continue the recording
if (isRecordingExpired(recordings[i]) || recordings[i]['tabId'] === tab_id) {
return true;
}
}
return false;
})
.catch(_.bind(function (err) {
this.report_error('Error checking recording registry', err);
}, this));
};
MixpanelLib.prototype._check_and_start_session_recording = addOptOutCheckMixpanelLib(function(force_start) {
if (!window['MutationObserver']) {
console.critical('Browser does not support MutationObserver; skipping session recording');
return;
}
var loadRecorder = _.bind(function(startNewIfInactive) {
var handleLoadedRecorder = _.bind(function() {
this._recorder = this._recorder || new window['__mp_recorder'](this);
this._recorder['resumeRecording'](startNewIfInactive);
}, this);
if (_.isUndefined(window['__mp_recorder'])) {
load_extra_bundle(this.get_config('recorder_src'), handleLoadedRecorder);
} else {
handleLoadedRecorder();
}
}, this);
/**
* If the user is sampled or start_session_recording is called, we always load the recorder since it's guaranteed a recording should start.
* Otherwise, if the recording registry has any records then it's likely there's a recording in progress or orphaned data that needs to be flushed.
*/
var is_sampled = this.get_config('record_sessions_percent') > 0 && Math.random() * 100 <= this.get_config('record_sessions_percent');
if (force_start || is_sampled) {
loadRecorder(true);
} else {
this._should_load_recorder()
.then(function (shouldLoad) {
if (shouldLoad) {
loadRecorder(false);
}
});
}
});
MixpanelLib.prototype.start_session_recording = function () {
this._check_and_start_session_recording(true);
};
MixpanelLib.prototype.stop_session_recording = function () {
if (this._recorder) {
return this._recorder['stopRecording']();
}
return Promise.resolve();
};
MixpanelLib.prototype.pause_session_recording = function () {
if (this._recorder) {
return this._recorder['pauseRecording']();
}
return Promise.resolve();
};
MixpanelLib.prototype.resume_session_recording = function () {
if (this._recorder) {
return this._recorder['resumeRecording']();
}
return Promise.resolve();
};
MixpanelLib.prototype.is_recording_heatmap_data = function () {
return this._get_session_replay_id() && this.get_config('record_heatmap_data');
};
MixpanelLib.prototype.get_session_recording_properties = function () {
var props = {};
var replay_id = this._get_session_replay_id();
if (replay_id) {
props['$mp_replay_id'] = replay_id;
}
return props;
};
MixpanelLib.prototype.get_session_replay_url = function () {
var replay_url = null;
var replay_id = this._get_session_replay_id();
if (replay_id) {
var query_params = _.HTTPBuildQuery({
'replay_id': replay_id,
'distinct_id': this.get_distinct_id(),
'token': this.get_config('token')
});
replay_url = 'https://mixpanel.com/projects/replay-redirect?' + query_params;
}
return replay_url;
};
MixpanelLib.prototype._get_session_replay_id = function () {
var replay_id = null;
if (this._recorder) {
replay_id = this._recorder['replayId'];
}
return replay_id || null;
};
// "private" public method to reach into the recorder in test cases
MixpanelLib.prototype.__get_recorder = function () {
return this._recorder;
};
// Private methods
MixpanelLib.prototype._loaded = function() {
this.get_config('loaded')(this);
this._set_default_superprops();
this['people'].set_once(this['persistence'].get_referrer_info());
// `store_google` is now deprecated and previously stored UTM parameters are cleared
// from persistence by default.
if (this.get_config('store_google') && this.get_config('stop_utm_persistence')) {
var utm_params = _.info.campaignParams(null);
_.each(utm_params, function(_utm_value, utm_key) {
// We need to unregister persisted UTM parameters so old values
// are not mixed with the new UTM parameters
this.unregister(utm_key);
}.bind(this));
}
};
// update persistence with info on referrer, UTM params, etc
MixpanelLib.prototype._set_default_superprops = function() {
this['persistence'].update_search_keyword(document.referrer);
// Registering super properties for UTM persistence by 'store_google' is deprecated.
if (this.get_config('store_google') && !this.get_config('stop_utm_persistence')) {
this.register(_.info.campaignParams());
}
if (this.get_config('save_referrer')) {
this['persistence'].update_referrer_info(document.referrer);
}
};
MixpanelLib.prototype._dom_loaded = function() {
_.each(this.__dom_loaded_queue, function(item) {
this._track_dom.apply(this, item);
}, this);
if (!this.has_opted_out_tracking()) {
_.each(this.__request_queue, function(item) {
this._send_request.apply(this, item);
}, this);
}
delete this.__dom_loaded_queue;
delete this.__request_queue;
};
MixpanelLib.prototype._track_dom = function(DomClass, args) {
if (this.get_config('img')) {
this.report_error('You can\'t use DOM tracking functions with img = true.');
return false;
}
if (!DOM_LOADED) {
this.__dom_loaded_queue.push([DomClass, args]);
return false;
}
var dt = new DomClass().init(this);
return dt.track.apply(dt, args);
};
/**
* _prepare_callback() should be called by callers of _send_request for use
* as the callback argument.
*
* If there is no callback, this returns null.
* If we are going to make XHR/XDR requests, this returns a function.
* If we are going to use script tags, this returns a string to use as the
* callback GET param.
*/
MixpanelLib.prototype._prepare_callback = function(callback, data) {
if (_.isUndefined(callback)) {
return null;
}
if (USE_XHR) {
var callback_function = function(response) {
callback(response, data);
};
return callback_function;
} else {
// if the user gives us a callback, we store as a random
// property on this instances jsc function and update our
// callback string to reflect that.
var jsc = this['_jsc'];
var randomized_cb = '' + Math.floor(Math.random() * 100000000);
var callback_string = this.get_config('callback_fn') + '[' + randomized_cb + ']';
jsc[randomized_cb] = function(response) {
delete jsc[randomized_cb];
callback(response, data);
};
return callback_string;
}
};
MixpanelLib.prototype._send_request = function(url, data, options, callback) {
var succeeded = true;
if (ENQUEUE_REQUESTS) {
this.__request_queue.push(arguments);
return succeeded;
}
var DEFAULT_OPTIONS = {
method: this.get_config('api_method'),
transport: this.get_config('api_transport'),
verbose: this.get_config('verbose')
};
var body_data = null;
if (!callback && (_.isFunction(options) || typeof options === 'string')) {
callback = options;
options = null;
}
options = _.extend(DEFAULT_OPTIONS, options || {});
if (!USE_XHR) {
options.method = 'GET';
}
var use_post = options.method === 'POST';
var use_sendBeacon = sendBeacon && use_post && options.transport.toLowerCase() === 'sendbeacon';
// needed to correctly format responses
var verbose_mode = options.verbose;
if (data['verbose']) { verbose_mode = true; }
if (this.get_config('test')) { data['test'] = 1; }
if (verbose_mode) { data['verbose'] = 1; }
if (this.get_config('img')) { data['img'] = 1; }
if (!USE_XHR) {
if (callback) {
data['callback'] = callback;
} else if (verbose_mode || this.get_config('test')) {
// Verbose output (from verbose mode, or an error in test mode) is a json blob,
// which by itself is not valid javascript. Without a callback, this verbose output will
// cause an error when returned via jsonp, so we force a no-op callback param.
// See the ECMA script spec: http://www.ecma-international.org/ecma-262/5.1/#sec-12.4
data['callback'] = '(function(){})';
}
}
data['ip'] = this.get_config('ip')?1:0;
data['_'] = new Date().getTime().toString();
if (use_post) {
body_data = 'data=' + encodeURIComponent(data['data']);
delete data['data'];
}
_.extend(data, this.get_config('api_extra_query_params'));
url += '?' + _.HTTPBuildQuery(data);
var lib = this;
if ('img' in data) {
var img = document.createElement('img');
img.src = url;
document.body.appendChild(img);
} else if (use_sendBeacon) {
try {
succeeded = sendBeacon(url, body_data);
} catch (e) {
lib.report_error(e);
succeeded = false;
}
try {
if (callback) {
callback(succeeded ? 1 : 0);
}
} catch (e) {
lib.report_error(e);
}
} else if (USE_XHR) {
try {
var req = new XMLHttpRequest();
req.open(options.method, url, true);
var headers = this.get_config('xhr_headers');
if (use_post) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
_.each(headers, function(headerValue, headerName) {
req.setRequestHeader(headerName, headerValue);
});
if (options.timeout_ms && typeof req.timeout !== 'undefined') {
req.timeout = options.timeout_ms;
var start_time = new Date().getTime();
}
// send the mp_optout cookie
// withCredentials cannot be modified until after calling .open on Android and Mobile Safari
req.withCredentials = true;
req.onreadystatechange = function () {
if (req.readyState === 4) { // XMLHttpRequest.DONE == 4, except in safari 4
if (req.status === 200) {
if (callback) {
if (verbose_mode) {
var response;
try {
response = _.JSONDecode(req.responseText);
} catch (e) {
lib.report_error(e);
if (options.ignore_json_errors) {
response = req.responseText;
} else {
return;
}
}
callback(response);
} else {
callback(Number(req.responseText));
}
}
} else {
var error;
if (
req.timeout &&
!req.status &&
new Date().getTime() - start_time >= req.timeout
) {
error = 'timeout';
} else {
error = 'Bad HTTP status: ' + req.status + ' ' + req.statusText;
}
lib.report_error(error);
if (callback) {
if (verbose_mode) {
var response_headers = req['responseHeaders'] || {};
callback({status: 0, httpStatusCode: req['status'], error: error, retryAfter: response_headers['Retry-After']});
} else {
callback(0);
}
}
}
}
};
req.send(body_data);
} catch (e) {
lib.report_error(e);
succeeded = false;
}
} else {
var script = document.createElement('script');
script.type = 'text/javascript';
script.async = true;
script.defer = true;
script.src = url;
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(script, s);
}
return succeeded;
};
/**
* _execute_array() deals with processing any mixpanel function
* calls that were called before the Mixpanel library were loaded
* (and are thus stored in an array so they can be called later)
*
* Note: we fire off all the mixpanel function calls && user defined
* functions BEFORE we fire off mixpanel tracking calls. This is so
* identify/register/set_config calls can properly modify early
* tracking calls.
*
* @param {Array} array
*/
MixpanelLib.prototype._execute_array = function(array) {
var fn_name, alias_calls = [], other_calls = [], tracking_calls = [];
_.each(array, function(item) {
if (item) {
fn_name = item[0];
if (_.isArray(fn_name)) {
tracking_calls.push(item); // chained call e.g. mixpanel.get_group().set()
} else if (typeof(item) === 'function') {
item.call(this);
} else if (_.isArray(item) && fn_name === 'alias') {
alias_calls.push(item);
} else if (_.isArray(item) && fn_name.indexOf('track') !== -1 && typeof(this[fn_name]) === 'function') {
tracking_calls.push(item);
} else {
other_calls.push(item);
}
}
}, this);
var execute = function(calls, context) {
_.each(calls, function(item) {
if (_.isArray(item[0])) {
// chained call
var caller = context;
_.each(item, function(call) {
caller = caller[call[0]].apply(caller, call.slice(1));
});
} else {
this[item[0]].apply(this, item.slice(1));
}
}, context);
};
execute(alias_calls, this);
execute(other_calls, this);
execute(tracking_calls, this);
};
// request queueing utils
MixpanelLib.prototype.are_batchers_initialized = function() {
return !!this.request_batchers.events;
};
MixpanelLib.prototype.get_batcher_configs = function() {
var queue_prefix = '__mpq_' + this.get_config('token');
this._batcher_configs = this._batcher_configs || {
events: {type: 'events', api_name: 'track', queue_key: queue_prefix + '_ev'},
people: {type: 'people', api_name: 'engage', queue_key: queue_prefix + '_pp'},
groups: {type: 'groups', api_name: 'groups', queue_key: queue_prefix + '_gr'}
};
return this._batcher_configs;
};
MixpanelLib.prototype.init_batchers = function() {
if (!this.are_batchers_initialized()) {
var batcher_for = _.bind(function(attrs) {
return new RequestBatcher(
attrs.queue_key,
{
libConfig: this['config'],
errorReporter: this.get_config('error_reporter'),
sendRequestFunc: _.bind(function(data, options, cb) {
var api_routes = this.get_config('api_routes');
this._send_request(
this.get_api_host(attrs.api_name) + '/' + api_routes[attrs.api_name],
this._encode_data_for_request(data),
options,
this._prepare_callback(cb, data)
);
}, this),
beforeSendHook: _.bind(function(item) {
return this._run_hook('before_send_' + attrs.type, item);
}, this),
stopAllBatchingFunc: _.bind(this.stop_batch_senders, this),
usePersistence: true,
}
);
}, this);
var batcher_configs = this.get_batcher_configs();
this.request_batchers = {
events: batcher_for(batcher_configs.events),
people: batcher_for(batcher_configs.people),
groups: batcher_for(batcher_configs.groups)
};
}
if (this.get_config('batch_autostart')) {
this.start_batch_senders();
}
};
MixpanelLib.prototype.start_batch_senders = function() {
this._batchers_were_started = true;
if (this.are_batchers_initialized()) {
this._batch_requests = true;
_.each(this.request_batchers, function(batcher) {
batcher.start();
});
}
};
MixpanelLib.prototype.stop_batch_senders = function() {
this._batch_requests = false;
_.each(this.request_batchers, function(batcher) {
batcher.stop();
batcher.clear();
});
};
/**
* push() keeps the standard async-array-push
* behavior around after the lib is loaded.
* This is only useful for external integrations that
* do not wish to rely on our convenience methods
* (created in the snippet).
*
* ### Usage:
* mixpanel.push(['register', { a: 'b' }]);
*
* @param {Array} item A [function_name, args...] array to be executed
*/
MixpanelLib.prototype.push = function(item) {
this._execute_array([item]);
};
/**
* Disable events on the Mixpanel object. If passed no arguments,
* this function disables tracking of any event. If passed an
* array of event names, those events will be disabled, but other
* events will continue to be tracked.
*
* Note: this function does not stop other mixpanel functions from
* firing, such as register() or people.set().
*
* @param {Array} [events] An array of event names to disable
*/
MixpanelLib.prototype.disable = function(events) {
if (typeof(events) === 'undefined') {
this._flags.disable_all_events = true;
} else {
this.__disabled_events = this.__disabled_events.concat(events);
}
};
MixpanelLib.prototype._encode_data_for_request = function(data) {
var encoded_data = JSONStringify(data);
if (this.get_config('api_payload_format') === PAYLOAD_TYPE_BASE64) {
encoded_data = _.base64Encode(encoded_data);
}
return {'data': encoded_data};
};
// internal method for handling track vs batch-enqueue logic
MixpanelLib.prototype._track_or_batch = function(options, callback) {
var truncated_data = _.truncate(options.data, 255);
var endpoint = options.endpoint;
var batcher = options.batcher;
var should_send_immediately = options.should_send_immediately;
var send_request_options = options.send_request_options || {};
callback = callback || NOOP_FUNC;
var request_enqueued_or_initiated = true;
var send_request_immediately = _.bind(function() {
if (!send_request_options.skip_hooks) {
truncated_data = this._run_hook('before_send_' + options.type, truncated_data);
}
if (truncated_data) {
console.log('MIXPANEL REQUEST:');
console.log(truncated_data);
return this._send_request(
endpoint,
this._encode_data_for_request(truncated_data),
send_request_options,
this._prepare_callback(callback, truncated_data)
);
} else {
return null;
}
}, this);
if (this._batch_requests && !should_send_immediately) {
batcher.enqueue(truncated_data).then(function(succeeded) {
if (succeeded) {
callback(1, truncated_data);
} else {
send_request_immediately();
}
});
} else {
request_enqueued_or_initiated = send_request_immediately();
}
return request_enqueued_or_initiated && truncated_data;
};
/**
* Track an event. This is the most important and
* frequently used Mixpanel function.
*
* ### Usage:
*
* // track an event named 'Registered'
* mixpanel.track('Registered', {'Gender': 'Male', 'Age': 21});
*
* // track an event using navigator.sendBeacon
* mixpanel.track('Left page', {'duration_seconds': 35}, {transport: 'sendBeacon'});
*
* To track link clicks or form submissions, see track_links() or track_forms().
*
* @param {String} event_name The name of the event. This can be anything the user does - 'Button Click', 'Sign Up', 'Item Purchased', etc.
* @param {Object} [properties] 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.
* @param {Object} [options] Optional configuration for this track request.
* @param {String} [options.transport] Transport method for network request ('xhr' or 'sendBeacon').
* @param {Boolean} [options.send_immediately] Whether to bypass batching/queueing and send track request immediately.
* @param {Function} [callback] If provided, the callback function will be called after tracking the event.
* @returns {Boolean|Object} If the tracking request was successfully initiated/queued, an object
* with the tracking payload sent to the API server is returned; otherwise false.
*/
MixpanelLib.prototype.track = addOptOutCheckMixpanelLib(function(event_name, properties, options, callback) {
if (!callback && typeof options === 'function') {
callback = options;
options = null;
}
options = options || {};
var transport = options['transport']; // external API, don't minify 'transport' prop
if (transport) {
options.transport = transport; // 'transport' prop name can be minified internally
}
var should_send_immediately = options['send_immediately'];
if (typeof callback !== 'function') {
callback = NOOP_FUNC;
}
if (_.isUndefined(event_name)) {
this.report_error('No event name provided to mixpanel.track');
return;
}
if (this._event_is_disabled(event_name)) {
callback(0);
return;
}
// set defaults
properties = _.extend({}, properties);
properties['token'] = this.get_config('token');
// set $duration if time_event was previously called for this event
var start_timestamp = this['persistence'].remove_event_timer(event_name);
if (!_.isUndefined(start_timestamp)) {
var duration_in_ms = new Date().getTime() - start_timestamp;
properties['$duration'] = parseFloat((duration_in_ms / 1000).toFixed(3));
}
this._set_default_superprops();
var marketing_properties = this.get_config('track_marketing')
? _.info.marketingParams()
: {};
// note: extend writes to the first object, so lets make sure we
// don't write to the persistence properties object and info
// properties object by passing in a new object
// update properties with pageview info and super-properties
properties = _.extend(
{},
_.info.properties({'mp_loader': this.get_config('mp_loader')}),
marketing_properties,
this['persistence'].properties(),
this.unpersisted_superprops,
this.get_session_recording_properties(),
properties
);
var property_blacklist = this.get_config('property_blacklist');
if (_.isArray(property_blacklist)) {
_.each(property_blacklist, function(blacklisted_prop) {
delete properties[blacklisted_prop];
});
} else {
this.report_error('Invalid value for property_blacklist config: ' + property_blacklist);
}
var data = {
'event': event_name,
'properties': properties
};
var ret = this._track_or_batch({
type: 'events',
data: data,
endpoint: this.get_api_host('events') + '/' + this.get_config('api_routes')['track'],
batcher: this.request_batchers.events,
should_send_immediately: should_send_immediately,
send_request_options: options
}, callback);
return ret;
});
/**
* Register the current user into one/many groups.
*
* ### Usage:
*
* mixpanel.set_group('company', ['mixpanel', 'google']) // an array of IDs
* mixpanel.set_group('company', 'mixpanel')
* mixpanel.set_group('company', 128746312)
*
* @param {String} group_key Group key
* @param {Array|String|Number} group_ids An array of group IDs, or a singular group ID
* @param {Function} [callback] If provided, the callback will be called after tracking the event.
*
*/
MixpanelLib.prototype.set_group = addOptOutCheckMixpanelLib(function(group_key, group_ids, callback) {
if (!_.isArray(group_ids)) {
group_ids = [group_ids];
}
var prop = {};
prop[group_key] = group_ids;
this.register(prop);
return this['people'].set(group_key, group_ids, callback);
});
/**
* Add a new group for this user.
*
* ### Usage:
*
* mixpanel.add_group('company', 'mixpanel')
*
* @param {String} group_key Group key
* @param {*} group_id A valid Mixpanel property type
* @param {Function} [callback] If provided, the callback will be called after tracking the event.
*/
MixpanelLib.prototype.add_group = addOptOutCheckMixpanelLib(function(group_key, group_id, callback) {
var old_values = this.get_property(group_key);
var prop = {};
if (old_values === undefined) {
prop[group_key] = [group_id];
this.register(prop);
} else {
if (old_values.indexOf(group_id) === -1) {
old_values.push(group_id);
prop[group_key] = old_values;
this.register(prop);
}
}
return this['people'].union(group_key, group_id, callback);
});
/**
* Remove a group from this user.
*
* ### Usage:
*
* mixpanel.remove_group('company', 'mixpanel')
*
* @param {String} group_key Group key
* @param {*} group_id A valid Mixpanel property type
* @param {Function} [callback] If provided, the callback will be called after tracking the event.
*/
MixpanelLib.prototype.remove_group = addOptOutCheckMixpanelLib(function(group_key, group_id, callback) {
var old_value = this.get_property(group_key);
// if the value doesn't exist, the persistent store is unchanged
if (old_value !== undefined) {
var idx = old_value.indexOf(group_id);
if (idx > -1) {
old_value.splice(idx, 1);
this.register({group_key: old_value});
}
if (old_value.length === 0) {
this.unregister(group_key);
}
}
return this['people'].remove(group_key, group_id, callback);
});
/**
* Track an event with specific groups.
*
* ### Usage:
*
* mixpanel.track_with_groups('purchase', {'product': 'iphone'}, {'University': ['UCB', 'UCLA']})
*
* @param {String} event_name The name of the event (see `mixpanel.track()`)
* @param {Object=} properties A set of properties to include with the event you're sending (see `mixpanel.track()`)
* @param {Object=} groups An object mapping group name keys to one or more values
* @param {Function} [callback] If provided, the callback will be called after tracking the event.
*/
MixpanelLib.prototype.track_with_groups = addOptOutCheckMixpanelLib(function(event_name, properties, groups, callback) {
var tracking_props = _.extend({}, properties || {});
_.each(groups, function(v, k) {
if (v !== null && v !== undefined) {
tracking_props[k] = v;
}
});
return this.track(event_name, tracking_props, callback);
});
MixpanelLib.prototype._create_map_key = function (group_key, group_id) {
return group_key + '_' + JSON.stringify(group_id);
};
MixpanelLib.prototype._remove_group_from_cache = function (group_key, group_id) {
delete this._cached_groups[this._create_map_key(group_key, group_id)];
};
/**
* Look up reference to a Mixpanel group
*
* ### Usage:
*
* mixpanel.get_group(group_key, group_id)
*
* @param {String} group_key Group key
* @param {Object} group_id A valid Mixpanel property type
* @returns {Object} A MixpanelGroup identifier
*/
MixpanelLib.prototype.get_group = function (group_key, group_id) {
var map_key = this._create_map_key(group_key, group_id);
var group = this._cached_groups[map_key];
if (group === undefined || group._group_key !== group_key || group._group_id !== group_id) {
group = new MixpanelGroup();
group._init(this, group_key, group_id);
this._cached_groups[map_key] = group;
}
return group;
};
/**
* Track a default Mixpanel page view event, which includes extra default event properties to
* improve page view data.
*
* ### Usage:
*
* // track a default $mp_web_page_view event
* mixpanel.track_pageview();
*
* // track a page view event with additional event properties
* mixpanel.track_pageview({'ab_test_variant': 'card-layout-b'});
*
* // example approach to track page views on different page types as event properties
* mixpanel.track_pageview({'page': 'pricing'});
* mixpanel.track_pageview({'page': 'homepage'});
*
* // UNCOMMON: Tracking a page view event with a custom event_name option. NOT expected to be used for
* // individual pages on the same site or product. Use cases for custom event_name may be page
* // views on different products or internal applications that are considered completely separate
* mixpanel.track_pageview({'page': 'customer-search'}, {'event_name': '[internal] Admin Page View'});
*
* ### Notes:
*
* The `config.track_pageview` option for <a href="#mixpanelinit">mixpanel.init()</a>
* may be turned on for tracking page loads automatically.
*
* // track only page loads
* mixpanel.init(PROJECT_TOKEN, {track_pageview: true});
*
* // track when the URL changes in any manner
* mixpanel.init(PROJECT_TOKEN, {track_pageview: 'full-url'});
*
* // track when the URL changes, ignoring any changes in the hash part
* mixpanel.init(PROJECT_TOKEN, {track_pageview: 'url-with-path-and-query-string'});
*
* // track when the path changes, ignoring any query parameter or hash changes
* mixpanel.init(PROJECT_TOKEN, {track_pageview: 'url-with-path'});
*
* @param {Object} [properties] An optional set of additional properties to send with the page view event
* @param {Object} [options] Page view tracking options
* @param {String} [options.event_name] - Alternate name for the tracking event
* @returns {Boolean|Object} If the tracking request was successfully initiated/queued, an object
* with the tracking payload sent to the API server is returned; otherwise false.
*/
MixpanelLib.prototype.track_pageview = addOptOutCheckMixpanelLib(function(properties, options) {
if (typeof properties !== 'object') {
properties = {};
}
options = options || {};
var event_name = options['event_name'] || '$mp_web_page_view';
var default_page_properties = _.extend(
_.info.mpPageViewProperties(),
_.info.campaignParams(),
_.info.clickParams()
);
var event_properties = _.extend(
{},
default_page_properties,
properties
);
return this.track(event_name, event_properties);
});
/**
* Track clicks on a set of document elements. Selector must be a
* valid query. Elements must exist on the page at the time track_links is called.
*
* ### Usage:
*
* // track click for link id #nav
* mixpanel.track_links('#nav', 'Clicked Nav Link');
*
* ### Notes:
*
* This function will wait up to 300 ms for the Mixpanel
* servers to respond. If they have not responded by that time
* it will head to the link without ensuring that your event
* has been tracked. To configure this timeout please see the
* set_config() documentation below.
*
* If you pass a function in as the properties argument, the
* function will receive the DOMElement that triggered the
* event as an argument. You are expected to return an object
* from the function; any properties defined on this object
* will be sent to mixpanel as event properties.
*
* @type {Function}
* @param {Object|String} query A valid DOM query, element or jQuery