mixpanel-browser 2.75.0 → 2.77.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 +14 -0
- package/.github/dependabot.yml +8 -0
- package/.github/workflows/integration-tests.yml +4 -4
- package/.github/workflows/unit-tests.yml +4 -4
- package/CHANGELOG.md +14 -0
- package/build.sh +10 -8
- package/dist/async-modules/mixpanel-recorder-DLKbUIEE.js +23669 -0
- package/dist/async-modules/mixpanel-recorder-wIWnMDLA.min.js +2 -0
- package/dist/async-modules/mixpanel-recorder-wIWnMDLA.min.js.map +1 -0
- package/dist/async-modules/mixpanel-targeting-CTcftSJC.min.js +2 -0
- package/dist/async-modules/mixpanel-targeting-CTcftSJC.min.js.map +1 -0
- package/dist/async-modules/mixpanel-targeting-CmVvUyFM.js +2520 -0
- package/dist/mixpanel-core.cjs.d.ts +70 -1
- package/dist/mixpanel-core.cjs.js +724 -426
- package/dist/mixpanel-recorder.js +791 -41
- package/dist/mixpanel-recorder.min.js +1 -1
- package/dist/mixpanel-recorder.min.js.map +1 -1
- package/dist/mixpanel-targeting.js +6 -62
- package/dist/mixpanel-targeting.min.js +1 -1
- package/dist/mixpanel-targeting.min.js.map +1 -1
- package/dist/mixpanel-with-async-modules.cjs.d.ts +70 -1
- package/dist/mixpanel-with-async-modules.cjs.js +724 -426
- package/dist/mixpanel-with-async-recorder.cjs.d.ts +70 -1
- package/dist/mixpanel-with-async-recorder.cjs.js +724 -426
- package/dist/mixpanel-with-recorder.d.ts +70 -1
- package/dist/mixpanel-with-recorder.js +1471 -450
- package/dist/mixpanel-with-recorder.min.d.ts +70 -1
- package/dist/mixpanel-with-recorder.min.js +1 -1
- package/dist/mixpanel.amd.d.ts +70 -1
- package/dist/mixpanel.amd.js +1473 -504
- package/dist/mixpanel.cjs.d.ts +70 -1
- package/dist/mixpanel.cjs.js +1473 -504
- package/dist/mixpanel.globals.js +724 -426
- package/dist/mixpanel.min.js +189 -182
- package/dist/mixpanel.module.d.ts +70 -1
- package/dist/mixpanel.module.js +1473 -504
- package/dist/mixpanel.umd.d.ts +70 -1
- package/dist/mixpanel.umd.js +1473 -504
- package/dist/rrweb-bundled.js +61 -9
- package/dist/rrweb-compiled.js +56 -9
- package/logo.svg +5 -0
- package/package.json +6 -4
- package/rollup.config.mjs +163 -46
- package/src/autocapture/index.js +10 -27
- package/src/config.js +9 -3
- package/src/flags/index.js +1 -2
- package/src/index.d.ts +70 -1
- package/src/mixpanel-core.js +77 -112
- package/src/recorder/index.js +1 -1
- package/src/recorder/recorder.js +5 -1
- package/src/recorder/rrweb-network-plugin.js +649 -0
- package/src/recorder/session-recording.js +36 -12
- package/src/recorder/utils.js +27 -1
- package/src/recorder-manager.js +324 -0
- package/src/request-batcher.js +1 -1
- package/src/targeting/event-matcher.js +2 -57
- package/src/targeting/index.js +1 -1
- package/src/targeting/loader.js +1 -1
- package/src/utils.js +13 -1
- package/testServer.js +69 -1
- package/src/globals.js +0 -14
- /package/src/loaders/{loader-module-with-async-recorder.d.ts → loader-module-with-async-modules.d.ts} +0 -0
|
@@ -9,9 +9,10 @@ import { IDBStorageWrapper, RECORDING_EVENTS_STORE_NAME } from '../storage/index
|
|
|
9
9
|
import { addOptOutCheckMixpanelLib } from '../gdpr-utils';
|
|
10
10
|
import { RequestBatcher } from '../request-batcher';
|
|
11
11
|
|
|
12
|
-
import Config from '../config';
|
|
13
|
-
import { RECORD_ENQUEUE_THROTTLE_MS } from './utils';
|
|
12
|
+
import { Config } from '../config';
|
|
13
|
+
import { RECORD_ENQUEUE_THROTTLE_MS, validateAllowedOrigins } from './utils';
|
|
14
14
|
import { shouldMaskInput, shouldMaskText, getPrivacyConfig } from './masking';
|
|
15
|
+
import { getRecordNetworkPlugin } from './rrweb-network-plugin';
|
|
15
16
|
|
|
16
17
|
var logger = console_with_prefix('recorder');
|
|
17
18
|
var CompressionStream = window['CompressionStream'];
|
|
@@ -241,6 +242,31 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
|
|
|
241
242
|
|
|
242
243
|
var privacyConfig = getPrivacyConfig(this._mixpanel);
|
|
243
244
|
|
|
245
|
+
var plugins = [];
|
|
246
|
+
if (this.getConfig('record_network')) {
|
|
247
|
+
var options = this.getConfig('record_network_options') || {};
|
|
248
|
+
// don't track requests to Mixpanel /record API
|
|
249
|
+
var ignoreRequestUrls = (options.ignoreRequestUrls || []).slice();
|
|
250
|
+
ignoreRequestUrls.push(this._getApiRoute());
|
|
251
|
+
options.ignoreRequestUrls = ignoreRequestUrls;
|
|
252
|
+
|
|
253
|
+
plugins.push(getRecordNetworkPlugin(options));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (this.getConfig('record_console')) {
|
|
257
|
+
plugins.push(
|
|
258
|
+
getRecordConsolePlugin({
|
|
259
|
+
stringifyOptions: {
|
|
260
|
+
stringLengthLimit: 1000,
|
|
261
|
+
numOfKeysLimit: 50,
|
|
262
|
+
depthOfLimit: 2
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
var validatedOrigins = validateAllowedOrigins(this.getConfig('record_allowed_iframe_origins'), logger);
|
|
269
|
+
|
|
244
270
|
try {
|
|
245
271
|
this._stopRecording = this._rrwebRecord({
|
|
246
272
|
'emit': function (ev) {
|
|
@@ -275,19 +301,13 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
|
|
|
275
301
|
'maskTextSelector': '*',
|
|
276
302
|
'maskInputFn': this._getMaskFn(shouldMaskInput, privacyConfig),
|
|
277
303
|
'maskTextFn': this._getMaskFn(shouldMaskText, privacyConfig),
|
|
304
|
+
'recordCrossOriginIframes': validatedOrigins.length > 0,
|
|
305
|
+
'allowedIframeOrigins': validatedOrigins,
|
|
278
306
|
'recordCanvas': this.getConfig('record_canvas'),
|
|
279
307
|
'sampling': {
|
|
280
308
|
'canvas': 15
|
|
281
309
|
},
|
|
282
|
-
'plugins':
|
|
283
|
-
getRecordConsolePlugin({
|
|
284
|
-
stringifyOptions: {
|
|
285
|
-
stringLengthLimit: 1000,
|
|
286
|
-
numOfKeysLimit: 50,
|
|
287
|
-
depthOfLimit: 2
|
|
288
|
-
}
|
|
289
|
-
})
|
|
290
|
-
] : []
|
|
310
|
+
'plugins': plugins,
|
|
291
311
|
});
|
|
292
312
|
} catch (err) {
|
|
293
313
|
this.reportError('Unexpected error when starting rrweb recording.', err);
|
|
@@ -402,6 +422,10 @@ SessionRecording.deserialize = function (serializedRecording, options) {
|
|
|
402
422
|
return recording;
|
|
403
423
|
};
|
|
404
424
|
|
|
425
|
+
SessionRecording.prototype._getApiRoute = function () {
|
|
426
|
+
return this.getConfig('api_routes')['record'];
|
|
427
|
+
};
|
|
428
|
+
|
|
405
429
|
SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, reqBody, callback) {
|
|
406
430
|
var onSuccess = function (response, responseBody) {
|
|
407
431
|
// Update batch specific props only if the request was successful to guarantee ordering.
|
|
@@ -421,7 +445,7 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
|
|
|
421
445
|
});
|
|
422
446
|
}.bind(this);
|
|
423
447
|
var apiHost = (this._mixpanel.get_api_host && this._mixpanel.get_api_host('record')) || this.getConfig('api_host');
|
|
424
|
-
window['fetch'](apiHost + '/' + this.
|
|
448
|
+
window['fetch'](apiHost + '/' + this._getApiRoute() + '?' + new URLSearchParams(reqParams), {
|
|
425
449
|
'method': 'POST',
|
|
426
450
|
'headers': {
|
|
427
451
|
'Authorization': 'Basic ' + btoa(this.getConfig('token') + ':'),
|
package/src/recorder/utils.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { _ } from '../utils';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @param {import('./session-recording').SerializedRecording} serializedRecording
|
|
3
5
|
* @returns {boolean}
|
|
@@ -10,7 +12,31 @@ var isRecordingExpired = function(serializedRecording) {
|
|
|
10
12
|
|
|
11
13
|
var RECORD_ENQUEUE_THROTTLE_MS = 250;
|
|
12
14
|
|
|
15
|
+
var validateAllowedOrigins = function(origins, logger) {
|
|
16
|
+
if (!_.isArray(origins)) {
|
|
17
|
+
if (origins) {
|
|
18
|
+
logger.critical('record_allowed_iframe_origins must be an array of origin strings, cross-origin recording will be disabled.');
|
|
19
|
+
}
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
var valid = [];
|
|
23
|
+
for (var i = 0; i < origins.length; i++) {
|
|
24
|
+
try {
|
|
25
|
+
var origin = new URL(origins[i]).origin;
|
|
26
|
+
if (origin === 'null') {
|
|
27
|
+
logger.critical(origins[i] + ' has an opaque origin. Skipping this entry.');
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
valid.push(origin);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
logger.critical(origins[i] + ' is not a valid origin URL. Skipping this entry.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return valid;
|
|
36
|
+
};
|
|
37
|
+
|
|
13
38
|
export {
|
|
14
39
|
isRecordingExpired,
|
|
15
|
-
RECORD_ENQUEUE_THROTTLE_MS
|
|
40
|
+
RECORD_ENQUEUE_THROTTLE_MS,
|
|
41
|
+
validateAllowedOrigins
|
|
16
42
|
};
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/* eslint camelcase: "off" */
|
|
2
|
+
|
|
3
|
+
import {RECORDER_FILENAME, TARGETING_FILENAME, RECORDER_GLOBAL_NAME} from './config';
|
|
4
|
+
import { _, console, console_with_prefix, safewrap, safewrapClass } from './utils';
|
|
5
|
+
import { window } from './window';
|
|
6
|
+
import { Promise } from './promise-polyfill';
|
|
7
|
+
import { IDBStorageWrapper, RECORDING_REGISTRY_STORE_NAME } from './storage/indexed-db';
|
|
8
|
+
import { isRecordingExpired, validateAllowedOrigins } from './recorder/utils';
|
|
9
|
+
import { getTargetingPromise } from './targeting/loader';
|
|
10
|
+
|
|
11
|
+
var logger = console_with_prefix('recorder');
|
|
12
|
+
|
|
13
|
+
var IFRAME_HANDSHAKE_REQUEST = 'mp_iframe_handshake_request';
|
|
14
|
+
var IFRAME_HANDSHAKE_RESPONSE = 'mp_iframe_handshake_response';
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* RecorderManager: manages session recording initialization, lifecycle and state
|
|
19
|
+
* @constructor
|
|
20
|
+
*/
|
|
21
|
+
var RecorderManager = function(initOptions) {
|
|
22
|
+
// TODO - Passing in mixpanel instance as it is still needed for recorder creation
|
|
23
|
+
// but ideally we should be able to remove this dependency.
|
|
24
|
+
this.mixpanelInstance = initOptions.mixpanelInstance;
|
|
25
|
+
|
|
26
|
+
this.getMpConfig = initOptions.getConfigFunc;
|
|
27
|
+
this.getTabId = initOptions.getTabIdFunc;
|
|
28
|
+
this.reportError = initOptions.reportErrorFunc;
|
|
29
|
+
this.getDistinctId = initOptions.getDistinctIdFunc;
|
|
30
|
+
this.loadExtraBundle = initOptions.loadExtraBundle;
|
|
31
|
+
this.recorderSrc = initOptions.recorderSrc;
|
|
32
|
+
this.targetingSrc = initOptions.targetingSrc;
|
|
33
|
+
this.libBasePath = initOptions.libBasePath;
|
|
34
|
+
|
|
35
|
+
this._recorder = null;
|
|
36
|
+
this._parentReplayId = null;
|
|
37
|
+
this._parentFrameRetryInterval = null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
RecorderManager.prototype.shouldLoadRecorder = function() {
|
|
41
|
+
if (this.getMpConfig('disable_persistence')) {
|
|
42
|
+
console.log('Load recorder check skipped due to disable_persistence config');
|
|
43
|
+
return Promise.resolve(false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
var recording_registry_idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
|
|
47
|
+
var tab_id = this.getTabId();
|
|
48
|
+
return recording_registry_idb.init()
|
|
49
|
+
.then(function () {
|
|
50
|
+
return recording_registry_idb.getAll();
|
|
51
|
+
})
|
|
52
|
+
.then(function (recordings) {
|
|
53
|
+
for (var i = 0; i < recordings.length; i++) {
|
|
54
|
+
// if there are expired recordings in the registry, we should load the recorder to flush them
|
|
55
|
+
// if there's a recording for this tab id, we should load the recorder to continue the recording
|
|
56
|
+
if (isRecordingExpired(recordings[i]) || recordings[i]['tabId'] === tab_id) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
})
|
|
62
|
+
.catch(_.bind(function (err) {
|
|
63
|
+
this.reportError('Error checking recording registry', err);
|
|
64
|
+
return false;
|
|
65
|
+
}, this));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
RecorderManager.prototype.checkAndStartSessionRecording = function(force_start, rate) {
|
|
69
|
+
if (!window['MutationObserver']) {
|
|
70
|
+
console.critical('Browser does not support MutationObserver; skipping session recording');
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
var loadRecorder = _.bind(function(startNewIfInactive) {
|
|
75
|
+
return new Promise(_.bind(function(resolve) {
|
|
76
|
+
var handleLoadedRecorder = safewrap(_.bind(function() {
|
|
77
|
+
this._recorder = this._recorder || new window[RECORDER_GLOBAL_NAME](this.mixpanelInstance);
|
|
78
|
+
this._recorder['resumeRecording'](startNewIfInactive);
|
|
79
|
+
resolve();
|
|
80
|
+
}, this));
|
|
81
|
+
|
|
82
|
+
if (_.isUndefined(window[RECORDER_GLOBAL_NAME])) {
|
|
83
|
+
var recorderSrc = this.recorderSrc || (this.libBasePath + RECORDER_FILENAME);
|
|
84
|
+
this.loadExtraBundle(recorderSrc, handleLoadedRecorder);
|
|
85
|
+
} else {
|
|
86
|
+
handleLoadedRecorder();
|
|
87
|
+
}
|
|
88
|
+
}, this));
|
|
89
|
+
}, this);
|
|
90
|
+
|
|
91
|
+
// Cross-origin iframe handling
|
|
92
|
+
var allowedOrigins = validateAllowedOrigins(this.getMpConfig('record_allowed_iframe_origins'), logger);
|
|
93
|
+
var isCrossOriginRecordingEnabled = allowedOrigins.length > 0;
|
|
94
|
+
|
|
95
|
+
if (isCrossOriginRecordingEnabled) {
|
|
96
|
+
// listen for handshake requests from their own child iframes (including nested)
|
|
97
|
+
this._setupParentFrameListener(allowedOrigins);
|
|
98
|
+
|
|
99
|
+
if (window.parent !== window) {
|
|
100
|
+
// also wait for parent's replay ID
|
|
101
|
+
this._setupChildFrameListener(allowedOrigins, loadRecorder);
|
|
102
|
+
this._sendParentFrameRequestWithRetry(allowedOrigins);
|
|
103
|
+
return Promise.resolve();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* If the user is sampled or start_session_recording is called, we always load the recorder since it's guaranteed a recording should start.
|
|
109
|
+
* Otherwise, if the recording registry has any records then it's likely there's a recording in progress or orphaned data that needs to be flushed.
|
|
110
|
+
*/
|
|
111
|
+
var effective_rate = _.isUndefined(rate) ? this.getMpConfig('record_sessions_percent') : rate;
|
|
112
|
+
var is_sampled = effective_rate > 0 && Math.random() * 100 <= effective_rate;
|
|
113
|
+
if (force_start || is_sampled) {
|
|
114
|
+
return loadRecorder(true);
|
|
115
|
+
} else {
|
|
116
|
+
return this.shouldLoadRecorder()
|
|
117
|
+
.then(_.bind(function (shouldLoad) {
|
|
118
|
+
if (shouldLoad) {
|
|
119
|
+
return loadRecorder(false);
|
|
120
|
+
}
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
}, this));
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
RecorderManager.prototype.isRecording = function() {
|
|
127
|
+
// Safety check: ensure isRecording method exists (older CDN builds may not have it)
|
|
128
|
+
if (!this._recorder || !_.isFunction(this._recorder['isRecording'])) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return this._recorder['isRecording']();
|
|
133
|
+
} catch (e) {
|
|
134
|
+
this.reportError('Error checking if recording is active', e);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
RecorderManager.prototype.startRecordingOnEvent = function(event_name, properties) {
|
|
140
|
+
var isRecording = this.isRecording();
|
|
141
|
+
var recordingTriggerEvents = this.getMpConfig('recording_event_triggers');
|
|
142
|
+
|
|
143
|
+
if (!isRecording && recordingTriggerEvents) {
|
|
144
|
+
var trigger = recordingTriggerEvents[event_name];
|
|
145
|
+
if (trigger && typeof trigger['percentage'] === 'number') {
|
|
146
|
+
var newRate = trigger['percentage'];
|
|
147
|
+
var propertyFilters = trigger['property_filters'];
|
|
148
|
+
if (propertyFilters && !_.isEmptyObject(propertyFilters)) {
|
|
149
|
+
var targetingSrc = this.targetingSrc || (this.libBasePath + TARGETING_FILENAME);
|
|
150
|
+
getTargetingPromise(this.loadExtraBundle, targetingSrc)
|
|
151
|
+
.then(function(targeting) {
|
|
152
|
+
try {
|
|
153
|
+
var result = targeting['eventMatchesCriteria'](
|
|
154
|
+
event_name,
|
|
155
|
+
properties,
|
|
156
|
+
{
|
|
157
|
+
'event_name': event_name,
|
|
158
|
+
'property_filters': propertyFilters
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
if (result['matches']) {
|
|
162
|
+
this.checkAndStartSessionRecording(false, newRate);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.critical('Could not parse recording event trigger properties logic:', err);
|
|
166
|
+
}
|
|
167
|
+
}.bind(this)).catch(function(err) {
|
|
168
|
+
console.critical('Failed to load targeting library:', err);
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
this.checkAndStartSessionRecording(false, newRate);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
RecorderManager.prototype.stopSessionRecording = function() {
|
|
178
|
+
if (this._recorder) {
|
|
179
|
+
return this._recorder['stopRecording']();
|
|
180
|
+
}
|
|
181
|
+
return Promise.resolve();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
RecorderManager.prototype.pauseSessionRecording = function() {
|
|
185
|
+
if (this._recorder) {
|
|
186
|
+
return this._recorder['pauseRecording']();
|
|
187
|
+
}
|
|
188
|
+
return Promise.resolve();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
RecorderManager.prototype.resumeSessionRecording = function() {
|
|
192
|
+
if (this._recorder) {
|
|
193
|
+
return this._recorder['resumeRecording']();
|
|
194
|
+
}
|
|
195
|
+
return Promise.resolve();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
RecorderManager.prototype.isRecordingHeatmapData = function() {
|
|
199
|
+
return this.getSessionReplayId() && this.getMpConfig('record_heatmap_data');
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
RecorderManager.prototype.getSessionRecordingProperties = function() {
|
|
203
|
+
var props = {};
|
|
204
|
+
var replay_id = this.getSessionReplayId();
|
|
205
|
+
if (replay_id) {
|
|
206
|
+
props['$mp_replay_id'] = replay_id;
|
|
207
|
+
}
|
|
208
|
+
return props;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
RecorderManager.prototype.getSessionReplayUrl = function() {
|
|
212
|
+
var replay_url = null;
|
|
213
|
+
var replay_id = this.getSessionReplayId();
|
|
214
|
+
if (replay_id) {
|
|
215
|
+
var query_params = _.HTTPBuildQuery({
|
|
216
|
+
'replay_id': replay_id,
|
|
217
|
+
'distinct_id': this.getDistinctId(),
|
|
218
|
+
'token': this.getMpConfig('token')
|
|
219
|
+
});
|
|
220
|
+
replay_url = 'https://mixpanel.com/projects/replay-redirect?' + query_params;
|
|
221
|
+
}
|
|
222
|
+
return replay_url;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
RecorderManager.prototype.getSessionReplayId = function() {
|
|
226
|
+
// Child iframe uses parent's replay ID
|
|
227
|
+
if (this._parentReplayId) {
|
|
228
|
+
return this._parentReplayId;
|
|
229
|
+
}
|
|
230
|
+
var replay_id = null;
|
|
231
|
+
if (this._recorder) {
|
|
232
|
+
replay_id = this._recorder['replayId'];
|
|
233
|
+
}
|
|
234
|
+
return replay_id || null;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// "private" public method to reach into the recorder in test cases
|
|
238
|
+
RecorderManager.prototype.getRecorder = function() {
|
|
239
|
+
return this._recorder;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
RecorderManager.prototype._setupChildFrameListener = function(allowedOrigins, loadRecorder) {
|
|
243
|
+
if (this._childFrameMessageHandler) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
var self = this;
|
|
247
|
+
this._childFrameMessageHandler = function(event) {
|
|
248
|
+
if (allowedOrigins.indexOf(event.origin) === -1) return;
|
|
249
|
+
var data = event.data;
|
|
250
|
+
if (data && data['type'] === IFRAME_HANDSHAKE_RESPONSE && data['token'] === self.getMpConfig('token') && data['replayId']) {
|
|
251
|
+
self._parentReplayId = data['replayId'];
|
|
252
|
+
if (data['distinctId']) {
|
|
253
|
+
self.mixpanelInstance['identify'](data['distinctId']);
|
|
254
|
+
}
|
|
255
|
+
self._parentFrameRetryActive = false;
|
|
256
|
+
window.removeEventListener('message', self._childFrameMessageHandler);
|
|
257
|
+
self._childFrameMessageHandler = null;
|
|
258
|
+
loadRecorder(true);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
window.addEventListener('message', this._childFrameMessageHandler);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
RecorderManager.prototype._sendParentFrameRequest = function(allowedOrigins) {
|
|
265
|
+
var message = {};
|
|
266
|
+
message['type'] = IFRAME_HANDSHAKE_REQUEST;
|
|
267
|
+
message['token'] = this.getMpConfig('token');
|
|
268
|
+
for (var i = 0; i < allowedOrigins.length; i++) {
|
|
269
|
+
try {
|
|
270
|
+
window.parent.postMessage(message, allowedOrigins[i]);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
// origin mismatch - ignore
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
RecorderManager.prototype._sendParentFrameRequestWithRetry = function(allowedOrigins) {
|
|
278
|
+
var self = this;
|
|
279
|
+
var maxRetries = 10;
|
|
280
|
+
var retryCount = 0;
|
|
281
|
+
var delay = 50;
|
|
282
|
+
this._parentFrameRetryActive = true;
|
|
283
|
+
|
|
284
|
+
this._sendParentFrameRequest(allowedOrigins);
|
|
285
|
+
|
|
286
|
+
function scheduleRetry() {
|
|
287
|
+
setTimeout(function() {
|
|
288
|
+
if (!self._parentFrameRetryActive || self._parentReplayId || ++retryCount >= maxRetries) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
self._sendParentFrameRequest(allowedOrigins);
|
|
292
|
+
delay *= 2;
|
|
293
|
+
scheduleRetry();
|
|
294
|
+
}, delay);
|
|
295
|
+
}
|
|
296
|
+
scheduleRetry();
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
RecorderManager.prototype._setupParentFrameListener = function(allowedOrigins) {
|
|
300
|
+
if (this._parentFrameMessageHandler) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
var self = this;
|
|
304
|
+
this._parentFrameMessageHandler = function(event) {
|
|
305
|
+
if (allowedOrigins.indexOf(event.origin) === -1) return;
|
|
306
|
+
var data = event.data;
|
|
307
|
+
if (data && data['type'] === IFRAME_HANDSHAKE_REQUEST && data['token'] === self.getMpConfig('token')) {
|
|
308
|
+
var replayId = self.getSessionReplayId();
|
|
309
|
+
if (replayId) {
|
|
310
|
+
var response = {};
|
|
311
|
+
response['type'] = IFRAME_HANDSHAKE_RESPONSE;
|
|
312
|
+
response['token'] = self.getMpConfig('token');
|
|
313
|
+
response['replayId'] = replayId;
|
|
314
|
+
response['distinctId'] = self.getDistinctId();
|
|
315
|
+
event.source.postMessage(response, event.origin);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
window.addEventListener('message', this._parentFrameMessageHandler);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
safewrapClass(RecorderManager);
|
|
323
|
+
|
|
324
|
+
export { RecorderManager };
|
package/src/request-batcher.js
CHANGED
|
@@ -1,53 +1,6 @@
|
|
|
1
1
|
import { _ } from '../utils';
|
|
2
2
|
import jsonLogic from 'json-logic-js';
|
|
3
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
4
|
/**
|
|
52
5
|
* Check if an event matches the given criteria
|
|
53
6
|
* @param {string} eventName - The name of the event being checked
|
|
@@ -71,13 +24,8 @@ var eventMatchesCriteria = function(eventName, properties, criteria) {
|
|
|
71
24
|
|
|
72
25
|
if (propertyFilters && !_.isEmptyObject(propertyFilters)) {
|
|
73
26
|
try {
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Lowercase only leaf nodes in JsonLogic filters (keep operators intact)
|
|
78
|
-
var lowercasedFilters = lowercaseOnlyLeafNodes(propertyFilters);
|
|
79
|
-
|
|
80
|
-
filtersMatch = jsonLogic.apply(lowercasedFilters, lowercasedProperties);
|
|
27
|
+
// Use properties as-is for case-sensitive matching
|
|
28
|
+
filtersMatch = jsonLogic.apply(propertyFilters, properties || {});
|
|
81
29
|
} catch (error) {
|
|
82
30
|
return {
|
|
83
31
|
matches: false,
|
|
@@ -90,8 +38,5 @@ var eventMatchesCriteria = function(eventName, properties, criteria) {
|
|
|
90
38
|
};
|
|
91
39
|
|
|
92
40
|
export {
|
|
93
|
-
lowercaseJson,
|
|
94
|
-
lowercaseKeysAndValues,
|
|
95
|
-
lowercaseOnlyLeafNodes,
|
|
96
41
|
eventMatchesCriteria
|
|
97
42
|
};
|
package/src/targeting/index.js
CHANGED
package/src/targeting/loader.js
CHANGED
package/src/utils.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint camelcase: "off", eqeqeq: "off" */
|
|
2
|
-
import Config from './config';
|
|
2
|
+
import { Config } from './config';
|
|
3
3
|
import { NpoPromise, Promise } from './promise-polyfill';
|
|
4
4
|
import { window } from './window';
|
|
5
5
|
|
|
@@ -1732,6 +1732,17 @@ var isOnline = function() {
|
|
|
1732
1732
|
|
|
1733
1733
|
var NOOP_FUNC = function () {};
|
|
1734
1734
|
|
|
1735
|
+
var urlMatchesRegexList = function (url, regexList) {
|
|
1736
|
+
var matches = false;
|
|
1737
|
+
for (var i = 0; i < regexList.length; i++) {
|
|
1738
|
+
if (url.match(regexList[i])) {
|
|
1739
|
+
matches = true;
|
|
1740
|
+
break;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
return matches;
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1735
1746
|
var JSONStringify = null, JSONParse = null;
|
|
1736
1747
|
if (typeof JSON !== 'undefined') {
|
|
1737
1748
|
JSONStringify = JSON.stringify;
|
|
@@ -1797,6 +1808,7 @@ export {
|
|
|
1797
1808
|
safewrap,
|
|
1798
1809
|
safewrapClass,
|
|
1799
1810
|
slice,
|
|
1811
|
+
urlMatchesRegexList,
|
|
1800
1812
|
userAgent,
|
|
1801
1813
|
windowOpera,
|
|
1802
1814
|
};
|
package/testServer.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const express = require('express');
|
|
4
4
|
const cookieParser = require('cookie-parser');
|
|
5
5
|
const logger = require('morgan');
|
|
6
|
+
const { PARENT_PORT, CHILD_PORT } = require('./tests/browser/test-ports');
|
|
6
7
|
|
|
7
8
|
const app = express();
|
|
8
9
|
|
|
@@ -31,10 +32,65 @@ const TEST_SUITES = {
|
|
|
31
32
|
|
|
32
33
|
app.use(cookieParser());
|
|
33
34
|
app.use(logger('dev'));
|
|
35
|
+
app.use(express.json());
|
|
36
|
+
app.use(express.urlencoded({ extended: true }));
|
|
34
37
|
|
|
35
38
|
app.set('views', __dirname + '/tests');
|
|
36
39
|
app.set('view engine', 'pug');
|
|
37
40
|
|
|
41
|
+
// ========================================
|
|
42
|
+
// Test API endpoints for network plugin tests
|
|
43
|
+
// ========================================
|
|
44
|
+
|
|
45
|
+
// Main test endpoint - handles all HTTP methods
|
|
46
|
+
app.all('/api/test', function(req, res) {
|
|
47
|
+
res.json({
|
|
48
|
+
success: true,
|
|
49
|
+
method: req.method,
|
|
50
|
+
headers: req.headers,
|
|
51
|
+
query: req.query,
|
|
52
|
+
body: req.body,
|
|
53
|
+
url: req.originalUrl,
|
|
54
|
+
timestamp: Date.now()
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Form submission endpoint
|
|
59
|
+
app.post('/api/test/form', function(req, res) {
|
|
60
|
+
res.json({
|
|
61
|
+
success: true,
|
|
62
|
+
method: 'POST',
|
|
63
|
+
contentType: req.get('Content-Type'),
|
|
64
|
+
formData: req.body,
|
|
65
|
+
timestamp: Date.now()
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Endpoint with custom response headers
|
|
70
|
+
app.get('/api/test/headers', function(req, res) {
|
|
71
|
+
res.set({
|
|
72
|
+
'X-Custom-Header': 'custom-value',
|
|
73
|
+
'X-Request-Id': 'test-request-123'
|
|
74
|
+
});
|
|
75
|
+
res.json({
|
|
76
|
+
success: true,
|
|
77
|
+
message: 'Response includes custom headers'
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Error response endpoint
|
|
82
|
+
app.get('/api/test/error/:status', function(req, res) {
|
|
83
|
+
const status = parseInt(req.params.status, 10) || 500;
|
|
84
|
+
res.status(status).json({ success: false, status: status });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Session recording endpoint (mimics Mixpanel's /record API)
|
|
88
|
+
app.post(/^\/record\/.*/, express.raw({ type: '*/*', limit: '10mb' }), function(req, res) {
|
|
89
|
+
res.json({ code: 200, status: 'OK' });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ========================================
|
|
93
|
+
|
|
38
94
|
app.use('/tests', express.static(__dirname + "/tests"));
|
|
39
95
|
app.get('/tests/cookie_included/:cookieName', function(req, res) {
|
|
40
96
|
if (req.cookies && req.cookies[req.params.cookieName]) {
|
|
@@ -64,8 +120,20 @@ for (const [suiteId, suite] of Object.entries(TEST_SUITES)) {
|
|
|
64
120
|
testUrl: suite.testUrl
|
|
65
121
|
});
|
|
66
122
|
});
|
|
123
|
+
|
|
124
|
+
// Cross-origin child iframe page for session recording tests
|
|
125
|
+
app.get('/tests/new/' + suiteId + '-cross-origin-page', function(req, res) {
|
|
126
|
+
res.render('cross-origin-page.pug', {
|
|
127
|
+
testUrl: './static/build/test/browser/cross-origin-page.js'
|
|
128
|
+
});
|
|
129
|
+
});
|
|
67
130
|
}
|
|
68
131
|
|
|
69
|
-
const server = app.listen(
|
|
132
|
+
const server = app.listen(PARENT_PORT, function () {
|
|
70
133
|
console.log(`Mixpanel test app listening on port ${server.address().port}`);
|
|
71
134
|
});
|
|
135
|
+
|
|
136
|
+
// Second port for cross-origin iframe tests
|
|
137
|
+
const server2 = app.listen(CHILD_PORT, function () {
|
|
138
|
+
console.log(`Mixpanel cross-origin test server listening on port ${server2.address().port}`);
|
|
139
|
+
});
|