mixpanel-browser 2.60.0 → 2.61.1

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.
@@ -1,3 +1,25 @@
1
+ // since es6 imports are static and we run unit tests from the console, window won't be defined when importing this file
2
+ var win;
3
+ if (typeof(window) === 'undefined') {
4
+ var loc = {
5
+ hostname: ''
6
+ };
7
+ win = {
8
+ navigator: { userAgent: '', onLine: true },
9
+ document: {
10
+ createElement: function() { return {}; },
11
+ location: loc,
12
+ referrer: ''
13
+ },
14
+ screen: { width: 0, height: 0 },
15
+ location: loc,
16
+ addEventListener: function() {},
17
+ removeEventListener: function() {}
18
+ };
19
+ } else {
20
+ win = window;
21
+ }
22
+
1
23
  var NodeType;
2
24
  (function (NodeType) {
3
25
  NodeType[NodeType["Document"] = 0] = "Document";
@@ -4474,64 +4496,6 @@ record.takeFullSnapshot = (isCheckout) => {
4474
4496
  };
4475
4497
  record.mirror = mirror;
4476
4498
 
4477
- var EventType = /* @__PURE__ */ ((EventType2) => {
4478
- EventType2[EventType2["DomContentLoaded"] = 0] = "DomContentLoaded";
4479
- EventType2[EventType2["Load"] = 1] = "Load";
4480
- EventType2[EventType2["FullSnapshot"] = 2] = "FullSnapshot";
4481
- EventType2[EventType2["IncrementalSnapshot"] = 3] = "IncrementalSnapshot";
4482
- EventType2[EventType2["Meta"] = 4] = "Meta";
4483
- EventType2[EventType2["Custom"] = 5] = "Custom";
4484
- EventType2[EventType2["Plugin"] = 6] = "Plugin";
4485
- return EventType2;
4486
- })(EventType || {});
4487
- var IncrementalSource = /* @__PURE__ */ ((IncrementalSource2) => {
4488
- IncrementalSource2[IncrementalSource2["Mutation"] = 0] = "Mutation";
4489
- IncrementalSource2[IncrementalSource2["MouseMove"] = 1] = "MouseMove";
4490
- IncrementalSource2[IncrementalSource2["MouseInteraction"] = 2] = "MouseInteraction";
4491
- IncrementalSource2[IncrementalSource2["Scroll"] = 3] = "Scroll";
4492
- IncrementalSource2[IncrementalSource2["ViewportResize"] = 4] = "ViewportResize";
4493
- IncrementalSource2[IncrementalSource2["Input"] = 5] = "Input";
4494
- IncrementalSource2[IncrementalSource2["TouchMove"] = 6] = "TouchMove";
4495
- IncrementalSource2[IncrementalSource2["MediaInteraction"] = 7] = "MediaInteraction";
4496
- IncrementalSource2[IncrementalSource2["StyleSheetRule"] = 8] = "StyleSheetRule";
4497
- IncrementalSource2[IncrementalSource2["CanvasMutation"] = 9] = "CanvasMutation";
4498
- IncrementalSource2[IncrementalSource2["Font"] = 10] = "Font";
4499
- IncrementalSource2[IncrementalSource2["Log"] = 11] = "Log";
4500
- IncrementalSource2[IncrementalSource2["Drag"] = 12] = "Drag";
4501
- IncrementalSource2[IncrementalSource2["StyleDeclaration"] = 13] = "StyleDeclaration";
4502
- IncrementalSource2[IncrementalSource2["Selection"] = 14] = "Selection";
4503
- IncrementalSource2[IncrementalSource2["AdoptedStyleSheet"] = 15] = "AdoptedStyleSheet";
4504
- IncrementalSource2[IncrementalSource2["CustomElement"] = 16] = "CustomElement";
4505
- return IncrementalSource2;
4506
- })(IncrementalSource || {});
4507
-
4508
- var Config = {
4509
- DEBUG: false,
4510
- LIB_VERSION: '2.60.0'
4511
- };
4512
-
4513
- // since es6 imports are static and we run unit tests from the console, window won't be defined when importing this file
4514
- var win;
4515
- if (typeof(window) === 'undefined') {
4516
- var loc = {
4517
- hostname: ''
4518
- };
4519
- win = {
4520
- navigator: { userAgent: '', onLine: true },
4521
- document: {
4522
- createElement: function() { return {}; },
4523
- location: loc,
4524
- referrer: ''
4525
- },
4526
- screen: { width: 0, height: 0 },
4527
- location: loc,
4528
- addEventListener: function() {},
4529
- removeEventListener: function() {}
4530
- };
4531
- } else {
4532
- win = window;
4533
- }
4534
-
4535
4499
  var setImmediate = win['setImmediate'];
4536
4500
  var builtInProp, cycle, schedulingQueue,
4537
4501
  ToString = Object.prototype.toString,
@@ -4894,6 +4858,42 @@ if (typeof Promise !== 'undefined' && Promise.toString().indexOf('[native code]'
4894
4858
  PromisePolyfill = NpoPromise;
4895
4859
  }
4896
4860
 
4861
+ var EventType = /* @__PURE__ */ ((EventType2) => {
4862
+ EventType2[EventType2["DomContentLoaded"] = 0] = "DomContentLoaded";
4863
+ EventType2[EventType2["Load"] = 1] = "Load";
4864
+ EventType2[EventType2["FullSnapshot"] = 2] = "FullSnapshot";
4865
+ EventType2[EventType2["IncrementalSnapshot"] = 3] = "IncrementalSnapshot";
4866
+ EventType2[EventType2["Meta"] = 4] = "Meta";
4867
+ EventType2[EventType2["Custom"] = 5] = "Custom";
4868
+ EventType2[EventType2["Plugin"] = 6] = "Plugin";
4869
+ return EventType2;
4870
+ })(EventType || {});
4871
+ var IncrementalSource = /* @__PURE__ */ ((IncrementalSource2) => {
4872
+ IncrementalSource2[IncrementalSource2["Mutation"] = 0] = "Mutation";
4873
+ IncrementalSource2[IncrementalSource2["MouseMove"] = 1] = "MouseMove";
4874
+ IncrementalSource2[IncrementalSource2["MouseInteraction"] = 2] = "MouseInteraction";
4875
+ IncrementalSource2[IncrementalSource2["Scroll"] = 3] = "Scroll";
4876
+ IncrementalSource2[IncrementalSource2["ViewportResize"] = 4] = "ViewportResize";
4877
+ IncrementalSource2[IncrementalSource2["Input"] = 5] = "Input";
4878
+ IncrementalSource2[IncrementalSource2["TouchMove"] = 6] = "TouchMove";
4879
+ IncrementalSource2[IncrementalSource2["MediaInteraction"] = 7] = "MediaInteraction";
4880
+ IncrementalSource2[IncrementalSource2["StyleSheetRule"] = 8] = "StyleSheetRule";
4881
+ IncrementalSource2[IncrementalSource2["CanvasMutation"] = 9] = "CanvasMutation";
4882
+ IncrementalSource2[IncrementalSource2["Font"] = 10] = "Font";
4883
+ IncrementalSource2[IncrementalSource2["Log"] = 11] = "Log";
4884
+ IncrementalSource2[IncrementalSource2["Drag"] = 12] = "Drag";
4885
+ IncrementalSource2[IncrementalSource2["StyleDeclaration"] = 13] = "StyleDeclaration";
4886
+ IncrementalSource2[IncrementalSource2["Selection"] = 14] = "Selection";
4887
+ IncrementalSource2[IncrementalSource2["AdoptedStyleSheet"] = 15] = "AdoptedStyleSheet";
4888
+ IncrementalSource2[IncrementalSource2["CustomElement"] = 16] = "CustomElement";
4889
+ return IncrementalSource2;
4890
+ })(IncrementalSource || {});
4891
+
4892
+ var Config = {
4893
+ DEBUG: false,
4894
+ LIB_VERSION: '2.61.1'
4895
+ };
4896
+
4897
4897
  /* eslint camelcase: "off", eqeqeq: "off" */
4898
4898
 
4899
4899
  // Maximum allowed session recording length
@@ -5973,15 +5973,9 @@ _.cookie = {
5973
5973
  }
5974
5974
  };
5975
5975
 
5976
- var _localStorageSupported = null;
5977
- var localStorageSupported = function(storage, forceCheck) {
5978
- if (_localStorageSupported !== null && !forceCheck) {
5979
- return _localStorageSupported;
5980
- }
5981
-
5976
+ var _testStorageSupported = function (storage) {
5982
5977
  var supported = true;
5983
5978
  try {
5984
- storage = storage || win.localStorage;
5985
5979
  var key = '__mplss_' + cheap_guid(8),
5986
5980
  val = 'xyz';
5987
5981
  storage.setItem(key, val);
@@ -5992,59 +5986,74 @@ var localStorageSupported = function(storage, forceCheck) {
5992
5986
  } catch (err) {
5993
5987
  supported = false;
5994
5988
  }
5995
-
5996
- _localStorageSupported = supported;
5997
5989
  return supported;
5998
5990
  };
5999
5991
 
6000
- // _.localStorage
6001
- _.localStorage = {
6002
- is_supported: function(force_check) {
6003
- var supported = localStorageSupported(null, force_check);
6004
- if (!supported) {
6005
- console$1.error('localStorage unsupported; falling back to cookie store');
6006
- }
6007
- return supported;
6008
- },
6009
-
6010
- error: function(msg) {
6011
- console$1.error('localStorage error: ' + msg);
6012
- },
5992
+ var _localStorageSupported = null;
5993
+ var localStorageSupported = function(storage, forceCheck) {
5994
+ if (_localStorageSupported !== null && !forceCheck) {
5995
+ return _localStorageSupported;
5996
+ }
5997
+ return _localStorageSupported = _testStorageSupported(storage || win.localStorage);
5998
+ };
6013
5999
 
6014
- get: function(name) {
6015
- try {
6016
- return win.localStorage.getItem(name);
6017
- } catch (err) {
6018
- _.localStorage.error(err);
6019
- }
6020
- return null;
6021
- },
6000
+ var _sessionStorageSupported = null;
6001
+ var sessionStorageSupported = function(storage, forceCheck) {
6002
+ if (_sessionStorageSupported !== null && !forceCheck) {
6003
+ return _sessionStorageSupported;
6004
+ }
6005
+ return _sessionStorageSupported = _testStorageSupported(storage || win.sessionStorage);
6006
+ };
6022
6007
 
6023
- parse: function(name) {
6024
- try {
6025
- return _.JSONDecode(_.localStorage.get(name)) || {};
6026
- } catch (err) {
6027
- // noop
6028
- }
6029
- return null;
6030
- },
6008
+ function _storageWrapper(storage, name, is_supported_fn) {
6009
+ var log_error = function(msg) {
6010
+ console$1.error(name + ' error: ' + msg);
6011
+ };
6031
6012
 
6032
- set: function(name, value) {
6033
- try {
6034
- win.localStorage.setItem(name, value);
6035
- } catch (err) {
6036
- _.localStorage.error(err);
6013
+ return {
6014
+ is_supported: function(forceCheck) {
6015
+ var supported = is_supported_fn(storage, forceCheck);
6016
+ if (!supported) {
6017
+ console$1.error(name + ' unsupported');
6018
+ }
6019
+ return supported;
6020
+ },
6021
+ error: log_error,
6022
+ get: function(key) {
6023
+ try {
6024
+ return storage.getItem(key);
6025
+ } catch (err) {
6026
+ log_error(err);
6027
+ }
6028
+ return null;
6029
+ },
6030
+ parse: function(key) {
6031
+ try {
6032
+ return _.JSONDecode(storage.getItem(key)) || {};
6033
+ } catch (err) {
6034
+ // noop
6035
+ }
6036
+ return null;
6037
+ },
6038
+ set: function(key, value) {
6039
+ try {
6040
+ storage.setItem(key, value);
6041
+ } catch (err) {
6042
+ log_error(err);
6043
+ }
6044
+ },
6045
+ remove: function(key) {
6046
+ try {
6047
+ storage.removeItem(key);
6048
+ } catch (err) {
6049
+ log_error(err);
6050
+ }
6037
6051
  }
6038
- },
6052
+ };
6053
+ }
6039
6054
 
6040
- remove: function(name) {
6041
- try {
6042
- win.localStorage.removeItem(name);
6043
- } catch (err) {
6044
- _.localStorage.error(err);
6045
- }
6046
- }
6047
- };
6055
+ _.localStorage = _storageWrapper(win.localStorage, 'localStorage', localStorageSupported);
6056
+ _.sessionStorage = _storageWrapper(win.sessionStorage, 'sessionStorage', sessionStorageSupported);
6048
6057
 
6049
6058
  _.register_event = (function() {
6050
6059
  // written by Dean Edwards, 2005
@@ -6571,6 +6580,31 @@ _.info = {
6571
6580
  }
6572
6581
  };
6573
6582
 
6583
+ /**
6584
+ * Returns a throttled function that will only run at most every `waitMs` and returns a promise that resolves with the next invocation.
6585
+ * Throttled calls will build up a batch of args and invoke the callback with all args since the last invocation.
6586
+ */
6587
+ var batchedThrottle = function (fn, waitMs) {
6588
+ var timeoutPromise = null;
6589
+ var throttledItems = [];
6590
+ return function (item) {
6591
+ var self = this;
6592
+ throttledItems.push(item);
6593
+
6594
+ if (!timeoutPromise) {
6595
+ timeoutPromise = new PromisePolyfill(function (resolve) {
6596
+ setTimeout(function () {
6597
+ var returnValue = fn.apply(self, [throttledItems]);
6598
+ timeoutPromise = null;
6599
+ throttledItems = [];
6600
+ resolve(returnValue);
6601
+ }, waitMs);
6602
+ });
6603
+ }
6604
+ return timeoutPromise;
6605
+ };
6606
+ };
6607
+
6574
6608
  var cheap_guid = function(maxlen) {
6575
6609
  var guid = Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10);
6576
6610
  return maxlen ? guid.substring(0, maxlen) : guid;
@@ -6613,6 +6647,8 @@ var isOnline = function() {
6613
6647
  return _.isUndefined(onLine) || onLine;
6614
6648
  };
6615
6649
 
6650
+ var NOOP_FUNC = function () {};
6651
+
6616
6652
  var JSONStringify = null, JSONParse = null;
6617
6653
  if (typeof JSON !== 'undefined') {
6618
6654
  JSONStringify = JSON.stringify;
@@ -6621,20 +6657,143 @@ if (typeof JSON !== 'undefined') {
6621
6657
  JSONStringify = JSONStringify || _.JSONEncode;
6622
6658
  JSONParse = JSONParse || _.JSONDecode;
6623
6659
 
6624
- // EXPORTS (for closure compiler)
6625
- _['toArray'] = _.toArray;
6626
- _['isObject'] = _.isObject;
6627
- _['JSONEncode'] = _.JSONEncode;
6628
- _['JSONDecode'] = _.JSONDecode;
6629
- _['isBlockedUA'] = _.isBlockedUA;
6630
- _['isEmptyObject'] = _.isEmptyObject;
6660
+ // UNMINIFIED EXPORTS (for closure compiler)
6631
6661
  _['info'] = _.info;
6632
- _['info']['device'] = _.info.device;
6633
6662
  _['info']['browser'] = _.info.browser;
6634
6663
  _['info']['browserVersion'] = _.info.browserVersion;
6664
+ _['info']['device'] = _.info.device;
6635
6665
  _['info']['properties'] = _.info.properties;
6666
+ _['isBlockedUA'] = _.isBlockedUA;
6667
+ _['isEmptyObject'] = _.isEmptyObject;
6668
+ _['isObject'] = _.isObject;
6669
+ _['JSONDecode'] = _.JSONDecode;
6670
+ _['JSONEncode'] = _.JSONEncode;
6671
+ _['toArray'] = _.toArray;
6636
6672
  _['NPO'] = NpoPromise;
6637
6673
 
6674
+ var MIXPANEL_DB_NAME = 'mixpanelBrowserDb';
6675
+
6676
+ var RECORDING_EVENTS_STORE_NAME = 'mixpanelRecordingEvents';
6677
+ var RECORDING_REGISTRY_STORE_NAME = 'mixpanelRecordingRegistry';
6678
+
6679
+ // note: increment the version number when adding new object stores
6680
+ var DB_VERSION = 1;
6681
+ var OBJECT_STORES = [RECORDING_EVENTS_STORE_NAME, RECORDING_REGISTRY_STORE_NAME];
6682
+
6683
+ /**
6684
+ * @type {import('./wrapper').StorageWrapper}
6685
+ */
6686
+ var IDBStorageWrapper = function (storeName) {
6687
+ /**
6688
+ * @type {Promise<IDBDatabase>|null}
6689
+ */
6690
+ this.dbPromise = null;
6691
+ this.storeName = storeName;
6692
+ };
6693
+
6694
+ IDBStorageWrapper.prototype._openDb = function () {
6695
+ return new PromisePolyfill(function (resolve, reject) {
6696
+ var openRequest = win.indexedDB.open(MIXPANEL_DB_NAME, DB_VERSION);
6697
+ openRequest['onerror'] = function () {
6698
+ reject(openRequest.error);
6699
+ };
6700
+
6701
+ openRequest['onsuccess'] = function () {
6702
+ resolve(openRequest.result);
6703
+ };
6704
+
6705
+ openRequest['onupgradeneeded'] = function (ev) {
6706
+ var db = ev.target.result;
6707
+
6708
+ OBJECT_STORES.forEach(function (storeName) {
6709
+ db.createObjectStore(storeName);
6710
+ });
6711
+ };
6712
+ });
6713
+ };
6714
+
6715
+ IDBStorageWrapper.prototype.init = function () {
6716
+ if (!win.indexedDB) {
6717
+ return PromisePolyfill.reject('indexedDB is not supported in this browser');
6718
+ }
6719
+
6720
+ if (!this.dbPromise) {
6721
+ this.dbPromise = this._openDb();
6722
+ }
6723
+
6724
+ return this.dbPromise
6725
+ .then(function (dbOrError) {
6726
+ if (dbOrError instanceof win['IDBDatabase']) {
6727
+ return PromisePolyfill.resolve();
6728
+ } else {
6729
+ return PromisePolyfill.reject(dbOrError);
6730
+ }
6731
+ });
6732
+ };
6733
+
6734
+ /**
6735
+ * @param {IDBTransactionMode} mode
6736
+ * @param {function(IDBObjectStore): void} storeCb
6737
+ */
6738
+ IDBStorageWrapper.prototype.makeTransaction = function (mode, storeCb) {
6739
+ var storeName = this.storeName;
6740
+ var doTransaction = function (db) {
6741
+ return new PromisePolyfill(function (resolve, reject) {
6742
+ var transaction = db.transaction(storeName, mode);
6743
+ transaction.oncomplete = function () {
6744
+ resolve(transaction);
6745
+ };
6746
+ transaction.onabort = transaction.onerror = function () {
6747
+ reject(transaction.error);
6748
+ };
6749
+
6750
+ storeCb(transaction.objectStore(storeName));
6751
+ });
6752
+ };
6753
+
6754
+ return this.dbPromise
6755
+ .then(doTransaction)
6756
+ .catch(function (err) {
6757
+ if (err && err['name'] === 'InvalidStateError') {
6758
+ // try reopening the DB if the connection is closed
6759
+ this.dbPromise = this._openDb();
6760
+ return this.dbPromise.then(doTransaction);
6761
+ } else {
6762
+ return PromisePolyfill.reject(err);
6763
+ }
6764
+ }.bind(this));
6765
+ };
6766
+
6767
+ IDBStorageWrapper.prototype.setItem = function (key, value) {
6768
+ return this.makeTransaction('readwrite', function (objectStore) {
6769
+ objectStore.put(value, key);
6770
+ });
6771
+ };
6772
+
6773
+ IDBStorageWrapper.prototype.getItem = function (key) {
6774
+ var req;
6775
+ return this.makeTransaction('readonly', function (objectStore) {
6776
+ req = objectStore.get(key);
6777
+ }).then(function () {
6778
+ return req.result;
6779
+ });
6780
+ };
6781
+
6782
+ IDBStorageWrapper.prototype.removeItem = function (key) {
6783
+ return this.makeTransaction('readwrite', function (objectStore) {
6784
+ objectStore.delete(key);
6785
+ });
6786
+ };
6787
+
6788
+ IDBStorageWrapper.prototype.getAll = function () {
6789
+ var req;
6790
+ return this.makeTransaction('readonly', function (objectStore) {
6791
+ req = objectStore.getAll();
6792
+ }).then(function () {
6793
+ return req.result;
6794
+ });
6795
+ };
6796
+
6638
6797
  /**
6639
6798
  * GDPR utils
6640
6799
  *
@@ -6960,7 +7119,7 @@ var SharedLock = function(key, options) {
6960
7119
  options = options || {};
6961
7120
 
6962
7121
  this.storageKey = key;
6963
- this.storage = options.storage || window.localStorage;
7122
+ this.storage = options.storage || win.localStorage;
6964
7123
  this.pollIntervalMS = options.pollIntervalMS || 100;
6965
7124
  this.timeoutMS = options.timeoutMS || 2000;
6966
7125
 
@@ -6975,7 +7134,6 @@ SharedLock.prototype.withLock = function(lockedCB, pid) {
6975
7134
  return new Promise(_.bind(function (resolve, reject) {
6976
7135
  var i = pid || (new Date().getTime() + '|' + Math.random());
6977
7136
  var startTime = new Date().getTime();
6978
-
6979
7137
  var key = this.storageKey;
6980
7138
  var pollIntervalMS = this.pollIntervalMS;
6981
7139
  var timeoutMS = this.timeoutMS;
@@ -7086,11 +7244,7 @@ SharedLock.prototype.withLock = function(lockedCB, pid) {
7086
7244
  };
7087
7245
 
7088
7246
  /**
7089
- * @typedef {import('./wrapper').StorageWrapper}
7090
- */
7091
-
7092
- /**
7093
- * @type {StorageWrapper}
7247
+ * @type {import('./wrapper').StorageWrapper}
7094
7248
  */
7095
7249
  var LocalStorageWrapper = function (storageOverride) {
7096
7250
  this.storage = storageOverride || localStorage;
@@ -7103,7 +7257,7 @@ LocalStorageWrapper.prototype.init = function () {
7103
7257
  LocalStorageWrapper.prototype.setItem = function (key, value) {
7104
7258
  return new PromisePolyfill(_.bind(function (resolve, reject) {
7105
7259
  try {
7106
- this.storage.setItem(key, value);
7260
+ this.storage.setItem(key, JSONStringify(value));
7107
7261
  } catch (e) {
7108
7262
  reject(e);
7109
7263
  }
@@ -7115,7 +7269,7 @@ LocalStorageWrapper.prototype.getItem = function (key) {
7115
7269
  return new PromisePolyfill(_.bind(function (resolve, reject) {
7116
7270
  var item;
7117
7271
  try {
7118
- item = this.storage.getItem(key);
7272
+ item = JSONParse(this.storage.getItem(key));
7119
7273
  } catch (e) {
7120
7274
  reject(e);
7121
7275
  }
@@ -7158,8 +7312,10 @@ var RequestQueue = function (storageKey, options) {
7158
7312
  this.usePersistence = options.usePersistence;
7159
7313
  if (this.usePersistence) {
7160
7314
  this.queueStorage = options.queueStorage || new LocalStorageWrapper();
7161
- this.lock = new SharedLock(storageKey, { storage: options.sharedLockStorage || window.localStorage });
7162
- this.queueStorage.init();
7315
+ this.lock = new SharedLock(storageKey, {
7316
+ storage: options.sharedLockStorage || win.localStorage,
7317
+ timeoutMS: options.sharedLockTimeoutMS,
7318
+ });
7163
7319
  }
7164
7320
  this.reportError = options.errorReporter || _.bind(logger$4.error, logger$4);
7165
7321
 
@@ -7167,6 +7323,14 @@ var RequestQueue = function (storageKey, options) {
7167
7323
 
7168
7324
  this.memQueue = [];
7169
7325
  this.initialized = false;
7326
+
7327
+ if (options.enqueueThrottleMs) {
7328
+ this.enqueuePersisted = batchedThrottle(_.bind(this._enqueuePersisted, this), options.enqueueThrottleMs);
7329
+ } else {
7330
+ this.enqueuePersisted = _.bind(function (queueEntry) {
7331
+ return this._enqueuePersisted([queueEntry]);
7332
+ }, this);
7333
+ }
7170
7334
  };
7171
7335
 
7172
7336
  RequestQueue.prototype.ensureInit = function () {
@@ -7209,36 +7373,39 @@ RequestQueue.prototype.enqueue = function (item, flushInterval) {
7209
7373
  this.memQueue.push(queueEntry);
7210
7374
  return PromisePolyfill.resolve(true);
7211
7375
  } else {
7376
+ return this.enqueuePersisted(queueEntry);
7377
+ }
7378
+ };
7212
7379
 
7213
- var enqueueItem = _.bind(function () {
7214
- return this.ensureInit()
7215
- .then(_.bind(function () {
7216
- return this.readFromStorage();
7217
- }, this))
7218
- .then(_.bind(function (storedQueue) {
7219
- storedQueue.push(queueEntry);
7220
- return this.saveToStorage(storedQueue);
7221
- }, this))
7222
- .then(_.bind(function (succeeded) {
7223
- // only add to in-memory queue when storage succeeds
7224
- if (succeeded) {
7225
- this.memQueue.push(queueEntry);
7226
- }
7227
- return succeeded;
7228
- }, this))
7229
- .catch(_.bind(function (err) {
7230
- this.reportError('Error enqueueing item', err, item);
7231
- return false;
7232
- }, this));
7233
- }, this);
7380
+ RequestQueue.prototype._enqueuePersisted = function (queueEntries) {
7381
+ var enqueueItem = _.bind(function () {
7382
+ return this.ensureInit()
7383
+ .then(_.bind(function () {
7384
+ return this.readFromStorage();
7385
+ }, this))
7386
+ .then(_.bind(function (storedQueue) {
7387
+ return this.saveToStorage(storedQueue.concat(queueEntries));
7388
+ }, this))
7389
+ .then(_.bind(function (succeeded) {
7390
+ // only add to in-memory queue when storage succeeds
7391
+ if (succeeded) {
7392
+ this.memQueue = this.memQueue.concat(queueEntries);
7393
+ }
7234
7394
 
7235
- return this.lock
7236
- .withLock(enqueueItem, this.pid)
7395
+ return succeeded;
7396
+ }, this))
7237
7397
  .catch(_.bind(function (err) {
7238
- this.reportError('Error acquiring storage lock', err);
7398
+ this.reportError('Error enqueueing items', err, queueEntries);
7239
7399
  return false;
7240
7400
  }, this));
7241
- }
7401
+ }, this);
7402
+
7403
+ return this.lock
7404
+ .withLock(enqueueItem, this.pid)
7405
+ .catch(_.bind(function (err) {
7406
+ this.reportError('Error acquiring storage lock', err);
7407
+ return false;
7408
+ }, this));
7242
7409
  };
7243
7410
 
7244
7411
  /**
@@ -7259,7 +7426,7 @@ RequestQueue.prototype.fillBatch = function (batchSize) {
7259
7426
  }, this))
7260
7427
  .then(_.bind(function (storedQueue) {
7261
7428
  if (storedQueue.length) {
7262
- // item IDs already in batch; don't duplicate out of storage
7429
+ // item IDs already in batch; don't duplicate out of storage
7263
7430
  var idsInBatch = {}; // poor man's Set
7264
7431
  _.each(batch, function (item) {
7265
7432
  idsInBatch[item['id']] = true;
@@ -7346,7 +7513,7 @@ RequestQueue.prototype.removeItemsByID = function (ids) {
7346
7513
  .withLock(removeFromStorage, this.pid)
7347
7514
  .catch(_.bind(function (err) {
7348
7515
  this.reportError('Error acquiring storage lock', err);
7349
- if (!localStorageSupported(this.queueStorage.storage, true)) {
7516
+ if (!localStorageSupported(this.lock.storage, true)) {
7350
7517
  // Looks like localStorage writes have stopped working sometime after
7351
7518
  // initialization (probably full), and so nobody can acquire locks
7352
7519
  // anymore. Consider it temporarily safe to remove items without the
@@ -7434,7 +7601,6 @@ RequestQueue.prototype.readFromStorage = function () {
7434
7601
  }, this))
7435
7602
  .then(_.bind(function (storageEntry) {
7436
7603
  if (storageEntry) {
7437
- storageEntry = JSONParse(storageEntry);
7438
7604
  if (!_.isArray(storageEntry)) {
7439
7605
  this.reportError('Invalid storage entry:', storageEntry);
7440
7606
  storageEntry = null;
@@ -7452,16 +7618,9 @@ RequestQueue.prototype.readFromStorage = function () {
7452
7618
  * Serialize the given items array to localStorage.
7453
7619
  */
7454
7620
  RequestQueue.prototype.saveToStorage = function (queue) {
7455
- try {
7456
- var serialized = JSONStringify(queue);
7457
- } catch (err) {
7458
- this.reportError('Error serializing queue', err);
7459
- return PromisePolyfill.resolve(false);
7460
- }
7461
-
7462
7621
  return this.ensureInit()
7463
7622
  .then(_.bind(function () {
7464
- return this.queueStorage.setItem(this.storageKey, serialized);
7623
+ return this.queueStorage.setItem(this.storageKey, queue);
7465
7624
  }, this))
7466
7625
  .then(function () {
7467
7626
  return true;
@@ -7505,7 +7664,9 @@ var RequestBatcher = function(storageKey, options) {
7505
7664
  errorReporter: _.bind(this.reportError, this),
7506
7665
  queueStorage: options.queueStorage,
7507
7666
  sharedLockStorage: options.sharedLockStorage,
7508
- usePersistence: options.usePersistence
7667
+ sharedLockTimeoutMS: options.sharedLockTimeoutMS,
7668
+ usePersistence: options.usePersistence,
7669
+ enqueueThrottleMs: options.enqueueThrottleMs
7509
7670
  });
7510
7671
 
7511
7672
  this.libConfig = options.libConfig;
@@ -7527,6 +7688,8 @@ var RequestBatcher = function(storageKey, options) {
7527
7688
  // as long as the queue is not empty. This is useful for high-frequency events like Session Replay where we might end up
7528
7689
  // in a request loop and get ratelimited by the server.
7529
7690
  this.flushOnlyOnInterval = options.flushOnlyOnInterval || false;
7691
+
7692
+ this._flushPromise = null;
7530
7693
  };
7531
7694
 
7532
7695
  /**
@@ -7586,7 +7749,7 @@ RequestBatcher.prototype.scheduleFlush = function(flushMS) {
7586
7749
  if (!this.stopped) { // don't schedule anymore if batching has been stopped
7587
7750
  this.timeoutID = setTimeout(_.bind(function() {
7588
7751
  if (!this.stopped) {
7589
- this.flush();
7752
+ this._flushPromise = this.flush();
7590
7753
  }
7591
7754
  }, this), this.flushInterval);
7592
7755
  }
@@ -7818,6 +7981,17 @@ RequestBatcher.prototype.reportError = function(msg, err) {
7818
7981
  }
7819
7982
  };
7820
7983
 
7984
+ /**
7985
+ * @param {import('./session-recording').SerializedRecording} serializedRecording
7986
+ * @returns {boolean}
7987
+ */
7988
+ var isRecordingExpired = function(serializedRecording) {
7989
+ var now = Date.now();
7990
+ return !serializedRecording || now > serializedRecording['maxExpires'] || now > serializedRecording['idleExpires'];
7991
+ };
7992
+
7993
+ var RECORD_ENQUEUE_THROTTLE_MS = 250;
7994
+
7821
7995
  var logger$2 = console_with_prefix('recorder');
7822
7996
  var CompressionStream = win['CompressionStream'];
7823
7997
 
@@ -7844,29 +8018,58 @@ function isUserEvent(ev) {
7844
8018
  return ev.type === EventType.IncrementalSnapshot && ACTIVE_SOURCES.has(ev.data.source);
7845
8019
  }
7846
8020
 
8021
+ /**
8022
+ * @typedef {Object} SerializedRecording
8023
+ * @property {number} idleExpires
8024
+ * @property {number} maxExpires
8025
+ * @property {number} replayStartTime
8026
+ * @property {number} seqNo
8027
+ * @property {string} batchStartUrl
8028
+ * @property {string} replayId
8029
+ * @property {string} tabId
8030
+ * @property {string} replayStartUrl
8031
+ */
8032
+
8033
+ /**
8034
+ * @typedef {Object} SessionRecordingOptions
8035
+ * @property {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
8036
+ * @property {String} [options.replayId] - unique uuid for a single replay
8037
+ * @property {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
8038
+ * @property {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
8039
+ * @property {Function} [options.rrwebRecord] - rrweb's `record` function
8040
+ * @property {Function} [options.onBatchSent] - callback when a batch of events is sent to the server
8041
+ * @property {Storage} [options.sharedLockStorage] - optional storage for shared lock, used for test dependency injection
8042
+ * optional properties for deserialization:
8043
+ * @property {number} idleExpires
8044
+ * @property {number} maxExpires
8045
+ * @property {number} replayStartTime
8046
+ * @property {number} seqNo
8047
+ * @property {string} batchStartUrl
8048
+ * @property {string} replayStartUrl
8049
+ */
8050
+
8051
+
7847
8052
  /**
7848
8053
  * This class encapsulates a single session recording and its lifecycle.
7849
- * @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
7850
- * @param {String} [options.replayId] - unique uuid for a single replay
7851
- * @param {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
7852
- * @param {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
7853
- * @param {Function} [options.rrwebRecord] - rrweb's `record` function
8054
+ * @param {SessionRecordingOptions} options
7854
8055
  */
7855
8056
  var SessionRecording = function(options) {
7856
8057
  this._mixpanel = options.mixpanelInstance;
7857
- this._onIdleTimeout = options.onIdleTimeout;
7858
- this._onMaxLengthReached = options.onMaxLengthReached;
7859
- this._rrwebRecord = options.rrwebRecord;
7860
-
7861
- this.replayId = options.replayId;
8058
+ this._onIdleTimeout = options.onIdleTimeout || NOOP_FUNC;
8059
+ this._onMaxLengthReached = options.onMaxLengthReached || NOOP_FUNC;
8060
+ this._onBatchSent = options.onBatchSent || NOOP_FUNC;
8061
+ this._rrwebRecord = options.rrwebRecord || null;
7862
8062
 
7863
8063
  // internal rrweb stopRecording function
7864
8064
  this._stopRecording = null;
8065
+ this.replayId = options.replayId;
7865
8066
 
7866
- this.seqNo = 0;
7867
- this.replayStartTime = null;
7868
- this.replayStartUrl = null;
7869
- this.batchStartUrl = null;
8067
+ this.batchStartUrl = options.batchStartUrl || null;
8068
+ this.replayStartUrl = options.replayStartUrl || null;
8069
+ this.idleExpires = options.idleExpires || null;
8070
+ this.maxExpires = options.maxExpires || null;
8071
+ this.replayStartTime = options.replayStartTime || null;
8072
+ this.seqNo = options.seqNo || 0;
7870
8073
 
7871
8074
  this.idleTimeoutId = null;
7872
8075
  this.maxTimeoutId = null;
@@ -7874,18 +8077,40 @@ var SessionRecording = function(options) {
7874
8077
  this.recordMaxMs = MAX_RECORDING_MS;
7875
8078
  this.recordMinMs = 0;
7876
8079
 
8080
+ // disable persistence if localStorage is not supported
8081
+ // request-queue will automatically disable persistence if indexedDB fails to initialize
8082
+ var usePersistence = localStorageSupported(options.sharedLockStorage, true);
8083
+
7877
8084
  // each replay has its own batcher key to avoid conflicts between rrweb events of different recordings
7878
8085
  // this will be important when persistence is introduced
7879
- var batcherKey = '__mprec_' + this.getConfig('token') + '_' + this.replayId;
7880
- this.batcher = new RequestBatcher(batcherKey, {
7881
- errorReporter: _.bind(this.reportError, this),
8086
+ this.batcherKey = '__mprec_' + this.getConfig('name') + '_' + this.getConfig('token') + '_' + this.replayId;
8087
+ this.queueStorage = new IDBStorageWrapper(RECORDING_EVENTS_STORE_NAME);
8088
+ this.batcher = new RequestBatcher(this.batcherKey, {
8089
+ errorReporter: this.reportError.bind(this),
7882
8090
  flushOnlyOnInterval: true,
7883
8091
  libConfig: RECORDER_BATCHER_LIB_CONFIG,
7884
- sendRequestFunc: _.bind(this.flushEventsWithOptOut, this),
7885
- usePersistence: false
8092
+ sendRequestFunc: this.flushEventsWithOptOut.bind(this),
8093
+ queueStorage: this.queueStorage,
8094
+ sharedLockStorage: options.sharedLockStorage,
8095
+ usePersistence: usePersistence,
8096
+ stopAllBatchingFunc: this.stopRecording.bind(this),
8097
+
8098
+ // increased throttle and shared lock timeout because recording events are very high frequency.
8099
+ // this will minimize the amount of lock contention between enqueued events.
8100
+ // for session recordings there is a lock for each tab anyway, so there's no risk of deadlock between tabs.
8101
+ enqueueThrottleMs: RECORD_ENQUEUE_THROTTLE_MS,
8102
+ sharedLockTimeoutMS: 10 * 1000,
7886
8103
  });
7887
8104
  };
7888
8105
 
8106
+ SessionRecording.prototype.unloadPersistedData = function () {
8107
+ this.batcher.stop();
8108
+ return this.batcher.flush()
8109
+ .then(function () {
8110
+ return this.queueStorage.removeItem(this.batcherKey);
8111
+ }.bind(this));
8112
+ };
8113
+
7889
8114
  SessionRecording.prototype.getConfig = function(configVar) {
7890
8115
  return this._mixpanel.get_config(configVar);
7891
8116
  };
@@ -7898,6 +8123,11 @@ SessionRecording.prototype.get_config = function(configVar) {
7898
8123
  };
7899
8124
 
7900
8125
  SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
8126
+ if (this._rrwebRecord === null) {
8127
+ this.reportError('rrweb record function not provided. ');
8128
+ return;
8129
+ }
8130
+
7901
8131
  if (this._stopRecording !== null) {
7902
8132
  logger$2.log('Recording already in progress, skipping startRecording.');
7903
8133
  return;
@@ -7909,15 +8139,21 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
7909
8139
  logger$2.critical('record_max_ms cannot be greater than ' + MAX_RECORDING_MS + 'ms. Capping value.');
7910
8140
  }
7911
8141
 
8142
+ if (!this.maxExpires) {
8143
+ this.maxExpires = new Date().getTime() + this.recordMaxMs;
8144
+ }
8145
+
7912
8146
  this.recordMinMs = this.getConfig('record_min_ms');
7913
8147
  if (this.recordMinMs > MAX_VALUE_FOR_MIN_RECORDING_MS) {
7914
8148
  this.recordMinMs = MAX_VALUE_FOR_MIN_RECORDING_MS;
7915
8149
  logger$2.critical('record_min_ms cannot be greater than ' + MAX_VALUE_FOR_MIN_RECORDING_MS + 'ms. Capping value.');
7916
8150
  }
7917
8151
 
7918
- this.replayStartTime = new Date().getTime();
7919
- this.batchStartUrl = _.info.currentUrl();
7920
- this.replayStartUrl = _.info.currentUrl();
8152
+ if (!this.replayStartTime) {
8153
+ this.replayStartTime = new Date().getTime();
8154
+ this.batchStartUrl = _.info.currentUrl();
8155
+ this.replayStartUrl = _.info.currentUrl();
8156
+ }
7921
8157
 
7922
8158
  if (shouldStopBatcher || this.recordMinMs > 0) {
7923
8159
  // the primary case for shouldStopBatcher is when we're starting recording after a reset
@@ -7930,42 +8166,49 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
7930
8166
  this.batcher.start();
7931
8167
  }
7932
8168
 
7933
- var resetIdleTimeout = _.bind(function () {
8169
+ var resetIdleTimeout = function () {
7934
8170
  clearTimeout(this.idleTimeoutId);
7935
- this.idleTimeoutId = setTimeout(this._onIdleTimeout, this.getConfig('record_idle_timeout_ms'));
7936
- }, this);
8171
+ var idleTimeoutMs = this.getConfig('record_idle_timeout_ms');
8172
+ this.idleTimeoutId = setTimeout(this._onIdleTimeout, idleTimeoutMs);
8173
+ this.idleExpires = new Date().getTime() + idleTimeoutMs;
8174
+ }.bind(this);
7937
8175
 
7938
8176
  var blockSelector = this.getConfig('record_block_selector');
7939
8177
  if (blockSelector === '' || blockSelector === null) {
7940
8178
  blockSelector = undefined;
7941
8179
  }
7942
8180
 
7943
- this._stopRecording = this._rrwebRecord({
7944
- 'emit': _.bind(function (ev) {
7945
- this.batcher.enqueue(ev);
7946
- if (isUserEvent(ev)) {
7947
- if (this.batcher.stopped && new Date().getTime() - this.replayStartTime >= this.recordMinMs) {
7948
- // start flushing again after user activity
7949
- this.batcher.start();
8181
+ try {
8182
+ this._stopRecording = this._rrwebRecord({
8183
+ 'emit': function (ev) {
8184
+ if (isUserEvent(ev)) {
8185
+ if (this.batcher.stopped && new Date().getTime() - this.replayStartTime >= this.recordMinMs) {
8186
+ // start flushing again after user activity
8187
+ this.batcher.start();
8188
+ }
8189
+ resetIdleTimeout();
7950
8190
  }
7951
- resetIdleTimeout();
8191
+ // promise only used to await during tests
8192
+ this.__enqueuePromise = this.batcher.enqueue(ev);
8193
+ }.bind(this),
8194
+ 'blockClass': this.getConfig('record_block_class'),
8195
+ 'blockSelector': blockSelector,
8196
+ 'collectFonts': this.getConfig('record_collect_fonts'),
8197
+ 'dataURLOptions': { // canvas image options (https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL)
8198
+ 'type': 'image/webp',
8199
+ 'quality': 0.6
8200
+ },
8201
+ 'maskAllInputs': true,
8202
+ 'maskTextClass': this.getConfig('record_mask_text_class'),
8203
+ 'maskTextSelector': this.getConfig('record_mask_text_selector'),
8204
+ 'recordCanvas': this.getConfig('record_canvas'),
8205
+ 'sampling': {
8206
+ 'canvas': 15
7952
8207
  }
7953
- }, this),
7954
- 'blockClass': this.getConfig('record_block_class'),
7955
- 'blockSelector': blockSelector,
7956
- 'collectFonts': this.getConfig('record_collect_fonts'),
7957
- 'dataURLOptions': { // canvas image options (https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL)
7958
- 'type': 'image/webp',
7959
- 'quality': 0.6
7960
- },
7961
- 'maskAllInputs': true,
7962
- 'maskTextClass': this.getConfig('record_mask_text_class'),
7963
- 'maskTextSelector': this.getConfig('record_mask_text_selector'),
7964
- 'recordCanvas': this.getConfig('record_canvas'),
7965
- 'sampling': {
7966
- 'canvas': 15
7967
- }
7968
- });
8208
+ });
8209
+ } catch (err) {
8210
+ this.reportError('Unexpected error when starting rrweb recording.', err);
8211
+ }
7969
8212
 
7970
8213
  if (typeof this._stopRecording !== 'function') {
7971
8214
  this.reportError('rrweb failed to start, skipping this recording.');
@@ -7976,10 +8219,11 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
7976
8219
 
7977
8220
  resetIdleTimeout();
7978
8221
 
7979
- this.maxTimeoutId = setTimeout(_.bind(this._onMaxLengthReached, this), this.recordMaxMs);
8222
+ var maxTimeoutMs = this.maxExpires - new Date().getTime();
8223
+ this.maxTimeoutId = setTimeout(this._onMaxLengthReached.bind(this), maxTimeoutMs);
7980
8224
  };
7981
8225
 
7982
- SessionRecording.prototype.stopRecording = function () {
8226
+ SessionRecording.prototype.stopRecording = function (skipFlush) {
7983
8227
  if (!this.isRrwebStopped()) {
7984
8228
  try {
7985
8229
  this._stopRecording();
@@ -7989,40 +8233,91 @@ SessionRecording.prototype.stopRecording = function () {
7989
8233
  this._stopRecording = null;
7990
8234
  }
7991
8235
 
8236
+ var flushPromise;
7992
8237
  if (this.batcher.stopped) {
7993
8238
  // never got user activity to flush after reset, so just clear the batcher
7994
- this.batcher.clear();
7995
- } else {
8239
+ flushPromise = this.batcher.clear();
8240
+ } else if (!skipFlush) {
7996
8241
  // flush any remaining events from running batcher
7997
- this.batcher.flush();
7998
- this.batcher.stop();
8242
+ flushPromise = this.batcher.flush();
7999
8243
  }
8244
+ this.batcher.stop();
8000
8245
 
8001
8246
  clearTimeout(this.idleTimeoutId);
8002
8247
  clearTimeout(this.maxTimeoutId);
8248
+ return flushPromise;
8003
8249
  };
8004
8250
 
8005
8251
  SessionRecording.prototype.isRrwebStopped = function () {
8006
8252
  return this._stopRecording === null;
8007
8253
  };
8008
8254
 
8255
+
8009
8256
  /**
8010
8257
  * Flushes the current batch of events to the server, but passes an opt-out callback to make sure
8011
8258
  * we stop recording and dump any queued events if the user has opted out.
8012
8259
  */
8013
8260
  SessionRecording.prototype.flushEventsWithOptOut = function (data, options, cb) {
8014
- this._flushEvents(data, options, cb, _.bind(this._onOptOut, this));
8261
+ var onOptOut = function (code) {
8262
+ // addOptOutCheckMixpanelLib invokes this function with code=0 when the user has opted out
8263
+ if (code === 0) {
8264
+ this.stopRecording();
8265
+ cb({error: 'Tracking has been opted out, stopping recording.'});
8266
+ }
8267
+ }.bind(this);
8268
+
8269
+ this._flushEvents(data, options, cb, onOptOut);
8015
8270
  };
8016
8271
 
8017
- SessionRecording.prototype._onOptOut = function (code) {
8018
- // addOptOutCheckMixpanelLib invokes this function with code=0 when the user has opted out
8019
- if (code === 0) {
8020
- this.stopRecording();
8272
+ /**
8273
+ * @returns {SerializedRecording}
8274
+ */
8275
+ SessionRecording.prototype.serialize = function () {
8276
+ // don't break if mixpanel instance was destroyed at some point
8277
+ var tabId;
8278
+ try {
8279
+ tabId = this._mixpanel.get_tab_id();
8280
+ } catch (e) {
8281
+ this.reportError('Error getting tab ID for serialization ', e);
8282
+ tabId = null;
8021
8283
  }
8284
+
8285
+ return {
8286
+ 'replayId': this.replayId,
8287
+ 'seqNo': this.seqNo,
8288
+ 'replayStartTime': this.replayStartTime,
8289
+ 'batchStartUrl': this.batchStartUrl,
8290
+ 'replayStartUrl': this.replayStartUrl,
8291
+ 'idleExpires': this.idleExpires,
8292
+ 'maxExpires': this.maxExpires,
8293
+ 'tabId': tabId,
8294
+ };
8295
+ };
8296
+
8297
+
8298
+ /**
8299
+ * @static
8300
+ * @param {SerializedRecording} serializedRecording
8301
+ * @param {SessionRecordingOptions} options
8302
+ * @returns {SessionRecording}
8303
+ */
8304
+ SessionRecording.deserialize = function (serializedRecording, options) {
8305
+ var recording = new SessionRecording(_.extend({}, options, {
8306
+ replayId: serializedRecording['replayId'],
8307
+ batchStartUrl: serializedRecording['batchStartUrl'],
8308
+ replayStartUrl: serializedRecording['replayStartUrl'],
8309
+ idleExpires: serializedRecording['idleExpires'],
8310
+ maxExpires: serializedRecording['maxExpires'],
8311
+ replayStartTime: serializedRecording['replayStartTime'],
8312
+ seqNo: serializedRecording['seqNo'],
8313
+ sharedLockStorage: options.sharedLockStorage,
8314
+ }));
8315
+
8316
+ return recording;
8022
8317
  };
8023
8318
 
8024
8319
  SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, reqBody, callback) {
8025
- var onSuccess = _.bind(function (response, responseBody) {
8320
+ var onSuccess = function (response, responseBody) {
8026
8321
  // Update batch specific props only if the request was successful to guarantee ordering.
8027
8322
  // RequestBatcher will always flush the next batch after the previous one succeeds.
8028
8323
  // extra check to see if the replay ID has changed so that we don't increment the seqNo on the wrong replay
@@ -8030,13 +8325,15 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
8030
8325
  this.seqNo++;
8031
8326
  this.batchStartUrl = _.info.currentUrl();
8032
8327
  }
8328
+
8329
+ this._onBatchSent();
8033
8330
  callback({
8034
8331
  status: 0,
8035
8332
  httpStatusCode: response.status,
8036
8333
  responseBody: responseBody,
8037
8334
  retryAfter: response.headers.get('Retry-After')
8038
8335
  });
8039
- }, this);
8336
+ }.bind(this);
8040
8337
 
8041
8338
  win['fetch'](this.getConfig('api_host') + '/' + this.getConfig('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
8042
8339
  'method': 'POST',
@@ -8057,21 +8354,36 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
8057
8354
  };
8058
8355
 
8059
8356
  SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (data, options, callback) {
8060
- const numEvents = data.length;
8357
+ var numEvents = data.length;
8061
8358
 
8062
8359
  if (numEvents > 0) {
8063
8360
  var replayId = this.replayId;
8361
+
8064
8362
  // each rrweb event has a timestamp - leverage those to get time properties
8065
- var batchStartTime = data[0].timestamp;
8066
- if (this.seqNo === 0 || !this.replayStartTime) {
8067
- // extra safety net so that we don't send a null replay start time
8068
- if (this.seqNo !== 0) {
8069
- this.reportError('Replay start time not set but seqNo is not 0. Using current batch start time as a fallback.');
8363
+ var batchStartTime = Infinity;
8364
+ var batchEndTime = -Infinity;
8365
+ var hasFullSnapshot = false;
8366
+ for (var i = 0; i < numEvents; i++) {
8367
+ batchStartTime = Math.min(batchStartTime, data[i].timestamp);
8368
+ batchEndTime = Math.max(batchEndTime, data[i].timestamp);
8369
+ if (data[i].type === EventType.FullSnapshot) {
8370
+ hasFullSnapshot = true;
8070
8371
  }
8372
+ }
8071
8373
 
8374
+ if (this.seqNo === 0) {
8375
+ if (!hasFullSnapshot) {
8376
+ callback({error: 'First batch does not contain a full snapshot. Aborting recording.'});
8377
+ this.stopRecording(true);
8378
+ return;
8379
+ }
8380
+ this.replayStartTime = batchStartTime;
8381
+ } else if (!this.replayStartTime) {
8382
+ this.reportError('Replay start time not set but seqNo is not 0. Using current batch start time as a fallback.');
8072
8383
  this.replayStartTime = batchStartTime;
8073
8384
  }
8074
- var replayLengthMs = data[numEvents - 1].timestamp - this.replayStartTime;
8385
+
8386
+ var replayLengthMs = batchEndTime - this.replayStartTime;
8075
8387
 
8076
8388
  var reqParams = {
8077
8389
  '$current_url': this.batchStartUrl,
@@ -8102,10 +8414,10 @@ SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (da
8102
8414
  var gzipStream = jsonStream.pipeThrough(new CompressionStream('gzip'));
8103
8415
  new Response(gzipStream)
8104
8416
  .blob()
8105
- .then(_.bind(function(compressedBlob) {
8417
+ .then(function(compressedBlob) {
8106
8418
  reqParams['format'] = 'gzip';
8107
8419
  this._sendRequest(replayId, reqParams, compressedBlob, callback);
8108
- }, this));
8420
+ }.bind(this));
8109
8421
  } else {
8110
8422
  reqParams['format'] = 'body';
8111
8423
  this._sendRequest(replayId, reqParams, eventsJson, callback);
@@ -8126,54 +8438,208 @@ SessionRecording.prototype.reportError = function(msg, err) {
8126
8438
  }
8127
8439
  };
8128
8440
 
8441
+ /**
8442
+ * Module for handling the storage and retrieval of recording metadata as well as any active recordings.
8443
+ * Makes sure that only one tab can be recording at a time.
8444
+ */
8445
+ var RecordingRegistry = function (options) {
8446
+ this.idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
8447
+ this.errorReporter = options.errorReporter;
8448
+ this.mixpanelInstance = options.mixpanelInstance;
8449
+ this.sharedLockStorage = options.sharedLockStorage;
8450
+ };
8451
+
8452
+ RecordingRegistry.prototype.handleError = function (err) {
8453
+ this.errorReporter('IndexedDB error: ', err);
8454
+ };
8455
+
8456
+ /**
8457
+ * @param {import('./session-recording').SerializedRecording} serializedRecording
8458
+ */
8459
+ RecordingRegistry.prototype.setActiveRecording = function (serializedRecording) {
8460
+ var tabId = serializedRecording['tabId'];
8461
+ if (!tabId) {
8462
+ console.warn('No tab ID is set, cannot persist recording metadata.');
8463
+ return PromisePolyfill.resolve();
8464
+ }
8465
+
8466
+ return this.idb.init()
8467
+ .then(function () {
8468
+ return this.idb.setItem(tabId, serializedRecording);
8469
+ }.bind(this))
8470
+ .catch(this.handleError.bind(this));
8471
+ };
8472
+
8473
+ /**
8474
+ * @returns {Promise<import('./session-recording').SerializedRecording>}
8475
+ */
8476
+ RecordingRegistry.prototype.getActiveRecording = function () {
8477
+ return this.idb.init()
8478
+ .then(function () {
8479
+ return this.idb.getItem(this.mixpanelInstance.get_tab_id());
8480
+ }.bind(this))
8481
+ .then(function (serializedRecording) {
8482
+ return isRecordingExpired(serializedRecording) ? null : serializedRecording;
8483
+ }.bind(this))
8484
+ .catch(this.handleError.bind(this));
8485
+ };
8486
+
8487
+ RecordingRegistry.prototype.clearActiveRecording = function () {
8488
+ // mark recording as expired instead of deleting it in case the page unloads mid-flush and doesn't make it to ingestion.
8489
+ // this will ensure the next pageload will flush the remaining events, but not try to continue the recording.
8490
+ return this.getActiveRecording()
8491
+ .then(function (serializedRecording) {
8492
+ if (serializedRecording) {
8493
+ serializedRecording['maxExpires'] = 0;
8494
+ return this.setActiveRecording(serializedRecording);
8495
+ }
8496
+ }.bind(this))
8497
+ .catch(this.handleError.bind(this));
8498
+ };
8499
+
8500
+ /**
8501
+ * Flush any inactive recordings from the registry to minimize data loss.
8502
+ * The main idea here is that we can flush remaining rrweb events on the next page load if a tab is closed mid-batch.
8503
+ */
8504
+ RecordingRegistry.prototype.flushInactiveRecordings = function () {
8505
+ return this.idb.init()
8506
+ .then(function() {
8507
+ return this.idb.getAll();
8508
+ }.bind(this))
8509
+ .then(function (serializedRecordings) {
8510
+ // clean up any expired recordings from the registry, non-expired ones may be active in other tabs
8511
+ var unloadPromises = serializedRecordings
8512
+ .filter(function (serializedRecording) {
8513
+ return isRecordingExpired(serializedRecording);
8514
+ })
8515
+ .map(function (serializedRecording) {
8516
+ var sessionRecording = SessionRecording.deserialize(serializedRecording, {
8517
+ mixpanelInstance: this.mixpanelInstance,
8518
+ sharedLockStorage: this.sharedLockStorage
8519
+ });
8520
+ return sessionRecording.unloadPersistedData()
8521
+ .then(function () {
8522
+ // expired recording was successfully flushed, we can clean it up from the registry
8523
+ return this.idb.removeItem(serializedRecording['tabId']);
8524
+ }.bind(this))
8525
+ .catch(this.handleError.bind(this));
8526
+ }.bind(this));
8527
+
8528
+ return PromisePolyfill.all(unloadPromises);
8529
+ }.bind(this))
8530
+ .catch(this.handleError.bind(this));
8531
+ };
8532
+
8129
8533
  var logger$1 = console_with_prefix('recorder');
8130
8534
 
8131
8535
  /**
8132
- * Recorder API: manages recordings and exposes methods public to the core Mixpanel library.
8536
+ * Recorder API: bundles rrweb and and exposes methods to start and stop recordings.
8133
8537
  * @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
8134
- */
8135
- var MixpanelRecorder = function(mixpanelInstance) {
8136
- this._mixpanel = mixpanelInstance;
8538
+ */
8539
+ var MixpanelRecorder = function(mixpanelInstance, rrwebRecord, sharedLockStorage) {
8540
+ this.mixpanelInstance = mixpanelInstance;
8541
+ this.rrwebRecord = rrwebRecord || record;
8542
+ this.sharedLockStorage = sharedLockStorage;
8543
+
8544
+ /**
8545
+ * @member {import('./registry').RecordingRegistry}
8546
+ */
8547
+ this.recordingRegistry = new RecordingRegistry({
8548
+ mixpanelInstance: this.mixpanelInstance,
8549
+ errorReporter: logger$1.error,
8550
+ sharedLockStorage: sharedLockStorage
8551
+ });
8552
+ this._flushInactivePromise = this.recordingRegistry.flushInactiveRecordings();
8553
+
8137
8554
  this.activeRecording = null;
8138
8555
  };
8139
8556
 
8140
- MixpanelRecorder.prototype.startRecording = function(shouldStopBatcher) {
8557
+ MixpanelRecorder.prototype.startRecording = function(options) {
8558
+ options = options || {};
8141
8559
  if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
8142
8560
  logger$1.log('Recording already in progress, skipping startRecording.');
8143
8561
  return;
8144
8562
  }
8145
8563
 
8146
- var onIdleTimeout = _.bind(function () {
8564
+ var onIdleTimeout = function () {
8147
8565
  logger$1.log('Idle timeout reached, restarting recording.');
8148
8566
  this.resetRecording();
8149
- }, this);
8567
+ }.bind(this);
8150
8568
 
8151
- var onMaxLengthReached = _.bind(function () {
8569
+ var onMaxLengthReached = function () {
8152
8570
  logger$1.log('Max recording length reached, stopping recording.');
8153
8571
  this.resetRecording();
8154
- }, this);
8572
+ }.bind(this);
8573
+
8574
+ var onBatchSent = function () {
8575
+ this.recordingRegistry.setActiveRecording(this.activeRecording.serialize());
8576
+ this['__flushPromise'] = this.activeRecording.batcher._flushPromise;
8577
+ }.bind(this);
8155
8578
 
8156
- this.activeRecording = new SessionRecording({
8157
- mixpanelInstance: this._mixpanel,
8579
+ /**
8580
+ * @type {import('./session-recording').SessionRecordingOptions}
8581
+ */
8582
+ var sessionRecordingOptions = {
8583
+ mixpanelInstance: this.mixpanelInstance,
8584
+ onBatchSent: onBatchSent,
8158
8585
  onIdleTimeout: onIdleTimeout,
8159
8586
  onMaxLengthReached: onMaxLengthReached,
8160
8587
  replayId: _.UUID(),
8161
- rrwebRecord: record
8162
- });
8588
+ rrwebRecord: this.rrwebRecord,
8589
+ sharedLockStorage: this.sharedLockStorage
8590
+ };
8591
+
8592
+ if (options.activeSerializedRecording) {
8593
+ this.activeRecording = SessionRecording.deserialize(options.activeSerializedRecording, sessionRecordingOptions);
8594
+ } else {
8595
+ this.activeRecording = new SessionRecording(sessionRecordingOptions);
8596
+ }
8163
8597
 
8164
- this.activeRecording.startRecording(shouldStopBatcher);
8598
+ this.activeRecording.startRecording(options.shouldStopBatcher);
8599
+ return this.recordingRegistry.setActiveRecording(this.activeRecording.serialize());
8165
8600
  };
8166
8601
 
8167
8602
  MixpanelRecorder.prototype.stopRecording = function() {
8603
+ var stopPromise = this._stopCurrentRecording(false);
8604
+ this.recordingRegistry.clearActiveRecording();
8605
+ this.activeRecording = null;
8606
+ return stopPromise;
8607
+ };
8608
+
8609
+ MixpanelRecorder.prototype.pauseRecording = function() {
8610
+ return this._stopCurrentRecording(false);
8611
+ };
8612
+
8613
+ MixpanelRecorder.prototype._stopCurrentRecording = function(skipFlush) {
8168
8614
  if (this.activeRecording) {
8169
- this.activeRecording.stopRecording();
8170
- this.activeRecording = null;
8615
+ return this.activeRecording.stopRecording(skipFlush);
8171
8616
  }
8617
+ return PromisePolyfill.resolve();
8172
8618
  };
8173
8619
 
8620
+ MixpanelRecorder.prototype.resumeRecording = function (startNewIfInactive) {
8621
+ if (this.activeRecording && this.activeRecording.isRrwebStopped()) {
8622
+ this.activeRecording.startRecording(false);
8623
+ return PromisePolyfill.resolve(null);
8624
+ }
8625
+
8626
+ return this.recordingRegistry.getActiveRecording()
8627
+ .then(function (activeSerializedRecording) {
8628
+ if (activeSerializedRecording) {
8629
+ return this.startRecording({activeSerializedRecording: activeSerializedRecording});
8630
+ } else if (startNewIfInactive) {
8631
+ return this.startRecording({shouldStopBatcher: false});
8632
+ } else {
8633
+ logger$1.log('No resumable recording found.');
8634
+ return null;
8635
+ }
8636
+ }.bind(this));
8637
+ };
8638
+
8639
+
8174
8640
  MixpanelRecorder.prototype.resetRecording = function () {
8175
8641
  this.stopRecording();
8176
- this.startRecording(true);
8642
+ this.startRecording({shouldStopBatcher: true});
8177
8643
  };
8178
8644
 
8179
8645
  MixpanelRecorder.prototype.getActiveReplayId = function () {
@@ -10389,11 +10855,6 @@ MixpanelPersistence.prototype.remove_event_timer = function(event_name) {
10389
10855
  * Released under the MIT License.
10390
10856
  */
10391
10857
 
10392
- // ==ClosureCompiler==
10393
- // @compilation_level ADVANCED_OPTIMIZATIONS
10394
- // @output_file_name mixpanel-2.8.min.js
10395
- // ==/ClosureCompiler==
10396
-
10397
10858
  /*
10398
10859
  SIMPLE STYLE GUIDE:
10399
10860
 
@@ -10416,7 +10877,6 @@ var INIT_MODULE = 0;
10416
10877
  var INIT_SNIPPET = 1;
10417
10878
 
10418
10879
  var IDENTITY_FUNC = function(x) {return x;};
10419
- var NOOP_FUNC = function() {};
10420
10880
 
10421
10881
  /** @const */ var PRIMARY_INSTANCE_NAME = 'mixpanel';
10422
10882
  /** @const */ var PAYLOAD_TYPE_BASE64 = 'base64';
@@ -10725,34 +11185,125 @@ MixpanelLib.prototype._init = function(token, config, name) {
10725
11185
  this.autocapture = new Autocapture(this);
10726
11186
  this.autocapture.init();
10727
11187
 
10728
- if (this.get_config('record_sessions_percent') > 0 && Math.random() * 100 <= this.get_config('record_sessions_percent')) {
10729
- this.start_session_recording();
11188
+ this._init_tab_id();
11189
+ this._check_and_start_session_recording();
11190
+ };
11191
+
11192
+ /**
11193
+ * Assigns a unique UUID to this tab / window by leveraging sessionStorage.
11194
+ * This is primarily used for session recording, where data must be isolated to the current tab.
11195
+ */
11196
+ MixpanelLib.prototype._init_tab_id = function() {
11197
+ if (_.sessionStorage.is_supported()) {
11198
+ try {
11199
+ var key_suffix = this.get_config('name') + '_' + this.get_config('token');
11200
+ var tab_id_key = 'mp_tab_id_' + key_suffix;
11201
+
11202
+ // A flag is used to determine if sessionStorage is copied over and we need to generate a new tab ID.
11203
+ // This enforces a unique ID in the cases like duplicated tab, window.open(...)
11204
+ var should_generate_new_tab_id_key = 'mp_gen_new_tab_id_' + key_suffix;
11205
+ if (_.sessionStorage.get(should_generate_new_tab_id_key) || !_.sessionStorage.get(tab_id_key)) {
11206
+ _.sessionStorage.set(tab_id_key, '$tab-' + _.UUID());
11207
+ }
11208
+
11209
+ _.sessionStorage.set(should_generate_new_tab_id_key, '1');
11210
+ this.tab_id = _.sessionStorage.get(tab_id_key);
11211
+
11212
+ // 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,
11213
+ // but reliable in cases where the user remains in the tab e.g. a refresh or href navigation.
11214
+ // If the flag is absent, this indicates to the next SDK instance that we can reuse the stored tab_id.
11215
+ win.addEventListener('beforeunload', function () {
11216
+ _.sessionStorage.remove(should_generate_new_tab_id_key);
11217
+ });
11218
+ } catch(err) {
11219
+ this.report_error('Error initializing tab id', err);
11220
+ }
11221
+ } else {
11222
+ this.report_error('Session storage is not supported, cannot keep track of unique tab ID.');
10730
11223
  }
10731
11224
  };
10732
11225
 
10733
- MixpanelLib.prototype.start_session_recording = addOptOutCheckMixpanelLib(function () {
11226
+ MixpanelLib.prototype.get_tab_id = function () {
11227
+ return this.tab_id || null;
11228
+ };
11229
+
11230
+ MixpanelLib.prototype._should_load_recorder = function () {
11231
+ var recording_registry_idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
11232
+ var tab_id = this.get_tab_id();
11233
+ return recording_registry_idb.init()
11234
+ .then(function () {
11235
+ return recording_registry_idb.getAll();
11236
+ })
11237
+ .then(function (recordings) {
11238
+ for (var i = 0; i < recordings.length; i++) {
11239
+ // if there are expired recordings in the registry, we should load the recorder to flush them
11240
+ // if there's a recording for this tab id, we should load the recorder to continue the recording
11241
+ if (isRecordingExpired(recordings[i]) || recordings[i]['tabId'] === tab_id) {
11242
+ return true;
11243
+ }
11244
+ }
11245
+ return false;
11246
+ })
11247
+ .catch(_.bind(function (err) {
11248
+ this.report_error('Error checking recording registry', err);
11249
+ }, this));
11250
+ };
11251
+
11252
+ MixpanelLib.prototype._check_and_start_session_recording = addOptOutCheckMixpanelLib(function(force_start) {
10734
11253
  if (!win['MutationObserver']) {
10735
11254
  console$1.critical('Browser does not support MutationObserver; skipping session recording');
10736
11255
  return;
10737
11256
  }
10738
11257
 
10739
- var handleLoadedRecorder = _.bind(function() {
10740
- this._recorder = this._recorder || new win['__mp_recorder'](this);
10741
- this._recorder['startRecording']();
11258
+ var loadRecorder = _.bind(function(startNewIfInactive) {
11259
+ var handleLoadedRecorder = _.bind(function() {
11260
+ this._recorder = this._recorder || new win['__mp_recorder'](this);
11261
+ this._recorder['resumeRecording'](startNewIfInactive);
11262
+ }, this);
11263
+
11264
+ if (_.isUndefined(win['__mp_recorder'])) {
11265
+ load_extra_bundle(this.get_config('recorder_src'), handleLoadedRecorder);
11266
+ } else {
11267
+ handleLoadedRecorder();
11268
+ }
10742
11269
  }, this);
10743
11270
 
10744
- if (_.isUndefined(win['__mp_recorder'])) {
10745
- load_extra_bundle(this.get_config('recorder_src'), handleLoadedRecorder);
11271
+ /**
11272
+ * If the user is sampled or start_session_recording is called, we always load the recorder since it's guaranteed a recording should start.
11273
+ * 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.
11274
+ */
11275
+ var is_sampled = this.get_config('record_sessions_percent') > 0 && Math.random() * 100 <= this.get_config('record_sessions_percent');
11276
+ if (force_start || is_sampled) {
11277
+ loadRecorder(true);
10746
11278
  } else {
10747
- handleLoadedRecorder();
11279
+ this._should_load_recorder()
11280
+ .then(function (shouldLoad) {
11281
+ if (shouldLoad) {
11282
+ loadRecorder(false);
11283
+ }
11284
+ });
10748
11285
  }
10749
11286
  });
10750
11287
 
11288
+ MixpanelLib.prototype.start_session_recording = function () {
11289
+ this._check_and_start_session_recording(true);
11290
+ };
11291
+
10751
11292
  MixpanelLib.prototype.stop_session_recording = function () {
10752
11293
  if (this._recorder) {
10753
11294
  this._recorder['stopRecording']();
10754
- } else {
10755
- console$1.critical('Session recorder module not loaded');
11295
+ }
11296
+ };
11297
+
11298
+ MixpanelLib.prototype.pause_session_recording = function () {
11299
+ if (this._recorder) {
11300
+ this._recorder['pauseRecording']();
11301
+ }
11302
+ };
11303
+
11304
+ MixpanelLib.prototype.resume_session_recording = function () {
11305
+ if (this._recorder) {
11306
+ this._recorder['resumeRecording']();
10756
11307
  }
10757
11308
  };
10758
11309
 
@@ -10787,6 +11338,11 @@ MixpanelLib.prototype._get_session_replay_id = function () {
10787
11338
  return replay_id || null;
10788
11339
  };
10789
11340
 
11341
+ // "private" public method to reach into the recorder in test cases
11342
+ MixpanelLib.prototype.__get_recorder = function () {
11343
+ return this._recorder;
11344
+ };
11345
+
10790
11346
  // Private methods
10791
11347
 
10792
11348
  MixpanelLib.prototype._loaded = function() {
@@ -11126,7 +11682,8 @@ MixpanelLib.prototype.init_batchers = function() {
11126
11682
  return this._run_hook('before_send_' + attrs.type, item);
11127
11683
  }, this),
11128
11684
  stopAllBatchingFunc: _.bind(this.stop_batch_senders, this),
11129
- usePersistence: true
11685
+ usePersistence: true,
11686
+ enqueueThrottleMs: 10,
11130
11687
  }
11131
11688
  );
11132
11689
  }, this);
@@ -12227,6 +12784,7 @@ MixpanelLib.prototype._gdpr_update_persistence = function(options) {
12227
12784
 
12228
12785
  if (disabled) {
12229
12786
  this.stop_batch_senders();
12787
+ this.stop_session_recording();
12230
12788
  } else {
12231
12789
  // only start batchers after opt-in if they have previously been started
12232
12790
  // in order to avoid unintentionally starting up batching for the first time
@@ -12467,10 +13025,16 @@ MixpanelLib.prototype['start_batch_senders'] = MixpanelLib.protot
12467
13025
  MixpanelLib.prototype['stop_batch_senders'] = MixpanelLib.prototype.stop_batch_senders;
12468
13026
  MixpanelLib.prototype['start_session_recording'] = MixpanelLib.prototype.start_session_recording;
12469
13027
  MixpanelLib.prototype['stop_session_recording'] = MixpanelLib.prototype.stop_session_recording;
13028
+ MixpanelLib.prototype['pause_session_recording'] = MixpanelLib.prototype.pause_session_recording;
13029
+ MixpanelLib.prototype['resume_session_recording'] = MixpanelLib.prototype.resume_session_recording;
12470
13030
  MixpanelLib.prototype['get_session_recording_properties'] = MixpanelLib.prototype.get_session_recording_properties;
12471
13031
  MixpanelLib.prototype['get_session_replay_url'] = MixpanelLib.prototype.get_session_replay_url;
13032
+ MixpanelLib.prototype['get_tab_id'] = MixpanelLib.prototype.get_tab_id;
12472
13033
  MixpanelLib.prototype['DEFAULT_API_ROUTES'] = DEFAULT_API_ROUTES;
12473
13034
 
13035
+ // Exports intended only for testing
13036
+ MixpanelLib.prototype['__get_recorder'] = MixpanelLib.prototype.__get_recorder;
13037
+
12474
13038
  // MixpanelPersistence Exports
12475
13039
  MixpanelPersistence.prototype['properties'] = MixpanelPersistence.prototype.properties;
12476
13040
  MixpanelPersistence.prototype['update_search_keyword'] = MixpanelPersistence.prototype.update_search_keyword;