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.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/dist/mixpanel-core.cjs.js +398 -128
- package/dist/mixpanel-recorder.js +721 -255
- 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 +837 -273
- package/dist/mixpanel.cjs.js +837 -273
- package/dist/mixpanel.globals.js +398 -128
- package/dist/mixpanel.min.js +143 -138
- package/dist/mixpanel.module.js +837 -273
- package/dist/mixpanel.umd.js +837 -273
- 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 +213 -74
- 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,42 +181,49 @@ 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) {
|
|
128
193
|
blockSelector = undefined;
|
|
129
194
|
}
|
|
130
195
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
196
|
+
try {
|
|
197
|
+
this._stopRecording = this._rrwebRecord({
|
|
198
|
+
'emit': function (ev) {
|
|
199
|
+
if (isUserEvent(ev)) {
|
|
200
|
+
if (this.batcher.stopped && new Date().getTime() - this.replayStartTime >= this.recordMinMs) {
|
|
201
|
+
// start flushing again after user activity
|
|
202
|
+
this.batcher.start();
|
|
203
|
+
}
|
|
204
|
+
resetIdleTimeout();
|
|
138
205
|
}
|
|
139
|
-
|
|
206
|
+
// promise only used to await during tests
|
|
207
|
+
this.__enqueuePromise = this.batcher.enqueue(ev);
|
|
208
|
+
}.bind(this),
|
|
209
|
+
'blockClass': this.getConfig('record_block_class'),
|
|
210
|
+
'blockSelector': blockSelector,
|
|
211
|
+
'collectFonts': this.getConfig('record_collect_fonts'),
|
|
212
|
+
'dataURLOptions': { // canvas image options (https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL)
|
|
213
|
+
'type': 'image/webp',
|
|
214
|
+
'quality': 0.6
|
|
215
|
+
},
|
|
216
|
+
'maskAllInputs': true,
|
|
217
|
+
'maskTextClass': this.getConfig('record_mask_text_class'),
|
|
218
|
+
'maskTextSelector': this.getConfig('record_mask_text_selector'),
|
|
219
|
+
'recordCanvas': this.getConfig('record_canvas'),
|
|
220
|
+
'sampling': {
|
|
221
|
+
'canvas': 15
|
|
140
222
|
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
'
|
|
144
|
-
|
|
145
|
-
'dataURLOptions': { // canvas image options (https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL)
|
|
146
|
-
'type': 'image/webp',
|
|
147
|
-
'quality': 0.6
|
|
148
|
-
},
|
|
149
|
-
'maskAllInputs': true,
|
|
150
|
-
'maskTextClass': this.getConfig('record_mask_text_class'),
|
|
151
|
-
'maskTextSelector': this.getConfig('record_mask_text_selector'),
|
|
152
|
-
'recordCanvas': this.getConfig('record_canvas'),
|
|
153
|
-
'sampling': {
|
|
154
|
-
'canvas': 15
|
|
155
|
-
}
|
|
156
|
-
});
|
|
223
|
+
});
|
|
224
|
+
} catch (err) {
|
|
225
|
+
this.reportError('Unexpected error when starting rrweb recording.', err);
|
|
226
|
+
}
|
|
157
227
|
|
|
158
228
|
if (typeof this._stopRecording !== 'function') {
|
|
159
229
|
this.reportError('rrweb failed to start, skipping this recording.');
|
|
@@ -164,10 +234,11 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
|
|
|
164
234
|
|
|
165
235
|
resetIdleTimeout();
|
|
166
236
|
|
|
167
|
-
|
|
237
|
+
var maxTimeoutMs = this.maxExpires - new Date().getTime();
|
|
238
|
+
this.maxTimeoutId = setTimeout(this._onMaxLengthReached.bind(this), maxTimeoutMs);
|
|
168
239
|
};
|
|
169
240
|
|
|
170
|
-
SessionRecording.prototype.stopRecording = function () {
|
|
241
|
+
SessionRecording.prototype.stopRecording = function (skipFlush) {
|
|
171
242
|
if (!this.isRrwebStopped()) {
|
|
172
243
|
try {
|
|
173
244
|
this._stopRecording();
|
|
@@ -177,40 +248,91 @@ SessionRecording.prototype.stopRecording = function () {
|
|
|
177
248
|
this._stopRecording = null;
|
|
178
249
|
}
|
|
179
250
|
|
|
251
|
+
var flushPromise;
|
|
180
252
|
if (this.batcher.stopped) {
|
|
181
253
|
// never got user activity to flush after reset, so just clear the batcher
|
|
182
|
-
this.batcher.clear();
|
|
183
|
-
} else {
|
|
254
|
+
flushPromise = this.batcher.clear();
|
|
255
|
+
} else if (!skipFlush) {
|
|
184
256
|
// flush any remaining events from running batcher
|
|
185
|
-
this.batcher.flush();
|
|
186
|
-
this.batcher.stop();
|
|
257
|
+
flushPromise = this.batcher.flush();
|
|
187
258
|
}
|
|
259
|
+
this.batcher.stop();
|
|
188
260
|
|
|
189
261
|
clearTimeout(this.idleTimeoutId);
|
|
190
262
|
clearTimeout(this.maxTimeoutId);
|
|
263
|
+
return flushPromise;
|
|
191
264
|
};
|
|
192
265
|
|
|
193
266
|
SessionRecording.prototype.isRrwebStopped = function () {
|
|
194
267
|
return this._stopRecording === null;
|
|
195
268
|
};
|
|
196
269
|
|
|
270
|
+
|
|
197
271
|
/**
|
|
198
272
|
* Flushes the current batch of events to the server, but passes an opt-out callback to make sure
|
|
199
273
|
* we stop recording and dump any queued events if the user has opted out.
|
|
200
274
|
*/
|
|
201
275
|
SessionRecording.prototype.flushEventsWithOptOut = function (data, options, cb) {
|
|
202
|
-
|
|
276
|
+
var onOptOut = function (code) {
|
|
277
|
+
// addOptOutCheckMixpanelLib invokes this function with code=0 when the user has opted out
|
|
278
|
+
if (code === 0) {
|
|
279
|
+
this.stopRecording();
|
|
280
|
+
cb({error: 'Tracking has been opted out, stopping recording.'});
|
|
281
|
+
}
|
|
282
|
+
}.bind(this);
|
|
283
|
+
|
|
284
|
+
this._flushEvents(data, options, cb, onOptOut);
|
|
203
285
|
};
|
|
204
286
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
287
|
+
/**
|
|
288
|
+
* @returns {SerializedRecording}
|
|
289
|
+
*/
|
|
290
|
+
SessionRecording.prototype.serialize = function () {
|
|
291
|
+
// don't break if mixpanel instance was destroyed at some point
|
|
292
|
+
var tabId;
|
|
293
|
+
try {
|
|
294
|
+
tabId = this._mixpanel.get_tab_id();
|
|
295
|
+
} catch (e) {
|
|
296
|
+
this.reportError('Error getting tab ID for serialization ', e);
|
|
297
|
+
tabId = null;
|
|
209
298
|
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
'replayId': this.replayId,
|
|
302
|
+
'seqNo': this.seqNo,
|
|
303
|
+
'replayStartTime': this.replayStartTime,
|
|
304
|
+
'batchStartUrl': this.batchStartUrl,
|
|
305
|
+
'replayStartUrl': this.replayStartUrl,
|
|
306
|
+
'idleExpires': this.idleExpires,
|
|
307
|
+
'maxExpires': this.maxExpires,
|
|
308
|
+
'tabId': tabId,
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @static
|
|
315
|
+
* @param {SerializedRecording} serializedRecording
|
|
316
|
+
* @param {SessionRecordingOptions} options
|
|
317
|
+
* @returns {SessionRecording}
|
|
318
|
+
*/
|
|
319
|
+
SessionRecording.deserialize = function (serializedRecording, options) {
|
|
320
|
+
var recording = new SessionRecording(_.extend({}, options, {
|
|
321
|
+
replayId: serializedRecording['replayId'],
|
|
322
|
+
batchStartUrl: serializedRecording['batchStartUrl'],
|
|
323
|
+
replayStartUrl: serializedRecording['replayStartUrl'],
|
|
324
|
+
idleExpires: serializedRecording['idleExpires'],
|
|
325
|
+
maxExpires: serializedRecording['maxExpires'],
|
|
326
|
+
replayStartTime: serializedRecording['replayStartTime'],
|
|
327
|
+
seqNo: serializedRecording['seqNo'],
|
|
328
|
+
sharedLockStorage: options.sharedLockStorage,
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
return recording;
|
|
210
332
|
};
|
|
211
333
|
|
|
212
334
|
SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, reqBody, callback) {
|
|
213
|
-
var onSuccess =
|
|
335
|
+
var onSuccess = function (response, responseBody) {
|
|
214
336
|
// Update batch specific props only if the request was successful to guarantee ordering.
|
|
215
337
|
// RequestBatcher will always flush the next batch after the previous one succeeds.
|
|
216
338
|
// extra check to see if the replay ID has changed so that we don't increment the seqNo on the wrong replay
|
|
@@ -218,13 +340,15 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
|
|
|
218
340
|
this.seqNo++;
|
|
219
341
|
this.batchStartUrl = _.info.currentUrl();
|
|
220
342
|
}
|
|
343
|
+
|
|
344
|
+
this._onBatchSent();
|
|
221
345
|
callback({
|
|
222
346
|
status: 0,
|
|
223
347
|
httpStatusCode: response.status,
|
|
224
348
|
responseBody: responseBody,
|
|
225
349
|
retryAfter: response.headers.get('Retry-After')
|
|
226
350
|
});
|
|
227
|
-
}
|
|
351
|
+
}.bind(this);
|
|
228
352
|
|
|
229
353
|
window['fetch'](this.getConfig('api_host') + '/' + this.getConfig('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
|
|
230
354
|
'method': 'POST',
|
|
@@ -245,21 +369,36 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
|
|
|
245
369
|
};
|
|
246
370
|
|
|
247
371
|
SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (data, options, callback) {
|
|
248
|
-
|
|
372
|
+
var numEvents = data.length;
|
|
249
373
|
|
|
250
374
|
if (numEvents > 0) {
|
|
251
375
|
var replayId = this.replayId;
|
|
376
|
+
|
|
252
377
|
// each rrweb event has a timestamp - leverage those to get time properties
|
|
253
|
-
var batchStartTime =
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
378
|
+
var batchStartTime = Infinity;
|
|
379
|
+
var batchEndTime = -Infinity;
|
|
380
|
+
var hasFullSnapshot = false;
|
|
381
|
+
for (var i = 0; i < numEvents; i++) {
|
|
382
|
+
batchStartTime = Math.min(batchStartTime, data[i].timestamp);
|
|
383
|
+
batchEndTime = Math.max(batchEndTime, data[i].timestamp);
|
|
384
|
+
if (data[i].type === EventType.FullSnapshot) {
|
|
385
|
+
hasFullSnapshot = true;
|
|
258
386
|
}
|
|
387
|
+
}
|
|
259
388
|
|
|
389
|
+
if (this.seqNo === 0) {
|
|
390
|
+
if (!hasFullSnapshot) {
|
|
391
|
+
callback({error: 'First batch does not contain a full snapshot. Aborting recording.'});
|
|
392
|
+
this.stopRecording(true);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
this.replayStartTime = batchStartTime;
|
|
396
|
+
} else if (!this.replayStartTime) {
|
|
397
|
+
this.reportError('Replay start time not set but seqNo is not 0. Using current batch start time as a fallback.');
|
|
260
398
|
this.replayStartTime = batchStartTime;
|
|
261
399
|
}
|
|
262
|
-
|
|
400
|
+
|
|
401
|
+
var replayLengthMs = batchEndTime - this.replayStartTime;
|
|
263
402
|
|
|
264
403
|
var reqParams = {
|
|
265
404
|
'$current_url': this.batchStartUrl,
|
|
@@ -290,10 +429,10 @@ SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (da
|
|
|
290
429
|
var gzipStream = jsonStream.pipeThrough(new CompressionStream('gzip'));
|
|
291
430
|
new Response(gzipStream)
|
|
292
431
|
.blob()
|
|
293
|
-
.then(
|
|
432
|
+
.then(function(compressedBlob) {
|
|
294
433
|
reqParams['format'] = 'gzip';
|
|
295
434
|
this._sendRequest(replayId, reqParams, compressedBlob, callback);
|
|
296
|
-
}
|
|
435
|
+
}.bind(this));
|
|
297
436
|
} else {
|
|
298
437
|
reqParams['format'] = 'body';
|
|
299
438
|
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;
|