mixpanel-browser 2.60.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.60.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",
package/src/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  var Config = {
2
2
  DEBUG: false,
3
- LIB_VERSION: '2.60.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;
@@ -1,73 +1,4 @@
1
- import {record} from 'rrweb';
2
-
3
- import { SessionRecording } from './session-recording';
4
- import { console_with_prefix, _ } from '../utils'; // eslint-disable-line camelcase
5
1
  import { window } from '../window';
6
-
7
- var logger = console_with_prefix('recorder');
8
-
9
- /**
10
- * Recorder API: manages recordings and exposes methods public to the core Mixpanel library.
11
- * @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
12
- */
13
- var MixpanelRecorder = function(mixpanelInstance) {
14
- this._mixpanel = mixpanelInstance;
15
- this.activeRecording = null;
16
- };
17
-
18
- MixpanelRecorder.prototype.startRecording = function(shouldStopBatcher) {
19
- if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
20
- logger.log('Recording already in progress, skipping startRecording.');
21
- return;
22
- }
23
-
24
- var onIdleTimeout = _.bind(function () {
25
- logger.log('Idle timeout reached, restarting recording.');
26
- this.resetRecording();
27
- }, this);
28
-
29
- var onMaxLengthReached = _.bind(function () {
30
- logger.log('Max recording length reached, stopping recording.');
31
- this.resetRecording();
32
- }, this);
33
-
34
- this.activeRecording = new SessionRecording({
35
- mixpanelInstance: this._mixpanel,
36
- onIdleTimeout: onIdleTimeout,
37
- onMaxLengthReached: onMaxLengthReached,
38
- replayId: _.UUID(),
39
- rrwebRecord: record
40
- });
41
-
42
- this.activeRecording.startRecording(shouldStopBatcher);
43
- };
44
-
45
- MixpanelRecorder.prototype.stopRecording = function() {
46
- if (this.activeRecording) {
47
- this.activeRecording.stopRecording();
48
- this.activeRecording = null;
49
- }
50
- };
51
-
52
- MixpanelRecorder.prototype.resetRecording = function () {
53
- this.stopRecording();
54
- this.startRecording(true);
55
- };
56
-
57
- MixpanelRecorder.prototype.getActiveReplayId = function () {
58
- if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
59
- return this.activeRecording.replayId;
60
- } else {
61
- return null;
62
- }
63
- };
64
-
65
- // getter so that older mixpanel-core versions can still retrieve the replay ID
66
- // when pulling the latest recorder bundle from the CDN
67
- Object.defineProperty(MixpanelRecorder.prototype, 'replayId', {
68
- get: function () {
69
- return this.getActiveReplayId();
70
- }
71
- });
2
+ import { MixpanelRecorder } from './recorder';
72
3
 
73
4
  window['__mp_recorder'] = MixpanelRecorder;
@@ -0,0 +1,137 @@
1
+ import { record } from 'rrweb';
2
+ import { Promise } from '../promise-polyfill';
3
+ import { SessionRecording } from './session-recording';
4
+ import { RecordingRegistry } from './recording-registry';
5
+
6
+ import { console_with_prefix, _ } from '../utils'; // eslint-disable-line camelcase
7
+
8
+ var logger = console_with_prefix('recorder');
9
+
10
+ /**
11
+ * Recorder API: bundles rrweb and and exposes methods to start and stop recordings.
12
+ * @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
13
+ */
14
+ var MixpanelRecorder = function(mixpanelInstance, rrwebRecord, sharedLockStorage) {
15
+ this.mixpanelInstance = mixpanelInstance;
16
+ this.rrwebRecord = rrwebRecord || record;
17
+ this.sharedLockStorage = sharedLockStorage;
18
+
19
+ /**
20
+ * @member {import('./registry').RecordingRegistry}
21
+ */
22
+ this.recordingRegistry = new RecordingRegistry({
23
+ mixpanelInstance: this.mixpanelInstance,
24
+ errorReporter: logger.error,
25
+ sharedLockStorage: sharedLockStorage
26
+ });
27
+ this._flushInactivePromise = this.recordingRegistry.flushInactiveRecordings();
28
+
29
+ this.activeRecording = null;
30
+ };
31
+
32
+ MixpanelRecorder.prototype.startRecording = function(options) {
33
+ options = options || {};
34
+ if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
35
+ logger.log('Recording already in progress, skipping startRecording.');
36
+ return;
37
+ }
38
+
39
+ var onIdleTimeout = function () {
40
+ logger.log('Idle timeout reached, restarting recording.');
41
+ this.resetRecording();
42
+ }.bind(this);
43
+
44
+ var onMaxLengthReached = function () {
45
+ logger.log('Max recording length reached, stopping recording.');
46
+ this.resetRecording();
47
+ }.bind(this);
48
+
49
+ var onBatchSent = function () {
50
+ this.recordingRegistry.setActiveRecording(this.activeRecording.serialize());
51
+ this['__flushPromise'] = this.activeRecording.batcher._flushPromise;
52
+ }.bind(this);
53
+
54
+ /**
55
+ * @type {import('./session-recording').SessionRecordingOptions}
56
+ */
57
+ var sessionRecordingOptions = {
58
+ mixpanelInstance: this.mixpanelInstance,
59
+ onBatchSent: onBatchSent,
60
+ onIdleTimeout: onIdleTimeout,
61
+ onMaxLengthReached: onMaxLengthReached,
62
+ replayId: _.UUID(),
63
+ rrwebRecord: this.rrwebRecord,
64
+ sharedLockStorage: this.sharedLockStorage
65
+ };
66
+
67
+ if (options.activeSerializedRecording) {
68
+ this.activeRecording = SessionRecording.deserialize(options.activeSerializedRecording, sessionRecordingOptions);
69
+ } else {
70
+ this.activeRecording = new SessionRecording(sessionRecordingOptions);
71
+ }
72
+
73
+ this.activeRecording.startRecording(options.shouldStopBatcher);
74
+ return this.recordingRegistry.setActiveRecording(this.activeRecording.serialize());
75
+ };
76
+
77
+ MixpanelRecorder.prototype.stopRecording = function() {
78
+ var stopPromise = this._stopCurrentRecording(false);
79
+ this.recordingRegistry.clearActiveRecording();
80
+ this.activeRecording = null;
81
+ return stopPromise;
82
+ };
83
+
84
+ MixpanelRecorder.prototype.pauseRecording = function() {
85
+ return this._stopCurrentRecording(false);
86
+ };
87
+
88
+ MixpanelRecorder.prototype._stopCurrentRecording = function(skipFlush) {
89
+ if (this.activeRecording) {
90
+ return this.activeRecording.stopRecording(skipFlush);
91
+ }
92
+ return Promise.resolve();
93
+ };
94
+
95
+ MixpanelRecorder.prototype.resumeRecording = function (startNewIfInactive) {
96
+ if (this.activeRecording && this.activeRecording.isRrwebStopped()) {
97
+ this.activeRecording.startRecording(false);
98
+ return Promise.resolve(null);
99
+ }
100
+
101
+ return this.recordingRegistry.getActiveRecording()
102
+ .then(function (activeSerializedRecording) {
103
+ if (activeSerializedRecording) {
104
+ return this.startRecording({activeSerializedRecording: activeSerializedRecording});
105
+ } else if (startNewIfInactive) {
106
+ return this.startRecording({shouldStopBatcher: false});
107
+ } else {
108
+ logger.log('No resumable recording found.');
109
+ return null;
110
+ }
111
+ }.bind(this));
112
+ };
113
+
114
+
115
+ MixpanelRecorder.prototype.resetRecording = function () {
116
+ this.stopRecording();
117
+ this.startRecording({shouldStopBatcher: true});
118
+ };
119
+
120
+ MixpanelRecorder.prototype.getActiveReplayId = function () {
121
+ if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
122
+ return this.activeRecording.replayId;
123
+ } else {
124
+ return null;
125
+ }
126
+ };
127
+
128
+ // getter so that older mixpanel-core versions can still retrieve the replay ID
129
+ // when pulling the latest recorder bundle from the CDN
130
+ Object.defineProperty(MixpanelRecorder.prototype, 'replayId', {
131
+ get: function () {
132
+ return this.getActiveReplayId();
133
+ }
134
+ });
135
+
136
+
137
+ export { MixpanelRecorder };
@@ -0,0 +1,98 @@
1
+ import { Promise } from '../promise-polyfill';
2
+ import { IDBStorageWrapper, RECORDING_REGISTRY_STORE_NAME } from '../storage/indexed-db';
3
+ import { SessionRecording } from './session-recording';
4
+ import { isRecordingExpired } from './utils';
5
+
6
+ /**
7
+ * Module for handling the storage and retrieval of recording metadata as well as any active recordings.
8
+ * Makes sure that only one tab can be recording at a time.
9
+ */
10
+ var RecordingRegistry = function (options) {
11
+ this.idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
12
+ this.errorReporter = options.errorReporter;
13
+ this.mixpanelInstance = options.mixpanelInstance;
14
+ this.sharedLockStorage = options.sharedLockStorage;
15
+ };
16
+
17
+ RecordingRegistry.prototype.handleError = function (err) {
18
+ this.errorReporter('IndexedDB error: ', err);
19
+ };
20
+
21
+ /**
22
+ * @param {import('./session-recording').SerializedRecording} serializedRecording
23
+ */
24
+ RecordingRegistry.prototype.setActiveRecording = function (serializedRecording) {
25
+ var tabId = serializedRecording['tabId'];
26
+ if (!tabId) {
27
+ console.warn('No tab ID is set, cannot persist recording metadata.');
28
+ return Promise.resolve();
29
+ }
30
+
31
+ return this.idb.init()
32
+ .then(function () {
33
+ return this.idb.setItem(tabId, serializedRecording);
34
+ }.bind(this))
35
+ .catch(this.handleError.bind(this));
36
+ };
37
+
38
+ /**
39
+ * @returns {Promise<import('./session-recording').SerializedRecording>}
40
+ */
41
+ RecordingRegistry.prototype.getActiveRecording = function () {
42
+ return this.idb.init()
43
+ .then(function () {
44
+ return this.idb.getItem(this.mixpanelInstance.get_tab_id());
45
+ }.bind(this))
46
+ .then(function (serializedRecording) {
47
+ return isRecordingExpired(serializedRecording) ? null : serializedRecording;
48
+ }.bind(this))
49
+ .catch(this.handleError.bind(this));
50
+ };
51
+
52
+ RecordingRegistry.prototype.clearActiveRecording = function () {
53
+ // mark recording as expired instead of deleting it in case the page unloads mid-flush and doesn't make it to ingestion.
54
+ // this will ensure the next pageload will flush the remaining events, but not try to continue the recording.
55
+ return this.getActiveRecording()
56
+ .then(function (serializedRecording) {
57
+ if (serializedRecording) {
58
+ serializedRecording['maxExpires'] = 0;
59
+ return this.setActiveRecording(serializedRecording);
60
+ }
61
+ }.bind(this))
62
+ .catch(this.handleError.bind(this));
63
+ };
64
+
65
+ /**
66
+ * Flush any inactive recordings from the registry to minimize data loss.
67
+ * The main idea here is that we can flush remaining rrweb events on the next page load if a tab is closed mid-batch.
68
+ */
69
+ RecordingRegistry.prototype.flushInactiveRecordings = function () {
70
+ return this.idb.init()
71
+ .then(function() {
72
+ return this.idb.getAll();
73
+ }.bind(this))
74
+ .then(function (serializedRecordings) {
75
+ // clean up any expired recordings from the registry, non-expired ones may be active in other tabs
76
+ var unloadPromises = serializedRecordings
77
+ .filter(function (serializedRecording) {
78
+ return isRecordingExpired(serializedRecording);
79
+ })
80
+ .map(function (serializedRecording) {
81
+ var sessionRecording = SessionRecording.deserialize(serializedRecording, {
82
+ mixpanelInstance: this.mixpanelInstance,
83
+ sharedLockStorage: this.sharedLockStorage
84
+ });
85
+ return sessionRecording.unloadPersistedData()
86
+ .then(function () {
87
+ // expired recording was successfully flushed, we can clean it up from the registry
88
+ return this.idb.removeItem(serializedRecording['tabId']);
89
+ }.bind(this))
90
+ .catch(this.handleError.bind(this));
91
+ }.bind(this));
92
+
93
+ return Promise.all(unloadPromises);
94
+ }.bind(this))
95
+ .catch(this.handleError.bind(this));
96
+ };
97
+
98
+ export { RecordingRegistry };