mixpanel-browser 2.55.1 → 2.56.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/.eslintrc.json +31 -1
- package/CHANGELOG.md +8 -0
- package/dist/mixpanel-core.cjs.js +32 -10
- package/dist/mixpanel-recorder.js +162 -74
- package/dist/mixpanel-recorder.min.js +8 -7
- package/dist/mixpanel-recorder.min.js.map +1 -0
- package/dist/mixpanel-with-async-recorder.cjs.js +32 -10
- package/dist/mixpanel.amd.js +188 -80
- package/dist/mixpanel.cjs.js +188 -80
- package/dist/mixpanel.globals.js +32 -10
- package/dist/mixpanel.min.js +106 -105
- package/dist/mixpanel.min.js.map +8 -0
- package/dist/mixpanel.module.js +188 -80
- package/dist/mixpanel.umd.js +188 -80
- package/package.json +1 -1
- package/src/config.js +1 -1
- package/src/mixpanel-core.js +26 -6
- package/src/recorder/index.js +39 -252
- package/src/recorder/rollup.config.js +2 -1
- package/src/recorder/session-recording.js +305 -0
- package/src/request-queue.js +5 -3
package/src/recorder/index.js
CHANGED
|
@@ -1,143 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { IncrementalSource, EventType } from '@rrweb/types';
|
|
1
|
+
import {record} from 'rrweb';
|
|
3
2
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { RequestBatcher } from '../request-batcher';
|
|
3
|
+
import { SessionRecording } from './session-recording';
|
|
4
|
+
import { console_with_prefix, _, window} from '../utils'; // eslint-disable-line camelcase
|
|
7
5
|
|
|
8
6
|
var logger = console_with_prefix('recorder');
|
|
9
|
-
var CompressionStream = window['CompressionStream'];
|
|
10
|
-
|
|
11
|
-
var RECORDER_BATCHER_LIB_CONFIG = {
|
|
12
|
-
'batch_size': 1000,
|
|
13
|
-
'batch_flush_interval_ms': 10 * 1000,
|
|
14
|
-
'batch_request_timeout_ms': 90 * 1000,
|
|
15
|
-
'batch_autostart': true
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
var ACTIVE_SOURCES = new Set([
|
|
19
|
-
IncrementalSource.MouseMove,
|
|
20
|
-
IncrementalSource.MouseInteraction,
|
|
21
|
-
IncrementalSource.Scroll,
|
|
22
|
-
IncrementalSource.ViewportResize,
|
|
23
|
-
IncrementalSource.Input,
|
|
24
|
-
IncrementalSource.TouchMove,
|
|
25
|
-
IncrementalSource.MediaInteraction,
|
|
26
|
-
IncrementalSource.Drag,
|
|
27
|
-
IncrementalSource.Selection,
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
function isUserEvent(ev) {
|
|
31
|
-
return ev.type === EventType.IncrementalSnapshot && ACTIVE_SOURCES.has(ev.data.source);
|
|
32
|
-
}
|
|
33
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Recorder API: manages recordings and exposes methods public to the core Mixpanel library.
|
|
10
|
+
* @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
|
|
11
|
+
*/
|
|
34
12
|
var MixpanelRecorder = function(mixpanelInstance) {
|
|
35
13
|
this._mixpanel = mixpanelInstance;
|
|
36
|
-
|
|
37
|
-
// internal rrweb stopRecording function
|
|
38
|
-
this._stopRecording = null;
|
|
39
|
-
|
|
40
|
-
this.recEvents = [];
|
|
41
|
-
this.seqNo = 0;
|
|
42
|
-
this.replayId = null;
|
|
43
|
-
this.replayStartTime = null;
|
|
44
|
-
this.sendBatchId = null;
|
|
45
|
-
|
|
46
|
-
this.idleTimeoutId = null;
|
|
47
|
-
this.maxTimeoutId = null;
|
|
48
|
-
|
|
49
|
-
this.recordMaxMs = MAX_RECORDING_MS;
|
|
50
|
-
this.recordMinMs = 0;
|
|
51
|
-
this._initBatcher();
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
MixpanelRecorder.prototype._initBatcher = function () {
|
|
56
|
-
this.batcher = new RequestBatcher('__mprec', {
|
|
57
|
-
libConfig: RECORDER_BATCHER_LIB_CONFIG,
|
|
58
|
-
sendRequestFunc: _.bind(this.flushEventsWithOptOut, this),
|
|
59
|
-
errorReporter: _.bind(this.reportError, this),
|
|
60
|
-
flushOnlyOnInterval: true,
|
|
61
|
-
usePersistence: false
|
|
62
|
-
});
|
|
14
|
+
this.activeRecording = null;
|
|
63
15
|
};
|
|
64
16
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return this._mixpanel.get_config(configVar);
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
MixpanelRecorder.prototype.startRecording = function (shouldStopBatcher) {
|
|
71
|
-
if (this._stopRecording !== null) {
|
|
17
|
+
MixpanelRecorder.prototype.startRecording = function(shouldStopBatcher) {
|
|
18
|
+
if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
|
|
72
19
|
logger.log('Recording already in progress, skipping startRecording.');
|
|
73
20
|
return;
|
|
74
21
|
}
|
|
75
22
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
logger.critical('record_max_ms cannot be greater than ' + MAX_RECORDING_MS + 'ms. Capping value.');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
this.recordMinMs = this.get_config('record_min_ms');
|
|
83
|
-
if (this.recordMinMs > MAX_VALUE_FOR_MIN_RECORDING_MS) {
|
|
84
|
-
this.recordMinMs = MAX_VALUE_FOR_MIN_RECORDING_MS;
|
|
85
|
-
logger.critical('record_min_ms cannot be greater than ' + MAX_VALUE_FOR_MIN_RECORDING_MS + 'ms. Capping value.');
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
this.recEvents = [];
|
|
89
|
-
this.seqNo = 0;
|
|
90
|
-
this.replayStartTime = new Date().getTime();
|
|
91
|
-
|
|
92
|
-
this.replayId = _.UUID();
|
|
93
|
-
|
|
94
|
-
if (shouldStopBatcher || this.recordMinMs > 0) {
|
|
95
|
-
// the primary case for shouldStopBatcher is when we're starting recording after a reset
|
|
96
|
-
// and don't want to send anything over the network until there's
|
|
97
|
-
// actual user activity
|
|
98
|
-
// this also applies if the minimum recording length has not been hit yet
|
|
99
|
-
// so that we don't send data until we know the recording will be long enough
|
|
100
|
-
this.batcher.stop();
|
|
101
|
-
} else {
|
|
102
|
-
this.batcher.start();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
var resetIdleTimeout = _.bind(function () {
|
|
106
|
-
clearTimeout(this.idleTimeoutId);
|
|
107
|
-
this.idleTimeoutId = setTimeout(_.bind(function () {
|
|
108
|
-
logger.log('Idle timeout reached, restarting recording.');
|
|
109
|
-
this.resetRecording();
|
|
110
|
-
}, this), this.get_config('record_idle_timeout_ms'));
|
|
23
|
+
var onIdleTimeout = _.bind(function () {
|
|
24
|
+
logger.log('Idle timeout reached, restarting recording.');
|
|
25
|
+
this.resetRecording();
|
|
111
26
|
}, this);
|
|
112
27
|
|
|
113
|
-
var
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
28
|
+
var onMaxLengthReached = _.bind(function () {
|
|
29
|
+
logger.log('Max recording length reached, stopping recording.');
|
|
30
|
+
this.resetRecording();
|
|
31
|
+
}, this);
|
|
117
32
|
|
|
118
|
-
this.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
this.batcher.start();
|
|
125
|
-
}
|
|
126
|
-
resetIdleTimeout();
|
|
127
|
-
}
|
|
128
|
-
}, this),
|
|
129
|
-
'blockClass': this.get_config('record_block_class'),
|
|
130
|
-
'blockSelector': blockSelector,
|
|
131
|
-
'collectFonts': this.get_config('record_collect_fonts'),
|
|
132
|
-
'inlineImages': this.get_config('record_inline_images'),
|
|
133
|
-
'maskAllInputs': true,
|
|
134
|
-
'maskTextClass': this.get_config('record_mask_text_class'),
|
|
135
|
-
'maskTextSelector': this.get_config('record_mask_text_selector')
|
|
33
|
+
this.activeRecording = new SessionRecording({
|
|
34
|
+
mixpanelInstance: this._mixpanel,
|
|
35
|
+
onIdleTimeout: onIdleTimeout,
|
|
36
|
+
onMaxLengthReached: onMaxLengthReached,
|
|
37
|
+
replayId: _.UUID(),
|
|
38
|
+
rrwebRecord: record
|
|
136
39
|
});
|
|
137
40
|
|
|
138
|
-
|
|
41
|
+
this.activeRecording.startRecording(shouldStopBatcher);
|
|
42
|
+
};
|
|
139
43
|
|
|
140
|
-
|
|
44
|
+
MixpanelRecorder.prototype.stopRecording = function() {
|
|
45
|
+
if (this.activeRecording) {
|
|
46
|
+
this.activeRecording.stopRecording();
|
|
47
|
+
this.activeRecording = null;
|
|
48
|
+
}
|
|
141
49
|
};
|
|
142
50
|
|
|
143
51
|
MixpanelRecorder.prototype.resetRecording = function () {
|
|
@@ -145,141 +53,20 @@ MixpanelRecorder.prototype.resetRecording = function () {
|
|
|
145
53
|
this.startRecording(true);
|
|
146
54
|
};
|
|
147
55
|
|
|
148
|
-
MixpanelRecorder.prototype.
|
|
149
|
-
if (this.
|
|
150
|
-
this.
|
|
151
|
-
this._stopRecording = null;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (this.batcher.stopped) {
|
|
155
|
-
// never got user activity to flush after reset, so just clear the batcher
|
|
156
|
-
this.batcher.clear();
|
|
56
|
+
MixpanelRecorder.prototype.getActiveReplayId = function () {
|
|
57
|
+
if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
|
|
58
|
+
return this.activeRecording.replayId;
|
|
157
59
|
} else {
|
|
158
|
-
|
|
159
|
-
this.batcher.flush();
|
|
160
|
-
this.batcher.stop();
|
|
60
|
+
return null;
|
|
161
61
|
}
|
|
162
|
-
this.replayId = null;
|
|
163
|
-
|
|
164
|
-
clearTimeout(this.idleTimeoutId);
|
|
165
|
-
clearTimeout(this.maxTimeoutId);
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Flushes the current batch of events to the server, but passes an opt-out callback to make sure
|
|
170
|
-
* we stop recording and dump any queued events if the user has opted out.
|
|
171
|
-
*/
|
|
172
|
-
MixpanelRecorder.prototype.flushEventsWithOptOut = function (data, options, cb) {
|
|
173
|
-
this._flushEvents(data, options, cb, _.bind(this._onOptOut, this));
|
|
174
62
|
};
|
|
175
63
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.
|
|
181
|
-
}
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
MixpanelRecorder.prototype._sendRequest = function(currentReplayId, reqParams, reqBody, callback) {
|
|
185
|
-
var onSuccess = _.bind(function (response, responseBody) {
|
|
186
|
-
// Increment sequence counter only if the request was successful to guarantee ordering.
|
|
187
|
-
// RequestBatcher will always flush the next batch after the previous one succeeds.
|
|
188
|
-
// extra check to see if the replay ID has changed so that we don't increment the seqNo on the wrong replay
|
|
189
|
-
if (response.status === 200 && this.replayId === currentReplayId) {
|
|
190
|
-
this.seqNo++;
|
|
191
|
-
}
|
|
192
|
-
callback({
|
|
193
|
-
status: 0,
|
|
194
|
-
httpStatusCode: response.status,
|
|
195
|
-
responseBody: responseBody,
|
|
196
|
-
retryAfter: response.headers.get('Retry-After')
|
|
197
|
-
});
|
|
198
|
-
}, this);
|
|
199
|
-
|
|
200
|
-
window['fetch'](this.get_config('api_host') + '/' + this.get_config('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
|
|
201
|
-
'method': 'POST',
|
|
202
|
-
'headers': {
|
|
203
|
-
'Authorization': 'Basic ' + btoa(this.get_config('token') + ':'),
|
|
204
|
-
'Content-Type': 'application/octet-stream'
|
|
205
|
-
},
|
|
206
|
-
'body': reqBody,
|
|
207
|
-
}).then(function (response) {
|
|
208
|
-
response.json().then(function (responseBody) {
|
|
209
|
-
onSuccess(response, responseBody);
|
|
210
|
-
}).catch(function (error) {
|
|
211
|
-
callback({error: error});
|
|
212
|
-
});
|
|
213
|
-
}).catch(function (error) {
|
|
214
|
-
callback({error: error, httpStatusCode: 0});
|
|
215
|
-
});
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
MixpanelRecorder.prototype._flushEvents = addOptOutCheckMixpanelLib(function (data, options, callback) {
|
|
219
|
-
const numEvents = data.length;
|
|
220
|
-
|
|
221
|
-
if (numEvents > 0) {
|
|
222
|
-
var replayId = this.replayId;
|
|
223
|
-
// each rrweb event has a timestamp - leverage those to get time properties
|
|
224
|
-
var batchStartTime = data[0].timestamp;
|
|
225
|
-
if (this.seqNo === 0 || !this.replayStartTime) {
|
|
226
|
-
// extra safety net so that we don't send a null replay start time
|
|
227
|
-
if (this.seqNo !== 0) {
|
|
228
|
-
this.reportError('Replay start time not set but seqNo is not 0. Using current batch start time as a fallback.');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
this.replayStartTime = batchStartTime;
|
|
232
|
-
}
|
|
233
|
-
var replayLengthMs = data[numEvents - 1].timestamp - this.replayStartTime;
|
|
234
|
-
|
|
235
|
-
var reqParams = {
|
|
236
|
-
'distinct_id': String(this._mixpanel.get_distinct_id()),
|
|
237
|
-
'seq': this.seqNo,
|
|
238
|
-
'batch_start_time': batchStartTime / 1000,
|
|
239
|
-
'replay_id': replayId,
|
|
240
|
-
'replay_length_ms': replayLengthMs,
|
|
241
|
-
'replay_start_time': this.replayStartTime / 1000
|
|
242
|
-
};
|
|
243
|
-
var eventsJson = _.JSONEncode(data);
|
|
244
|
-
|
|
245
|
-
// send ID management props if they exist
|
|
246
|
-
var deviceId = this._mixpanel.get_property('$device_id');
|
|
247
|
-
if (deviceId) {
|
|
248
|
-
reqParams['$device_id'] = deviceId;
|
|
249
|
-
}
|
|
250
|
-
var userId = this._mixpanel.get_property('$user_id');
|
|
251
|
-
if (userId) {
|
|
252
|
-
reqParams['$user_id'] = userId;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (CompressionStream) {
|
|
256
|
-
var jsonStream = new Blob([eventsJson], {type: 'application/json'}).stream();
|
|
257
|
-
var gzipStream = jsonStream.pipeThrough(new CompressionStream('gzip'));
|
|
258
|
-
new Response(gzipStream)
|
|
259
|
-
.blob()
|
|
260
|
-
.then(_.bind(function(compressedBlob) {
|
|
261
|
-
reqParams['format'] = 'gzip';
|
|
262
|
-
this._sendRequest(replayId, reqParams, compressedBlob, callback);
|
|
263
|
-
}, this));
|
|
264
|
-
} else {
|
|
265
|
-
reqParams['format'] = 'body';
|
|
266
|
-
this._sendRequest(replayId, reqParams, eventsJson, callback);
|
|
267
|
-
}
|
|
64
|
+
// getter so that older mixpanel-core versions can still retrieve the replay ID
|
|
65
|
+
// when pulling the latest recorder bundle from the CDN
|
|
66
|
+
Object.defineProperty(MixpanelRecorder.prototype, 'replayId', {
|
|
67
|
+
get: function () {
|
|
68
|
+
return this.getActiveReplayId();
|
|
268
69
|
}
|
|
269
70
|
});
|
|
270
71
|
|
|
271
|
-
|
|
272
|
-
MixpanelRecorder.prototype.reportError = function(msg, err) {
|
|
273
|
-
logger.error.apply(logger.error, arguments);
|
|
274
|
-
try {
|
|
275
|
-
if (!err && !(msg instanceof Error)) {
|
|
276
|
-
msg = new Error(msg);
|
|
277
|
-
}
|
|
278
|
-
this.get_config('error_reporter')(msg, err);
|
|
279
|
-
} catch(err) {
|
|
280
|
-
logger.error(err);
|
|
281
|
-
}
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
|
|
285
72
|
window['__mp_recorder'] = MixpanelRecorder;
|
|
@@ -12,7 +12,8 @@ export default {
|
|
|
12
12
|
file: 'build/mixpanel-recorder.min.js',
|
|
13
13
|
format: 'esm',
|
|
14
14
|
name: 'version',
|
|
15
|
-
plugins: [esbuild({minify: true})]
|
|
15
|
+
plugins: [esbuild({minify: true, sourceMap: true})],
|
|
16
|
+
sourcemap: true,
|
|
16
17
|
}
|
|
17
18
|
],
|
|
18
19
|
plugins: [nodeResolve({browser: true})],
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { IncrementalSource, EventType } from '@rrweb/types';
|
|
2
|
+
|
|
3
|
+
import { MAX_RECORDING_MS, MAX_VALUE_FOR_MIN_RECORDING_MS, console_with_prefix, _, window} from '../utils'; // eslint-disable-line camelcase
|
|
4
|
+
import { addOptOutCheckMixpanelLib } from '../gdpr-utils';
|
|
5
|
+
import { RequestBatcher } from '../request-batcher';
|
|
6
|
+
import Config from '../config';
|
|
7
|
+
|
|
8
|
+
var logger = console_with_prefix('recorder');
|
|
9
|
+
var CompressionStream = window['CompressionStream'];
|
|
10
|
+
|
|
11
|
+
var RECORDER_BATCHER_LIB_CONFIG = {
|
|
12
|
+
'batch_size': 1000,
|
|
13
|
+
'batch_flush_interval_ms': 10 * 1000,
|
|
14
|
+
'batch_request_timeout_ms': 90 * 1000,
|
|
15
|
+
'batch_autostart': true
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
var ACTIVE_SOURCES = new Set([
|
|
19
|
+
IncrementalSource.MouseMove,
|
|
20
|
+
IncrementalSource.MouseInteraction,
|
|
21
|
+
IncrementalSource.Scroll,
|
|
22
|
+
IncrementalSource.ViewportResize,
|
|
23
|
+
IncrementalSource.Input,
|
|
24
|
+
IncrementalSource.TouchMove,
|
|
25
|
+
IncrementalSource.MediaInteraction,
|
|
26
|
+
IncrementalSource.Drag,
|
|
27
|
+
IncrementalSource.Selection,
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function isUserEvent(ev) {
|
|
31
|
+
return ev.type === EventType.IncrementalSnapshot && ACTIVE_SOURCES.has(ev.data.source);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* This class encapsulates a single session recording and its lifecycle.
|
|
36
|
+
* @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
|
|
37
|
+
* @param {String} [options.replayId] - unique uuid for a single replay
|
|
38
|
+
* @param {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
|
|
39
|
+
* @param {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
|
|
40
|
+
* @param {Function} [options.rrwebRecord] - rrweb's `record` function
|
|
41
|
+
*/
|
|
42
|
+
var SessionRecording = function(options) {
|
|
43
|
+
this._mixpanel = options.mixpanelInstance;
|
|
44
|
+
this._onIdleTimeout = options.onIdleTimeout;
|
|
45
|
+
this._onMaxLengthReached = options.onMaxLengthReached;
|
|
46
|
+
this._rrwebRecord = options.rrwebRecord;
|
|
47
|
+
|
|
48
|
+
this.replayId = options.replayId;
|
|
49
|
+
|
|
50
|
+
// internal rrweb stopRecording function
|
|
51
|
+
this._stopRecording = null;
|
|
52
|
+
|
|
53
|
+
this.seqNo = 0;
|
|
54
|
+
this.replayStartTime = null;
|
|
55
|
+
this.batchStartUrl = null;
|
|
56
|
+
|
|
57
|
+
this.idleTimeoutId = null;
|
|
58
|
+
this.maxTimeoutId = null;
|
|
59
|
+
|
|
60
|
+
this.recordMaxMs = MAX_RECORDING_MS;
|
|
61
|
+
this.recordMinMs = 0;
|
|
62
|
+
|
|
63
|
+
// each replay has its own batcher key to avoid conflicts between rrweb events of different recordings
|
|
64
|
+
// this will be important when persistence is introduced
|
|
65
|
+
var batcherKey = '__mprec_' + this.getConfig('token') + '_' + this.replayId;
|
|
66
|
+
this.batcher = new RequestBatcher(batcherKey, {
|
|
67
|
+
errorReporter: _.bind(this.reportError, this),
|
|
68
|
+
flushOnlyOnInterval: true,
|
|
69
|
+
libConfig: RECORDER_BATCHER_LIB_CONFIG,
|
|
70
|
+
sendRequestFunc: _.bind(this.flushEventsWithOptOut, this),
|
|
71
|
+
usePersistence: false
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
SessionRecording.prototype.getConfig = function(configVar) {
|
|
76
|
+
return this._mixpanel.get_config(configVar);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Alias for getConfig, used by the common addOptOutCheckMixpanelLib function which
|
|
80
|
+
// reaches into this class instance and expects the snake case version of the function.
|
|
81
|
+
// eslint-disable-next-line camelcase
|
|
82
|
+
SessionRecording.prototype.get_config = function(configVar) {
|
|
83
|
+
return this.getConfig(configVar);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
|
|
87
|
+
if (this._stopRecording !== null) {
|
|
88
|
+
logger.log('Recording already in progress, skipping startRecording.');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.recordMaxMs = this.getConfig('record_max_ms');
|
|
93
|
+
if (this.recordMaxMs > MAX_RECORDING_MS) {
|
|
94
|
+
this.recordMaxMs = MAX_RECORDING_MS;
|
|
95
|
+
logger.critical('record_max_ms cannot be greater than ' + MAX_RECORDING_MS + 'ms. Capping value.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.recordMinMs = this.getConfig('record_min_ms');
|
|
99
|
+
if (this.recordMinMs > MAX_VALUE_FOR_MIN_RECORDING_MS) {
|
|
100
|
+
this.recordMinMs = MAX_VALUE_FOR_MIN_RECORDING_MS;
|
|
101
|
+
logger.critical('record_min_ms cannot be greater than ' + MAX_VALUE_FOR_MIN_RECORDING_MS + 'ms. Capping value.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.replayStartTime = new Date().getTime();
|
|
105
|
+
this.batchStartUrl = _.info.currentUrl();
|
|
106
|
+
|
|
107
|
+
if (shouldStopBatcher || this.recordMinMs > 0) {
|
|
108
|
+
// the primary case for shouldStopBatcher is when we're starting recording after a reset
|
|
109
|
+
// and don't want to send anything over the network until there's
|
|
110
|
+
// actual user activity
|
|
111
|
+
// this also applies if the minimum recording length has not been hit yet
|
|
112
|
+
// so that we don't send data until we know the recording will be long enough
|
|
113
|
+
this.batcher.stop();
|
|
114
|
+
} else {
|
|
115
|
+
this.batcher.start();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
var resetIdleTimeout = _.bind(function () {
|
|
119
|
+
clearTimeout(this.idleTimeoutId);
|
|
120
|
+
this.idleTimeoutId = setTimeout(this._onIdleTimeout, this.getConfig('record_idle_timeout_ms'));
|
|
121
|
+
}, this);
|
|
122
|
+
|
|
123
|
+
var blockSelector = this.getConfig('record_block_selector');
|
|
124
|
+
if (blockSelector === '' || blockSelector === null) {
|
|
125
|
+
blockSelector = undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this._stopRecording = this._rrwebRecord({
|
|
129
|
+
'emit': _.bind(function (ev) {
|
|
130
|
+
this.batcher.enqueue(ev);
|
|
131
|
+
if (isUserEvent(ev)) {
|
|
132
|
+
if (this.batcher.stopped && new Date().getTime() - this.replayStartTime >= this.recordMinMs) {
|
|
133
|
+
// start flushing again after user activity
|
|
134
|
+
this.batcher.start();
|
|
135
|
+
}
|
|
136
|
+
resetIdleTimeout();
|
|
137
|
+
}
|
|
138
|
+
}, this),
|
|
139
|
+
'blockClass': this.getConfig('record_block_class'),
|
|
140
|
+
'blockSelector': blockSelector,
|
|
141
|
+
'collectFonts': this.getConfig('record_collect_fonts'),
|
|
142
|
+
'maskAllInputs': true,
|
|
143
|
+
'maskTextClass': this.getConfig('record_mask_text_class'),
|
|
144
|
+
'maskTextSelector': this.getConfig('record_mask_text_selector')
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (typeof this._stopRecording !== 'function') {
|
|
148
|
+
this.reportError('rrweb failed to start, skipping this recording.');
|
|
149
|
+
this._stopRecording = null;
|
|
150
|
+
this.stopRecording(); // stop batcher looping and any timeouts
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
resetIdleTimeout();
|
|
155
|
+
|
|
156
|
+
this.maxTimeoutId = setTimeout(_.bind(this._onMaxLengthReached, this), this.recordMaxMs);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
SessionRecording.prototype.stopRecording = function () {
|
|
160
|
+
if (!this.isRrwebStopped()) {
|
|
161
|
+
try {
|
|
162
|
+
this._stopRecording();
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this.reportError('Error with rrweb stopRecording', err);
|
|
165
|
+
}
|
|
166
|
+
this._stopRecording = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (this.batcher.stopped) {
|
|
170
|
+
// never got user activity to flush after reset, so just clear the batcher
|
|
171
|
+
this.batcher.clear();
|
|
172
|
+
} else {
|
|
173
|
+
// flush any remaining events from running batcher
|
|
174
|
+
this.batcher.flush();
|
|
175
|
+
this.batcher.stop();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
clearTimeout(this.idleTimeoutId);
|
|
179
|
+
clearTimeout(this.maxTimeoutId);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
SessionRecording.prototype.isRrwebStopped = function () {
|
|
183
|
+
return this._stopRecording === null;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Flushes the current batch of events to the server, but passes an opt-out callback to make sure
|
|
188
|
+
* we stop recording and dump any queued events if the user has opted out.
|
|
189
|
+
*/
|
|
190
|
+
SessionRecording.prototype.flushEventsWithOptOut = function (data, options, cb) {
|
|
191
|
+
this._flushEvents(data, options, cb, _.bind(this._onOptOut, this));
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
SessionRecording.prototype._onOptOut = function (code) {
|
|
195
|
+
// addOptOutCheckMixpanelLib invokes this function with code=0 when the user has opted out
|
|
196
|
+
if (code === 0) {
|
|
197
|
+
this.stopRecording();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, reqBody, callback) {
|
|
202
|
+
var onSuccess = _.bind(function (response, responseBody) {
|
|
203
|
+
// Update batch specific props only if the request was successful to guarantee ordering.
|
|
204
|
+
// RequestBatcher will always flush the next batch after the previous one succeeds.
|
|
205
|
+
// extra check to see if the replay ID has changed so that we don't increment the seqNo on the wrong replay
|
|
206
|
+
if (response.status === 200 && this.replayId === currentReplayId) {
|
|
207
|
+
this.seqNo++;
|
|
208
|
+
this.batchStartUrl = _.info.currentUrl();
|
|
209
|
+
}
|
|
210
|
+
callback({
|
|
211
|
+
status: 0,
|
|
212
|
+
httpStatusCode: response.status,
|
|
213
|
+
responseBody: responseBody,
|
|
214
|
+
retryAfter: response.headers.get('Retry-After')
|
|
215
|
+
});
|
|
216
|
+
}, this);
|
|
217
|
+
|
|
218
|
+
window['fetch'](this.getConfig('api_host') + '/' + this.getConfig('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
|
|
219
|
+
'method': 'POST',
|
|
220
|
+
'headers': {
|
|
221
|
+
'Authorization': 'Basic ' + btoa(this.getConfig('token') + ':'),
|
|
222
|
+
'Content-Type': 'application/octet-stream'
|
|
223
|
+
},
|
|
224
|
+
'body': reqBody,
|
|
225
|
+
}).then(function (response) {
|
|
226
|
+
response.json().then(function (responseBody) {
|
|
227
|
+
onSuccess(response, responseBody);
|
|
228
|
+
}).catch(function (error) {
|
|
229
|
+
callback({error: error});
|
|
230
|
+
});
|
|
231
|
+
}).catch(function (error) {
|
|
232
|
+
callback({error: error, httpStatusCode: 0});
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (data, options, callback) {
|
|
237
|
+
const numEvents = data.length;
|
|
238
|
+
|
|
239
|
+
if (numEvents > 0) {
|
|
240
|
+
var replayId = this.replayId;
|
|
241
|
+
// each rrweb event has a timestamp - leverage those to get time properties
|
|
242
|
+
var batchStartTime = data[0].timestamp;
|
|
243
|
+
if (this.seqNo === 0 || !this.replayStartTime) {
|
|
244
|
+
// extra safety net so that we don't send a null replay start time
|
|
245
|
+
if (this.seqNo !== 0) {
|
|
246
|
+
this.reportError('Replay start time not set but seqNo is not 0. Using current batch start time as a fallback.');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.replayStartTime = batchStartTime;
|
|
250
|
+
}
|
|
251
|
+
var replayLengthMs = data[numEvents - 1].timestamp - this.replayStartTime;
|
|
252
|
+
|
|
253
|
+
var reqParams = {
|
|
254
|
+
'$current_url': this.batchStartUrl,
|
|
255
|
+
'$lib_version': Config.LIB_VERSION,
|
|
256
|
+
'batch_start_time': batchStartTime / 1000,
|
|
257
|
+
'distinct_id': String(this._mixpanel.get_distinct_id()),
|
|
258
|
+
'mp_lib': 'web',
|
|
259
|
+
'replay_id': replayId,
|
|
260
|
+
'replay_length_ms': replayLengthMs,
|
|
261
|
+
'replay_start_time': this.replayStartTime / 1000,
|
|
262
|
+
'seq': this.seqNo
|
|
263
|
+
};
|
|
264
|
+
var eventsJson = _.JSONEncode(data);
|
|
265
|
+
|
|
266
|
+
// send ID management props if they exist
|
|
267
|
+
var deviceId = this._mixpanel.get_property('$device_id');
|
|
268
|
+
if (deviceId) {
|
|
269
|
+
reqParams['$device_id'] = deviceId;
|
|
270
|
+
}
|
|
271
|
+
var userId = this._mixpanel.get_property('$user_id');
|
|
272
|
+
if (userId) {
|
|
273
|
+
reqParams['$user_id'] = userId;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (CompressionStream) {
|
|
277
|
+
var jsonStream = new Blob([eventsJson], {type: 'application/json'}).stream();
|
|
278
|
+
var gzipStream = jsonStream.pipeThrough(new CompressionStream('gzip'));
|
|
279
|
+
new Response(gzipStream)
|
|
280
|
+
.blob()
|
|
281
|
+
.then(_.bind(function(compressedBlob) {
|
|
282
|
+
reqParams['format'] = 'gzip';
|
|
283
|
+
this._sendRequest(replayId, reqParams, compressedBlob, callback);
|
|
284
|
+
}, this));
|
|
285
|
+
} else {
|
|
286
|
+
reqParams['format'] = 'body';
|
|
287
|
+
this._sendRequest(replayId, reqParams, eventsJson, callback);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
SessionRecording.prototype.reportError = function(msg, err) {
|
|
294
|
+
logger.error.apply(logger.error, arguments);
|
|
295
|
+
try {
|
|
296
|
+
if (!err && !(msg instanceof Error)) {
|
|
297
|
+
msg = new Error(msg);
|
|
298
|
+
}
|
|
299
|
+
this.getConfig('error_reporter')(msg, err);
|
|
300
|
+
} catch(err) {
|
|
301
|
+
logger.error(err);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
export { SessionRecording };
|
package/src/request-queue.js
CHANGED
|
@@ -22,11 +22,13 @@ var logger = console_with_prefix('batch');
|
|
|
22
22
|
var RequestQueue = function(storageKey, options) {
|
|
23
23
|
options = options || {};
|
|
24
24
|
this.storageKey = storageKey;
|
|
25
|
-
this.
|
|
25
|
+
this.usePersistence = options.usePersistence;
|
|
26
|
+
if (this.usePersistence) {
|
|
27
|
+
this.storage = options.storage || window.localStorage;
|
|
28
|
+
this.lock = new SharedLock(storageKey, {storage: this.storage});
|
|
29
|
+
}
|
|
26
30
|
this.reportError = options.errorReporter || _.bind(logger.error, logger);
|
|
27
|
-
this.lock = new SharedLock(storageKey, {storage: this.storage});
|
|
28
31
|
|
|
29
|
-
this.usePersistence = options.usePersistence;
|
|
30
32
|
this.pid = options.pid || null; // pass pid to test out storage lock contention scenarios
|
|
31
33
|
|
|
32
34
|
this.memQueue = [];
|