mixpanel-browser
Version:
The official Mixpanel JavaScript browser client library
600 lines (517 loc) • 22.5 kB
JavaScript
import { _, document, safewrap, safewrapClass } from '../utils';
import { window } from '../window';
import {
getPolyfillScrollEndFunction, getPropsForDOMEvent, logger, minDOMApisSupported,
EV_CHANGE, EV_CLICK, EV_HASHCHANGE, EV_MP_LOCATION_CHANGE, EV_LOAD,
EV_POPSTATE, EV_SCROLL, EV_SCROLLEND, EV_SUBMIT, EV_VISIBILITYCHANGE
} from './utils';
import { RageClickTracker } from './rageclick';
import { DeadClickTracker, DEFAULT_DEAD_CLICK_TIMEOUT_MS } from './deadclick';
var AUTOCAPTURE_CONFIG_KEY = 'autocapture';
var LEGACY_PAGEVIEW_CONFIG_KEY = 'track_pageview';
var PAGEVIEW_OPTION_FULL_URL = 'full-url';
var PAGEVIEW_OPTION_URL_WITH_PATH_AND_QUERY_STRING = 'url-with-path-and-query-string';
var PAGEVIEW_OPTION_URL_WITH_PATH = 'url-with-path';
var CONFIG_ALLOW_ELEMENT_CALLBACK = 'allow_element_callback';
var CONFIG_ALLOW_SELECTORS = 'allow_selectors';
var CONFIG_ALLOW_URL_REGEXES = 'allow_url_regexes';
var CONFIG_BLOCK_ATTRS = 'block_attrs';
var CONFIG_BLOCK_ELEMENT_CALLBACK = 'block_element_callback';
var CONFIG_BLOCK_SELECTORS = 'block_selectors';
var CONFIG_BLOCK_URL_REGEXES = 'block_url_regexes';
var CONFIG_CAPTURE_EXTRA_ATTRS = 'capture_extra_attrs';
var CONFIG_CAPTURE_TEXT_CONTENT = 'capture_text_content';
var CONFIG_SCROLL_CAPTURE_ALL = 'scroll_capture_all';
var CONFIG_SCROLL_CHECKPOINTS = 'scroll_depth_percent_checkpoints';
var CONFIG_TRACK_CLICK = 'click';
var CONFIG_TRACK_DEAD_CLICK = 'dead_click';
var CONFIG_TRACK_INPUT = 'input';
var CONFIG_TRACK_PAGEVIEW = 'pageview';
var CONFIG_TRACK_RAGE_CLICK = 'rage_click';
var CONFIG_TRACK_SCROLL = 'scroll';
var CONFIG_TRACK_PAGE_LEAVE = 'page_leave';
var CONFIG_TRACK_SUBMIT = 'submit';
var CONFIG_DEFAULTS = {};
CONFIG_DEFAULTS[CONFIG_ALLOW_SELECTORS] = [];
CONFIG_DEFAULTS[CONFIG_ALLOW_URL_REGEXES] = [];
CONFIG_DEFAULTS[CONFIG_BLOCK_ATTRS] = [];
CONFIG_DEFAULTS[CONFIG_BLOCK_ELEMENT_CALLBACK] = null;
CONFIG_DEFAULTS[CONFIG_BLOCK_SELECTORS] = [];
CONFIG_DEFAULTS[CONFIG_BLOCK_URL_REGEXES] = [];
CONFIG_DEFAULTS[CONFIG_CAPTURE_EXTRA_ATTRS] = [];
CONFIG_DEFAULTS[CONFIG_CAPTURE_TEXT_CONTENT] = false;
CONFIG_DEFAULTS[CONFIG_SCROLL_CAPTURE_ALL] = false;
CONFIG_DEFAULTS[CONFIG_SCROLL_CHECKPOINTS] = [25, 50, 75, 100];
CONFIG_DEFAULTS[CONFIG_TRACK_CLICK] = true;
CONFIG_DEFAULTS[CONFIG_TRACK_DEAD_CLICK] = true;
CONFIG_DEFAULTS[CONFIG_TRACK_INPUT] = true;
CONFIG_DEFAULTS[CONFIG_TRACK_PAGEVIEW] = PAGEVIEW_OPTION_FULL_URL;
CONFIG_DEFAULTS[CONFIG_TRACK_RAGE_CLICK] = true;
CONFIG_DEFAULTS[CONFIG_TRACK_SCROLL] = true;
CONFIG_DEFAULTS[CONFIG_TRACK_PAGE_LEAVE] = false;
CONFIG_DEFAULTS[CONFIG_TRACK_SUBMIT] = true;
var DEFAULT_PROPS = {
'$mp_autocapture': true
};
var MP_EV_CLICK = '$mp_click';
var MP_EV_DEAD_CLICK = '$mp_dead_click';
var MP_EV_INPUT = '$mp_input_change';
var MP_EV_RAGE_CLICK = '$mp_rage_click';
var MP_EV_SCROLL = '$mp_scroll';
var MP_EV_SUBMIT = '$mp_submit';
var MP_EV_PAGE_LEAVE = '$mp_page_leave';
/**
* Autocapture: manages automatic event tracking
* @constructor
*/
var Autocapture = function(mp) {
this.mp = mp;
this.maxScrollViewDepth = 0;
this.hasTrackedScrollSession = false;
this.previousScrollHeight = 0;
};
Autocapture.prototype.init = function() {
if (!minDOMApisSupported()) {
logger.critical('Autocapture unavailable: missing required DOM APIs');
return;
}
this.initPageListeners();
this.initPageviewTracking();
this.initClickTracking();
this.initDeadClickTracking();
this.initInputTracking();
this.initScrollTracking();
this.initSubmitTracking();
this.initRageClickTracking();
this.initPageLeaveTracking();
};
Autocapture.prototype.getFullConfig = function() {
var autocaptureConfig = this.mp.get_config(AUTOCAPTURE_CONFIG_KEY);
if (!autocaptureConfig) {
// Autocapture is completely off
return {};
} else if (_.isObject(autocaptureConfig)) {
return _.extend({}, CONFIG_DEFAULTS, autocaptureConfig);
} else {
// Autocapture config is non-object truthy value, return default
return CONFIG_DEFAULTS;
}
};
Autocapture.prototype.getConfig = function(key) {
return this.getFullConfig()[key];
};
Autocapture.prototype.currentUrlBlocked = function() {
var i;
var currentUrl = _.info.currentUrl();
var allowUrlRegexes = this.getConfig(CONFIG_ALLOW_URL_REGEXES) || [];
if (allowUrlRegexes.length) {
// we're using an allowlist, only track if current URL matches
var allowed = false;
for (i = 0; i < allowUrlRegexes.length; i++) {
var allowRegex = allowUrlRegexes[i];
try {
if (currentUrl.match(allowRegex)) {
allowed = true;
break;
}
} catch (err) {
logger.critical('Error while checking block URL regex: ' + allowRegex, err);
return true;
}
}
if (!allowed) {
// wasn't allowed by any regex
return true;
}
}
var blockUrlRegexes = this.getConfig(CONFIG_BLOCK_URL_REGEXES) || [];
if (!blockUrlRegexes || !blockUrlRegexes.length) {
return false;
}
for (i = 0; i < blockUrlRegexes.length; i++) {
try {
if (currentUrl.match(blockUrlRegexes[i])) {
return true;
}
} catch (err) {
logger.critical('Error while checking block URL regex: ' + blockUrlRegexes[i], err);
return true;
}
}
return false;
};
Autocapture.prototype.pageviewTrackingConfig = function() {
// supports both autocapture config and old track_pageview config
if (this.mp.get_config(AUTOCAPTURE_CONFIG_KEY)) {
return this.getConfig(CONFIG_TRACK_PAGEVIEW);
} else {
return this.mp.get_config(LEGACY_PAGEVIEW_CONFIG_KEY);
}
};
// helper for event handlers
Autocapture.prototype.trackDomEvent = function(ev, mpEventName) {
if (this.currentUrlBlocked()) {
return;
}
var isCapturedForHeatMap = this.mp.is_recording_heatmap_data() && (
(mpEventName === MP_EV_CLICK && !this.getConfig(CONFIG_TRACK_CLICK)) ||
(mpEventName === MP_EV_RAGE_CLICK && !this._getClickTrackingConfig(CONFIG_TRACK_RAGE_CLICK)) ||
(mpEventName === MP_EV_DEAD_CLICK && !this._getClickTrackingConfig(CONFIG_TRACK_DEAD_CLICK))
);
var props = getPropsForDOMEvent(ev, {
allowElementCallback: this.getConfig(CONFIG_ALLOW_ELEMENT_CALLBACK),
allowSelectors: this.getConfig(CONFIG_ALLOW_SELECTORS),
blockAttrs: this.getConfig(CONFIG_BLOCK_ATTRS),
blockElementCallback: this.getConfig(CONFIG_BLOCK_ELEMENT_CALLBACK),
blockSelectors: this.getConfig(CONFIG_BLOCK_SELECTORS),
captureExtraAttrs: this.getConfig(CONFIG_CAPTURE_EXTRA_ATTRS),
captureTextContent: this.getConfig(CONFIG_CAPTURE_TEXT_CONTENT),
capturedForHeatMap: isCapturedForHeatMap,
});
if (props) {
_.extend(props, DEFAULT_PROPS);
this.mp.track(mpEventName, props);
}
};
Autocapture.prototype.initPageListeners = function() {
window.removeEventListener(EV_POPSTATE, this.listenerPopstate);
window.removeEventListener(EV_HASHCHANGE, this.listenerHashchange);
if (!this.pageviewTrackingConfig() && !this.getConfig(CONFIG_TRACK_PAGE_LEAVE) && !this.mp.get_config('record_heatmap_data')) {
// These are all the configs that use these listeners
return;
}
this.listenerPopstate = function() {
window.dispatchEvent(new Event(EV_MP_LOCATION_CHANGE));
};
this.listenerHashchange = function() {
window.dispatchEvent(new Event(EV_MP_LOCATION_CHANGE));
};
window.addEventListener(EV_POPSTATE, this.listenerPopstate);
window.addEventListener(EV_HASHCHANGE, this.listenerHashchange);
var nativePushState = window.history.pushState;
if (typeof nativePushState === 'function') {
window.history.pushState = function(state, unused, url) {
nativePushState.call(window.history, state, unused, url);
window.dispatchEvent(new Event(EV_MP_LOCATION_CHANGE));
};
}
var nativeReplaceState = window.history.replaceState;
if (typeof nativeReplaceState === 'function') {
window.history.replaceState = function(state, unused, url) {
nativeReplaceState.call(window.history, state, unused, url);
window.dispatchEvent(new Event(EV_MP_LOCATION_CHANGE));
};
}
};
Autocapture.prototype._getClickTrackingConfig = function(configKey) {
var config = this.getConfig(configKey);
if (!config) {
return null; // click tracking disabled
}
if (config === true) {
return {}; // use defaults
}
if (typeof config === 'object') {
return config; // use custom configuration
}
return {}; // fallback to defaults for any other truthy value
};
Autocapture.prototype._trackPageLeave = function(ev, currentUrl, currentScrollHeight) {
if (this.hasTrackedScrollSession) {
// User has navigated away already ending their impression.
return;
}
if (!this.getConfig(CONFIG_TRACK_PAGE_LEAVE) && !this.mp.is_recording_heatmap_data()) {
return;
}
this.hasTrackedScrollSession = true;
var viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
var scrollPercentage = Math.round(Math.max(this.maxScrollViewDepth - viewportHeight, 0) / (currentScrollHeight - viewportHeight) * 100);
var foldLinePercentage = Math.round((viewportHeight / currentScrollHeight) * 100);
if (currentScrollHeight <= viewportHeight) {
// If the content fits within the viewport, consider it fully scrolled
scrollPercentage = 100;
foldLinePercentage = 100;
}
var props = _.extend({
'$max_scroll_view_depth': this.maxScrollViewDepth,
'$max_scroll_percentage': scrollPercentage,
'$fold_line_percentage': foldLinePercentage,
'$scroll_height': currentScrollHeight,
'$event_type': ev.type,
'$current_url': currentUrl || _.info.currentUrl(),
'$viewportHeight': viewportHeight, // This is the fold line
'$viewportWidth': Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
'$captured_for_heatmap': this.mp.is_recording_heatmap_data()
}, DEFAULT_PROPS);
// Send with beacon transport to ensure event is sent before unload
this.mp.track(MP_EV_PAGE_LEAVE, props, {transport: 'sendBeacon'});
};
Autocapture.prototype._initScrollDepthTracking = function() {
window.removeEventListener(EV_SCROLL, this.listenerScrollDepth);
window.removeEventListener(EV_SCROLLEND, this.listenerScrollDepth);
if (!this.mp.get_config('record_heatmap_data')) {
return;
}
logger.log('Initializing scroll depth tracking');
this.maxScrollViewDepth = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
var updateScrollDepth = function() {
if (this.currentUrlBlocked()) {
return;
}
var scrollViewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0) + window.scrollY;
if (scrollViewHeight > this.maxScrollViewDepth) {
this.maxScrollViewDepth = scrollViewHeight;
}
this.previousScrollHeight = document.body.scrollHeight;
}.bind(this);
var scrollEndPolyfill = getPolyfillScrollEndFunction(updateScrollDepth);
this.listenerScrollDepth = scrollEndPolyfill.listener;
window.addEventListener(scrollEndPolyfill.eventType, this.listenerScrollDepth);
};
Autocapture.prototype.initClickTracking = function() {
window.removeEventListener(EV_CLICK, this.listenerClick);
if (!this.getConfig(CONFIG_TRACK_CLICK) && !this.mp.get_config('record_heatmap_data')) {
return;
}
logger.log('Initializing click tracking');
this.listenerClick = function(ev) {
if (!this.getConfig(CONFIG_TRACK_CLICK) && !this.mp.is_recording_heatmap_data()) {
return;
}
this.trackDomEvent(ev, MP_EV_CLICK);
}.bind(this);
window.addEventListener(EV_CLICK, this.listenerClick);
};
Autocapture.prototype.initDeadClickTracking = function() {
var deadClickConfig = this._getClickTrackingConfig(CONFIG_TRACK_DEAD_CLICK);
if (!deadClickConfig && !this.mp.get_config('record_heatmap_data')) {
this.stopDeadClickTracking();
return;
}
logger.log('Initializing dead click tracking');
if (!this._deadClickTracker) {
this._deadClickTracker = new DeadClickTracker(function(deadClickEvent) {
this.trackDomEvent(deadClickEvent, MP_EV_DEAD_CLICK);
}.bind(this));
this._deadClickTracker.startTracking();
}
if (!this.listenerDeadClick) {
this.listenerDeadClick = function(ev) {
var currentDeadClickConfig = this._getClickTrackingConfig(CONFIG_TRACK_DEAD_CLICK);
if (!currentDeadClickConfig && !this.mp.is_recording_heatmap_data()) {
return;
}
if (this.currentUrlBlocked()) {
return;
}
// Normalize config to ensure timeout_ms is always set
var normalizedConfig = currentDeadClickConfig || {};
if (!normalizedConfig['timeout_ms']) {
normalizedConfig['timeout_ms'] = DEFAULT_DEAD_CLICK_TIMEOUT_MS;
}
this._deadClickTracker.trackClick(ev, normalizedConfig);
}.bind(this);
window.addEventListener(EV_CLICK, this.listenerDeadClick);
}
};
Autocapture.prototype.initInputTracking = function() {
window.removeEventListener(EV_CHANGE, this.listenerChange);
if (!this.getConfig(CONFIG_TRACK_INPUT)) {
return;
}
logger.log('Initializing input tracking');
this.listenerChange = function(ev) {
if (!this.getConfig(CONFIG_TRACK_INPUT)) {
return;
}
this.trackDomEvent(ev, MP_EV_INPUT);
}.bind(this);
window.addEventListener(EV_CHANGE, this.listenerChange);
};
Autocapture.prototype.initPageviewTracking = function() {
window.removeEventListener(EV_MP_LOCATION_CHANGE, this.listenerLocationchange);
if (!this.pageviewTrackingConfig()) {
return;
}
logger.log('Initializing pageview tracking');
var previousTrackedUrl = '';
var tracked = false;
if (!this.currentUrlBlocked()) {
tracked = this.mp.track_pageview(DEFAULT_PROPS);
}
if (tracked) {
previousTrackedUrl = _.info.currentUrl();
}
this.listenerLocationchange = safewrap(function() {
if (this.currentUrlBlocked()) {
return;
}
var currentUrl = _.info.currentUrl();
var shouldTrack = false;
var didPathChange = currentUrl.split('#')[0].split('?')[0] !== previousTrackedUrl.split('#')[0].split('?')[0];
var trackPageviewOption = this.pageviewTrackingConfig();
if (trackPageviewOption === PAGEVIEW_OPTION_FULL_URL) {
shouldTrack = currentUrl !== previousTrackedUrl;
} else if (trackPageviewOption === PAGEVIEW_OPTION_URL_WITH_PATH_AND_QUERY_STRING) {
shouldTrack = currentUrl.split('#')[0] !== previousTrackedUrl.split('#')[0];
} else if (trackPageviewOption === PAGEVIEW_OPTION_URL_WITH_PATH) {
shouldTrack = didPathChange;
}
if (shouldTrack) {
var tracked = this.mp.track_pageview(DEFAULT_PROPS);
if (tracked) {
previousTrackedUrl = currentUrl;
}
if (didPathChange) {
this.lastScrollCheckpoint = 0;
logger.log('Path change: re-initializing scroll depth checkpoints');
}
}
}.bind(this));
window.addEventListener(EV_MP_LOCATION_CHANGE, this.listenerLocationchange);
};
Autocapture.prototype.initRageClickTracking = function() {
window.removeEventListener(EV_CLICK, this.listenerRageClick);
var rageClickConfig = this._getClickTrackingConfig(CONFIG_TRACK_RAGE_CLICK);
if (!rageClickConfig && !this.mp.get_config('record_heatmap_data')) {
return;
}
logger.log('Initializing rage click tracking');
if (!this._rageClickTracker) {
this._rageClickTracker = new RageClickTracker();
}
this.listenerRageClick = function(ev) {
var currentRageClickConfig = this._getClickTrackingConfig(CONFIG_TRACK_RAGE_CLICK);
if (!currentRageClickConfig && !this.mp.is_recording_heatmap_data()) {
return;
}
if (this.currentUrlBlocked()) {
return;
}
if (this._rageClickTracker.isRageClick(ev, currentRageClickConfig)) {
this.trackDomEvent(ev, MP_EV_RAGE_CLICK);
}
}.bind(this);
window.addEventListener(EV_CLICK, this.listenerRageClick);
};
Autocapture.prototype.initScrollTracking = function() {
window.removeEventListener(EV_SCROLLEND, this.listenerScroll);
window.removeEventListener(EV_SCROLL, this.listenerScroll);
if (!this.getConfig(CONFIG_TRACK_SCROLL)) {
return;
}
logger.log('Initializing scroll tracking');
this.lastScrollCheckpoint = 0;
var scrollTrackFunction = function() {
if (!this.getConfig(CONFIG_TRACK_SCROLL)) {
return;
}
if (this.currentUrlBlocked()) {
return;
}
var shouldTrack = this.getConfig(CONFIG_SCROLL_CAPTURE_ALL);
var scrollCheckpoints = (this.getConfig(CONFIG_SCROLL_CHECKPOINTS) || [])
.slice()
.sort(function(a, b) { return a - b; });
var scrollTop = window.scrollY;
var props = _.extend({'$scroll_top': scrollTop}, DEFAULT_PROPS);
try {
var scrollHeight = document.body.scrollHeight;
var scrollPercentage = Math.round((scrollTop / (scrollHeight - window.innerHeight)) * 100);
props['$scroll_height'] = scrollHeight;
props['$scroll_percentage'] = scrollPercentage;
if (scrollPercentage > this.lastScrollCheckpoint) {
for (var i = 0; i < scrollCheckpoints.length; i++) {
var checkpoint = scrollCheckpoints[i];
if (
scrollPercentage >= checkpoint &&
this.lastScrollCheckpoint < checkpoint
) {
props['$scroll_checkpoint'] = checkpoint;
this.lastScrollCheckpoint = checkpoint;
shouldTrack = true;
}
}
}
} catch (err) {
logger.critical('Error while calculating scroll percentage', err);
}
if (shouldTrack) {
this.mp.track(MP_EV_SCROLL, props);
}
}.bind(this);
var scrollEndPolyfill = getPolyfillScrollEndFunction(scrollTrackFunction);
this.listenerScroll = scrollEndPolyfill.listener;
window.addEventListener(scrollEndPolyfill.eventType, this.listenerScroll);
};
Autocapture.prototype.initSubmitTracking = function() {
window.removeEventListener(EV_SUBMIT, this.listenerSubmit);
if (!this.getConfig(CONFIG_TRACK_SUBMIT)) {
return;
}
logger.log('Initializing submit tracking');
this.listenerSubmit = function(ev) {
if (!this.getConfig(CONFIG_TRACK_SUBMIT)) {
return;
}
this.trackDomEvent(ev, MP_EV_SUBMIT);
}.bind(this);
window.addEventListener(EV_SUBMIT, this.listenerSubmit);
};
Autocapture.prototype.initPageLeaveTracking = function() {
// Capture page_leave both when the user navigates away from the page (visibilitychange) as well
// as when they navigate to a different page within the SPA (popstate/pushstate/hashchange).
document.removeEventListener(EV_VISIBILITYCHANGE, this.listenerPageLeaveVisibilitychange);
window.removeEventListener(EV_MP_LOCATION_CHANGE, this.listenerPageLeaveLocationchange);
window.removeEventListener(EV_LOAD, this.listenerPageLoad);
if (!this.getConfig(CONFIG_TRACK_PAGE_LEAVE) && !this.mp.get_config('record_heatmap_data')) {
return;
}
logger.log('Initializing page visibility tracking.');
this._initScrollDepthTracking();
var previousTrackedUrl = _.info.currentUrl();
// Initialize previousScrollHeight on `load` which handles async loading
// https://developer.mozilla.org/en-US/docs/Web/API/Window/load_event
this.listenerPageLoad = function() {
this.previousScrollHeight = document.body.scrollHeight;
}.bind(this);
window.addEventListener(EV_LOAD, this.listenerPageLoad);
// Track page navigation events similar to how initPageviewTracking does it
this.listenerPageLeaveLocationchange = safewrap(function(ev) {
if (this.currentUrlBlocked()) {
return;
}
var currentUrl = _.info.currentUrl();
// Track all URL changes including query string or fragment changes as separate scroll sessions
var shouldTrack = currentUrl !== previousTrackedUrl;
if (shouldTrack) {
this._trackPageLeave(ev, previousTrackedUrl, this.previousScrollHeight);
previousTrackedUrl = currentUrl;
// Fragment navigation should call scroll(end) and trigger listener, don't add window.scrollY here.
this.maxScrollViewDepth = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
this.previousScrollHeight = document.body.scrollHeight;
this.hasTrackedScrollSession = false;
}
}.bind(this));
window.addEventListener(EV_MP_LOCATION_CHANGE, this.listenerPageLeaveLocationchange);
this.listenerPageLeaveVisibilitychange = function(ev) {
if (document.hidden) {
this._trackPageLeave(ev, previousTrackedUrl, this.previousScrollHeight);
}
}.bind(this);
document.addEventListener(EV_VISIBILITYCHANGE, this.listenerPageLeaveVisibilitychange);
};
Autocapture.prototype.stopDeadClickTracking = function() {
if (this.listenerDeadClick) {
window.removeEventListener(EV_CLICK, this.listenerDeadClick);
this.listenerDeadClick = null;
}
if (this._deadClickTracker) {
this._deadClickTracker.stopTracking();
this._deadClickTracker = null;
}
};
// TODO integrate error_reporter from mixpanel instance
safewrapClass(Autocapture);
export { Autocapture };