mixpanel-browser 2.73.0 → 2.75.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 +7 -4
- package/.github/workflows/integration-tests.yml +52 -0
- package/.github/workflows/unit-tests.yml +40 -0
- package/CHANGELOG.md +12 -0
- package/README.md +3 -3
- package/build.sh +1 -5
- package/dist/mixpanel-core.cjs.d.ts +12 -1
- package/dist/mixpanel-core.cjs.js +432 -34
- package/dist/mixpanel-recorder.js +5364 -684
- package/dist/mixpanel-recorder.min.js +1 -1
- package/dist/mixpanel-recorder.min.js.map +1 -1
- package/dist/mixpanel-targeting.js +2576 -0
- package/dist/mixpanel-targeting.min.js +2 -0
- package/dist/mixpanel-targeting.min.js.map +1 -0
- package/dist/mixpanel-with-async-modules.cjs.d.ts +522 -0
- package/dist/mixpanel-with-async-modules.cjs.js +9700 -0
- package/dist/mixpanel-with-async-recorder.cjs.d.ts +12 -1
- package/dist/mixpanel-with-async-recorder.cjs.js +432 -34
- package/dist/mixpanel-with-recorder.d.ts +12 -1
- package/dist/mixpanel-with-recorder.js +7889 -2839
- 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 +8446 -2813
- package/dist/mixpanel.cjs.d.ts +12 -1
- package/dist/mixpanel.cjs.js +8446 -2813
- package/dist/mixpanel.globals.js +432 -34
- package/dist/mixpanel.min.js +182 -173
- package/dist/mixpanel.module.d.ts +12 -1
- package/dist/mixpanel.module.js +8446 -2813
- package/dist/mixpanel.umd.d.ts +12 -1
- package/dist/mixpanel.umd.js +8446 -2813
- package/dist/rrweb-bundled.js +4434 -596
- package/dist/rrweb-compiled.js +5078 -646
- package/package.json +33 -7
- package/rollup.config.mjs +286 -224
- package/src/autocapture/utils.js +15 -7
- package/src/config.js +1 -1
- package/src/flags/index.js +269 -8
- package/src/globals.js +14 -0
- package/src/index.d.ts +12 -1
- package/src/loaders/loader-module.js +1 -0
- package/src/mixpanel-core.js +101 -8
- package/src/recorder/index.js +2 -1
- 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/targeting/event-matcher.js +97 -0
- package/src/targeting/index.js +11 -0
- package/src/targeting/loader.js +36 -0
- package/src/utils.js +12 -10
- package/testServer.js +51 -7
- package/.github/workflows/tests.yml +0 -25
- /package/src/loaders/{loader-module-with-async-recorder.js → loader-module-with-async-modules.js} +0 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { _ } from '../utils';
|
|
2
|
+
import jsonLogic from 'json-logic-js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared helper to recursively lowercase strings in nested structures
|
|
6
|
+
* @param {*} obj - Value to process
|
|
7
|
+
* @param {boolean} lowercaseKeys - Whether to lowercase object keys
|
|
8
|
+
* @returns {*} Processed value with lowercased strings
|
|
9
|
+
*/
|
|
10
|
+
var lowercaseJson = function(obj, lowercaseKeys) {
|
|
11
|
+
if (obj === null || obj === undefined) {
|
|
12
|
+
return obj;
|
|
13
|
+
} else if (typeof obj === 'string') {
|
|
14
|
+
return obj.toLowerCase();
|
|
15
|
+
} else if (Array.isArray(obj)) {
|
|
16
|
+
return obj.map(function(item) {
|
|
17
|
+
return lowercaseJson(item, lowercaseKeys);
|
|
18
|
+
});
|
|
19
|
+
} else if (obj === Object(obj)) {
|
|
20
|
+
var result = {};
|
|
21
|
+
for (var key in obj) {
|
|
22
|
+
if (obj.hasOwnProperty(key)) {
|
|
23
|
+
var newKey = lowercaseKeys && typeof key === 'string' ? key.toLowerCase() : key;
|
|
24
|
+
result[newKey] = lowercaseJson(obj[key], lowercaseKeys);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
} else {
|
|
29
|
+
return obj;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lowercase all string keys and values in a nested structure
|
|
35
|
+
* @param {*} val - Value to process
|
|
36
|
+
* @returns {*} Processed value with lowercased strings
|
|
37
|
+
*/
|
|
38
|
+
var lowercaseKeysAndValues = function(val) {
|
|
39
|
+
return lowercaseJson(val, true);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Lowercase only leaf node string values in a nested structure (keys unchanged)
|
|
44
|
+
* @param {*} val - Value to process
|
|
45
|
+
* @returns {*} Processed value with lowercased leaf strings
|
|
46
|
+
*/
|
|
47
|
+
var lowercaseOnlyLeafNodes = function(val) {
|
|
48
|
+
return lowercaseJson(val, false);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if an event matches the given criteria
|
|
53
|
+
* @param {string} eventName - The name of the event being checked
|
|
54
|
+
* @param {Object} properties - Event properties to evaluate against property filters
|
|
55
|
+
* @param {Object} criteria - Criteria to match against, with:
|
|
56
|
+
* - event_name: string - Required event name (case-sensitive match)
|
|
57
|
+
* - property_filters: Object - Optional JsonLogic filters for properties
|
|
58
|
+
* @returns {Object} Result object with:
|
|
59
|
+
* - matches: boolean - Whether the event matches the criteria
|
|
60
|
+
* - error: string|undefined - Error message if evaluation failed
|
|
61
|
+
*/
|
|
62
|
+
var eventMatchesCriteria = function(eventName, properties, criteria) {
|
|
63
|
+
// Check exact event name match (case-sensitive)
|
|
64
|
+
if (eventName !== criteria.event_name) {
|
|
65
|
+
return { matches: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Evaluate property filters using JsonLogic
|
|
69
|
+
var propertyFilters = criteria.property_filters;
|
|
70
|
+
var filtersMatch = true; // default to true if no filters
|
|
71
|
+
|
|
72
|
+
if (propertyFilters && !_.isEmptyObject(propertyFilters)) {
|
|
73
|
+
try {
|
|
74
|
+
// Lowercase all keys and values in event properties for case-insensitive matching
|
|
75
|
+
var lowercasedProperties = lowercaseKeysAndValues(properties || {});
|
|
76
|
+
|
|
77
|
+
// Lowercase only leaf nodes in JsonLogic filters (keep operators intact)
|
|
78
|
+
var lowercasedFilters = lowercaseOnlyLeafNodes(propertyFilters);
|
|
79
|
+
|
|
80
|
+
filtersMatch = jsonLogic.apply(lowercasedFilters, lowercasedProperties);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return {
|
|
83
|
+
matches: false,
|
|
84
|
+
error: error.toString()
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { matches: filtersMatch };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export {
|
|
93
|
+
lowercaseJson,
|
|
94
|
+
lowercaseKeysAndValues,
|
|
95
|
+
lowercaseOnlyLeafNodes,
|
|
96
|
+
eventMatchesCriteria
|
|
97
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { window } from '../window';
|
|
2
|
+
import { TARGETING_GLOBAL_NAME } from '../globals';
|
|
3
|
+
import { eventMatchesCriteria } from './event-matcher';
|
|
4
|
+
|
|
5
|
+
// Create targeting library object
|
|
6
|
+
var targetingLibrary = {};
|
|
7
|
+
targetingLibrary['eventMatchesCriteria'] = eventMatchesCriteria;
|
|
8
|
+
|
|
9
|
+
// Set global Promise (use bracket notation to prevent minification)
|
|
10
|
+
// This is the ONE AND ONLY global - matches recorder pattern
|
|
11
|
+
window[TARGETING_GLOBAL_NAME] = Promise.resolve(targetingLibrary);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { window } from '../window';
|
|
2
|
+
import { TARGETING_GLOBAL_NAME } from '../globals';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get the promise-based targeting loader
|
|
6
|
+
* @param {Function} loadExtraBundle - Function to load external bundle (callback-based)
|
|
7
|
+
* @param {string} targetingSrc - URL to targeting bundle
|
|
8
|
+
* @returns {Promise} Promise that resolves with targeting library
|
|
9
|
+
*/
|
|
10
|
+
var getTargetingPromise = function(loadExtraBundle, targetingSrc) {
|
|
11
|
+
// Return existing promise if already initialized or loading
|
|
12
|
+
if (window[TARGETING_GLOBAL_NAME] && typeof window[TARGETING_GLOBAL_NAME].then === 'function') {
|
|
13
|
+
return window[TARGETING_GLOBAL_NAME];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Create loading promise and set it as the global immediately
|
|
17
|
+
// This makes minified build behavior consistent with dev/CJS builds
|
|
18
|
+
window[TARGETING_GLOBAL_NAME] = new Promise(function (resolve) {
|
|
19
|
+
loadExtraBundle(targetingSrc, resolve);
|
|
20
|
+
}).then(function () {
|
|
21
|
+
var p = window[TARGETING_GLOBAL_NAME];
|
|
22
|
+
if (p && typeof p.then === 'function') {
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
throw new Error('targeting failed to load');
|
|
26
|
+
}).catch(function (err) {
|
|
27
|
+
delete window[TARGETING_GLOBAL_NAME];
|
|
28
|
+
throw err;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return window[TARGETING_GLOBAL_NAME];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
getTargetingPromise
|
|
36
|
+
};
|
package/src/utils.js
CHANGED
|
@@ -204,15 +204,8 @@ _.isArray = nativeIsArray || function(obj) {
|
|
|
204
204
|
return toString.call(obj) === '[object Array]';
|
|
205
205
|
};
|
|
206
206
|
|
|
207
|
-
// from a comment on http://dbj.org/dbj/?p=286
|
|
208
|
-
// fails on only one very rare and deliberate custom object:
|
|
209
|
-
// var bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }};
|
|
210
207
|
_.isFunction = function(f) {
|
|
211
|
-
|
|
212
|
-
return /^\s*\bfunction\b/.test(f);
|
|
213
|
-
} catch (x) {
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
208
|
+
return typeof f === 'function';
|
|
216
209
|
};
|
|
217
210
|
|
|
218
211
|
_.isArguments = function(obj) {
|
|
@@ -1115,8 +1108,17 @@ function _storageWrapper(storage, name, is_supported_fn) {
|
|
|
1115
1108
|
};
|
|
1116
1109
|
}
|
|
1117
1110
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1111
|
+
// Safari errors out accessing localStorage/sessionStorage when cookies are disabled,
|
|
1112
|
+
// so create dummy storage wrappers that silently fail as a fallback.
|
|
1113
|
+
var windowLocalStorage = null, windowSessionStorage = null;
|
|
1114
|
+
try {
|
|
1115
|
+
windowLocalStorage = window.localStorage;
|
|
1116
|
+
windowSessionStorage = window.sessionStorage;
|
|
1117
|
+
// eslint-disable-next-line no-empty
|
|
1118
|
+
} catch (_err) {}
|
|
1119
|
+
|
|
1120
|
+
_.localStorage = _storageWrapper(windowLocalStorage, 'localStorage', localStorageSupported);
|
|
1121
|
+
_.sessionStorage = _storageWrapper(windowSessionStorage, 'sessionStorage', sessionStorageSupported);
|
|
1120
1122
|
|
|
1121
1123
|
_.register_event = (function() {
|
|
1122
1124
|
// 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
|
/package/src/loaders/{loader-module-with-async-recorder.js → loader-module-with-async-modules.js}
RENAMED
|
File without changes
|