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.
@@ -345,8 +345,12 @@ MixpanelPersistence.prototype._add_to_people_queue = function(queue, data) {
345
345
  if (!(k in union_q)) {
346
346
  union_q[k] = [];
347
347
  }
348
- // We may send duplicates, the server will dedup them.
349
- union_q[k] = union_q[k].concat(v);
348
+ // Prevent duplicate values
349
+ _.each(v, function(item) {
350
+ if (!_.include(union_q[k], item)) {
351
+ union_q[k].push(item);
352
+ }
353
+ });
350
354
  }
351
355
  });
352
356
  this._pop_from_people_queue(UNSET_ACTION, q_data);
@@ -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 };
@@ -1,10 +1,11 @@
1
- import { IncrementalSource, EventType } from '@rrweb/types';
2
-
3
- import { MAX_RECORDING_MS, MAX_VALUE_FOR_MIN_RECORDING_MS, console_with_prefix, _} from '../utils'; // eslint-disable-line camelcase
4
1
  import { window } from '../window';
2
+ import { IncrementalSource, EventType } from '@rrweb/types';
3
+ import { MAX_RECORDING_MS, MAX_VALUE_FOR_MIN_RECORDING_MS, console_with_prefix, NOOP_FUNC, _, localStorageSupported} from '../utils'; // eslint-disable-line camelcase
4
+ import { IDBStorageWrapper, RECORDING_EVENTS_STORE_NAME } from '../storage/indexed-db';
5
5
  import { addOptOutCheckMixpanelLib } from '../gdpr-utils';
6
6
  import { RequestBatcher } from '../request-batcher';
7
7
  import Config from '../config';
8
+ import { RECORD_ENQUEUE_THROTTLE_MS } from './utils';
8
9
 
9
10
  var logger = console_with_prefix('recorder');
10
11
  var CompressionStream = window['CompressionStream'];
@@ -32,29 +33,58 @@ function isUserEvent(ev) {
32
33
  return ev.type === EventType.IncrementalSnapshot && ACTIVE_SOURCES.has(ev.data.source);
33
34
  }
34
35
 
36
+ /**
37
+ * @typedef {Object} SerializedRecording
38
+ * @property {number} idleExpires
39
+ * @property {number} maxExpires
40
+ * @property {number} replayStartTime
41
+ * @property {number} seqNo
42
+ * @property {string} batchStartUrl
43
+ * @property {string} replayId
44
+ * @property {string} tabId
45
+ * @property {string} replayStartUrl
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} SessionRecordingOptions
50
+ * @property {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
51
+ * @property {String} [options.replayId] - unique uuid for a single replay
52
+ * @property {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
53
+ * @property {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
54
+ * @property {Function} [options.rrwebRecord] - rrweb's `record` function
55
+ * @property {Function} [options.onBatchSent] - callback when a batch of events is sent to the server
56
+ * @property {Storage} [options.sharedLockStorage] - optional storage for shared lock, used for test dependency injection
57
+ * optional properties for deserialization:
58
+ * @property {number} idleExpires
59
+ * @property {number} maxExpires
60
+ * @property {number} replayStartTime
61
+ * @property {number} seqNo
62
+ * @property {string} batchStartUrl
63
+ * @property {string} replayStartUrl
64
+ */
65
+
66
+
35
67
  /**
36
68
  * This class encapsulates a single session recording and its lifecycle.
37
- * @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
38
- * @param {String} [options.replayId] - unique uuid for a single replay
39
- * @param {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
40
- * @param {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
41
- * @param {Function} [options.rrwebRecord] - rrweb's `record` function
69
+ * @param {SessionRecordingOptions} options
42
70
  */
43
71
  var SessionRecording = function(options) {
44
72
  this._mixpanel = options.mixpanelInstance;
45
- this._onIdleTimeout = options.onIdleTimeout;
46
- this._onMaxLengthReached = options.onMaxLengthReached;
47
- this._rrwebRecord = options.rrwebRecord;
48
-
49
- this.replayId = options.replayId;
73
+ this._onIdleTimeout = options.onIdleTimeout || NOOP_FUNC;
74
+ this._onMaxLengthReached = options.onMaxLengthReached || NOOP_FUNC;
75
+ this._onBatchSent = options.onBatchSent || NOOP_FUNC;
76
+ this._rrwebRecord = options.rrwebRecord || null;
50
77
 
51
78
  // internal rrweb stopRecording function
52
79
  this._stopRecording = null;
80
+ this.replayId = options.replayId;
53
81
 
54
- this.seqNo = 0;
55
- this.replayStartTime = null;
56
- this.replayStartUrl = null;
57
- this.batchStartUrl = null;
82
+ this.batchStartUrl = options.batchStartUrl || null;
83
+ this.replayStartUrl = options.replayStartUrl || null;
84
+ this.idleExpires = options.idleExpires || null;
85
+ this.maxExpires = options.maxExpires || null;
86
+ this.replayStartTime = options.replayStartTime || null;
87
+ this.seqNo = options.seqNo || 0;
58
88
 
59
89
  this.idleTimeoutId = null;
60
90
  this.maxTimeoutId = null;
@@ -62,18 +92,40 @@ var SessionRecording = function(options) {
62
92
  this.recordMaxMs = MAX_RECORDING_MS;
63
93
  this.recordMinMs = 0;
64
94
 
95
+ // disable persistence if localStorage is not supported
96
+ // request-queue will automatically disable persistence if indexedDB fails to initialize
97
+ var usePersistence = localStorageSupported(options.sharedLockStorage, true);
98
+
65
99
  // each replay has its own batcher key to avoid conflicts between rrweb events of different recordings
66
100
  // this will be important when persistence is introduced
67
- var batcherKey = '__mprec_' + this.getConfig('token') + '_' + this.replayId;
68
- this.batcher = new RequestBatcher(batcherKey, {
69
- errorReporter: _.bind(this.reportError, this),
101
+ this.batcherKey = '__mprec_' + this.getConfig('name') + '_' + this.getConfig('token') + '_' + this.replayId;
102
+ this.queueStorage = new IDBStorageWrapper(RECORDING_EVENTS_STORE_NAME);
103
+ this.batcher = new RequestBatcher(this.batcherKey, {
104
+ errorReporter: this.reportError.bind(this),
70
105
  flushOnlyOnInterval: true,
71
106
  libConfig: RECORDER_BATCHER_LIB_CONFIG,
72
- sendRequestFunc: _.bind(this.flushEventsWithOptOut, this),
73
- usePersistence: false
107
+ sendRequestFunc: this.flushEventsWithOptOut.bind(this),
108
+ queueStorage: this.queueStorage,
109
+ sharedLockStorage: options.sharedLockStorage,
110
+ usePersistence: usePersistence,
111
+ stopAllBatchingFunc: this.stopRecording.bind(this),
112
+
113
+ // increased throttle and shared lock timeout because recording events are very high frequency.
114
+ // this will minimize the amount of lock contention between enqueued events.
115
+ // for session recordings there is a lock for each tab anyway, so there's no risk of deadlock between tabs.
116
+ enqueueThrottleMs: RECORD_ENQUEUE_THROTTLE_MS,
117
+ sharedLockTimeoutMS: 10 * 1000,
74
118
  });
75
119
  };
76
120
 
121
+ SessionRecording.prototype.unloadPersistedData = function () {
122
+ this.batcher.stop();
123
+ return this.batcher.flush()
124
+ .then(function () {
125
+ return this.queueStorage.removeItem(this.batcherKey);
126
+ }.bind(this));
127
+ };
128
+
77
129
  SessionRecording.prototype.getConfig = function(configVar) {
78
130
  return this._mixpanel.get_config(configVar);
79
131
  };
@@ -86,6 +138,11 @@ SessionRecording.prototype.get_config = function(configVar) {
86
138
  };
87
139
 
88
140
  SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
141
+ if (this._rrwebRecord === null) {
142
+ this.reportError('rrweb record function not provided. ');
143
+ return;
144
+ }
145
+
89
146
  if (this._stopRecording !== null) {
90
147
  logger.log('Recording already in progress, skipping startRecording.');
91
148
  return;
@@ -97,15 +154,21 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
97
154
  logger.critical('record_max_ms cannot be greater than ' + MAX_RECORDING_MS + 'ms. Capping value.');
98
155
  }
99
156
 
157
+ if (!this.maxExpires) {
158
+ this.maxExpires = new Date().getTime() + this.recordMaxMs;
159
+ }
160
+
100
161
  this.recordMinMs = this.getConfig('record_min_ms');
101
162
  if (this.recordMinMs > MAX_VALUE_FOR_MIN_RECORDING_MS) {
102
163
  this.recordMinMs = MAX_VALUE_FOR_MIN_RECORDING_MS;
103
164
  logger.critical('record_min_ms cannot be greater than ' + MAX_VALUE_FOR_MIN_RECORDING_MS + 'ms. Capping value.');
104
165
  }
105
166
 
106
- this.replayStartTime = new Date().getTime();
107
- this.batchStartUrl = _.info.currentUrl();
108
- this.replayStartUrl = _.info.currentUrl();
167
+ if (!this.replayStartTime) {
168
+ this.replayStartTime = new Date().getTime();
169
+ this.batchStartUrl = _.info.currentUrl();
170
+ this.replayStartUrl = _.info.currentUrl();
171
+ }
109
172
 
110
173
  if (shouldStopBatcher || this.recordMinMs > 0) {
111
174
  // the primary case for shouldStopBatcher is when we're starting recording after a reset
@@ -118,10 +181,12 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
118
181
  this.batcher.start();
119
182
  }
120
183
 
121
- var resetIdleTimeout = _.bind(function () {
184
+ var resetIdleTimeout = function () {
122
185
  clearTimeout(this.idleTimeoutId);
123
- this.idleTimeoutId = setTimeout(this._onIdleTimeout, this.getConfig('record_idle_timeout_ms'));
124
- }, this);
186
+ var idleTimeoutMs = this.getConfig('record_idle_timeout_ms');
187
+ this.idleTimeoutId = setTimeout(this._onIdleTimeout, idleTimeoutMs);
188
+ this.idleExpires = new Date().getTime() + idleTimeoutMs;
189
+ }.bind(this);
125
190
 
126
191
  var blockSelector = this.getConfig('record_block_selector');
127
192
  if (blockSelector === '' || blockSelector === null) {
@@ -129,8 +194,7 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
129
194
  }
130
195
 
131
196
  this._stopRecording = this._rrwebRecord({
132
- 'emit': _.bind(function (ev) {
133
- this.batcher.enqueue(ev);
197
+ 'emit': addOptOutCheckMixpanelLib(function (ev) {
134
198
  if (isUserEvent(ev)) {
135
199
  if (this.batcher.stopped && new Date().getTime() - this.replayStartTime >= this.recordMinMs) {
136
200
  // start flushing again after user activity
@@ -138,7 +202,10 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
138
202
  }
139
203
  resetIdleTimeout();
140
204
  }
141
- }, this),
205
+
206
+ // promise only used to await during tests
207
+ this.__enqueuePromise = this.batcher.enqueue(ev);
208
+ }.bind(this)),
142
209
  'blockClass': this.getConfig('record_block_class'),
143
210
  'blockSelector': blockSelector,
144
211
  'collectFonts': this.getConfig('record_collect_fonts'),
@@ -164,10 +231,11 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
164
231
 
165
232
  resetIdleTimeout();
166
233
 
167
- this.maxTimeoutId = setTimeout(_.bind(this._onMaxLengthReached, this), this.recordMaxMs);
234
+ var maxTimeoutMs = this.maxExpires - new Date().getTime();
235
+ this.maxTimeoutId = setTimeout(this._onMaxLengthReached.bind(this), maxTimeoutMs);
168
236
  };
169
237
 
170
- SessionRecording.prototype.stopRecording = function () {
238
+ SessionRecording.prototype.stopRecording = function (skipFlush) {
171
239
  if (!this.isRrwebStopped()) {
172
240
  try {
173
241
  this._stopRecording();
@@ -177,17 +245,19 @@ SessionRecording.prototype.stopRecording = function () {
177
245
  this._stopRecording = null;
178
246
  }
179
247
 
248
+ var flushPromise;
180
249
  if (this.batcher.stopped) {
181
250
  // never got user activity to flush after reset, so just clear the batcher
182
- this.batcher.clear();
183
- } else {
251
+ flushPromise = this.batcher.clear();
252
+ } else if (!skipFlush) {
184
253
  // flush any remaining events from running batcher
185
- this.batcher.flush();
186
- this.batcher.stop();
254
+ flushPromise = this.batcher.flush();
187
255
  }
256
+ this.batcher.stop();
188
257
 
189
258
  clearTimeout(this.idleTimeoutId);
190
259
  clearTimeout(this.maxTimeoutId);
260
+ return flushPromise;
191
261
  };
192
262
 
193
263
  SessionRecording.prototype.isRrwebStopped = function () {
@@ -199,7 +269,54 @@ SessionRecording.prototype.isRrwebStopped = function () {
199
269
  * we stop recording and dump any queued events if the user has opted out.
200
270
  */
201
271
  SessionRecording.prototype.flushEventsWithOptOut = function (data, options, cb) {
202
- this._flushEvents(data, options, cb, _.bind(this._onOptOut, this));
272
+ this._flushEvents(data, options, cb, this._onOptOut.bind(this));
273
+ };
274
+
275
+ /**
276
+ * @returns {SerializedRecording}
277
+ */
278
+ SessionRecording.prototype.serialize = function () {
279
+ // don't break if mixpanel instance was destroyed at some point
280
+ var tabId;
281
+ try {
282
+ tabId = this._mixpanel.get_tab_id();
283
+ } catch (e) {
284
+ this.reportError('Error getting tab ID for serialization ', e);
285
+ tabId = null;
286
+ }
287
+
288
+ return {
289
+ 'replayId': this.replayId,
290
+ 'seqNo': this.seqNo,
291
+ 'replayStartTime': this.replayStartTime,
292
+ 'batchStartUrl': this.batchStartUrl,
293
+ 'replayStartUrl': this.replayStartUrl,
294
+ 'idleExpires': this.idleExpires,
295
+ 'maxExpires': this.maxExpires,
296
+ 'tabId': tabId,
297
+ };
298
+ };
299
+
300
+
301
+ /**
302
+ * @static
303
+ * @param {SerializedRecording} serializedRecording
304
+ * @param {SessionRecordingOptions} options
305
+ * @returns {SessionRecording}
306
+ */
307
+ SessionRecording.deserialize = function (serializedRecording, options) {
308
+ var recording = new SessionRecording(_.extend({}, options, {
309
+ replayId: serializedRecording['replayId'],
310
+ batchStartUrl: serializedRecording['batchStartUrl'],
311
+ replayStartUrl: serializedRecording['replayStartUrl'],
312
+ idleExpires: serializedRecording['idleExpires'],
313
+ maxExpires: serializedRecording['maxExpires'],
314
+ replayStartTime: serializedRecording['replayStartTime'],
315
+ seqNo: serializedRecording['seqNo'],
316
+ sharedLockStorage: options.sharedLockStorage,
317
+ }));
318
+
319
+ return recording;
203
320
  };
204
321
 
205
322
  SessionRecording.prototype._onOptOut = function (code) {
@@ -210,7 +327,7 @@ SessionRecording.prototype._onOptOut = function (code) {
210
327
  };
211
328
 
212
329
  SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, reqBody, callback) {
213
- var onSuccess = _.bind(function (response, responseBody) {
330
+ var onSuccess = function (response, responseBody) {
214
331
  // Update batch specific props only if the request was successful to guarantee ordering.
215
332
  // RequestBatcher will always flush the next batch after the previous one succeeds.
216
333
  // extra check to see if the replay ID has changed so that we don't increment the seqNo on the wrong replay
@@ -218,13 +335,15 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
218
335
  this.seqNo++;
219
336
  this.batchStartUrl = _.info.currentUrl();
220
337
  }
338
+
339
+ this._onBatchSent();
221
340
  callback({
222
341
  status: 0,
223
342
  httpStatusCode: response.status,
224
343
  responseBody: responseBody,
225
344
  retryAfter: response.headers.get('Retry-After')
226
345
  });
227
- }, this);
346
+ }.bind(this);
228
347
 
229
348
  window['fetch'](this.getConfig('api_host') + '/' + this.getConfig('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
230
349
  'method': 'POST',
@@ -245,7 +364,7 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
245
364
  };
246
365
 
247
366
  SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (data, options, callback) {
248
- const numEvents = data.length;
367
+ var numEvents = data.length;
249
368
 
250
369
  if (numEvents > 0) {
251
370
  var replayId = this.replayId;
@@ -290,10 +409,10 @@ SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (da
290
409
  var gzipStream = jsonStream.pipeThrough(new CompressionStream('gzip'));
291
410
  new Response(gzipStream)
292
411
  .blob()
293
- .then(_.bind(function(compressedBlob) {
412
+ .then(function(compressedBlob) {
294
413
  reqParams['format'] = 'gzip';
295
414
  this._sendRequest(replayId, reqParams, compressedBlob, callback);
296
- }, this));
415
+ }.bind(this));
297
416
  } else {
298
417
  reqParams['format'] = 'body';
299
418
  this._sendRequest(replayId, reqParams, eventsJson, callback);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @param {import('./session-recording').SerializedRecording} serializedRecording
3
+ * @returns {boolean}
4
+ */
5
+ var isRecordingExpired = function(serializedRecording) {
6
+ var now = Date.now();
7
+ return !serializedRecording || now > serializedRecording['maxExpires'] || now > serializedRecording['idleExpires'];
8
+ };
9
+
10
+ var RECORD_ENQUEUE_THROTTLE_MS = 250;
11
+
12
+ export { isRecordingExpired, RECORD_ENQUEUE_THROTTLE_MS};