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/README.md +2 -2
- package/dist/mixpanel-core.cjs.js +398 -128
- package/dist/mixpanel-recorder.js +670 -224
- package/dist/mixpanel-recorder.min.js +11 -11
- package/dist/mixpanel-recorder.min.js.map +1 -1
- package/dist/mixpanel-with-async-recorder.cjs.js +398 -128
- package/dist/mixpanel.amd.js +786 -242
- package/dist/mixpanel.cjs.js +786 -242
- package/dist/mixpanel.globals.js +398 -128
- package/dist/mixpanel.min.js +143 -138
- package/dist/mixpanel.module.js +786 -242
- package/dist/mixpanel.umd.js +786 -242
- package/package.json +2 -1
- package/src/config.js +1 -1
- package/src/mixpanel-core.js +119 -19
- package/src/recorder/index.js +1 -70
- package/src/recorder/recorder.js +137 -0
- package/src/recorder/recording-registry.js +98 -0
- package/src/recorder/session-recording.js +162 -43
- package/src/recorder/utils.js +12 -0
- package/src/request-batcher.js +6 -2
- package/src/request-queue.js +45 -39
- package/src/shared-lock.js +1 -1
- package/src/storage/indexed-db.js +127 -0
- package/src/storage/local-storage.js +4 -8
- package/src/storage/wrapper.js +3 -3
- package/src/utils.js +99 -61
|
@@ -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 {
|
|
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.
|
|
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.
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
57
|
-
this.
|
|
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
|
-
|
|
68
|
-
this.
|
|
69
|
-
|
|
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:
|
|
73
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
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 =
|
|
184
|
+
var resetIdleTimeout = function () {
|
|
122
185
|
clearTimeout(this.idleTimeoutId);
|
|
123
|
-
|
|
124
|
-
|
|
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':
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
}
|
|
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
|
-
|
|
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(
|
|
412
|
+
.then(function(compressedBlob) {
|
|
294
413
|
reqParams['format'] = 'gzip';
|
|
295
414
|
this._sendRequest(replayId, reqParams, compressedBlob, callback);
|
|
296
|
-
}
|
|
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};
|
package/src/request-batcher.js
CHANGED
|
@@ -20,7 +20,9 @@ var RequestBatcher = function(storageKey, options) {
|
|
|
20
20
|
errorReporter: _.bind(this.reportError, this),
|
|
21
21
|
queueStorage: options.queueStorage,
|
|
22
22
|
sharedLockStorage: options.sharedLockStorage,
|
|
23
|
-
|
|
23
|
+
sharedLockTimeoutMS: options.sharedLockTimeoutMS,
|
|
24
|
+
usePersistence: options.usePersistence,
|
|
25
|
+
enqueueThrottleMs: options.enqueueThrottleMs
|
|
24
26
|
});
|
|
25
27
|
|
|
26
28
|
this.libConfig = options.libConfig;
|
|
@@ -42,6 +44,8 @@ var RequestBatcher = function(storageKey, options) {
|
|
|
42
44
|
// as long as the queue is not empty. This is useful for high-frequency events like Session Replay where we might end up
|
|
43
45
|
// in a request loop and get ratelimited by the server.
|
|
44
46
|
this.flushOnlyOnInterval = options.flushOnlyOnInterval || false;
|
|
47
|
+
|
|
48
|
+
this._flushPromise = null;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
/**
|
|
@@ -101,7 +105,7 @@ RequestBatcher.prototype.scheduleFlush = function(flushMS) {
|
|
|
101
105
|
if (!this.stopped) { // don't schedule anymore if batching has been stopped
|
|
102
106
|
this.timeoutID = setTimeout(_.bind(function() {
|
|
103
107
|
if (!this.stopped) {
|
|
104
|
-
this.flush();
|
|
108
|
+
this._flushPromise = this.flush();
|
|
105
109
|
}
|
|
106
110
|
}, this), this.flushInterval);
|
|
107
111
|
}
|
package/src/request-queue.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SharedLock } from './shared-lock';
|
|
2
|
-
import { cheap_guid, console_with_prefix, localStorageSupported,
|
|
2
|
+
import { batchedThrottle, cheap_guid, console_with_prefix, localStorageSupported, _ } from './utils'; // eslint-disable-line camelcase
|
|
3
|
+
import { window } from './window';
|
|
3
4
|
import { LocalStorageWrapper } from './storage/local-storage';
|
|
4
5
|
import { Promise } from './promise-polyfill';
|
|
5
6
|
|
|
@@ -27,8 +28,10 @@ var RequestQueue = function (storageKey, options) {
|
|
|
27
28
|
this.usePersistence = options.usePersistence;
|
|
28
29
|
if (this.usePersistence) {
|
|
29
30
|
this.queueStorage = options.queueStorage || new LocalStorageWrapper();
|
|
30
|
-
this.lock = new SharedLock(storageKey, {
|
|
31
|
-
|
|
31
|
+
this.lock = new SharedLock(storageKey, {
|
|
32
|
+
storage: options.sharedLockStorage || window.localStorage,
|
|
33
|
+
timeoutMS: options.sharedLockTimeoutMS,
|
|
34
|
+
});
|
|
32
35
|
}
|
|
33
36
|
this.reportError = options.errorReporter || _.bind(logger.error, logger);
|
|
34
37
|
|
|
@@ -36,6 +39,14 @@ var RequestQueue = function (storageKey, options) {
|
|
|
36
39
|
|
|
37
40
|
this.memQueue = [];
|
|
38
41
|
this.initialized = false;
|
|
42
|
+
|
|
43
|
+
if (options.enqueueThrottleMs) {
|
|
44
|
+
this.enqueuePersisted = batchedThrottle(_.bind(this._enqueuePersisted, this), options.enqueueThrottleMs);
|
|
45
|
+
} else {
|
|
46
|
+
this.enqueuePersisted = _.bind(function (queueEntry) {
|
|
47
|
+
return this._enqueuePersisted([queueEntry]);
|
|
48
|
+
}, this);
|
|
49
|
+
}
|
|
39
50
|
};
|
|
40
51
|
|
|
41
52
|
RequestQueue.prototype.ensureInit = function () {
|
|
@@ -78,36 +89,39 @@ RequestQueue.prototype.enqueue = function (item, flushInterval) {
|
|
|
78
89
|
this.memQueue.push(queueEntry);
|
|
79
90
|
return Promise.resolve(true);
|
|
80
91
|
} else {
|
|
92
|
+
return this.enqueuePersisted(queueEntry);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
81
95
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return succeeded;
|
|
97
|
-
}, this))
|
|
98
|
-
.catch(_.bind(function (err) {
|
|
99
|
-
this.reportError('Error enqueueing item', err, item);
|
|
100
|
-
return false;
|
|
101
|
-
}, this));
|
|
102
|
-
}, this);
|
|
96
|
+
RequestQueue.prototype._enqueuePersisted = function (queueEntries) {
|
|
97
|
+
var enqueueItem = _.bind(function () {
|
|
98
|
+
return this.ensureInit()
|
|
99
|
+
.then(_.bind(function () {
|
|
100
|
+
return this.readFromStorage();
|
|
101
|
+
}, this))
|
|
102
|
+
.then(_.bind(function (storedQueue) {
|
|
103
|
+
return this.saveToStorage(storedQueue.concat(queueEntries));
|
|
104
|
+
}, this))
|
|
105
|
+
.then(_.bind(function (succeeded) {
|
|
106
|
+
// only add to in-memory queue when storage succeeds
|
|
107
|
+
if (succeeded) {
|
|
108
|
+
this.memQueue = this.memQueue.concat(queueEntries);
|
|
109
|
+
}
|
|
103
110
|
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
return succeeded;
|
|
112
|
+
}, this))
|
|
106
113
|
.catch(_.bind(function (err) {
|
|
107
|
-
this.reportError('Error
|
|
114
|
+
this.reportError('Error enqueueing items', err, queueEntries);
|
|
108
115
|
return false;
|
|
109
116
|
}, this));
|
|
110
|
-
}
|
|
117
|
+
}, this);
|
|
118
|
+
|
|
119
|
+
return this.lock
|
|
120
|
+
.withLock(enqueueItem, this.pid)
|
|
121
|
+
.catch(_.bind(function (err) {
|
|
122
|
+
this.reportError('Error acquiring storage lock', err);
|
|
123
|
+
return false;
|
|
124
|
+
}, this));
|
|
111
125
|
};
|
|
112
126
|
|
|
113
127
|
/**
|
|
@@ -128,7 +142,7 @@ RequestQueue.prototype.fillBatch = function (batchSize) {
|
|
|
128
142
|
}, this))
|
|
129
143
|
.then(_.bind(function (storedQueue) {
|
|
130
144
|
if (storedQueue.length) {
|
|
131
|
-
|
|
145
|
+
// item IDs already in batch; don't duplicate out of storage
|
|
132
146
|
var idsInBatch = {}; // poor man's Set
|
|
133
147
|
_.each(batch, function (item) {
|
|
134
148
|
idsInBatch[item['id']] = true;
|
|
@@ -215,7 +229,7 @@ RequestQueue.prototype.removeItemsByID = function (ids) {
|
|
|
215
229
|
.withLock(removeFromStorage, this.pid)
|
|
216
230
|
.catch(_.bind(function (err) {
|
|
217
231
|
this.reportError('Error acquiring storage lock', err);
|
|
218
|
-
if (!localStorageSupported(this.
|
|
232
|
+
if (!localStorageSupported(this.lock.storage, true)) {
|
|
219
233
|
// Looks like localStorage writes have stopped working sometime after
|
|
220
234
|
// initialization (probably full), and so nobody can acquire locks
|
|
221
235
|
// anymore. Consider it temporarily safe to remove items without the
|
|
@@ -303,7 +317,6 @@ RequestQueue.prototype.readFromStorage = function () {
|
|
|
303
317
|
}, this))
|
|
304
318
|
.then(_.bind(function (storageEntry) {
|
|
305
319
|
if (storageEntry) {
|
|
306
|
-
storageEntry = JSONParse(storageEntry);
|
|
307
320
|
if (!_.isArray(storageEntry)) {
|
|
308
321
|
this.reportError('Invalid storage entry:', storageEntry);
|
|
309
322
|
storageEntry = null;
|
|
@@ -321,16 +334,9 @@ RequestQueue.prototype.readFromStorage = function () {
|
|
|
321
334
|
* Serialize the given items array to localStorage.
|
|
322
335
|
*/
|
|
323
336
|
RequestQueue.prototype.saveToStorage = function (queue) {
|
|
324
|
-
try {
|
|
325
|
-
var serialized = JSONStringify(queue);
|
|
326
|
-
} catch (err) {
|
|
327
|
-
this.reportError('Error serializing queue', err);
|
|
328
|
-
return Promise.resolve(false);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
337
|
return this.ensureInit()
|
|
332
338
|
.then(_.bind(function () {
|
|
333
|
-
return this.queueStorage.setItem(this.storageKey,
|
|
339
|
+
return this.queueStorage.setItem(this.storageKey, queue);
|
|
334
340
|
}, this))
|
|
335
341
|
.then(function () {
|
|
336
342
|
return true;
|
package/src/shared-lock.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Promise } from './promise-polyfill';
|
|
2
2
|
import { console_with_prefix, localStorageSupported, _ } from './utils'; // eslint-disable-line camelcase
|
|
3
|
+
import { window } from './window';
|
|
3
4
|
|
|
4
5
|
var logger = console_with_prefix('lock');
|
|
5
6
|
|
|
@@ -42,7 +43,6 @@ SharedLock.prototype.withLock = function(lockedCB, pid) {
|
|
|
42
43
|
return new Promise(_.bind(function (resolve, reject) {
|
|
43
44
|
var i = pid || (new Date().getTime() + '|' + Math.random());
|
|
44
45
|
var startTime = new Date().getTime();
|
|
45
|
-
|
|
46
46
|
var key = this.storageKey;
|
|
47
47
|
var pollIntervalMS = this.pollIntervalMS;
|
|
48
48
|
var timeoutMS = this.timeoutMS;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Promise } from '../promise-polyfill';
|
|
2
|
+
import { window } from '../window';
|
|
3
|
+
|
|
4
|
+
var MIXPANEL_DB_NAME = 'mixpanelBrowserDb';
|
|
5
|
+
|
|
6
|
+
var RECORDING_EVENTS_STORE_NAME = 'mixpanelRecordingEvents';
|
|
7
|
+
var RECORDING_REGISTRY_STORE_NAME = 'mixpanelRecordingRegistry';
|
|
8
|
+
|
|
9
|
+
// note: increment the version number when adding new object stores
|
|
10
|
+
var DB_VERSION = 1;
|
|
11
|
+
var OBJECT_STORES = [RECORDING_EVENTS_STORE_NAME, RECORDING_REGISTRY_STORE_NAME];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @type {import('./wrapper').StorageWrapper}
|
|
15
|
+
*/
|
|
16
|
+
var IDBStorageWrapper = function (storeName) {
|
|
17
|
+
/**
|
|
18
|
+
* @type {Promise<IDBDatabase>|null}
|
|
19
|
+
*/
|
|
20
|
+
this.dbPromise = null;
|
|
21
|
+
this.storeName = storeName;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
IDBStorageWrapper.prototype._openDb = function () {
|
|
25
|
+
return new Promise(function (resolve, reject) {
|
|
26
|
+
var openRequest = window.indexedDB.open(MIXPANEL_DB_NAME, DB_VERSION);
|
|
27
|
+
openRequest['onerror'] = function () {
|
|
28
|
+
reject(openRequest.error);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
openRequest['onsuccess'] = function () {
|
|
32
|
+
resolve(openRequest.result);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
openRequest['onupgradeneeded'] = function (ev) {
|
|
36
|
+
var db = ev.target.result;
|
|
37
|
+
|
|
38
|
+
OBJECT_STORES.forEach(function (storeName) {
|
|
39
|
+
db.createObjectStore(storeName);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
IDBStorageWrapper.prototype.init = function () {
|
|
46
|
+
if (!window.indexedDB) {
|
|
47
|
+
return Promise.reject('indexedDB is not supported in this browser');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!this.dbPromise) {
|
|
51
|
+
this.dbPromise = this._openDb();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return this.dbPromise
|
|
55
|
+
.then(function (dbOrError) {
|
|
56
|
+
if (dbOrError instanceof window['IDBDatabase']) {
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
} else {
|
|
59
|
+
return Promise.reject(dbOrError);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {IDBTransactionMode} mode
|
|
66
|
+
* @param {function(IDBObjectStore): void} storeCb
|
|
67
|
+
*/
|
|
68
|
+
IDBStorageWrapper.prototype.makeTransaction = function (mode, storeCb) {
|
|
69
|
+
var storeName = this.storeName;
|
|
70
|
+
var doTransaction = function (db) {
|
|
71
|
+
return new Promise(function (resolve, reject) {
|
|
72
|
+
var transaction = db.transaction(storeName, mode);
|
|
73
|
+
transaction.oncomplete = function () {
|
|
74
|
+
resolve(transaction);
|
|
75
|
+
};
|
|
76
|
+
transaction.onabort = transaction.onerror = function () {
|
|
77
|
+
reject(transaction.error);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
storeCb(transaction.objectStore(storeName));
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return this.dbPromise
|
|
85
|
+
.then(doTransaction)
|
|
86
|
+
.catch(function (err) {
|
|
87
|
+
if (err['name'] === 'InvalidStateError') {
|
|
88
|
+
// try reopening the DB if the connection is closed
|
|
89
|
+
this.dbPromise = this._openDb();
|
|
90
|
+
return this.dbPromise.then(doTransaction);
|
|
91
|
+
} else {
|
|
92
|
+
return Promise.reject(err);
|
|
93
|
+
}
|
|
94
|
+
}.bind(this));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
IDBStorageWrapper.prototype.setItem = function (key, value) {
|
|
98
|
+
return this.makeTransaction('readwrite', function (objectStore) {
|
|
99
|
+
objectStore.put(value, key);
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
IDBStorageWrapper.prototype.getItem = function (key) {
|
|
104
|
+
var req;
|
|
105
|
+
return this.makeTransaction('readonly', function (objectStore) {
|
|
106
|
+
req = objectStore.get(key);
|
|
107
|
+
}).then(function () {
|
|
108
|
+
return req.result;
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
IDBStorageWrapper.prototype.removeItem = function (key) {
|
|
113
|
+
return this.makeTransaction('readwrite', function (objectStore) {
|
|
114
|
+
objectStore.delete(key);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
IDBStorageWrapper.prototype.getAll = function () {
|
|
119
|
+
var req;
|
|
120
|
+
return this.makeTransaction('readonly', function (objectStore) {
|
|
121
|
+
req = objectStore.getAll();
|
|
122
|
+
}).then(function () {
|
|
123
|
+
return req.result;
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export { IDBStorageWrapper, RECORDING_EVENTS_STORE_NAME, RECORDING_REGISTRY_STORE_NAME };
|