mixpanel-browser 2.55.0 → 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 +14 -0
- package/README.md +1 -1
- package/dist/mixpanel-core.cjs.js +52 -15
- package/dist/mixpanel-recorder.js +211 -87
- package/dist/mixpanel-recorder.min.js +10 -9
- package/dist/mixpanel-recorder.min.js.map +1 -0
- package/dist/mixpanel-with-async-recorder.cjs.js +52 -15
- package/dist/mixpanel.amd.js +238 -93
- package/dist/mixpanel.cjs.js +238 -93
- package/dist/mixpanel.globals.js +52 -15
- package/dist/mixpanel.min.js +109 -107
- package/dist/mixpanel.min.js.map +8 -0
- package/dist/mixpanel.module.js +238 -93
- package/dist/mixpanel.umd.js +238 -93
- package/package.json +1 -1
- package/src/config.js +1 -1
- package/src/mixpanel-core.js +27 -6
- package/src/recorder/index.js +39 -232
- package/src/recorder/rollup.config.js +2 -1
- package/src/recorder/session-recording.js +305 -0
- package/src/request-batcher.js +7 -2
- package/src/request-queue.js +5 -3
- package/src/utils.js +26 -13
package/src/mixpanel-core.js
CHANGED
|
@@ -148,10 +148,10 @@ var DEFAULT_CONFIG = {
|
|
|
148
148
|
'record_block_selector': 'img, video',
|
|
149
149
|
'record_collect_fonts': false,
|
|
150
150
|
'record_idle_timeout_ms': 30 * 60 * 1000, // 30 minutes
|
|
151
|
-
'record_inline_images': false,
|
|
152
151
|
'record_mask_text_class': new RegExp('^(mp-mask|fs-mask|amp-mask|rr-mask|ph-mask)$'),
|
|
153
152
|
'record_mask_text_selector': '*',
|
|
154
153
|
'record_max_ms': MAX_RECORDING_MS,
|
|
154
|
+
'record_min_ms': 0,
|
|
155
155
|
'record_sessions_percent': 0,
|
|
156
156
|
'recorder_src': 'https://cdn.mxpnl.com/libs/mixpanel-recorder.min.js'
|
|
157
157
|
};
|
|
@@ -400,15 +400,35 @@ MixpanelLib.prototype.stop_session_recording = function () {
|
|
|
400
400
|
|
|
401
401
|
MixpanelLib.prototype.get_session_recording_properties = function () {
|
|
402
402
|
var props = {};
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
props['$mp_replay_id'] = replay_id;
|
|
407
|
-
}
|
|
403
|
+
var replay_id = this._get_session_replay_id();
|
|
404
|
+
if (replay_id) {
|
|
405
|
+
props['$mp_replay_id'] = replay_id;
|
|
408
406
|
}
|
|
409
407
|
return props;
|
|
410
408
|
};
|
|
411
409
|
|
|
410
|
+
MixpanelLib.prototype.get_session_replay_url = function () {
|
|
411
|
+
var replay_url = null;
|
|
412
|
+
var replay_id = this._get_session_replay_id();
|
|
413
|
+
if (replay_id) {
|
|
414
|
+
var query_params = _.HTTPBuildQuery({
|
|
415
|
+
'replay_id': replay_id,
|
|
416
|
+
'distinct_id': this.get_distinct_id(),
|
|
417
|
+
'token': this.get_config('token')
|
|
418
|
+
});
|
|
419
|
+
replay_url = 'https://mixpanel.com/projects/replay-redirect?' + query_params;
|
|
420
|
+
}
|
|
421
|
+
return replay_url;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
MixpanelLib.prototype._get_session_replay_id = function () {
|
|
425
|
+
var replay_id = null;
|
|
426
|
+
if (this._recorder) {
|
|
427
|
+
replay_id = this._recorder['replayId'];
|
|
428
|
+
}
|
|
429
|
+
return replay_id || null;
|
|
430
|
+
};
|
|
431
|
+
|
|
412
432
|
// Private methods
|
|
413
433
|
|
|
414
434
|
MixpanelLib.prototype._loaded = function() {
|
|
@@ -2135,6 +2155,7 @@ MixpanelLib.prototype['stop_batch_senders'] = MixpanelLib.protot
|
|
|
2135
2155
|
MixpanelLib.prototype['start_session_recording'] = MixpanelLib.prototype.start_session_recording;
|
|
2136
2156
|
MixpanelLib.prototype['stop_session_recording'] = MixpanelLib.prototype.stop_session_recording;
|
|
2137
2157
|
MixpanelLib.prototype['get_session_recording_properties'] = MixpanelLib.prototype.get_session_recording_properties;
|
|
2158
|
+
MixpanelLib.prototype['get_session_replay_url'] = MixpanelLib.prototype.get_session_replay_url;
|
|
2138
2159
|
MixpanelLib.prototype['DEFAULT_API_ROUTES'] = DEFAULT_API_ROUTES;
|
|
2139
2160
|
|
|
2140
2161
|
// MixpanelPersistence Exports
|
package/src/recorder/index.js
CHANGED
|
@@ -1,129 +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._initBatcher();
|
|
14
|
+
this.activeRecording = null;
|
|
51
15
|
};
|
|
52
16
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.batcher = new RequestBatcher('__mprec', {
|
|
56
|
-
libConfig: RECORDER_BATCHER_LIB_CONFIG,
|
|
57
|
-
sendRequestFunc: _.bind(this.flushEventsWithOptOut, this),
|
|
58
|
-
errorReporter: _.bind(this.reportError, this),
|
|
59
|
-
flushOnlyOnInterval: true,
|
|
60
|
-
usePersistence: false
|
|
61
|
-
});
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
// eslint-disable-next-line camelcase
|
|
65
|
-
MixpanelRecorder.prototype.get_config = function(configVar) {
|
|
66
|
-
return this._mixpanel.get_config(configVar);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
MixpanelRecorder.prototype.startRecording = function (shouldStopBatcher) {
|
|
70
|
-
if (this._stopRecording !== null) {
|
|
17
|
+
MixpanelRecorder.prototype.startRecording = function(shouldStopBatcher) {
|
|
18
|
+
if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
|
|
71
19
|
logger.log('Recording already in progress, skipping startRecording.');
|
|
72
20
|
return;
|
|
73
21
|
}
|
|
74
22
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
this.recEvents = [];
|
|
82
|
-
this.seqNo = 0;
|
|
83
|
-
this.replayStartTime = null;
|
|
84
|
-
|
|
85
|
-
this.replayId = _.UUID();
|
|
86
|
-
|
|
87
|
-
if (shouldStopBatcher) {
|
|
88
|
-
// this is the case when we're starting recording after a reset
|
|
89
|
-
// and don't want to send anything over the network until there's
|
|
90
|
-
// actual user activity
|
|
91
|
-
this.batcher.stop();
|
|
92
|
-
} else {
|
|
93
|
-
this.batcher.start();
|
|
94
|
-
}
|
|
23
|
+
var onIdleTimeout = _.bind(function () {
|
|
24
|
+
logger.log('Idle timeout reached, restarting recording.');
|
|
25
|
+
this.resetRecording();
|
|
26
|
+
}, this);
|
|
95
27
|
|
|
96
|
-
var
|
|
97
|
-
|
|
98
|
-
this.
|
|
99
|
-
logger.log('Idle timeout reached, restarting recording.');
|
|
100
|
-
this.resetRecording();
|
|
101
|
-
}, this), this.get_config('record_idle_timeout_ms'));
|
|
28
|
+
var onMaxLengthReached = _.bind(function () {
|
|
29
|
+
logger.log('Max recording length reached, stopping recording.');
|
|
30
|
+
this.resetRecording();
|
|
102
31
|
}, this);
|
|
103
32
|
|
|
104
|
-
this.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
this.batcher.start();
|
|
111
|
-
}
|
|
112
|
-
resetIdleTimeout();
|
|
113
|
-
}
|
|
114
|
-
}, this),
|
|
115
|
-
'blockClass': this.get_config('record_block_class'),
|
|
116
|
-
'blockSelector': this.get_config('record_block_selector'),
|
|
117
|
-
'collectFonts': this.get_config('record_collect_fonts'),
|
|
118
|
-
'inlineImages': this.get_config('record_inline_images'),
|
|
119
|
-
'maskAllInputs': true,
|
|
120
|
-
'maskTextClass': this.get_config('record_mask_text_class'),
|
|
121
|
-
'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
|
|
122
39
|
});
|
|
123
40
|
|
|
124
|
-
|
|
41
|
+
this.activeRecording.startRecording(shouldStopBatcher);
|
|
42
|
+
};
|
|
125
43
|
|
|
126
|
-
|
|
44
|
+
MixpanelRecorder.prototype.stopRecording = function() {
|
|
45
|
+
if (this.activeRecording) {
|
|
46
|
+
this.activeRecording.stopRecording();
|
|
47
|
+
this.activeRecording = null;
|
|
48
|
+
}
|
|
127
49
|
};
|
|
128
50
|
|
|
129
51
|
MixpanelRecorder.prototype.resetRecording = function () {
|
|
@@ -131,135 +53,20 @@ MixpanelRecorder.prototype.resetRecording = function () {
|
|
|
131
53
|
this.startRecording(true);
|
|
132
54
|
};
|
|
133
55
|
|
|
134
|
-
MixpanelRecorder.prototype.
|
|
135
|
-
if (this.
|
|
136
|
-
this.
|
|
137
|
-
this._stopRecording = null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (this.batcher.stopped) {
|
|
141
|
-
// never got user activity to flush after reset, so just clear the batcher
|
|
142
|
-
this.batcher.clear();
|
|
56
|
+
MixpanelRecorder.prototype.getActiveReplayId = function () {
|
|
57
|
+
if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
|
|
58
|
+
return this.activeRecording.replayId;
|
|
143
59
|
} else {
|
|
144
|
-
|
|
145
|
-
this.batcher.flush();
|
|
146
|
-
this.batcher.stop();
|
|
60
|
+
return null;
|
|
147
61
|
}
|
|
148
|
-
this.replayId = null;
|
|
149
|
-
|
|
150
|
-
clearTimeout(this.idleTimeoutId);
|
|
151
|
-
clearTimeout(this.maxTimeoutId);
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Flushes the current batch of events to the server, but passes an opt-out callback to make sure
|
|
156
|
-
* we stop recording and dump any queued events if the user has opted out.
|
|
157
|
-
*/
|
|
158
|
-
MixpanelRecorder.prototype.flushEventsWithOptOut = function (data, options, cb) {
|
|
159
|
-
this._flushEvents(data, options, cb, _.bind(this._onOptOut, this));
|
|
160
62
|
};
|
|
161
63
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
this.
|
|
167
|
-
}
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
MixpanelRecorder.prototype._sendRequest = function(reqParams, reqBody, callback) {
|
|
171
|
-
var onSuccess = _.bind(function (response, responseBody) {
|
|
172
|
-
// Increment sequence counter only if the request was successful to guarantee ordering.
|
|
173
|
-
// RequestBatcher will always flush the next batch after the previous one succeeds.
|
|
174
|
-
if (response.status === 200) {
|
|
175
|
-
this.seqNo++;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
callback({
|
|
179
|
-
status: 0,
|
|
180
|
-
httpStatusCode: response.status,
|
|
181
|
-
responseBody: responseBody,
|
|
182
|
-
retryAfter: response.headers.get('Retry-After')
|
|
183
|
-
});
|
|
184
|
-
}, this);
|
|
185
|
-
|
|
186
|
-
window['fetch'](this.get_config('api_host') + '/' + this.get_config('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
|
|
187
|
-
'method': 'POST',
|
|
188
|
-
'headers': {
|
|
189
|
-
'Authorization': 'Basic ' + btoa(this.get_config('token') + ':'),
|
|
190
|
-
'Content-Type': 'application/octet-stream'
|
|
191
|
-
},
|
|
192
|
-
'body': reqBody,
|
|
193
|
-
}).then(function (response) {
|
|
194
|
-
response.json().then(function (responseBody) {
|
|
195
|
-
onSuccess(response, responseBody);
|
|
196
|
-
}).catch(function (error) {
|
|
197
|
-
callback({error: error});
|
|
198
|
-
});
|
|
199
|
-
}).catch(function (error) {
|
|
200
|
-
callback({error: error});
|
|
201
|
-
});
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
MixpanelRecorder.prototype._flushEvents = addOptOutCheckMixpanelLib(function (data, options, callback) {
|
|
205
|
-
const numEvents = data.length;
|
|
206
|
-
|
|
207
|
-
if (numEvents > 0) {
|
|
208
|
-
// each rrweb event has a timestamp - leverage those to get time properties
|
|
209
|
-
var batchStartTime = data[0].timestamp;
|
|
210
|
-
if (this.seqNo === 0) {
|
|
211
|
-
this.replayStartTime = batchStartTime;
|
|
212
|
-
}
|
|
213
|
-
var replayLengthMs = data[numEvents - 1].timestamp - this.replayStartTime;
|
|
214
|
-
|
|
215
|
-
var reqParams = {
|
|
216
|
-
'distinct_id': String(this._mixpanel.get_distinct_id()),
|
|
217
|
-
'seq': this.seqNo,
|
|
218
|
-
'batch_start_time': batchStartTime / 1000,
|
|
219
|
-
'replay_id': this.replayId,
|
|
220
|
-
'replay_length_ms': replayLengthMs,
|
|
221
|
-
'replay_start_time': this.replayStartTime / 1000
|
|
222
|
-
};
|
|
223
|
-
var eventsJson = _.JSONEncode(data);
|
|
224
|
-
|
|
225
|
-
// send ID management props if they exist
|
|
226
|
-
var deviceId = this._mixpanel.get_property('$device_id');
|
|
227
|
-
if (deviceId) {
|
|
228
|
-
reqParams['$device_id'] = deviceId;
|
|
229
|
-
}
|
|
230
|
-
var userId = this._mixpanel.get_property('$user_id');
|
|
231
|
-
if (userId) {
|
|
232
|
-
reqParams['$user_id'] = userId;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (CompressionStream) {
|
|
236
|
-
var jsonStream = new Blob([eventsJson], {type: 'application/json'}).stream();
|
|
237
|
-
var gzipStream = jsonStream.pipeThrough(new CompressionStream('gzip'));
|
|
238
|
-
new Response(gzipStream)
|
|
239
|
-
.blob()
|
|
240
|
-
.then(_.bind(function(compressedBlob) {
|
|
241
|
-
reqParams['format'] = 'gzip';
|
|
242
|
-
this._sendRequest(reqParams, compressedBlob, callback);
|
|
243
|
-
}, this));
|
|
244
|
-
} else {
|
|
245
|
-
reqParams['format'] = 'body';
|
|
246
|
-
this._sendRequest(reqParams, eventsJson, callback);
|
|
247
|
-
}
|
|
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();
|
|
248
69
|
}
|
|
249
70
|
});
|
|
250
71
|
|
|
251
|
-
|
|
252
|
-
MixpanelRecorder.prototype.reportError = function(msg, err) {
|
|
253
|
-
logger.error.apply(logger.error, arguments);
|
|
254
|
-
try {
|
|
255
|
-
if (!err && !(msg instanceof Error)) {
|
|
256
|
-
msg = new Error(msg);
|
|
257
|
-
}
|
|
258
|
-
this.get_config('error_reporter')(msg, err);
|
|
259
|
-
} catch(err) {
|
|
260
|
-
logger.error(err);
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
|
|
265
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 };
|