mixpanel-browser 2.59.0 → 2.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixpanel-browser",
3
- "version": "2.59.0",
3
+ "version": "2.61.0",
4
4
  "description": "The official Mixpanel JavaScript browser client library",
5
5
  "main": "dist/mixpanel.cjs.js",
6
6
  "module": "dist/mixpanel.module.js",
@@ -45,6 +45,7 @@
45
45
  "dox": "0.9.0",
46
46
  "eslint": "4.18.2",
47
47
  "express": "4.12.2",
48
+ "fake-indexeddb": "6.0.0",
48
49
  "jsdom": "16.5.0",
49
50
  "jsdom-global": "3.0.2",
50
51
  "localStorage": "1.0.4",
@@ -13,9 +13,17 @@ var PAGEVIEW_OPTION_FULL_URL = 'full-url';
13
13
  var PAGEVIEW_OPTION_URL_WITH_PATH_AND_QUERY_STRING = 'url-with-path-and-query-string';
14
14
  var PAGEVIEW_OPTION_URL_WITH_PATH = 'url-with-path';
15
15
 
16
+ var CONFIG_ALLOW_ELEMENT_CALLBACK = 'allow_element_callback';
17
+ var CONFIG_ALLOW_SELECTORS = 'allow_selectors';
18
+ var CONFIG_ALLOW_URL_REGEXES = 'allow_url_regexes';
19
+ var CONFIG_BLOCK_ATTRS = 'block_attrs';
20
+ var CONFIG_BLOCK_ELEMENT_CALLBACK = 'block_element_callback';
16
21
  var CONFIG_BLOCK_SELECTORS = 'block_selectors';
17
22
  var CONFIG_BLOCK_URL_REGEXES = 'block_url_regexes';
23
+ var CONFIG_CAPTURE_EXTRA_ATTRS = 'capture_extra_attrs';
18
24
  var CONFIG_CAPTURE_TEXT_CONTENT = 'capture_text_content';
25
+ var CONFIG_SCROLL_CAPTURE_ALL = 'scroll_capture_all';
26
+ var CONFIG_SCROLL_CHECKPOINTS = 'scroll_depth_percent_checkpoints';
19
27
  var CONFIG_TRACK_CLICK = 'click';
20
28
  var CONFIG_TRACK_INPUT = 'input';
21
29
  var CONFIG_TRACK_PAGEVIEW = 'pageview';
@@ -23,7 +31,16 @@ var CONFIG_TRACK_SCROLL = 'scroll';
23
31
  var CONFIG_TRACK_SUBMIT = 'submit';
24
32
 
25
33
  var CONFIG_DEFAULTS = {};
34
+ CONFIG_DEFAULTS[CONFIG_ALLOW_SELECTORS] = [];
35
+ CONFIG_DEFAULTS[CONFIG_ALLOW_URL_REGEXES] = [];
36
+ CONFIG_DEFAULTS[CONFIG_BLOCK_ATTRS] = [];
37
+ CONFIG_DEFAULTS[CONFIG_BLOCK_ELEMENT_CALLBACK] = null;
38
+ CONFIG_DEFAULTS[CONFIG_BLOCK_SELECTORS] = [];
39
+ CONFIG_DEFAULTS[CONFIG_BLOCK_URL_REGEXES] = [];
40
+ CONFIG_DEFAULTS[CONFIG_CAPTURE_EXTRA_ATTRS] = [];
26
41
  CONFIG_DEFAULTS[CONFIG_CAPTURE_TEXT_CONTENT] = false;
42
+ CONFIG_DEFAULTS[CONFIG_SCROLL_CAPTURE_ALL] = false;
43
+ CONFIG_DEFAULTS[CONFIG_SCROLL_CHECKPOINTS] = [25, 50, 75, 100];
27
44
  CONFIG_DEFAULTS[CONFIG_TRACK_CLICK] = true;
28
45
  CONFIG_DEFAULTS[CONFIG_TRACK_INPUT] = true;
29
46
  CONFIG_DEFAULTS[CONFIG_TRACK_PAGEVIEW] = PAGEVIEW_OPTION_FULL_URL;
@@ -78,13 +95,37 @@ Autocapture.prototype.getConfig = function(key) {
78
95
  };
79
96
 
80
97
  Autocapture.prototype.currentUrlBlocked = function() {
98
+ var i;
99
+ var currentUrl = _.info.currentUrl();
100
+
101
+ var allowUrlRegexes = this.getConfig(CONFIG_ALLOW_URL_REGEXES) || [];
102
+ if (allowUrlRegexes.length) {
103
+ // we're using an allowlist, only track if current URL matches
104
+ var allowed = false;
105
+ for (i = 0; i < allowUrlRegexes.length; i++) {
106
+ var allowRegex = allowUrlRegexes[i];
107
+ try {
108
+ if (currentUrl.match(allowRegex)) {
109
+ allowed = true;
110
+ break;
111
+ }
112
+ } catch (err) {
113
+ logger.critical('Error while checking block URL regex: ' + allowRegex, err);
114
+ return true;
115
+ }
116
+ }
117
+ if (!allowed) {
118
+ // wasn't allowed by any regex
119
+ return true;
120
+ }
121
+ }
122
+
81
123
  var blockUrlRegexes = this.getConfig(CONFIG_BLOCK_URL_REGEXES) || [];
82
124
  if (!blockUrlRegexes || !blockUrlRegexes.length) {
83
125
  return false;
84
126
  }
85
127
 
86
- var currentUrl = _.info.currentUrl();
87
- for (var i = 0; i < blockUrlRegexes.length; i++) {
128
+ for (i = 0; i < blockUrlRegexes.length; i++) {
88
129
  try {
89
130
  if (currentUrl.match(blockUrlRegexes[i])) {
90
131
  return true;
@@ -112,11 +153,15 @@ Autocapture.prototype.trackDomEvent = function(ev, mpEventName) {
112
153
  return;
113
154
  }
114
155
 
115
- var props = getPropsForDOMEvent(
116
- ev,
117
- this.getConfig(CONFIG_BLOCK_SELECTORS),
118
- this.getConfig(CONFIG_CAPTURE_TEXT_CONTENT)
119
- );
156
+ var props = getPropsForDOMEvent(ev, {
157
+ allowElementCallback: this.getConfig(CONFIG_ALLOW_ELEMENT_CALLBACK),
158
+ allowSelectors: this.getConfig(CONFIG_ALLOW_SELECTORS),
159
+ blockAttrs: this.getConfig(CONFIG_BLOCK_ATTRS),
160
+ blockElementCallback: this.getConfig(CONFIG_BLOCK_ELEMENT_CALLBACK),
161
+ blockSelectors: this.getConfig(CONFIG_BLOCK_SELECTORS),
162
+ captureExtraAttrs: this.getConfig(CONFIG_CAPTURE_EXTRA_ATTRS),
163
+ captureTextContent: this.getConfig(CONFIG_CAPTURE_TEXT_CONTENT)
164
+ });
120
165
  if (props) {
121
166
  _.extend(props, DEFAULT_PROPS);
122
167
  this.mp.track(mpEventName, props);
@@ -201,13 +246,14 @@ Autocapture.prototype.initPageviewTracking = function() {
201
246
 
202
247
  var currentUrl = _.info.currentUrl();
203
248
  var shouldTrack = false;
249
+ var didPathChange = currentUrl.split('#')[0].split('?')[0] !== previousTrackedUrl.split('#')[0].split('?')[0];
204
250
  var trackPageviewOption = this.pageviewTrackingConfig();
205
251
  if (trackPageviewOption === PAGEVIEW_OPTION_FULL_URL) {
206
252
  shouldTrack = currentUrl !== previousTrackedUrl;
207
253
  } else if (trackPageviewOption === PAGEVIEW_OPTION_URL_WITH_PATH_AND_QUERY_STRING) {
208
254
  shouldTrack = currentUrl.split('#')[0] !== previousTrackedUrl.split('#')[0];
209
255
  } else if (trackPageviewOption === PAGEVIEW_OPTION_URL_WITH_PATH) {
210
- shouldTrack = currentUrl.split('#')[0].split('?')[0] !== previousTrackedUrl.split('#')[0].split('?')[0];
256
+ shouldTrack = didPathChange;
211
257
  }
212
258
 
213
259
  if (shouldTrack) {
@@ -215,6 +261,10 @@ Autocapture.prototype.initPageviewTracking = function() {
215
261
  if (tracked) {
216
262
  previousTrackedUrl = currentUrl;
217
263
  }
264
+ if (didPathChange) {
265
+ this.lastScrollCheckpoint = 0;
266
+ logger.log('Path change: re-initializing scroll depth checkpoints');
267
+ }
218
268
  }
219
269
  }.bind(this)));
220
270
  };
@@ -226,6 +276,7 @@ Autocapture.prototype.initScrollTracking = function() {
226
276
  return;
227
277
  }
228
278
  logger.log('Initializing scroll tracking');
279
+ this.lastScrollCheckpoint = 0;
229
280
 
230
281
  this.listenerScroll = window.addEventListener(EV_SCROLLEND, safewrap(function() {
231
282
  if (!this.getConfig(CONFIG_TRACK_SCROLL)) {
@@ -235,6 +286,11 @@ Autocapture.prototype.initScrollTracking = function() {
235
286
  return;
236
287
  }
237
288
 
289
+ var shouldTrack = this.getConfig(CONFIG_SCROLL_CAPTURE_ALL);
290
+ var scrollCheckpoints = (this.getConfig(CONFIG_SCROLL_CHECKPOINTS) || [])
291
+ .slice()
292
+ .sort(function(a, b) { return a - b; });
293
+
238
294
  var scrollTop = window.scrollY;
239
295
  var props = _.extend({'$scroll_top': scrollTop}, DEFAULT_PROPS);
240
296
  try {
@@ -242,10 +298,25 @@ Autocapture.prototype.initScrollTracking = function() {
242
298
  var scrollPercentage = Math.round((scrollTop / (scrollHeight - window.innerHeight)) * 100);
243
299
  props['$scroll_height'] = scrollHeight;
244
300
  props['$scroll_percentage'] = scrollPercentage;
301
+ if (scrollPercentage > this.lastScrollCheckpoint) {
302
+ for (var i = 0; i < scrollCheckpoints.length; i++) {
303
+ var checkpoint = scrollCheckpoints[i];
304
+ if (
305
+ scrollPercentage >= checkpoint &&
306
+ this.lastScrollCheckpoint < checkpoint
307
+ ) {
308
+ props['$scroll_checkpoint'] = checkpoint;
309
+ this.lastScrollCheckpoint = checkpoint;
310
+ shouldTrack = true;
311
+ }
312
+ }
313
+ }
245
314
  } catch (err) {
246
315
  logger.critical('Error while calculating scroll percentage', err);
247
316
  }
248
- this.mp.track(MP_EV_SCROLL, props);
317
+ if (shouldTrack) {
318
+ this.mp.track(MP_EV_SCROLL, props);
319
+ }
249
320
  }.bind(this)));
250
321
  };
251
322
 
@@ -70,7 +70,7 @@ function getPreviousElementSibling(el) {
70
70
  }
71
71
  }
72
72
 
73
- function getPropertiesFromElement(el) {
73
+ function getPropertiesFromElement(el, ev, blockAttrsSet, extraAttrs, allowElementCallback, allowSelectors) {
74
74
  var props = {
75
75
  '$classes': getClassName(el).split(' '),
76
76
  '$tag_name': el.tagName.toLowerCase()
@@ -80,9 +80,9 @@ function getPropertiesFromElement(el) {
80
80
  props['$id'] = elId;
81
81
  }
82
82
 
83
- if (shouldTrackElement(el)) {
84
- _.each(TRACKED_ATTRS, function(attr) {
85
- if (el.hasAttribute(attr)) {
83
+ if (shouldTrackElementDetails(el, ev, allowElementCallback, allowSelectors)) {
84
+ _.each(TRACKED_ATTRS.concat(extraAttrs), function(attr) {
85
+ if (el.hasAttribute(attr) && !blockAttrsSet[attr]) {
86
86
  var attrVal = el.getAttribute(attr);
87
87
  if (shouldTrackValue(attrVal)) {
88
88
  props['$attr-' + attr] = attrVal;
@@ -106,8 +106,21 @@ function getPropertiesFromElement(el) {
106
106
  return props;
107
107
  }
108
108
 
109
- function getPropsForDOMEvent(ev, blockSelectors, captureTextContent) {
110
- blockSelectors = blockSelectors || [];
109
+ function getPropsForDOMEvent(ev, config) {
110
+ var allowElementCallback = config.allowElementCallback;
111
+ var allowSelectors = config.allowSelectors || [];
112
+ var blockAttrs = config.blockAttrs || [];
113
+ var blockElementCallback = config.blockElementCallback;
114
+ var blockSelectors = config.blockSelectors || [];
115
+ var captureTextContent = config.captureTextContent || false;
116
+ var captureExtraAttrs = config.captureExtraAttrs || [];
117
+
118
+ // convert array to set every time, as the config may have changed
119
+ var blockAttrsSet = {};
120
+ _.each(blockAttrs, function(attr) {
121
+ blockAttrsSet[attr] = true;
122
+ });
123
+
111
124
  var props = null;
112
125
 
113
126
  var target = typeof ev.target === 'undefined' ? ev.srcElement : ev.target;
@@ -115,7 +128,11 @@ function getPropsForDOMEvent(ev, blockSelectors, captureTextContent) {
115
128
  target = target.parentNode;
116
129
  }
117
130
 
118
- if (shouldTrackDomEvent(target, ev)) {
131
+ if (
132
+ shouldTrackDomEvent(target, ev) &&
133
+ isElementAllowed(target, ev, allowElementCallback, allowSelectors) &&
134
+ !isElementBlocked(target, ev, blockElementCallback, blockSelectors)
135
+ ) {
119
136
  var targetElementList = [target];
120
137
  var curEl = target;
121
138
  while (curEl.parentNode && !isTag(curEl, 'body')) {
@@ -126,37 +143,20 @@ function getPropsForDOMEvent(ev, blockSelectors, captureTextContent) {
126
143
  var elementsJson = [];
127
144
  var href, explicitNoTrack = false;
128
145
  _.each(targetElementList, function(el) {
129
- var shouldTrackEl = shouldTrackElement(el);
146
+ var shouldTrackDetails = shouldTrackElementDetails(el, ev, allowElementCallback, allowSelectors);
130
147
 
131
148
  // if the element or a parent element is an anchor tag
132
149
  // include the href as a property
133
- if (el.tagName.toLowerCase() === 'a') {
150
+ if (!blockAttrsSet['href'] && el.tagName.toLowerCase() === 'a') {
134
151
  href = el.getAttribute('href');
135
- href = shouldTrackEl && shouldTrackValue(href) && href;
152
+ href = shouldTrackDetails && shouldTrackValue(href) && href;
136
153
  }
137
154
 
138
- // allow users to programmatically prevent tracking of elements by adding classes such as 'mp-no-track'
139
- var classes = getClasses(el);
140
- _.each(OPT_OUT_CLASSES, function(cls) {
141
- if (classes[cls]) {
142
- explicitNoTrack = true;
143
- }
144
- });
145
-
146
- if (!explicitNoTrack) {
147
- // programmatically prevent tracking of elements that match CSS selectors
148
- _.each(blockSelectors, function(sel) {
149
- try {
150
- if (el['matches'](sel)) {
151
- explicitNoTrack = true;
152
- }
153
- } catch (err) {
154
- logger.critical('Error while checking selector: ' + sel, err);
155
- }
156
- });
155
+ if (isElementBlocked(el, ev, blockElementCallback, blockSelectors)) {
156
+ explicitNoTrack = true;
157
157
  }
158
158
 
159
- elementsJson.push(getPropertiesFromElement(el));
159
+ elementsJson.push(getPropertiesFromElement(el, ev, blockAttrsSet, captureExtraAttrs, allowElementCallback, allowSelectors));
160
160
  }, this);
161
161
 
162
162
  if (!explicitNoTrack) {
@@ -170,9 +170,17 @@ function getPropsForDOMEvent(ev, blockSelectors, captureTextContent) {
170
170
  '$viewportHeight': Math.max(docElement['clientHeight'], window['innerHeight'] || 0),
171
171
  '$viewportWidth': Math.max(docElement['clientWidth'], window['innerWidth'] || 0)
172
172
  };
173
+ _.each(captureExtraAttrs, function(attr) {
174
+ if (!blockAttrsSet[attr] && target.hasAttribute(attr)) {
175
+ var attrVal = target.getAttribute(attr);
176
+ if (shouldTrackValue(attrVal)) {
177
+ props['$el_attr__' + attr] = attrVal;
178
+ }
179
+ }
180
+ });
173
181
 
174
182
  if (captureTextContent) {
175
- elementText = getSafeText(target);
183
+ elementText = getSafeText(target, ev, allowElementCallback, allowSelectors);
176
184
  if (elementText && elementText.length) {
177
185
  props['$el_text'] = elementText;
178
186
  }
@@ -188,14 +196,22 @@ function getPropsForDOMEvent(ev, blockSelectors, captureTextContent) {
188
196
  }
189
197
  // prioritize text content from "real" click target if different from original target
190
198
  if (captureTextContent) {
191
- var elementText = getSafeText(target);
199
+ var elementText = getSafeText(target, ev, allowElementCallback, allowSelectors);
192
200
  if (elementText && elementText.length) {
193
201
  props['$el_text'] = elementText;
194
202
  }
195
203
  }
196
204
 
197
205
  if (target) {
198
- var targetProps = getPropertiesFromElement(target);
206
+ // target may have been recalculated; check allowlists and blocklists again
207
+ if (
208
+ !isElementAllowed(target, ev, allowElementCallback, allowSelectors) ||
209
+ isElementBlocked(target, ev, blockElementCallback, blockSelectors)
210
+ ) {
211
+ return null;
212
+ }
213
+
214
+ var targetProps = getPropertiesFromElement(target, ev, blockAttrsSet, captureExtraAttrs, allowElementCallback, allowSelectors);
199
215
  props['$target'] = targetProps;
200
216
  // pull up more props onto main event props
201
217
  props['$el_classes'] = targetProps['$classes'];
@@ -211,19 +227,20 @@ function getPropsForDOMEvent(ev, blockSelectors, captureTextContent) {
211
227
  }
212
228
 
213
229
 
214
- /*
230
+ /**
215
231
  * Get the direct text content of an element, protecting against sensitive data collection.
216
232
  * Concats textContent of each of the element's text node children; this avoids potential
217
233
  * collection of sensitive data that could happen if we used element.textContent and the
218
234
  * element had sensitive child elements, since element.textContent includes child content.
219
235
  * Scrubs values that look like they could be sensitive (i.e. cc or ssn number).
220
236
  * @param {Element} el - element to get the text of
237
+ * @param {Array<string>} allowSelectors - CSS selectors for elements that should be included
221
238
  * @returns {string} the element's direct text content
222
239
  */
223
- function getSafeText(el) {
240
+ function getSafeText(el, ev, allowElementCallback, allowSelectors) {
224
241
  var elText = '';
225
242
 
226
- if (shouldTrackElement(el) && el.childNodes && el.childNodes.length) {
243
+ if (shouldTrackElementDetails(el, ev, allowElementCallback, allowSelectors) && el.childNodes && el.childNodes.length) {
227
244
  _.each(el.childNodes, function(child) {
228
245
  if (isTextNode(child) && child.textContent) {
229
246
  elText += _.trim(child.textContent)
@@ -262,6 +279,75 @@ function guessRealClickTarget(ev) {
262
279
  return target;
263
280
  }
264
281
 
282
+ function isElementAllowed(el, ev, allowElementCallback, allowSelectors) {
283
+ if (allowElementCallback) {
284
+ try {
285
+ if (!allowElementCallback(el, ev)) {
286
+ return false;
287
+ }
288
+ } catch (err) {
289
+ logger.critical('Error while checking element in allowElementCallback', err);
290
+ return false;
291
+ }
292
+ }
293
+
294
+ if (!allowSelectors.length) {
295
+ // no allowlist; all elements are fair game
296
+ return true;
297
+ }
298
+
299
+ for (var i = 0; i < allowSelectors.length; i++) {
300
+ var sel = allowSelectors[i];
301
+ try {
302
+ if (el['matches'](sel)) {
303
+ return true;
304
+ }
305
+ } catch (err) {
306
+ logger.critical('Error while checking selector: ' + sel, err);
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+
312
+ function isElementBlocked(el, ev, blockElementCallback, blockSelectors) {
313
+ var i;
314
+
315
+ if (blockElementCallback) {
316
+ try {
317
+ if (blockElementCallback(el, ev)) {
318
+ return true;
319
+ }
320
+ } catch (err) {
321
+ logger.critical('Error while checking element in blockElementCallback', err);
322
+ return true;
323
+ }
324
+ }
325
+
326
+ if (blockSelectors && blockSelectors.length) {
327
+ // programmatically prevent tracking of elements that match CSS selectors
328
+ for (i = 0; i < blockSelectors.length; i++) {
329
+ var sel = blockSelectors[i];
330
+ try {
331
+ if (el['matches'](sel)) {
332
+ return true;
333
+ }
334
+ } catch (err) {
335
+ logger.critical('Error while checking selector: ' + sel, err);
336
+ }
337
+ }
338
+ }
339
+
340
+ // allow users to programmatically prevent tracking of elements by adding default classes such as 'mp-no-track'
341
+ var classes = getClasses(el);
342
+ for (i = 0; i < OPT_OUT_CLASSES.length; i++) {
343
+ if (classes[OPT_OUT_CLASSES[i]]) {
344
+ return true;
345
+ }
346
+ }
347
+
348
+ return false;
349
+ }
350
+
265
351
  /*
266
352
  * Check whether a DOM node has nodeType Node.ELEMENT_NODE
267
353
  * @param {Node} node - node to check
@@ -336,11 +422,16 @@ function shouldTrackDomEvent(el, ev) {
336
422
  * Check whether a DOM element should be "tracked" or if it may contain sensitive data
337
423
  * using a variety of heuristics.
338
424
  * @param {Element} el - element to check
425
+ * @param {Array<string>} allowSelectors - CSS selectors for elements that should be included
339
426
  * @returns {boolean} whether the element should be tracked
340
427
  */
341
- function shouldTrackElement(el) {
428
+ function shouldTrackElementDetails(el, ev, allowElementCallback, allowSelectors) {
342
429
  var i;
343
430
 
431
+ if (!isElementAllowed(el, ev, allowElementCallback, allowSelectors)) {
432
+ return false;
433
+ }
434
+
344
435
  for (var curEl = el; curEl.parentNode && !isTag(curEl, 'body'); curEl = curEl.parentNode) {
345
436
  var classes = getClasses(curEl);
346
437
  for (i = 0; i < SENSITIVE_DATA_CLASSES.length; i++) {
@@ -428,7 +519,7 @@ export {
428
519
  getSafeText,
429
520
  logger,
430
521
  minDOMApisSupported,
431
- shouldTrackDomEvent, shouldTrackElement, shouldTrackValue,
522
+ shouldTrackDomEvent, shouldTrackElementDetails, shouldTrackValue,
432
523
  EV_CHANGE, EV_CLICK, EV_HASHCHANGE, EV_MP_LOCATION_CHANGE, EV_POPSTATE,
433
524
  EV_SCROLLEND, EV_SUBMIT
434
525
  };
package/src/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  var Config = {
2
2
  DEBUG: false,
3
- LIB_VERSION: '2.59.0'
3
+ LIB_VERSION: '2.61.0'
4
4
  };
5
5
 
6
6
  export default Config;
@@ -1,6 +1,7 @@
1
1
  /* eslint camelcase: "off" */
2
2
  import Config from './config';
3
- import { MAX_RECORDING_MS, _, console, userAgent, document, navigator, slice } from './utils';
3
+ import { MAX_RECORDING_MS, _, console, userAgent, document, navigator, slice, NOOP_FUNC } from './utils';
4
+ import { isRecordingExpired } from './recorder/utils';
4
5
  import { window } from './window';
5
6
  import { Autocapture } from './autocapture';
6
7
  import { FormTracker, LinkTracker } from './dom-trackers';
@@ -20,6 +21,7 @@ import {
20
21
  clearOptInOut,
21
22
  addOptOutCheckMixpanelLib
22
23
  } from './gdpr-utils';
24
+ import { IDBStorageWrapper, RECORDING_REGISTRY_STORE_NAME } from './storage/indexed-db';
23
25
 
24
26
  /*
25
27
  * Mixpanel JS Library
@@ -33,11 +35,6 @@ import {
33
35
  * Released under the MIT License.
34
36
  */
35
37
 
36
- // ==ClosureCompiler==
37
- // @compilation_level ADVANCED_OPTIMIZATIONS
38
- // @output_file_name mixpanel-2.8.min.js
39
- // ==/ClosureCompiler==
40
-
41
38
  /*
42
39
  SIMPLE STYLE GUIDE:
43
40
 
@@ -60,7 +57,6 @@ var INIT_MODULE = 0;
60
57
  var INIT_SNIPPET = 1;
61
58
 
62
59
  var IDENTITY_FUNC = function(x) {return x;};
63
- var NOOP_FUNC = function() {};
64
60
 
65
61
  /** @const */ var PRIMARY_INSTANCE_NAME = 'mixpanel';
66
62
  /** @const */ var PAYLOAD_TYPE_BASE64 = 'base64';
@@ -369,34 +365,125 @@ MixpanelLib.prototype._init = function(token, config, name) {
369
365
  this.autocapture = new Autocapture(this);
370
366
  this.autocapture.init();
371
367
 
372
- if (this.get_config('record_sessions_percent') > 0 && Math.random() * 100 <= this.get_config('record_sessions_percent')) {
373
- this.start_session_recording();
368
+ this._init_tab_id();
369
+ this._check_and_start_session_recording();
370
+ };
371
+
372
+ /**
373
+ * Assigns a unique UUID to this tab / window by leveraging sessionStorage.
374
+ * This is primarily used for session recording, where data must be isolated to the current tab.
375
+ */
376
+ MixpanelLib.prototype._init_tab_id = function() {
377
+ if (_.sessionStorage.is_supported()) {
378
+ try {
379
+ var key_suffix = this.get_config('name') + '_' + this.get_config('token');
380
+ var tab_id_key = 'mp_tab_id_' + key_suffix;
381
+
382
+ // A flag is used to determine if sessionStorage is copied over and we need to generate a new tab ID.
383
+ // This enforces a unique ID in the cases like duplicated tab, window.open(...)
384
+ var should_generate_new_tab_id_key = 'mp_gen_new_tab_id_' + key_suffix;
385
+ if (_.sessionStorage.get(should_generate_new_tab_id_key) || !_.sessionStorage.get(tab_id_key)) {
386
+ _.sessionStorage.set(tab_id_key, '$tab-' + _.UUID());
387
+ }
388
+
389
+ _.sessionStorage.set(should_generate_new_tab_id_key, '1');
390
+ this.tab_id = _.sessionStorage.get(tab_id_key);
391
+
392
+ // 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,
393
+ // but reliable in cases where the user remains in the tab e.g. a refresh or href navigation.
394
+ // If the flag is absent, this indicates to the next SDK instance that we can reuse the stored tab_id.
395
+ window.addEventListener('beforeunload', function () {
396
+ _.sessionStorage.remove(should_generate_new_tab_id_key);
397
+ });
398
+ } catch(err) {
399
+ this.report_error('Error initializing tab id', err);
400
+ }
401
+ } else {
402
+ this.report_error('Session storage is not supported, cannot keep track of unique tab ID.');
374
403
  }
375
404
  };
376
405
 
377
- MixpanelLib.prototype.start_session_recording = addOptOutCheckMixpanelLib(function () {
406
+ MixpanelLib.prototype.get_tab_id = function () {
407
+ return this.tab_id || null;
408
+ };
409
+
410
+ MixpanelLib.prototype._should_load_recorder = function () {
411
+ var recording_registry_idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
412
+ var tab_id = this.get_tab_id();
413
+ return recording_registry_idb.init()
414
+ .then(function () {
415
+ return recording_registry_idb.getAll();
416
+ })
417
+ .then(function (recordings) {
418
+ for (var i = 0; i < recordings.length; i++) {
419
+ // if there are expired recordings in the registry, we should load the recorder to flush them
420
+ // if there's a recording for this tab id, we should load the recorder to continue the recording
421
+ if (isRecordingExpired(recordings[i]) || recordings[i]['tabId'] === tab_id) {
422
+ return true;
423
+ }
424
+ }
425
+ return false;
426
+ })
427
+ .catch(_.bind(function (err) {
428
+ this.report_error('Error checking recording registry', err);
429
+ }, this));
430
+ };
431
+
432
+ MixpanelLib.prototype._check_and_start_session_recording = addOptOutCheckMixpanelLib(function(force_start) {
378
433
  if (!window['MutationObserver']) {
379
434
  console.critical('Browser does not support MutationObserver; skipping session recording');
380
435
  return;
381
436
  }
382
437
 
383
- var handleLoadedRecorder = _.bind(function() {
384
- this._recorder = this._recorder || new window['__mp_recorder'](this);
385
- this._recorder['startRecording']();
438
+ var loadRecorder = _.bind(function(startNewIfInactive) {
439
+ var handleLoadedRecorder = _.bind(function() {
440
+ this._recorder = this._recorder || new window['__mp_recorder'](this);
441
+ this._recorder['resumeRecording'](startNewIfInactive);
442
+ }, this);
443
+
444
+ if (_.isUndefined(window['__mp_recorder'])) {
445
+ load_extra_bundle(this.get_config('recorder_src'), handleLoadedRecorder);
446
+ } else {
447
+ handleLoadedRecorder();
448
+ }
386
449
  }, this);
387
450
 
388
- if (_.isUndefined(window['__mp_recorder'])) {
389
- load_extra_bundle(this.get_config('recorder_src'), handleLoadedRecorder);
451
+ /**
452
+ * If the user is sampled or start_session_recording is called, we always load the recorder since it's guaranteed a recording should start.
453
+ * 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.
454
+ */
455
+ var is_sampled = this.get_config('record_sessions_percent') > 0 && Math.random() * 100 <= this.get_config('record_sessions_percent');
456
+ if (force_start || is_sampled) {
457
+ loadRecorder(true);
390
458
  } else {
391
- handleLoadedRecorder();
459
+ this._should_load_recorder()
460
+ .then(function (shouldLoad) {
461
+ if (shouldLoad) {
462
+ loadRecorder(false);
463
+ }
464
+ });
392
465
  }
393
466
  });
394
467
 
468
+ MixpanelLib.prototype.start_session_recording = function () {
469
+ this._check_and_start_session_recording(true);
470
+ };
471
+
395
472
  MixpanelLib.prototype.stop_session_recording = function () {
396
473
  if (this._recorder) {
397
474
  this._recorder['stopRecording']();
398
- } else {
399
- console.critical('Session recorder module not loaded');
475
+ }
476
+ };
477
+
478
+ MixpanelLib.prototype.pause_session_recording = function () {
479
+ if (this._recorder) {
480
+ this._recorder['pauseRecording']();
481
+ }
482
+ };
483
+
484
+ MixpanelLib.prototype.resume_session_recording = function () {
485
+ if (this._recorder) {
486
+ this._recorder['resumeRecording']();
400
487
  }
401
488
  };
402
489
 
@@ -431,6 +518,11 @@ MixpanelLib.prototype._get_session_replay_id = function () {
431
518
  return replay_id || null;
432
519
  };
433
520
 
521
+ // "private" public method to reach into the recorder in test cases
522
+ MixpanelLib.prototype.__get_recorder = function () {
523
+ return this._recorder;
524
+ };
525
+
434
526
  // Private methods
435
527
 
436
528
  MixpanelLib.prototype._loaded = function() {
@@ -770,7 +862,8 @@ MixpanelLib.prototype.init_batchers = function() {
770
862
  return this._run_hook('before_send_' + attrs.type, item);
771
863
  }, this),
772
864
  stopAllBatchingFunc: _.bind(this.stop_batch_senders, this),
773
- usePersistence: true
865
+ usePersistence: true,
866
+ enqueueThrottleMs: 10,
774
867
  }
775
868
  );
776
869
  }, this);
@@ -1871,6 +1964,7 @@ MixpanelLib.prototype._gdpr_update_persistence = function(options) {
1871
1964
 
1872
1965
  if (disabled) {
1873
1966
  this.stop_batch_senders();
1967
+ this.stop_session_recording();
1874
1968
  } else {
1875
1969
  // only start batchers after opt-in if they have previously been started
1876
1970
  // in order to avoid unintentionally starting up batching for the first time
@@ -2111,10 +2205,16 @@ MixpanelLib.prototype['start_batch_senders'] = MixpanelLib.protot
2111
2205
  MixpanelLib.prototype['stop_batch_senders'] = MixpanelLib.prototype.stop_batch_senders;
2112
2206
  MixpanelLib.prototype['start_session_recording'] = MixpanelLib.prototype.start_session_recording;
2113
2207
  MixpanelLib.prototype['stop_session_recording'] = MixpanelLib.prototype.stop_session_recording;
2208
+ MixpanelLib.prototype['pause_session_recording'] = MixpanelLib.prototype.pause_session_recording;
2209
+ MixpanelLib.prototype['resume_session_recording'] = MixpanelLib.prototype.resume_session_recording;
2114
2210
  MixpanelLib.prototype['get_session_recording_properties'] = MixpanelLib.prototype.get_session_recording_properties;
2115
2211
  MixpanelLib.prototype['get_session_replay_url'] = MixpanelLib.prototype.get_session_replay_url;
2212
+ MixpanelLib.prototype['get_tab_id'] = MixpanelLib.prototype.get_tab_id;
2116
2213
  MixpanelLib.prototype['DEFAULT_API_ROUTES'] = DEFAULT_API_ROUTES;
2117
2214
 
2215
+ // Exports intended only for testing
2216
+ MixpanelLib.prototype['__get_recorder'] = MixpanelLib.prototype.__get_recorder;
2217
+
2118
2218
  // MixpanelPersistence Exports
2119
2219
  MixpanelPersistence.prototype['properties'] = MixpanelPersistence.prototype.properties;
2120
2220
  MixpanelPersistence.prototype['update_search_keyword'] = MixpanelPersistence.prototype.update_search_keyword;