UNPKG

mixpanel-browser

Version:

The official Mixpanel JavaScript browser client library

1,336 lines (1,207 loc) 96.6 kB
/* 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