mixpanel-browser 2.74.0 → 2.76.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.
Files changed (61) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/.github/workflows/integration-tests.yml +2 -2
  3. package/.github/workflows/unit-tests.yml +3 -3
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +2 -2
  6. package/build.sh +10 -8
  7. package/dist/async-modules/mixpanel-recorder-bIS4LMGd.js +23595 -0
  8. package/dist/async-modules/mixpanel-recorder-hFoTniVR.min.js +2 -0
  9. package/dist/async-modules/mixpanel-recorder-hFoTniVR.min.js.map +1 -0
  10. package/dist/async-modules/mixpanel-targeting-BcAPS-Mz.js +2520 -0
  11. package/dist/async-modules/mixpanel-targeting-VOeN7RWY.min.js +2 -0
  12. package/dist/async-modules/mixpanel-targeting-VOeN7RWY.min.js.map +1 -0
  13. package/dist/mixpanel-core.cjs.d.ts +68 -0
  14. package/dist/mixpanel-core.cjs.js +802 -337
  15. package/dist/mixpanel-recorder.js +828 -40
  16. package/dist/mixpanel-recorder.min.js +1 -1
  17. package/dist/mixpanel-recorder.min.js.map +1 -1
  18. package/dist/mixpanel-targeting.js +2520 -0
  19. package/dist/mixpanel-targeting.min.js +2 -0
  20. package/dist/mixpanel-targeting.min.js.map +1 -0
  21. package/dist/mixpanel-with-async-modules.cjs.d.ts +590 -0
  22. package/dist/mixpanel-with-async-modules.cjs.js +9867 -0
  23. package/dist/mixpanel-with-async-recorder.cjs.d.ts +68 -0
  24. package/dist/mixpanel-with-async-recorder.cjs.js +802 -337
  25. package/dist/mixpanel-with-recorder.d.ts +68 -0
  26. package/dist/mixpanel-with-recorder.js +1591 -343
  27. package/dist/mixpanel-with-recorder.min.d.ts +68 -0
  28. package/dist/mixpanel-with-recorder.min.js +1 -1
  29. package/dist/mixpanel.amd.d.ts +68 -0
  30. package/dist/mixpanel.amd.js +2124 -345
  31. package/dist/mixpanel.cjs.d.ts +68 -0
  32. package/dist/mixpanel.cjs.js +2124 -345
  33. package/dist/mixpanel.globals.js +802 -337
  34. package/dist/mixpanel.min.js +185 -175
  35. package/dist/mixpanel.module.d.ts +68 -0
  36. package/dist/mixpanel.module.js +2124 -345
  37. package/dist/mixpanel.umd.d.ts +68 -0
  38. package/dist/mixpanel.umd.js +2124 -345
  39. package/dist/rrweb-bundled.js +119 -5
  40. package/dist/rrweb-compiled.js +116 -5
  41. package/logo.svg +5 -0
  42. package/package.json +5 -3
  43. package/rollup.config.mjs +189 -40
  44. package/src/autocapture/index.js +10 -27
  45. package/src/config.js +9 -3
  46. package/src/flags/index.js +269 -9
  47. package/src/index.d.ts +68 -0
  48. package/src/loaders/loader-module.js +1 -0
  49. package/src/mixpanel-core.js +83 -109
  50. package/src/recorder/index.js +2 -1
  51. package/src/recorder/recorder.js +5 -1
  52. package/src/recorder/rrweb-network-plugin.js +649 -0
  53. package/src/recorder/session-recording.js +31 -11
  54. package/src/recorder-manager.js +216 -0
  55. package/src/request-batcher.js +1 -1
  56. package/src/targeting/event-matcher.js +42 -0
  57. package/src/targeting/index.js +11 -0
  58. package/src/targeting/loader.js +36 -0
  59. package/src/utils.js +14 -9
  60. package/testServer.js +55 -0
  61. /package/src/loaders/{loader-module-with-async-recorder.js → loader-module-with-async-modules.js} +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';
12
+ import { Config } from '../config';
13
13
  import { RECORD_ENQUEUE_THROTTLE_MS } 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,29 @@ 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
+
244
268
  try {
245
269
  this._stopRecording = this._rrwebRecord({
246
270
  'emit': function (ev) {
@@ -279,15 +303,7 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
279
303
  'sampling': {
280
304
  'canvas': 15
281
305
  },
282
- 'plugins': this.getConfig('record_console') ? [
283
- getRecordConsolePlugin({
284
- stringifyOptions: {
285
- stringLengthLimit: 1000,
286
- numOfKeysLimit: 50,
287
- depthOfLimit: 2
288
- }
289
- })
290
- ] : []
306
+ 'plugins': plugins,
291
307
  });
292
308
  } catch (err) {
293
309
  this.reportError('Unexpected error when starting rrweb recording.', err);
@@ -402,6 +418,10 @@ SessionRecording.deserialize = function (serializedRecording, options) {
402
418
  return recording;
403
419
  };
404
420
 
421
+ SessionRecording.prototype._getApiRoute = function () {
422
+ return this.getConfig('api_routes')['record'];
423
+ };
424
+
405
425
  SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, reqBody, callback) {
406
426
  var onSuccess = function (response, responseBody) {
407
427
  // Update batch specific props only if the request was successful to guarantee ordering.
@@ -421,7 +441,7 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
421
441
  });
422
442
  }.bind(this);
423
443
  var apiHost = (this._mixpanel.get_api_host && this._mixpanel.get_api_host('record')) || this.getConfig('api_host');
424
- window['fetch'](apiHost + '/' + this.getConfig('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
444
+ window['fetch'](apiHost + '/' + this._getApiRoute() + '?' + new URLSearchParams(reqParams), {
425
445
  'method': 'POST',
426
446
  'headers': {
427
447
  'Authorization': 'Basic ' + btoa(this.getConfig('token') + ':'),
@@ -0,0 +1,216 @@
1
+ /* eslint camelcase: "off" */
2
+ import {RECORDER_FILENAME, TARGETING_FILENAME, RECORDER_GLOBAL_NAME} from './config';
3
+ import { _, console, safewrap, safewrapClass } from './utils';
4
+ import { window } from './window';
5
+ import { Promise } from './promise-polyfill';
6
+ import { IDBStorageWrapper, RECORDING_REGISTRY_STORE_NAME } from './storage/indexed-db';
7
+ import { isRecordingExpired } from './recorder/utils';
8
+ import { getTargetingPromise } from './targeting/loader';
9
+
10
+
11
+ /**
12
+ * RecorderManager: manages session recording initialization, lifecycle and state
13
+ * @constructor
14
+ */
15
+ var RecorderManager = function(initOptions) {
16
+ // TODO - Passing in mixpanel instance as it is still needed for recorder creation
17
+ // but ideally we should be able to remove this dependency.
18
+ this.mixpanelInstance = initOptions.mixpanelInstance;
19
+
20
+ this.getMpConfig = initOptions.getConfigFunc;
21
+ this.getTabId = initOptions.getTabIdFunc;
22
+ this.reportError = initOptions.reportErrorFunc;
23
+ this.getDistinctId = initOptions.getDistinctIdFunc;
24
+ this.loadExtraBundle = initOptions.loadExtraBundle;
25
+ this.recorderSrc = initOptions.recorderSrc;
26
+ this.targetingSrc = initOptions.targetingSrc;
27
+ this.libBasePath = initOptions.libBasePath;
28
+
29
+ this._recorder = null;
30
+ };
31
+
32
+ RecorderManager.prototype.shouldLoadRecorder = function() {
33
+ if (this.getMpConfig('disable_persistence')) {
34
+ console.log('Load recorder check skipped due to disable_persistence config');
35
+ return Promise.resolve(false);
36
+ }
37
+
38
+ var recording_registry_idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
39
+ var tab_id = this.getTabId();
40
+ return recording_registry_idb.init()
41
+ .then(function () {
42
+ return recording_registry_idb.getAll();
43
+ })
44
+ .then(function (recordings) {
45
+ for (var i = 0; i < recordings.length; i++) {
46
+ // if there are expired recordings in the registry, we should load the recorder to flush them
47
+ // if there's a recording for this tab id, we should load the recorder to continue the recording
48
+ if (isRecordingExpired(recordings[i]) || recordings[i]['tabId'] === tab_id) {
49
+ return true;
50
+ }
51
+ }
52
+ return false;
53
+ })
54
+ .catch(_.bind(function (err) {
55
+ this.reportError('Error checking recording registry', err);
56
+ return false;
57
+ }, this));
58
+ };
59
+
60
+ RecorderManager.prototype.checkAndStartSessionRecording = function(force_start, rate) {
61
+ if (!window['MutationObserver']) {
62
+ console.critical('Browser does not support MutationObserver; skipping session recording');
63
+ return Promise.resolve();
64
+ }
65
+
66
+ var loadRecorder = _.bind(function(startNewIfInactive) {
67
+ return new Promise(_.bind(function(resolve) {
68
+ var handleLoadedRecorder = safewrap(_.bind(function() {
69
+ this._recorder = this._recorder || new window[RECORDER_GLOBAL_NAME](this.mixpanelInstance);
70
+ this._recorder['resumeRecording'](startNewIfInactive);
71
+ resolve();
72
+ }, this));
73
+
74
+ if (_.isUndefined(window[RECORDER_GLOBAL_NAME])) {
75
+ var recorderSrc = this.recorderSrc || (this.libBasePath + RECORDER_FILENAME);
76
+ this.loadExtraBundle(recorderSrc, handleLoadedRecorder);
77
+ } else {
78
+ handleLoadedRecorder();
79
+ }
80
+ }, this));
81
+ }, this);
82
+
83
+ /**
84
+ * If the user is sampled or start_session_recording is called, we always load the recorder since it's guaranteed a recording should start.
85
+ * 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.
86
+ */
87
+ var effective_rate = _.isUndefined(rate) ? this.getMpConfig('record_sessions_percent') : rate;
88
+ var is_sampled = effective_rate > 0 && Math.random() * 100 <= effective_rate;
89
+ if (force_start || is_sampled) {
90
+ return loadRecorder(true);
91
+ } else {
92
+ return this.shouldLoadRecorder()
93
+ .then(_.bind(function (shouldLoad) {
94
+ if (shouldLoad) {
95
+ return loadRecorder(false);
96
+ }
97
+ return Promise.resolve();
98
+ }, this));
99
+ }
100
+ };
101
+
102
+ RecorderManager.prototype.isRecording = function() {
103
+ // Safety check: ensure isRecording method exists (older CDN builds may not have it)
104
+ if (!this._recorder || !_.isFunction(this._recorder['isRecording'])) {
105
+ return false;
106
+ }
107
+ try {
108
+ return this._recorder['isRecording']();
109
+ } catch (e) {
110
+ this.reportError('Error checking if recording is active', e);
111
+ return false;
112
+ }
113
+ };
114
+
115
+ RecorderManager.prototype.startRecordingOnEvent = function(event_name, properties) {
116
+ var isRecording = this.isRecording();
117
+ var recordingTriggerEvents = this.getMpConfig('recording_event_triggers');
118
+
119
+ if (!isRecording && recordingTriggerEvents) {
120
+ var trigger = recordingTriggerEvents[event_name];
121
+ if (trigger && typeof trigger['percentage'] === 'number') {
122
+ var newRate = trigger['percentage'];
123
+ var propertyFilters = trigger['property_filters'];
124
+ if (propertyFilters && !_.isEmptyObject(propertyFilters)) {
125
+ var targetingSrc = this.targetingSrc || (this.libBasePath + TARGETING_FILENAME);
126
+ getTargetingPromise(this.loadExtraBundle, targetingSrc)
127
+ .then(function(targeting) {
128
+ try {
129
+ var result = targeting['eventMatchesCriteria'](
130
+ event_name,
131
+ properties,
132
+ {
133
+ 'event_name': event_name,
134
+ 'property_filters': propertyFilters
135
+ }
136
+ );
137
+ if (result['matches']) {
138
+ this.checkAndStartSessionRecording(false, newRate);
139
+ }
140
+ } catch (err) {
141
+ console.critical('Could not parse recording event trigger properties logic:', err);
142
+ }
143
+ }.bind(this)).catch(function(err) {
144
+ console.critical('Failed to load targeting library:', err);
145
+ });
146
+ } else {
147
+ this.checkAndStartSessionRecording(false, newRate);
148
+ }
149
+ }
150
+ }
151
+ };
152
+
153
+ RecorderManager.prototype.stopSessionRecording = function() {
154
+ if (this._recorder) {
155
+ return this._recorder['stopRecording']();
156
+ }
157
+ return Promise.resolve();
158
+ };
159
+
160
+ RecorderManager.prototype.pauseSessionRecording = function() {
161
+ if (this._recorder) {
162
+ return this._recorder['pauseRecording']();
163
+ }
164
+ return Promise.resolve();
165
+ };
166
+
167
+ RecorderManager.prototype.resumeSessionRecording = function() {
168
+ if (this._recorder) {
169
+ return this._recorder['resumeRecording']();
170
+ }
171
+ return Promise.resolve();
172
+ };
173
+
174
+ RecorderManager.prototype.isRecordingHeatmapData = function() {
175
+ return this.getSessionReplayId() && this.getMpConfig('record_heatmap_data');
176
+ };
177
+
178
+ RecorderManager.prototype.getSessionRecordingProperties = function() {
179
+ var props = {};
180
+ var replay_id = this.getSessionReplayId();
181
+ if (replay_id) {
182
+ props['$mp_replay_id'] = replay_id;
183
+ }
184
+ return props;
185
+ };
186
+
187
+ RecorderManager.prototype.getSessionReplayUrl = function() {
188
+ var replay_url = null;
189
+ var replay_id = this.getSessionReplayId();
190
+ if (replay_id) {
191
+ var query_params = _.HTTPBuildQuery({
192
+ 'replay_id': replay_id,
193
+ 'distinct_id': this.getDistinctId(),
194
+ 'token': this.getMpConfig('token')
195
+ });
196
+ replay_url = 'https://mixpanel.com/projects/replay-redirect?' + query_params;
197
+ }
198
+ return replay_url;
199
+ };
200
+
201
+ RecorderManager.prototype.getSessionReplayId = function() {
202
+ var replay_id = null;
203
+ if (this._recorder) {
204
+ replay_id = this._recorder['replayId'];
205
+ }
206
+ return replay_id || null;
207
+ };
208
+
209
+ // "private" public method to reach into the recorder in test cases
210
+ RecorderManager.prototype.getRecorder = function() {
211
+ return this._recorder;
212
+ };
213
+
214
+ safewrapClass(RecorderManager);
215
+
216
+ export { RecorderManager };
@@ -1,4 +1,4 @@
1
- import Config from './config';
1
+ import { Config } from './config';
2
2
  import { Promise } from './promise-polyfill';
3
3
  import { RequestQueue } from './request-queue';
4
4
  import { console_with_prefix, isOnline, _ } from './utils'; // eslint-disable-line camelcase
@@ -0,0 +1,42 @@
1
+ import { _ } from '../utils';
2
+ import jsonLogic from 'json-logic-js';
3
+
4
+ /**
5
+ * Check if an event matches the given criteria
6
+ * @param {string} eventName - The name of the event being checked
7
+ * @param {Object} properties - Event properties to evaluate against property filters
8
+ * @param {Object} criteria - Criteria to match against, with:
9
+ * - event_name: string - Required event name (case-sensitive match)
10
+ * - property_filters: Object - Optional JsonLogic filters for properties
11
+ * @returns {Object} Result object with:
12
+ * - matches: boolean - Whether the event matches the criteria
13
+ * - error: string|undefined - Error message if evaluation failed
14
+ */
15
+ var eventMatchesCriteria = function(eventName, properties, criteria) {
16
+ // Check exact event name match (case-sensitive)
17
+ if (eventName !== criteria.event_name) {
18
+ return { matches: false };
19
+ }
20
+
21
+ // Evaluate property filters using JsonLogic
22
+ var propertyFilters = criteria.property_filters;
23
+ var filtersMatch = true; // default to true if no filters
24
+
25
+ if (propertyFilters && !_.isEmptyObject(propertyFilters)) {
26
+ try {
27
+ // Use properties as-is for case-sensitive matching
28
+ filtersMatch = jsonLogic.apply(propertyFilters, properties || {});
29
+ } catch (error) {
30
+ return {
31
+ matches: false,
32
+ error: error.toString()
33
+ };
34
+ }
35
+ }
36
+
37
+ return { matches: filtersMatch };
38
+ };
39
+
40
+ export {
41
+ eventMatchesCriteria
42
+ };
@@ -0,0 +1,11 @@
1
+ import { window } from '../window';
2
+ import { TARGETING_GLOBAL_NAME } from '../config';
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 '../config';
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
@@ -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
 
@@ -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
- try {
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) {
@@ -1739,6 +1732,17 @@ var isOnline = function() {
1739
1732
 
1740
1733
  var NOOP_FUNC = function () {};
1741
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
+
1742
1746
  var JSONStringify = null, JSONParse = null;
1743
1747
  if (typeof JSON !== 'undefined') {
1744
1748
  JSONStringify = JSON.stringify;
@@ -1804,6 +1808,7 @@ export {
1804
1808
  safewrap,
1805
1809
  safewrapClass,
1806
1810
  slice,
1811
+ urlMatchesRegexList,
1807
1812
  userAgent,
1808
1813
  windowOpera,
1809
1814
  };
package/testServer.js CHANGED
@@ -31,10 +31,65 @@ const TEST_SUITES = {
31
31
 
32
32
  app.use(cookieParser());
33
33
  app.use(logger('dev'));
34
+ app.use(express.json());
35
+ app.use(express.urlencoded({ extended: true }));
34
36
 
35
37
  app.set('views', __dirname + '/tests');
36
38
  app.set('view engine', 'pug');
37
39
 
40
+ // ========================================
41
+ // Test API endpoints for network plugin tests
42
+ // ========================================
43
+
44
+ // Main test endpoint - handles all HTTP methods
45
+ app.all('/api/test', function(req, res) {
46
+ res.json({
47
+ success: true,
48
+ method: req.method,
49
+ headers: req.headers,
50
+ query: req.query,
51
+ body: req.body,
52
+ url: req.originalUrl,
53
+ timestamp: Date.now()
54
+ });
55
+ });
56
+
57
+ // Form submission endpoint
58
+ app.post('/api/test/form', function(req, res) {
59
+ res.json({
60
+ success: true,
61
+ method: 'POST',
62
+ contentType: req.get('Content-Type'),
63
+ formData: req.body,
64
+ timestamp: Date.now()
65
+ });
66
+ });
67
+
68
+ // Endpoint with custom response headers
69
+ app.get('/api/test/headers', function(req, res) {
70
+ res.set({
71
+ 'X-Custom-Header': 'custom-value',
72
+ 'X-Request-Id': 'test-request-123'
73
+ });
74
+ res.json({
75
+ success: true,
76
+ message: 'Response includes custom headers'
77
+ });
78
+ });
79
+
80
+ // Error response endpoint
81
+ app.get('/api/test/error/:status', function(req, res) {
82
+ const status = parseInt(req.params.status, 10) || 500;
83
+ res.status(status).json({ success: false, status: status });
84
+ });
85
+
86
+ // Session recording endpoint (mimics Mixpanel's /record API)
87
+ app.post(/^\/record\/.*/, express.raw({ type: '*/*', limit: '10mb' }), function(req, res) {
88
+ res.json({ code: 200, status: 'OK' });
89
+ });
90
+
91
+ // ========================================
92
+
38
93
  app.use('/tests', express.static(__dirname + "/tests"));
39
94
  app.get('/tests/cookie_included/:cookieName', function(req, res) {
40
95
  if (req.cookies && req.cookies[req.params.cookieName]) {