mixpanel-browser 2.73.0 → 2.74.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/.claude/settings.local.json +12 -0
- package/.eslintrc.json +7 -4
- package/.github/workflows/integration-tests.yml +52 -0
- package/.github/workflows/unit-tests.yml +40 -0
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/build.sh +1 -5
- package/dist/mixpanel-core.cjs.d.ts +12 -1
- package/dist/mixpanel-core.cjs.js +115 -15
- package/dist/mixpanel-recorder.js +5255 -687
- package/dist/mixpanel-recorder.min.js +1 -1
- package/dist/mixpanel-recorder.min.js.map +1 -1
- package/dist/mixpanel-with-async-recorder.cjs.d.ts +12 -1
- package/dist/mixpanel-with-async-recorder.cjs.js +115 -15
- package/dist/mixpanel-with-recorder.d.ts +12 -1
- package/dist/mixpanel-with-recorder.js +6720 -2079
- package/dist/mixpanel-with-recorder.min.d.ts +12 -1
- package/dist/mixpanel-with-recorder.min.js +1 -1
- package/dist/mixpanel.amd.d.ts +12 -1
- package/dist/mixpanel.amd.js +6720 -2079
- package/dist/mixpanel.cjs.d.ts +12 -1
- package/dist/mixpanel.cjs.js +6720 -2079
- package/dist/mixpanel.globals.js +115 -15
- package/dist/mixpanel.min.js +174 -172
- package/dist/mixpanel.module.d.ts +12 -1
- package/dist/mixpanel.module.js +6720 -2079
- package/dist/mixpanel.umd.d.ts +12 -1
- package/dist/mixpanel.umd.js +6720 -2079
- package/dist/rrweb-bundled.js +4315 -591
- package/dist/rrweb-compiled.js +4962 -641
- package/package.json +30 -5
- package/rollup.config.mjs +254 -224
- package/src/autocapture/utils.js +15 -7
- package/src/config.js +1 -1
- package/src/index.d.ts +12 -1
- package/src/mixpanel-core.js +89 -5
- package/src/recorder/masking.js +197 -0
- package/src/recorder/rrweb-entrypoint.js +2 -1
- package/src/recorder/session-recording.js +43 -4
- package/src/recorder/utils.js +5 -1
- package/src/utils.js +11 -2
- package/testServer.js +51 -7
- package/.github/workflows/tests.yml +0 -25
package/src/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type PushItem = Array<string | Dict | ((this: Mixpanel) => void)>;
|
|
|
6
6
|
|
|
7
7
|
export type Query = string | Element | Element[];
|
|
8
8
|
|
|
9
|
+
export type RemoteSettingType = "disabled" | "fallback" | "strict";
|
|
10
|
+
|
|
9
11
|
export interface Dict {
|
|
10
12
|
[key: string]: any;
|
|
11
13
|
}
|
|
@@ -166,6 +168,8 @@ export interface Config {
|
|
|
166
168
|
track?: string;
|
|
167
169
|
engage?: string;
|
|
168
170
|
groups?: string;
|
|
171
|
+
record?: string;
|
|
172
|
+
flags?: string;
|
|
169
173
|
};
|
|
170
174
|
api_method: string;
|
|
171
175
|
api_transport: string;
|
|
@@ -225,12 +229,18 @@ export interface Config {
|
|
|
225
229
|
record_idle_timeout_ms: number;
|
|
226
230
|
record_inline_images: boolean;
|
|
227
231
|
record_mask_text_class: string | RegExp;
|
|
228
|
-
record_mask_text_selector: string;
|
|
232
|
+
record_mask_text_selector: string | string[];
|
|
233
|
+
record_unmask_text_selector: string | string[];
|
|
234
|
+
record_mask_all_text: boolean;
|
|
235
|
+
record_mask_input_selector: string | string[];
|
|
236
|
+
record_unmask_input_selector: string | string[];
|
|
237
|
+
record_mask_all_inputs: boolean;
|
|
229
238
|
record_min_ms: number;
|
|
230
239
|
record_max_ms: number;
|
|
231
240
|
record_sessions_percent: number;
|
|
232
241
|
record_canvas: boolean;
|
|
233
242
|
record_heatmap_data: boolean;
|
|
243
|
+
remote_settings_mode: RemoteSettingType;
|
|
234
244
|
hooks: {
|
|
235
245
|
before_identify?: (new_distinct_id: string) => string | null;
|
|
236
246
|
before_register?: (
|
|
@@ -256,6 +266,7 @@ export interface Config {
|
|
|
256
266
|
};
|
|
257
267
|
}
|
|
258
268
|
|
|
269
|
+
|
|
259
270
|
export type VerboseResponse =
|
|
260
271
|
| {
|
|
261
272
|
status: 1;
|
package/src/mixpanel-core.js
CHANGED
|
@@ -61,6 +61,9 @@ var INIT_SNIPPET = 1;
|
|
|
61
61
|
/** @const */ var PAYLOAD_TYPE_BASE64 = 'base64';
|
|
62
62
|
/** @const */ var PAYLOAD_TYPE_JSON = 'json';
|
|
63
63
|
/** @const */ var DEVICE_ID_PREFIX = '$device:';
|
|
64
|
+
/** @const */ var SETTING_STRICT = 'strict';
|
|
65
|
+
/** @const */ var SETTING_FALLBACK = 'fallback';
|
|
66
|
+
/** @const */ var SETTING_DISABLED = 'disabled';
|
|
64
67
|
|
|
65
68
|
|
|
66
69
|
/*
|
|
@@ -89,7 +92,8 @@ var DEFAULT_API_ROUTES = {
|
|
|
89
92
|
'engage': 'engage/',
|
|
90
93
|
'groups': 'groups/',
|
|
91
94
|
'record': 'record/',
|
|
92
|
-
'flags': 'flags/'
|
|
95
|
+
'flags': 'flags/',
|
|
96
|
+
'settings': 'settings/'
|
|
93
97
|
};
|
|
94
98
|
|
|
95
99
|
/*
|
|
@@ -153,12 +157,12 @@ var DEFAULT_CONFIG = {
|
|
|
153
157
|
'record_console': true,
|
|
154
158
|
'record_heatmap_data': false,
|
|
155
159
|
'record_idle_timeout_ms': 30 * 60 * 1000, // 30 minutes
|
|
156
|
-
'
|
|
157
|
-
'record_mask_text_selector': '*',
|
|
160
|
+
'record_mask_inputs': true,
|
|
158
161
|
'record_max_ms': MAX_RECORDING_MS,
|
|
159
162
|
'record_min_ms': 0,
|
|
160
163
|
'record_sessions_percent': 0,
|
|
161
|
-
'recorder_src': 'https://cdn.mxpnl.com/libs/mixpanel-recorder.min.js'
|
|
164
|
+
'recorder_src': 'https://cdn.mxpnl.com/libs/mixpanel-recorder.min.js',
|
|
165
|
+
'remote_settings_mode': SETTING_DISABLED // 'strict', 'fallback', 'disabled'
|
|
162
166
|
};
|
|
163
167
|
|
|
164
168
|
var DOM_LOADED = false;
|
|
@@ -396,7 +400,16 @@ MixpanelLib.prototype._init = function(token, config, name) {
|
|
|
396
400
|
this.autocapture.init();
|
|
397
401
|
|
|
398
402
|
this._init_tab_id();
|
|
399
|
-
|
|
403
|
+
|
|
404
|
+
// Based on remote_settings_mode, fetch remote settings and then start session recording if applicable
|
|
405
|
+
var mode = this.get_config('remote_settings_mode');
|
|
406
|
+
if (mode === SETTING_STRICT || mode === SETTING_FALLBACK) {
|
|
407
|
+
this._fetch_remote_settings(mode).then(_.bind(function() {
|
|
408
|
+
this._check_and_start_session_recording();
|
|
409
|
+
}, this));
|
|
410
|
+
} else {
|
|
411
|
+
this._check_and_start_session_recording();
|
|
412
|
+
}
|
|
400
413
|
};
|
|
401
414
|
|
|
402
415
|
/**
|
|
@@ -821,6 +834,77 @@ MixpanelLib.prototype._send_request = function(url, data, options, callback) {
|
|
|
821
834
|
return succeeded;
|
|
822
835
|
};
|
|
823
836
|
|
|
837
|
+
MixpanelLib.prototype._fetch_remote_settings = function(mode) {
|
|
838
|
+
var disableRecordingIfStrict = function() {
|
|
839
|
+
if (mode === 'strict') {
|
|
840
|
+
self.set_config({'record_sessions_percent': 0});
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
if (!window['AbortController']) {
|
|
845
|
+
console.critical('Remote settings unavailable: missing minimum required APIs');
|
|
846
|
+
disableRecordingIfStrict();
|
|
847
|
+
return Promise.resolve();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
var settings_endpoint = this.get_api_host('settings') + '/' + this.get_config('api_routes')['settings'];
|
|
851
|
+
var request_params = {
|
|
852
|
+
'$lib_version': Config.LIB_VERSION,
|
|
853
|
+
'mp_lib': 'web',
|
|
854
|
+
'sdk_config': '1',
|
|
855
|
+
};
|
|
856
|
+
var query_string = _.HTTPBuildQuery(request_params);
|
|
857
|
+
var full_url = settings_endpoint + '?' + query_string;
|
|
858
|
+
var self = this;
|
|
859
|
+
|
|
860
|
+
var abortController = new AbortController();
|
|
861
|
+
var timeout_id = setTimeout(function() {
|
|
862
|
+
abortController.abort();
|
|
863
|
+
}, 500);
|
|
864
|
+
var fetchOptions = {
|
|
865
|
+
'method': 'GET',
|
|
866
|
+
'headers': {
|
|
867
|
+
'Authorization': 'Basic ' + btoa(self.get_config('token') + ':'),
|
|
868
|
+
},
|
|
869
|
+
'signal': abortController.signal
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
return window['fetch'](full_url, fetchOptions).then(function(response) {
|
|
873
|
+
clearTimeout(timeout_id);
|
|
874
|
+
if (!response['ok']) {
|
|
875
|
+
console.critical('Network response was not ok');
|
|
876
|
+
disableRecordingIfStrict();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
return response.json();
|
|
880
|
+
}).then(function(result) {
|
|
881
|
+
if (result && result['sdk_config'] && result['sdk_config']['config']) {
|
|
882
|
+
var remote_config = result['sdk_config']['config'];
|
|
883
|
+
|
|
884
|
+
// Verify that remote config contains only valid keys from DEFAULT_CONFIG
|
|
885
|
+
var valid_config = {};
|
|
886
|
+
_.each(remote_config, function(value, key) {
|
|
887
|
+
if (DEFAULT_CONFIG.hasOwnProperty(key)) {
|
|
888
|
+
valid_config[key] = value;
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
if (_.isEmptyObject(valid_config)) {
|
|
893
|
+
console.critical('No valid config keys found in remote settings.');
|
|
894
|
+
disableRecordingIfStrict();
|
|
895
|
+
} else {
|
|
896
|
+
self.set_config(valid_config);
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
disableRecordingIfStrict();
|
|
900
|
+
}
|
|
901
|
+
}).catch(function(err) {
|
|
902
|
+
clearTimeout(timeout_id);
|
|
903
|
+
console.critical('Failed to fetch remote settings', err);
|
|
904
|
+
disableRecordingIfStrict();
|
|
905
|
+
});
|
|
906
|
+
};
|
|
907
|
+
|
|
824
908
|
/**
|
|
825
909
|
* _execute_array() deals with processing any mixpanel function
|
|
826
910
|
* calls that were called before the Mixpanel library were loaded
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} MaskingConfig
|
|
3
|
+
* @property {string} maskingSelector - Combined CSS selector string to mask
|
|
4
|
+
* @property {string} unmaskingSelector - Combined CSS selector string to unmask
|
|
5
|
+
* @property {boolean} maskAll - Whether to mask all by default
|
|
6
|
+
* @property {RegExp} [_legacyClassRegex] - Legacy class regex for backwards compatibility
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} PrivacyConfig
|
|
11
|
+
* @property {MaskingConfig} input - Input masking configuration
|
|
12
|
+
* @property {MaskingConfig} text - Text masking configuration
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { classMatchesRegex } from './rrweb-entrypoint';
|
|
16
|
+
import { elementLooksSensitive } from '../autocapture/utils';
|
|
17
|
+
|
|
18
|
+
var COMMON_MASK_CLASSES_SELECTOR = '.mp-mask, .fs-mask, .amp-mask, .rr-mask, .ph-mask';
|
|
19
|
+
var RRWEB_PASSWORD_ATTRIBUTE = 'data-rr-is-password';
|
|
20
|
+
var ALWAYS_MASKED_INPUT_TYPES = ['password', 'email', 'tel', 'hidden'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalizes a selector value to an array of selectors
|
|
24
|
+
* @param {string|string[]|undefined} selector
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
*/
|
|
27
|
+
function normalizeSelectors(selector) {
|
|
28
|
+
if (!selector) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(selector)) {
|
|
32
|
+
return selector;
|
|
33
|
+
}
|
|
34
|
+
return [selector];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reads flat config options and normalizes them into internal privacy config structure
|
|
39
|
+
* @param {Object} mixpanelInstance
|
|
40
|
+
* @returns {PrivacyConfig} privacyConfig
|
|
41
|
+
*/
|
|
42
|
+
function getPrivacyConfig(mixpanelInstance) {
|
|
43
|
+
var privacyConfig = {
|
|
44
|
+
input: {
|
|
45
|
+
maskingSelector: '',
|
|
46
|
+
unmaskingSelector: '',
|
|
47
|
+
maskAll: true
|
|
48
|
+
},
|
|
49
|
+
text: {
|
|
50
|
+
maskingSelector: '',
|
|
51
|
+
unmaskingSelector: '',
|
|
52
|
+
maskAll: true
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Input related config
|
|
57
|
+
var maskInputSelector = mixpanelInstance.get_config('record_mask_input_selector');
|
|
58
|
+
var unmaskInputSelector = mixpanelInstance.get_config('record_unmask_input_selector');
|
|
59
|
+
var maskAllInputs = mixpanelInstance.get_config('record_mask_all_inputs');
|
|
60
|
+
|
|
61
|
+
privacyConfig.input.maskingSelector = normalizeSelectors(maskInputSelector).join(',');
|
|
62
|
+
privacyConfig.input.unmaskingSelector = normalizeSelectors(unmaskInputSelector).join(',');
|
|
63
|
+
if (maskAllInputs !== undefined) {
|
|
64
|
+
privacyConfig.input.maskAll = maskAllInputs;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Text related config
|
|
68
|
+
var maskTextSelector = mixpanelInstance.get_config('record_mask_text_selector');
|
|
69
|
+
var unmaskTextSelector = mixpanelInstance.get_config('record_unmask_text_selector');
|
|
70
|
+
var maskAllText = mixpanelInstance.get_config('record_mask_all_text');
|
|
71
|
+
var legacyMaskTextClass = mixpanelInstance.get_config('record_mask_text_class');
|
|
72
|
+
|
|
73
|
+
var textMaskingSelectors = normalizeSelectors(maskTextSelector);
|
|
74
|
+
|
|
75
|
+
// Handle legacy record_mask_text_class
|
|
76
|
+
if (legacyMaskTextClass) {
|
|
77
|
+
if (legacyMaskTextClass instanceof RegExp) {
|
|
78
|
+
// For RegExp classes, we'll need to handle this differently in the shouldMaskText function
|
|
79
|
+
privacyConfig.text._legacyClassRegex = legacyMaskTextClass;
|
|
80
|
+
} else {
|
|
81
|
+
// String class name - convert to selector
|
|
82
|
+
var classSelector = '.' + legacyMaskTextClass;
|
|
83
|
+
if (textMaskingSelectors.indexOf(classSelector) === -1) {
|
|
84
|
+
textMaskingSelectors.push(classSelector);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
privacyConfig.text.maskingSelector = textMaskingSelectors.join(',');
|
|
90
|
+
privacyConfig.text.unmaskingSelector = normalizeSelectors(unmaskTextSelector).join(',');
|
|
91
|
+
|
|
92
|
+
// Migrate old config: if only record_mask_text_selector is specified, set maskAll to false
|
|
93
|
+
// preserves behavior where the masking selector defaulted to "*"
|
|
94
|
+
if (maskAllText === undefined && maskTextSelector !== undefined) {
|
|
95
|
+
privacyConfig.text.maskAll = false;
|
|
96
|
+
} else if (maskAllText !== undefined) {
|
|
97
|
+
privacyConfig.text.maskAll = maskAllText;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return privacyConfig;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Checks if element matches a combined CSS selector
|
|
105
|
+
* @param {HTMLElement} element
|
|
106
|
+
* @param {string} selector - Combined CSS selector (comma-separated)
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
function elementMatchesSelector(element, selector) {
|
|
110
|
+
if (!selector) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return !!element.closest(selector);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Determines if an input should be masked based on privacy config
|
|
118
|
+
* @param {HTMLElement} element
|
|
119
|
+
* @param {PrivacyConfig} privacyConfig
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
function shouldMaskInput(element, privacyConfig) {
|
|
123
|
+
var inputType = (element.getAttribute('type') || '').toLowerCase();
|
|
124
|
+
if (ALWAYS_MASKED_INPUT_TYPES.indexOf(inputType) !== -1) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
var autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase();
|
|
129
|
+
if (autocomplete && autocomplete !== '' && autocomplete !== 'off') {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (element.hasAttribute(RRWEB_PASSWORD_ATTRIBUTE)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (elementLooksSensitive(element)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (privacyConfig.input.maskAll) {
|
|
142
|
+
if (elementMatchesSelector(element, privacyConfig.input.unmaskingSelector)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return true;
|
|
147
|
+
} else {
|
|
148
|
+
if (elementMatchesSelector(element, privacyConfig.input.maskingSelector)) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (elementMatchesSelector(element, COMMON_MASK_CLASSES_SELECTOR)) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Determines if text should be masked based on privacy config
|
|
160
|
+
* @param {HTMLElement|null} element
|
|
161
|
+
* @param {PrivacyConfig} privacyConfig
|
|
162
|
+
* @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
function shouldMaskText(element, privacyConfig) {
|
|
165
|
+
if (!element) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check legacy class regex if present (for backwards compatibility)
|
|
170
|
+
if (privacyConfig.text._legacyClassRegex) {
|
|
171
|
+
if (classMatchesRegex(element, privacyConfig.text._legacyClassRegex, true)) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (privacyConfig.text.maskAll) {
|
|
177
|
+
if (elementMatchesSelector(element, privacyConfig.text.unmaskingSelector)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
} else {
|
|
182
|
+
if (elementMatchesSelector(element, privacyConfig.text.maskingSelector)) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (elementMatchesSelector(element, COMMON_MASK_CLASSES_SELECTOR)) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export {
|
|
193
|
+
COMMON_MASK_CLASSES_SELECTOR,
|
|
194
|
+
getPrivacyConfig,
|
|
195
|
+
shouldMaskInput,
|
|
196
|
+
shouldMaskText
|
|
197
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// this file exists as an entry point to be able to transpile rrweb packages to es5
|
|
2
2
|
// compatible code without needing to transpile the entire mixpanel-js codebase
|
|
3
3
|
import {record, EventType, IncrementalSource} from '@mixpanel/rrweb';
|
|
4
|
+
import {classMatchesRegex} from '@mixpanel/rrweb-snapshot';
|
|
4
5
|
import {getRecordConsolePlugin} from '@mixpanel/rrweb-plugin-console-record';
|
|
5
6
|
|
|
6
|
-
export { record, EventType, IncrementalSource, getRecordConsolePlugin };
|
|
7
|
+
export { record, EventType, IncrementalSource, getRecordConsolePlugin, classMatchesRegex };
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('../index').RecordPrivacyConfig} RecordPrivacyConfig
|
|
3
|
+
*/
|
|
4
|
+
|
|
1
5
|
import { window } from '../window';
|
|
2
|
-
import {
|
|
6
|
+
import { EventType, getRecordConsolePlugin, IncrementalSource } from './rrweb-entrypoint';
|
|
3
7
|
import { MAX_RECORDING_MS, MAX_VALUE_FOR_MIN_RECORDING_MS, console_with_prefix, NOOP_FUNC, _, localStorageSupported, canUseCompressionStream, navigator, userAgent, windowOpera} from '../utils'; // eslint-disable-line camelcase
|
|
4
8
|
import { IDBStorageWrapper, RECORDING_EVENTS_STORE_NAME } from '../storage/indexed-db';
|
|
5
9
|
import { addOptOutCheckMixpanelLib } from '../gdpr-utils';
|
|
6
10
|
import { RequestBatcher } from '../request-batcher';
|
|
11
|
+
|
|
7
12
|
import Config from '../config';
|
|
8
13
|
import { RECORD_ENQUEUE_THROTTLE_MS } from './utils';
|
|
14
|
+
import { shouldMaskInput, shouldMaskText, getPrivacyConfig } from './masking';
|
|
9
15
|
|
|
10
16
|
var logger = console_with_prefix('recorder');
|
|
11
17
|
var CompressionStream = window['CompressionStream'];
|
|
@@ -52,7 +58,7 @@ function isUserEvent(ev) {
|
|
|
52
58
|
* @property {String} [options.replayId] - unique uuid for a single replay
|
|
53
59
|
* @property {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
|
|
54
60
|
* @property {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
|
|
55
|
-
* @property {
|
|
61
|
+
* @property {import('./rrweb-entrypoint').record} [options.rrwebRecord] - rrweb's `record` function
|
|
56
62
|
* @property {Function} [options.onBatchSent] - callback when a batch of events is sent to the server
|
|
57
63
|
* @property {Storage} [options.sharedLockStorage] - optional storage for shared lock, used for test dependency injection
|
|
58
64
|
* optional properties for deserialization:
|
|
@@ -233,6 +239,8 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
|
|
|
233
239
|
blockSelector = undefined;
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
var privacyConfig = getPrivacyConfig(this._mixpanel);
|
|
243
|
+
|
|
236
244
|
try {
|
|
237
245
|
this._stopRecording = this._rrwebRecord({
|
|
238
246
|
'emit': function (ev) {
|
|
@@ -262,9 +270,11 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
|
|
|
262
270
|
'type': 'image/webp',
|
|
263
271
|
'quality': 0.6
|
|
264
272
|
},
|
|
273
|
+
// mask all inputs and text by default, unmasking is applied via callbacks maskInputFn and maskTextFn
|
|
265
274
|
'maskAllInputs': true,
|
|
266
|
-
'
|
|
267
|
-
'
|
|
275
|
+
'maskTextSelector': '*',
|
|
276
|
+
'maskInputFn': this._getMaskFn(shouldMaskInput, privacyConfig),
|
|
277
|
+
'maskTextFn': this._getMaskFn(shouldMaskText, privacyConfig),
|
|
268
278
|
'recordCanvas': this.getConfig('record_canvas'),
|
|
269
279
|
'sampling': {
|
|
270
280
|
'canvas': 15
|
|
@@ -536,4 +546,33 @@ SessionRecording.prototype._getRecordMinMs = function() {
|
|
|
536
546
|
return configValue;
|
|
537
547
|
};
|
|
538
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Creates a masking function for rrweb's maskInputFn or maskTextFn
|
|
551
|
+
* @param {(element: HTMLElement, privacyConfig: RecordPrivacyConfig) => boolean} shouldMaskFn - Function that determines if element should be masked
|
|
552
|
+
* @param {RecordPrivacyConfig} privacyConfig - Privacy configuration
|
|
553
|
+
* @returns {(text: string, element: HTMLElement) => string} Function that masks text based on privacy config
|
|
554
|
+
*/
|
|
555
|
+
SessionRecording.prototype._getMaskFn = function(shouldMaskFn, privacyConfig) {
|
|
556
|
+
return function(text, element) {
|
|
557
|
+
// prevent visual artifacts from random whitespace
|
|
558
|
+
if (!text.trim().length) {
|
|
559
|
+
return '';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
var shouldMask = true;
|
|
563
|
+
try {
|
|
564
|
+
shouldMask = shouldMaskFn(element, privacyConfig);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
this.reportError('Error checking if text should be masked, defaulting to masked', err);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (shouldMask) {
|
|
570
|
+
var textLength = Math.min(text.length, 10000); // limit to 10000 chars to optimize performance
|
|
571
|
+
return '*'.repeat(textLength);
|
|
572
|
+
} else {
|
|
573
|
+
return text;
|
|
574
|
+
}
|
|
575
|
+
}.bind(this);
|
|
576
|
+
};
|
|
577
|
+
|
|
539
578
|
export { SessionRecording };
|
package/src/recorder/utils.js
CHANGED
|
@@ -7,6 +7,10 @@ var isRecordingExpired = function(serializedRecording) {
|
|
|
7
7
|
return !serializedRecording || now > serializedRecording['maxExpires'] || now > serializedRecording['idleExpires'];
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
|
|
10
11
|
var RECORD_ENQUEUE_THROTTLE_MS = 250;
|
|
11
12
|
|
|
12
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
isRecordingExpired,
|
|
15
|
+
RECORD_ENQUEUE_THROTTLE_MS
|
|
16
|
+
};
|
package/src/utils.js
CHANGED
|
@@ -1115,8 +1115,17 @@ function _storageWrapper(storage, name, is_supported_fn) {
|
|
|
1115
1115
|
};
|
|
1116
1116
|
}
|
|
1117
1117
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1118
|
+
// Safari errors out accessing localStorage/sessionStorage when cookies are disabled,
|
|
1119
|
+
// so create dummy storage wrappers that silently fail as a fallback.
|
|
1120
|
+
var windowLocalStorage = null, windowSessionStorage = null;
|
|
1121
|
+
try {
|
|
1122
|
+
windowLocalStorage = window.localStorage;
|
|
1123
|
+
windowSessionStorage = window.sessionStorage;
|
|
1124
|
+
// eslint-disable-next-line no-empty
|
|
1125
|
+
} catch (_err) {}
|
|
1126
|
+
|
|
1127
|
+
_.localStorage = _storageWrapper(windowLocalStorage, 'localStorage', localStorageSupported);
|
|
1128
|
+
_.sessionStorage = _storageWrapper(windowSessionStorage, 'sessionStorage', sessionStorageSupported);
|
|
1120
1129
|
|
|
1121
1130
|
_.register_event = (function() {
|
|
1122
1131
|
// written by Dean Edwards, 2005
|
package/testServer.js
CHANGED
|
@@ -1,14 +1,40 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const cookieParser = require('cookie-parser');
|
|
5
|
+
const logger = require('morgan');
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const app = express();
|
|
8
|
+
|
|
9
|
+
// Test suite definitions
|
|
10
|
+
const TEST_SUITES = {
|
|
11
|
+
'dev': {
|
|
12
|
+
name: 'Development Build',
|
|
13
|
+
description: 'Unminified library for easier debugging',
|
|
14
|
+
customLibUrl: './static/build/mixpanel.js',
|
|
15
|
+
snippetUrl: './static/src/loaders/mixpanel-jslib-snippet.js',
|
|
16
|
+
testUrl: './static/build/test/browser/snippet-test.js'
|
|
17
|
+
},
|
|
18
|
+
'minified': {
|
|
19
|
+
name: 'Minified Build',
|
|
20
|
+
description: 'Production build (closure compiled, served via Mixpanel CDN)',
|
|
21
|
+
customLibUrl: './static/build/mixpanel.min.js',
|
|
22
|
+
snippetUrl: './static/build/mixpanel-jslib-snippet.min.test.js',
|
|
23
|
+
testUrl: './static/build/test/browser/snippet-test.js'
|
|
24
|
+
},
|
|
25
|
+
'module-cjs': {
|
|
26
|
+
name: 'CommonJS Module',
|
|
27
|
+
description: 'Node.js compatible module (served via npm install)',
|
|
28
|
+
testUrl: './static/build/test/browser/module-cjs-test.js'
|
|
29
|
+
}
|
|
30
|
+
};
|
|
8
31
|
|
|
9
32
|
app.use(cookieParser());
|
|
10
33
|
app.use(logger('dev'));
|
|
11
34
|
|
|
35
|
+
app.set('views', __dirname + '/tests');
|
|
36
|
+
app.set('view engine', 'pug');
|
|
37
|
+
|
|
12
38
|
app.use('/tests', express.static(__dirname + "/tests"));
|
|
13
39
|
app.get('/tests/cookie_included/:cookieName', function(req, res) {
|
|
14
40
|
if (req.cookies && req.cookies[req.params.cookieName]) {
|
|
@@ -19,9 +45,27 @@ app.get('/tests/cookie_included/:cookieName', function(req, res) {
|
|
|
19
45
|
});
|
|
20
46
|
app.use(express.static(__dirname));
|
|
21
47
|
app.get('/', function(req, res) {
|
|
22
|
-
res.redirect(301, '/tests/');
|
|
48
|
+
res.redirect(301, '/tests/new');
|
|
23
49
|
});
|
|
24
50
|
|
|
25
|
-
|
|
26
|
-
|
|
51
|
+
app.get('/tests/new', function(req, res) {
|
|
52
|
+
res.render('directory.pug', { testSuites: TEST_SUITES });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
app.use('/tests/new/static', express.static(__dirname));
|
|
56
|
+
|
|
57
|
+
// register routes for each test suite
|
|
58
|
+
for (const [suiteId, suite] of Object.entries(TEST_SUITES)) {
|
|
59
|
+
app.get('/tests/new/' + suiteId, function(req, res) {
|
|
60
|
+
res.render('integration.pug', {
|
|
61
|
+
suiteName: suite.name,
|
|
62
|
+
customLibUrl: suite.customLibUrl,
|
|
63
|
+
snippetUrl: suite.snippetUrl,
|
|
64
|
+
testUrl: suite.testUrl
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const server = app.listen(3001, function () {
|
|
70
|
+
console.log(`Mixpanel test app listening on port ${server.address().port}`);
|
|
27
71
|
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
name: Tests
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [master]
|
|
6
|
-
pull_request:
|
|
7
|
-
branches: [master]
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
build:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
|
|
13
|
-
strategy:
|
|
14
|
-
matrix:
|
|
15
|
-
node-version: [20.x, 22.x]
|
|
16
|
-
|
|
17
|
-
steps:
|
|
18
|
-
- uses: actions/checkout@v4
|
|
19
|
-
- name: Use Node.js ${{ matrix.node-version }}
|
|
20
|
-
uses: actions/setup-node@v4
|
|
21
|
-
with:
|
|
22
|
-
node-version: ${{ matrix.node-version }}
|
|
23
|
-
- run: npm ci
|
|
24
|
-
- run: npm test
|
|
25
|
-
- run: npm run build-dist
|