mixpanel-browser 2.59.0 → 2.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -1
- package/README.md +3 -3
- package/dist/mixpanel-core.cjs.js +612 -176
- 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 +612 -176
- package/dist/mixpanel.amd.js +1000 -290
- package/dist/mixpanel.cjs.js +1000 -290
- package/dist/mixpanel.globals.js +612 -176
- package/dist/mixpanel.min.js +143 -134
- package/dist/mixpanel.module.js +1000 -290
- package/dist/mixpanel.umd.js +1000 -290
- package/package.json +2 -1
- package/src/autocapture/index.js +80 -9
- package/src/autocapture/utils.js +129 -38
- package/src/config.js +1 -1
- package/src/mixpanel-core.js +119 -19
- package/src/mixpanel-persistence.js +6 -2
- 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
|
@@ -345,8 +345,12 @@ MixpanelPersistence.prototype._add_to_people_queue = function(queue, data) {
|
|
|
345
345
|
if (!(k in union_q)) {
|
|
346
346
|
union_q[k] = [];
|
|
347
347
|
}
|
|
348
|
-
//
|
|
349
|
-
|
|
348
|
+
// Prevent duplicate values
|
|
349
|
+
_.each(v, function(item) {
|
|
350
|
+
if (!_.include(union_q[k], item)) {
|
|
351
|
+
union_q[k].push(item);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
350
354
|
}
|
|
351
355
|
});
|
|
352
356
|
this._pop_from_people_queue(UNSET_ACTION, q_data);
|
package/src/recorder/index.js
CHANGED
|
@@ -1,73 +1,4 @@
|
|
|
1
|
-
import {record} from 'rrweb';
|
|
2
|
-
|
|
3
|
-
import { SessionRecording } from './session-recording';
|
|
4
|
-
import { console_with_prefix, _ } from '../utils'; // eslint-disable-line camelcase
|
|
5
1
|
import { window } from '../window';
|
|
6
|
-
|
|
7
|
-
var logger = console_with_prefix('recorder');
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Recorder API: manages recordings and exposes methods public to the core Mixpanel library.
|
|
11
|
-
* @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
|
|
12
|
-
*/
|
|
13
|
-
var MixpanelRecorder = function(mixpanelInstance) {
|
|
14
|
-
this._mixpanel = mixpanelInstance;
|
|
15
|
-
this.activeRecording = null;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
MixpanelRecorder.prototype.startRecording = function(shouldStopBatcher) {
|
|
19
|
-
if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
|
|
20
|
-
logger.log('Recording already in progress, skipping startRecording.');
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
var onIdleTimeout = _.bind(function () {
|
|
25
|
-
logger.log('Idle timeout reached, restarting recording.');
|
|
26
|
-
this.resetRecording();
|
|
27
|
-
}, this);
|
|
28
|
-
|
|
29
|
-
var onMaxLengthReached = _.bind(function () {
|
|
30
|
-
logger.log('Max recording length reached, stopping recording.');
|
|
31
|
-
this.resetRecording();
|
|
32
|
-
}, this);
|
|
33
|
-
|
|
34
|
-
this.activeRecording = new SessionRecording({
|
|
35
|
-
mixpanelInstance: this._mixpanel,
|
|
36
|
-
onIdleTimeout: onIdleTimeout,
|
|
37
|
-
onMaxLengthReached: onMaxLengthReached,
|
|
38
|
-
replayId: _.UUID(),
|
|
39
|
-
rrwebRecord: record
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
this.activeRecording.startRecording(shouldStopBatcher);
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
MixpanelRecorder.prototype.stopRecording = function() {
|
|
46
|
-
if (this.activeRecording) {
|
|
47
|
-
this.activeRecording.stopRecording();
|
|
48
|
-
this.activeRecording = null;
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
MixpanelRecorder.prototype.resetRecording = function () {
|
|
53
|
-
this.stopRecording();
|
|
54
|
-
this.startRecording(true);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
MixpanelRecorder.prototype.getActiveReplayId = function () {
|
|
58
|
-
if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
|
|
59
|
-
return this.activeRecording.replayId;
|
|
60
|
-
} else {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// getter so that older mixpanel-core versions can still retrieve the replay ID
|
|
66
|
-
// when pulling the latest recorder bundle from the CDN
|
|
67
|
-
Object.defineProperty(MixpanelRecorder.prototype, 'replayId', {
|
|
68
|
-
get: function () {
|
|
69
|
-
return this.getActiveReplayId();
|
|
70
|
-
}
|
|
71
|
-
});
|
|
2
|
+
import { MixpanelRecorder } from './recorder';
|
|
72
3
|
|
|
73
4
|
window['__mp_recorder'] = MixpanelRecorder;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { record } from 'rrweb';
|
|
2
|
+
import { Promise } from '../promise-polyfill';
|
|
3
|
+
import { SessionRecording } from './session-recording';
|
|
4
|
+
import { RecordingRegistry } from './recording-registry';
|
|
5
|
+
|
|
6
|
+
import { console_with_prefix, _ } from '../utils'; // eslint-disable-line camelcase
|
|
7
|
+
|
|
8
|
+
var logger = console_with_prefix('recorder');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recorder API: bundles rrweb and and exposes methods to start and stop recordings.
|
|
12
|
+
* @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
|
|
13
|
+
*/
|
|
14
|
+
var MixpanelRecorder = function(mixpanelInstance, rrwebRecord, sharedLockStorage) {
|
|
15
|
+
this.mixpanelInstance = mixpanelInstance;
|
|
16
|
+
this.rrwebRecord = rrwebRecord || record;
|
|
17
|
+
this.sharedLockStorage = sharedLockStorage;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @member {import('./registry').RecordingRegistry}
|
|
21
|
+
*/
|
|
22
|
+
this.recordingRegistry = new RecordingRegistry({
|
|
23
|
+
mixpanelInstance: this.mixpanelInstance,
|
|
24
|
+
errorReporter: logger.error,
|
|
25
|
+
sharedLockStorage: sharedLockStorage
|
|
26
|
+
});
|
|
27
|
+
this._flushInactivePromise = this.recordingRegistry.flushInactiveRecordings();
|
|
28
|
+
|
|
29
|
+
this.activeRecording = null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
MixpanelRecorder.prototype.startRecording = function(options) {
|
|
33
|
+
options = options || {};
|
|
34
|
+
if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
|
|
35
|
+
logger.log('Recording already in progress, skipping startRecording.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var onIdleTimeout = function () {
|
|
40
|
+
logger.log('Idle timeout reached, restarting recording.');
|
|
41
|
+
this.resetRecording();
|
|
42
|
+
}.bind(this);
|
|
43
|
+
|
|
44
|
+
var onMaxLengthReached = function () {
|
|
45
|
+
logger.log('Max recording length reached, stopping recording.');
|
|
46
|
+
this.resetRecording();
|
|
47
|
+
}.bind(this);
|
|
48
|
+
|
|
49
|
+
var onBatchSent = function () {
|
|
50
|
+
this.recordingRegistry.setActiveRecording(this.activeRecording.serialize());
|
|
51
|
+
this['__flushPromise'] = this.activeRecording.batcher._flushPromise;
|
|
52
|
+
}.bind(this);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @type {import('./session-recording').SessionRecordingOptions}
|
|
56
|
+
*/
|
|
57
|
+
var sessionRecordingOptions = {
|
|
58
|
+
mixpanelInstance: this.mixpanelInstance,
|
|
59
|
+
onBatchSent: onBatchSent,
|
|
60
|
+
onIdleTimeout: onIdleTimeout,
|
|
61
|
+
onMaxLengthReached: onMaxLengthReached,
|
|
62
|
+
replayId: _.UUID(),
|
|
63
|
+
rrwebRecord: this.rrwebRecord,
|
|
64
|
+
sharedLockStorage: this.sharedLockStorage
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (options.activeSerializedRecording) {
|
|
68
|
+
this.activeRecording = SessionRecording.deserialize(options.activeSerializedRecording, sessionRecordingOptions);
|
|
69
|
+
} else {
|
|
70
|
+
this.activeRecording = new SessionRecording(sessionRecordingOptions);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.activeRecording.startRecording(options.shouldStopBatcher);
|
|
74
|
+
return this.recordingRegistry.setActiveRecording(this.activeRecording.serialize());
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
MixpanelRecorder.prototype.stopRecording = function() {
|
|
78
|
+
var stopPromise = this._stopCurrentRecording(false);
|
|
79
|
+
this.recordingRegistry.clearActiveRecording();
|
|
80
|
+
this.activeRecording = null;
|
|
81
|
+
return stopPromise;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
MixpanelRecorder.prototype.pauseRecording = function() {
|
|
85
|
+
return this._stopCurrentRecording(false);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
MixpanelRecorder.prototype._stopCurrentRecording = function(skipFlush) {
|
|
89
|
+
if (this.activeRecording) {
|
|
90
|
+
return this.activeRecording.stopRecording(skipFlush);
|
|
91
|
+
}
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
MixpanelRecorder.prototype.resumeRecording = function (startNewIfInactive) {
|
|
96
|
+
if (this.activeRecording && this.activeRecording.isRrwebStopped()) {
|
|
97
|
+
this.activeRecording.startRecording(false);
|
|
98
|
+
return Promise.resolve(null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return this.recordingRegistry.getActiveRecording()
|
|
102
|
+
.then(function (activeSerializedRecording) {
|
|
103
|
+
if (activeSerializedRecording) {
|
|
104
|
+
return this.startRecording({activeSerializedRecording: activeSerializedRecording});
|
|
105
|
+
} else if (startNewIfInactive) {
|
|
106
|
+
return this.startRecording({shouldStopBatcher: false});
|
|
107
|
+
} else {
|
|
108
|
+
logger.log('No resumable recording found.');
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}.bind(this));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
MixpanelRecorder.prototype.resetRecording = function () {
|
|
116
|
+
this.stopRecording();
|
|
117
|
+
this.startRecording({shouldStopBatcher: true});
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
MixpanelRecorder.prototype.getActiveReplayId = function () {
|
|
121
|
+
if (this.activeRecording && !this.activeRecording.isRrwebStopped()) {
|
|
122
|
+
return this.activeRecording.replayId;
|
|
123
|
+
} else {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// getter so that older mixpanel-core versions can still retrieve the replay ID
|
|
129
|
+
// when pulling the latest recorder bundle from the CDN
|
|
130
|
+
Object.defineProperty(MixpanelRecorder.prototype, 'replayId', {
|
|
131
|
+
get: function () {
|
|
132
|
+
return this.getActiveReplayId();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
export { MixpanelRecorder };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Promise } from '../promise-polyfill';
|
|
2
|
+
import { IDBStorageWrapper, RECORDING_REGISTRY_STORE_NAME } from '../storage/indexed-db';
|
|
3
|
+
import { SessionRecording } from './session-recording';
|
|
4
|
+
import { isRecordingExpired } from './utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Module for handling the storage and retrieval of recording metadata as well as any active recordings.
|
|
8
|
+
* Makes sure that only one tab can be recording at a time.
|
|
9
|
+
*/
|
|
10
|
+
var RecordingRegistry = function (options) {
|
|
11
|
+
this.idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
|
|
12
|
+
this.errorReporter = options.errorReporter;
|
|
13
|
+
this.mixpanelInstance = options.mixpanelInstance;
|
|
14
|
+
this.sharedLockStorage = options.sharedLockStorage;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
RecordingRegistry.prototype.handleError = function (err) {
|
|
18
|
+
this.errorReporter('IndexedDB error: ', err);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {import('./session-recording').SerializedRecording} serializedRecording
|
|
23
|
+
*/
|
|
24
|
+
RecordingRegistry.prototype.setActiveRecording = function (serializedRecording) {
|
|
25
|
+
var tabId = serializedRecording['tabId'];
|
|
26
|
+
if (!tabId) {
|
|
27
|
+
console.warn('No tab ID is set, cannot persist recording metadata.');
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return this.idb.init()
|
|
32
|
+
.then(function () {
|
|
33
|
+
return this.idb.setItem(tabId, serializedRecording);
|
|
34
|
+
}.bind(this))
|
|
35
|
+
.catch(this.handleError.bind(this));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @returns {Promise<import('./session-recording').SerializedRecording>}
|
|
40
|
+
*/
|
|
41
|
+
RecordingRegistry.prototype.getActiveRecording = function () {
|
|
42
|
+
return this.idb.init()
|
|
43
|
+
.then(function () {
|
|
44
|
+
return this.idb.getItem(this.mixpanelInstance.get_tab_id());
|
|
45
|
+
}.bind(this))
|
|
46
|
+
.then(function (serializedRecording) {
|
|
47
|
+
return isRecordingExpired(serializedRecording) ? null : serializedRecording;
|
|
48
|
+
}.bind(this))
|
|
49
|
+
.catch(this.handleError.bind(this));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
RecordingRegistry.prototype.clearActiveRecording = function () {
|
|
53
|
+
// mark recording as expired instead of deleting it in case the page unloads mid-flush and doesn't make it to ingestion.
|
|
54
|
+
// this will ensure the next pageload will flush the remaining events, but not try to continue the recording.
|
|
55
|
+
return this.getActiveRecording()
|
|
56
|
+
.then(function (serializedRecording) {
|
|
57
|
+
if (serializedRecording) {
|
|
58
|
+
serializedRecording['maxExpires'] = 0;
|
|
59
|
+
return this.setActiveRecording(serializedRecording);
|
|
60
|
+
}
|
|
61
|
+
}.bind(this))
|
|
62
|
+
.catch(this.handleError.bind(this));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Flush any inactive recordings from the registry to minimize data loss.
|
|
67
|
+
* The main idea here is that we can flush remaining rrweb events on the next page load if a tab is closed mid-batch.
|
|
68
|
+
*/
|
|
69
|
+
RecordingRegistry.prototype.flushInactiveRecordings = function () {
|
|
70
|
+
return this.idb.init()
|
|
71
|
+
.then(function() {
|
|
72
|
+
return this.idb.getAll();
|
|
73
|
+
}.bind(this))
|
|
74
|
+
.then(function (serializedRecordings) {
|
|
75
|
+
// clean up any expired recordings from the registry, non-expired ones may be active in other tabs
|
|
76
|
+
var unloadPromises = serializedRecordings
|
|
77
|
+
.filter(function (serializedRecording) {
|
|
78
|
+
return isRecordingExpired(serializedRecording);
|
|
79
|
+
})
|
|
80
|
+
.map(function (serializedRecording) {
|
|
81
|
+
var sessionRecording = SessionRecording.deserialize(serializedRecording, {
|
|
82
|
+
mixpanelInstance: this.mixpanelInstance,
|
|
83
|
+
sharedLockStorage: this.sharedLockStorage
|
|
84
|
+
});
|
|
85
|
+
return sessionRecording.unloadPersistedData()
|
|
86
|
+
.then(function () {
|
|
87
|
+
// expired recording was successfully flushed, we can clean it up from the registry
|
|
88
|
+
return this.idb.removeItem(serializedRecording['tabId']);
|
|
89
|
+
}.bind(this))
|
|
90
|
+
.catch(this.handleError.bind(this));
|
|
91
|
+
}.bind(this));
|
|
92
|
+
|
|
93
|
+
return Promise.all(unloadPromises);
|
|
94
|
+
}.bind(this))
|
|
95
|
+
.catch(this.handleError.bind(this));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export { RecordingRegistry };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { IncrementalSource, EventType } from '@rrweb/types';
|
|
2
|
-
|
|
3
|
-
import { MAX_RECORDING_MS, MAX_VALUE_FOR_MIN_RECORDING_MS, console_with_prefix, _} from '../utils'; // eslint-disable-line camelcase
|
|
4
1
|
import { window } from '../window';
|
|
2
|
+
import { IncrementalSource, EventType } from '@rrweb/types';
|
|
3
|
+
import { MAX_RECORDING_MS, MAX_VALUE_FOR_MIN_RECORDING_MS, console_with_prefix, NOOP_FUNC, _, localStorageSupported} from '../utils'; // eslint-disable-line camelcase
|
|
4
|
+
import { IDBStorageWrapper, RECORDING_EVENTS_STORE_NAME } from '../storage/indexed-db';
|
|
5
5
|
import { addOptOutCheckMixpanelLib } from '../gdpr-utils';
|
|
6
6
|
import { RequestBatcher } from '../request-batcher';
|
|
7
7
|
import Config from '../config';
|
|
8
|
+
import { RECORD_ENQUEUE_THROTTLE_MS } from './utils';
|
|
8
9
|
|
|
9
10
|
var logger = console_with_prefix('recorder');
|
|
10
11
|
var CompressionStream = window['CompressionStream'];
|
|
@@ -32,29 +33,58 @@ function isUserEvent(ev) {
|
|
|
32
33
|
return ev.type === EventType.IncrementalSnapshot && ACTIVE_SOURCES.has(ev.data.source);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} SerializedRecording
|
|
38
|
+
* @property {number} idleExpires
|
|
39
|
+
* @property {number} maxExpires
|
|
40
|
+
* @property {number} replayStartTime
|
|
41
|
+
* @property {number} seqNo
|
|
42
|
+
* @property {string} batchStartUrl
|
|
43
|
+
* @property {string} replayId
|
|
44
|
+
* @property {string} tabId
|
|
45
|
+
* @property {string} replayStartUrl
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} SessionRecordingOptions
|
|
50
|
+
* @property {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
|
|
51
|
+
* @property {String} [options.replayId] - unique uuid for a single replay
|
|
52
|
+
* @property {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
|
|
53
|
+
* @property {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
|
|
54
|
+
* @property {Function} [options.rrwebRecord] - rrweb's `record` function
|
|
55
|
+
* @property {Function} [options.onBatchSent] - callback when a batch of events is sent to the server
|
|
56
|
+
* @property {Storage} [options.sharedLockStorage] - optional storage for shared lock, used for test dependency injection
|
|
57
|
+
* optional properties for deserialization:
|
|
58
|
+
* @property {number} idleExpires
|
|
59
|
+
* @property {number} maxExpires
|
|
60
|
+
* @property {number} replayStartTime
|
|
61
|
+
* @property {number} seqNo
|
|
62
|
+
* @property {string} batchStartUrl
|
|
63
|
+
* @property {string} replayStartUrl
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
|
|
35
67
|
/**
|
|
36
68
|
* This class encapsulates a single session recording and its lifecycle.
|
|
37
|
-
* @param {
|
|
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};
|