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
@@ -3,9 +3,17 @@
3
3
 
4
4
  var Config = {
5
5
  DEBUG: false,
6
- LIB_VERSION: '2.74.0'
6
+ LIB_VERSION: '2.76.0'
7
7
  };
8
8
 
9
+ // Window global names for async modules
10
+ var TARGETING_GLOBAL_NAME = '__mp_targeting';
11
+ var RECORDER_GLOBAL_NAME = '__mp_recorder';
12
+
13
+ // Constants that are injected at build-time for the names of async modules.
14
+ var RECORDER_FILENAME = 'mixpanel-recorder-bIS4LMGd.js';
15
+ var TARGETING_FILENAME = 'mixpanel-targeting-BcAPS-Mz.js';
16
+
9
17
  // since es6 imports are static and we run unit tests from the console, window won't be defined when importing this file
10
18
  var win;
11
19
  if (typeof(window) === 'undefined') {
@@ -594,15 +602,8 @@
594
602
  return toString.call(obj) === '[object Array]';
595
603
  };
596
604
 
597
- // from a comment on http://dbj.org/dbj/?p=286
598
- // fails on only one very rare and deliberate custom object:
599
- // var bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }};
600
605
  _.isFunction = function(f) {
601
- try {
602
- return /^\s*\bfunction\b/.test(f);
603
- } catch (x) {
604
- return false;
605
- }
606
+ return typeof f === 'function';
606
607
  };
607
608
 
608
609
  _.isArguments = function(obj) {
@@ -2129,6 +2130,17 @@
2129
2130
 
2130
2131
  var NOOP_FUNC = function () {};
2131
2132
 
2133
+ var urlMatchesRegexList = function (url, regexList) {
2134
+ var matches = false;
2135
+ for (var i = 0; i < regexList.length; i++) {
2136
+ if (url.match(regexList[i])) {
2137
+ matches = true;
2138
+ break;
2139
+ }
2140
+ }
2141
+ return matches;
2142
+ };
2143
+
2132
2144
  var JSONStringify = null, JSONParse = null;
2133
2145
  if (typeof JSON !== 'undefined') {
2134
2146
  JSONStringify = JSON.stringify;
@@ -2151,15 +2163,6 @@
2151
2163
  _['toArray'] = _.toArray;
2152
2164
  _['NPO'] = NpoPromise;
2153
2165
 
2154
- /**
2155
- * @param {import('./session-recording').SerializedRecording} serializedRecording
2156
- * @returns {boolean}
2157
- */
2158
- var isRecordingExpired = function(serializedRecording) {
2159
- var now = Date.now();
2160
- return !serializedRecording || now > serializedRecording['maxExpires'] || now > serializedRecording['idleExpires'];
2161
- };
2162
-
2163
2166
  // stateless utils
2164
2167
  // mostly from https://github.com/mixpanel/mixpanel-js/blob/989ada50f518edab47b9c4fd9535f9fbd5ec5fc0/src/autotrack-utils.js
2165
2168
 
@@ -3424,27 +3427,15 @@
3424
3427
  };
3425
3428
 
3426
3429
  Autocapture.prototype.currentUrlBlocked = function() {
3427
- var i;
3428
3430
  var currentUrl = _.info.currentUrl();
3429
3431
 
3430
3432
  var allowUrlRegexes = this.getConfig(CONFIG_ALLOW_URL_REGEXES) || [];
3431
3433
  if (allowUrlRegexes.length) {
3432
3434
  // we're using an allowlist, only track if current URL matches
3433
- var allowed = false;
3434
- for (i = 0; i < allowUrlRegexes.length; i++) {
3435
- var allowRegex = allowUrlRegexes[i];
3436
- try {
3437
- if (currentUrl.match(allowRegex)) {
3438
- allowed = true;
3439
- break;
3440
- }
3441
- } catch (err) {
3442
- logger$4.critical('Error while checking block URL regex: ' + allowRegex, err);
3443
- return true;
3444
- }
3445
- }
3446
- if (!allowed) {
3447
- // wasn't allowed by any regex
3435
+ try {
3436
+ return !urlMatchesRegexList(currentUrl, allowUrlRegexes);
3437
+ } catch (err) {
3438
+ logger$4.critical('Error while checking block URL regexes: ', err);
3448
3439
  return true;
3449
3440
  }
3450
3441
  }
@@ -3454,17 +3445,12 @@
3454
3445
  return false;
3455
3446
  }
3456
3447
 
3457
- for (i = 0; i < blockUrlRegexes.length; i++) {
3458
- try {
3459
- if (currentUrl.match(blockUrlRegexes[i])) {
3460
- return true;
3461
- }
3462
- } catch (err) {
3463
- logger$4.critical('Error while checking block URL regex: ' + blockUrlRegexes[i], err);
3464
- return true;
3465
- }
3448
+ try {
3449
+ return urlMatchesRegexList(currentUrl, blockUrlRegexes);
3450
+ } catch (err) {
3451
+ logger$4.critical('Error while checking block URL regexes: ', err);
3452
+ return true;
3466
3453
  }
3467
- return false;
3468
3454
  };
3469
3455
 
3470
3456
  Autocapture.prototype.pageviewTrackingConfig = function() {
@@ -3908,14 +3894,62 @@
3908
3894
  // TODO integrate error_reporter from mixpanel instance
3909
3895
  safewrapClass(Autocapture);
3910
3896
 
3911
- var logger$3 = console_with_prefix('flags');
3897
+ /**
3898
+ * Get the promise-based targeting loader
3899
+ * @param {Function} loadExtraBundle - Function to load external bundle (callback-based)
3900
+ * @param {string} targetingSrc - URL to targeting bundle
3901
+ * @returns {Promise} Promise that resolves with targeting library
3902
+ */
3903
+ var getTargetingPromise = function(loadExtraBundle, targetingSrc) {
3904
+ // Return existing promise if already initialized or loading
3905
+ if (win[TARGETING_GLOBAL_NAME] && typeof win[TARGETING_GLOBAL_NAME].then === 'function') {
3906
+ return win[TARGETING_GLOBAL_NAME];
3907
+ }
3908
+
3909
+ // Create loading promise and set it as the global immediately
3910
+ // This makes minified build behavior consistent with dev/CJS builds
3911
+ win[TARGETING_GLOBAL_NAME] = new Promise(function (resolve) {
3912
+ loadExtraBundle(targetingSrc, resolve);
3913
+ }).then(function () {
3914
+ var p = win[TARGETING_GLOBAL_NAME];
3915
+ if (p && typeof p.then === 'function') {
3916
+ return p;
3917
+ }
3918
+ throw new Error('targeting failed to load');
3919
+ }).catch(function (err) {
3920
+ delete win[TARGETING_GLOBAL_NAME];
3921
+ throw err;
3922
+ });
3923
+
3924
+ return win[TARGETING_GLOBAL_NAME];
3925
+ };
3912
3926
 
3927
+ var logger$3 = console_with_prefix('flags');
3913
3928
  var FLAGS_CONFIG_KEY = 'flags';
3914
3929
 
3915
3930
  var CONFIG_CONTEXT = 'context';
3916
3931
  var CONFIG_DEFAULTS = {};
3917
3932
  CONFIG_DEFAULTS[CONFIG_CONTEXT] = {};
3918
3933
 
3934
+ /**
3935
+ * Generate a unique key for a pending first-time event
3936
+ * @param {string} flagKey - The flag key
3937
+ * @param {string} firstTimeEventHash - The first_time_event_hash from the pending event definition
3938
+ * @returns {string} Composite key in format "flagKey:firstTimeEventHash"
3939
+ */
3940
+ var getPendingEventKey = function(flagKey, firstTimeEventHash) {
3941
+ return flagKey + ':' + firstTimeEventHash;
3942
+ };
3943
+
3944
+ /**
3945
+ * Extract the flag key from a pending event key
3946
+ * @param {string} eventKey - The composite event key in format "flagKey:firstTimeEventHash"
3947
+ * @returns {string} The flag key portion
3948
+ */
3949
+ var getFlagKeyFromPendingEventKey = function(eventKey) {
3950
+ return eventKey.split(':')[0];
3951
+ };
3952
+
3919
3953
  /**
3920
3954
  * FeatureFlagManager: support for Mixpanel's feature flagging product
3921
3955
  * @constructor
@@ -3927,6 +3961,8 @@
3927
3961
  this.setMpConfig = initOptions.setConfigFunc;
3928
3962
  this.getMpProperty = initOptions.getPropertyFunc;
3929
3963
  this.track = initOptions.trackingFunc;
3964
+ this.loadExtraBundle = initOptions.loadExtraBundle || function() {};
3965
+ this.targetingSrc = initOptions.targetingSrc || '';
3930
3966
  };
3931
3967
 
3932
3968
  FeatureFlagManager.prototype.init = function() {
@@ -3939,6 +3975,8 @@
3939
3975
  this.fetchFlags();
3940
3976
 
3941
3977
  this.trackedFeatures = new Set();
3978
+ this.pendingFirstTimeEvents = {};
3979
+ this.activatedFirstTimeEvents = {};
3942
3980
  };
3943
3981
 
3944
3982
  FeatureFlagManager.prototype.getFullConfig = function() {
@@ -4019,17 +4057,78 @@
4019
4057
  throw new Error('No flags in API response');
4020
4058
  }
4021
4059
  var flags = new Map();
4060
+ var pendingFirstTimeEvents = {};
4061
+
4062
+ // Process flags from response
4022
4063
  _.each(responseFlags, function(data, key) {
4023
- flags.set(key, {
4024
- 'key': data['variant_key'],
4025
- 'value': data['variant_value'],
4026
- 'experiment_id': data['experiment_id'],
4027
- 'is_experiment_active': data['is_experiment_active'],
4028
- 'is_qa_tester': data['is_qa_tester']
4064
+ // Check if this flag has any activated first-time events this session
4065
+ var hasActivatedEvent = false;
4066
+ var prefix = key + ':';
4067
+ _.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
4068
+ if (eventKey.startsWith(prefix)) {
4069
+ hasActivatedEvent = true;
4070
+ }
4029
4071
  });
4030
- });
4072
+
4073
+ if (hasActivatedEvent) {
4074
+ // Preserve the activated variant, don't overwrite with server's current variant
4075
+ var currentFlag = this.flags && this.flags.get(key);
4076
+ if (currentFlag) {
4077
+ flags.set(key, currentFlag);
4078
+ }
4079
+ } else {
4080
+ // Use server's current variant
4081
+ flags.set(key, {
4082
+ 'key': data['variant_key'],
4083
+ 'value': data['variant_value'],
4084
+ 'experiment_id': data['experiment_id'],
4085
+ 'is_experiment_active': data['is_experiment_active'],
4086
+ 'is_qa_tester': data['is_qa_tester']
4087
+ });
4088
+ }
4089
+ }, this);
4090
+
4091
+ // Process top-level pending_first_time_events array
4092
+ var topLevelDefinitions = responseBody['pending_first_time_events'];
4093
+ if (topLevelDefinitions && topLevelDefinitions.length > 0) {
4094
+ _.each(topLevelDefinitions, function(def) {
4095
+ var flagKey = def['flag_key'];
4096
+ var eventKey = getPendingEventKey(flagKey, def['first_time_event_hash']);
4097
+
4098
+ // Skip if this specific event has already been activated this session
4099
+ if (this.activatedFirstTimeEvents[eventKey]) {
4100
+ return;
4101
+ }
4102
+
4103
+ // Store pending event definition using composite key
4104
+ pendingFirstTimeEvents[eventKey] = {
4105
+ 'flag_key': flagKey,
4106
+ 'flag_id': def['flag_id'],
4107
+ 'project_id': def['project_id'],
4108
+ 'first_time_event_hash': def['first_time_event_hash'],
4109
+ 'event_name': def['event_name'],
4110
+ 'property_filters': def['property_filters'],
4111
+ 'pending_variant': def['pending_variant']
4112
+ };
4113
+ }, this);
4114
+ }
4115
+
4116
+ // Preserve any activated orphaned flags (flags that were activated but are no longer in response)
4117
+ if (this.activatedFirstTimeEvents) {
4118
+ _.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
4119
+ var flagKey = getFlagKeyFromPendingEventKey(eventKey);
4120
+ if (activated && !flags.has(flagKey) && this.flags && this.flags.has(flagKey)) {
4121
+ // Keep the activated flag even though it's not in the new response
4122
+ flags.set(flagKey, this.flags.get(flagKey));
4123
+ }
4124
+ }, this);
4125
+ }
4126
+
4031
4127
  this.flags = flags;
4128
+ this.pendingFirstTimeEvents = pendingFirstTimeEvents;
4032
4129
  this._traceparent = traceparent;
4130
+
4131
+ this._loadTargetingIfNeeded();
4033
4132
  }.bind(this)).catch(function(error) {
4034
4133
  this.markFetchComplete();
4035
4134
  logger$3.error(error);
@@ -4053,6 +4152,177 @@
4053
4152
  this._fetchInProgressStartTime = null;
4054
4153
  };
4055
4154
 
4155
+ /**
4156
+ * Proactively load targeting bundle if any pending events have property filters
4157
+ */
4158
+ FeatureFlagManager.prototype._loadTargetingIfNeeded = function() {
4159
+ var hasPropertyFilters = false;
4160
+ _.each(this.pendingFirstTimeEvents, function(evt) {
4161
+ if (evt['property_filters'] && !_.isEmptyObject(evt['property_filters'])) {
4162
+ hasPropertyFilters = true;
4163
+ }
4164
+ });
4165
+
4166
+ if (hasPropertyFilters) {
4167
+ this.getTargeting().then(function() {
4168
+ logger$3.log('targeting loaded for property filter evaluation');
4169
+ });
4170
+ }
4171
+ };
4172
+
4173
+ /**
4174
+ * Get the targeting library (initializes if not already loaded)
4175
+ * This method is primarily for testing - production code should rely on automatic loading
4176
+ * @returns {Promise} Promise that resolves with targeting library
4177
+ */
4178
+ FeatureFlagManager.prototype.getTargeting = function() {
4179
+ return getTargetingPromise(
4180
+ this.loadExtraBundle.bind(this),
4181
+ this.targetingSrc
4182
+ ).catch(function(error) {
4183
+ logger$3.error('Failed to load targeting: ' + error);
4184
+ }.bind(this));
4185
+ };
4186
+
4187
+ /**
4188
+ * Check if a tracked event matches any pending first-time events and activate the corresponding flag variant
4189
+ * @param {string} eventName - The name of the event being tracked
4190
+ * @param {Object} properties - Event properties to evaluate against property filters
4191
+ *
4192
+ * When a match is found (event name matches and property filters pass), this method:
4193
+ * - Switches the flag to the pending variant
4194
+ * - Marks the event as activated for this session
4195
+ * - Records the activation via the API (fire-and-forget)
4196
+ */
4197
+ FeatureFlagManager.prototype.checkFirstTimeEvents = function(eventName, properties) {
4198
+ if (!this.pendingFirstTimeEvents || _.isEmptyObject(this.pendingFirstTimeEvents)) {
4199
+ return;
4200
+ }
4201
+
4202
+ // Check if targeting promise exists (either bundled or async loaded)
4203
+ if (win[TARGETING_GLOBAL_NAME] && _.isFunction(win[TARGETING_GLOBAL_NAME].then)) {
4204
+ win[TARGETING_GLOBAL_NAME].then(function(library) {
4205
+ this._processFirstTimeEventCheck(eventName, properties, library);
4206
+ }.bind(this)).catch(function() {
4207
+ // If targeting failed to load, process with null
4208
+ // Events without property filters will still match
4209
+ this._processFirstTimeEventCheck(eventName, properties, null);
4210
+ }.bind(this));
4211
+ } else {
4212
+ // No targeting available, process with null
4213
+ // Events without property filters will still match
4214
+ this._processFirstTimeEventCheck(eventName, properties, null);
4215
+ }
4216
+ };
4217
+
4218
+ /**
4219
+ * Internal method to process first-time event checks with loaded targeting library
4220
+ * @param {string} eventName - The name of the event being tracked
4221
+ * @param {Object} properties - Event properties to evaluate against property filters
4222
+ * @param {Object} targeting - The loaded targeting library
4223
+ */
4224
+ FeatureFlagManager.prototype._processFirstTimeEventCheck = function(eventName, properties, targeting) {
4225
+ _.each(this.pendingFirstTimeEvents, function(pendingEvent, eventKey) {
4226
+ if (this.activatedFirstTimeEvents[eventKey]) {
4227
+ return;
4228
+ }
4229
+
4230
+ var flagKey = pendingEvent['flag_key'];
4231
+
4232
+ // Use targeting module to check if event matches
4233
+ var matchResult;
4234
+
4235
+ // If no targeting library and event has property filters, skip it
4236
+ if (!targeting && pendingEvent['property_filters'] && !_.isEmptyObject(pendingEvent['property_filters'])) {
4237
+ logger$3.warn('Skipping event check for "' + flagKey + '" - property filters require targeting library');
4238
+ return;
4239
+ }
4240
+
4241
+ // For simple events (no property filters), just check event name
4242
+ if (!targeting) {
4243
+ matchResult = {
4244
+ matches: eventName === pendingEvent['event_name'],
4245
+ error: null
4246
+ };
4247
+ } else {
4248
+ var criteria = {
4249
+ 'event_name': pendingEvent['event_name'],
4250
+ 'property_filters': pendingEvent['property_filters']
4251
+ };
4252
+ matchResult = targeting['eventMatchesCriteria'](
4253
+ eventName,
4254
+ properties,
4255
+ criteria
4256
+ );
4257
+ }
4258
+
4259
+ if (matchResult.error) {
4260
+ logger$3.error('Error checking first-time event for flag "' + flagKey + '": ' + matchResult.error);
4261
+ return;
4262
+ }
4263
+
4264
+ if (!matchResult.matches) {
4265
+ return;
4266
+ }
4267
+
4268
+ logger$3.log('First-time event matched for flag "' + flagKey + '": ' + eventName);
4269
+
4270
+ var newVariant = {
4271
+ 'key': pendingEvent['pending_variant']['variant_key'],
4272
+ 'value': pendingEvent['pending_variant']['variant_value'],
4273
+ 'experiment_id': pendingEvent['pending_variant']['experiment_id'],
4274
+ 'is_experiment_active': pendingEvent['pending_variant']['is_experiment_active']
4275
+ };
4276
+
4277
+ this.flags.set(flagKey, newVariant);
4278
+ this.activatedFirstTimeEvents[eventKey] = true;
4279
+
4280
+ this.recordFirstTimeEvent(
4281
+ pendingEvent['flag_id'],
4282
+ pendingEvent['project_id'],
4283
+ pendingEvent['first_time_event_hash']
4284
+ );
4285
+ }, this);
4286
+ };
4287
+
4288
+ FeatureFlagManager.prototype.getFirstTimeEventApiRoute = function(flagId) {
4289
+ // Construct URL: {api_host}/flags/{flagId}/first-time-events
4290
+ return this.getFullApiRoute() + '/' + flagId + '/first-time-events';
4291
+ };
4292
+
4293
+ FeatureFlagManager.prototype.recordFirstTimeEvent = function(flagId, projectId, firstTimeEventHash) {
4294
+ var distinctId = this.getMpProperty('distinct_id');
4295
+ var traceparent = generateTraceparent();
4296
+
4297
+ // Build URL with query string parameters
4298
+ var searchParams = new URLSearchParams();
4299
+ searchParams.set('mp_lib', 'web');
4300
+ searchParams.set('$lib_version', Config.LIB_VERSION);
4301
+ var url = this.getFirstTimeEventApiRoute(flagId) + '?' + searchParams.toString();
4302
+
4303
+ var payload = {
4304
+ 'distinct_id': distinctId,
4305
+ 'project_id': projectId,
4306
+ 'first_time_event_hash': firstTimeEventHash
4307
+ };
4308
+
4309
+ logger$3.log('Recording first-time event for flag: ' + flagId);
4310
+
4311
+ // Fire-and-forget POST request
4312
+ this.fetch.call(win, url, {
4313
+ 'method': 'POST',
4314
+ 'headers': {
4315
+ 'Content-Type': 'application/json',
4316
+ 'Authorization': 'Basic ' + btoa(this.getMpConfig('token') + ':'),
4317
+ 'traceparent': traceparent
4318
+ },
4319
+ 'body': JSON.stringify(payload)
4320
+ }).catch(function(error) {
4321
+ // Silent failure - cohort sync will catch up
4322
+ logger$3.error('Failed to record first-time event for flag ' + flagId + ': ' + error);
4323
+ });
4324
+ };
4325
+
4056
4326
  FeatureFlagManager.prototype.getVariant = function(featureName, fallback) {
4057
4327
  if (!this.fetchPromise) {
4058
4328
  return new Promise(function(resolve) {
@@ -4171,76 +4441,423 @@
4171
4441
  // Deprecated method
4172
4442
  FeatureFlagManager.prototype['get_feature_data'] = FeatureFlagManager.prototype.getFeatureData;
4173
4443
 
4174
- /* eslint camelcase: "off" */
4444
+ // Exports intended only for testing
4445
+ FeatureFlagManager.prototype['getTargeting'] = FeatureFlagManager.prototype.getTargeting;
4446
+
4447
+ var MIXPANEL_DB_NAME = 'mixpanelBrowserDb';
4175
4448
 
4449
+ var RECORDING_EVENTS_STORE_NAME = 'mixpanelRecordingEvents';
4450
+ var RECORDING_REGISTRY_STORE_NAME = 'mixpanelRecordingRegistry';
4451
+
4452
+ // note: increment the version number when adding new object stores
4453
+ var DB_VERSION = 1;
4454
+ var OBJECT_STORES = [RECORDING_EVENTS_STORE_NAME, RECORDING_REGISTRY_STORE_NAME];
4176
4455
 
4177
4456
  /**
4178
- * DomTracker Object
4179
- * @constructor
4457
+ * @type {import('./wrapper').StorageWrapper}
4180
4458
  */
4181
- var DomTracker = function() {};
4459
+ var IDBStorageWrapper = function (storeName) {
4460
+ /**
4461
+ * @type {Promise<IDBDatabase>|null}
4462
+ */
4463
+ this.dbPromise = null;
4464
+ this.storeName = storeName;
4465
+ };
4182
4466
 
4467
+ IDBStorageWrapper.prototype._openDb = function () {
4468
+ return new PromisePolyfill(function (resolve, reject) {
4469
+ var openRequest = win.indexedDB.open(MIXPANEL_DB_NAME, DB_VERSION);
4470
+ openRequest['onerror'] = function () {
4471
+ reject(openRequest.error);
4472
+ };
4183
4473
 
4184
- // interface
4185
- DomTracker.prototype.create_properties = function() {};
4186
- DomTracker.prototype.event_handler = function() {};
4187
- DomTracker.prototype.after_track_handler = function() {};
4474
+ openRequest['onsuccess'] = function () {
4475
+ resolve(openRequest.result);
4476
+ };
4188
4477
 
4189
- DomTracker.prototype.init = function(mixpanel_instance) {
4190
- this.mp = mixpanel_instance;
4191
- return this;
4192
- };
4478
+ openRequest['onupgradeneeded'] = function (ev) {
4479
+ var db = ev.target.result;
4193
4480
 
4194
- /**
4195
- * @param {Object|string} query
4196
- * @param {string} event_name
4197
- * @param {Object=} properties
4198
- * @param {function=} user_callback
4199
- */
4200
- DomTracker.prototype.track = function(query, event_name, properties, user_callback) {
4201
- var that = this;
4202
- var elements = _.dom_query(query);
4481
+ OBJECT_STORES.forEach(function (storeName) {
4482
+ db.createObjectStore(storeName);
4483
+ });
4484
+ };
4485
+ });
4486
+ };
4203
4487
 
4204
- if (elements.length === 0) {
4205
- console.error('The DOM query (' + query + ') returned 0 elements');
4206
- return;
4488
+ IDBStorageWrapper.prototype.init = function () {
4489
+ if (!win.indexedDB) {
4490
+ return PromisePolyfill.reject('indexedDB is not supported in this browser');
4207
4491
  }
4208
4492
 
4209
- _.each(elements, function(element) {
4210
- _.register_event(element, this.override_event, function(e) {
4211
- var options = {};
4212
- var props = that.create_properties(properties, this);
4213
- var timeout = that.mp.get_config('track_links_timeout');
4214
-
4215
- that.event_handler(e, this, options);
4216
-
4217
- // in case the mixpanel servers don't get back to us in time
4218
- window.setTimeout(that.track_callback(user_callback, props, options, true), timeout);
4493
+ if (!this.dbPromise) {
4494
+ this.dbPromise = this._openDb();
4495
+ }
4219
4496
 
4220
- // fire the tracking event
4221
- that.mp.track(event_name, props, that.track_callback(user_callback, props, options));
4497
+ return this.dbPromise
4498
+ .then(function (dbOrError) {
4499
+ if (dbOrError instanceof win['IDBDatabase']) {
4500
+ return PromisePolyfill.resolve();
4501
+ } else {
4502
+ return PromisePolyfill.reject(dbOrError);
4503
+ }
4222
4504
  });
4223
- }, this);
4505
+ };
4224
4506
 
4225
- return true;
4507
+ IDBStorageWrapper.prototype.isInitialized = function () {
4508
+ return !!this.dbPromise;
4226
4509
  };
4227
4510
 
4228
4511
  /**
4229
- * @param {function} user_callback
4230
- * @param {Object} props
4231
- * @param {boolean=} timeout_occured
4512
+ * @param {IDBTransactionMode} mode
4513
+ * @param {function(IDBObjectStore): void} storeCb
4232
4514
  */
4233
- DomTracker.prototype.track_callback = function(user_callback, props, options, timeout_occured) {
4234
- timeout_occured = timeout_occured || false;
4235
- var that = this;
4236
-
4237
- return function() {
4238
- // options is referenced from both callbacks, so we can have
4239
- // a 'lock' of sorts to ensure only one fires
4240
- if (options.callback_fired) { return; }
4241
- options.callback_fired = true;
4242
-
4243
- if (user_callback && user_callback(timeout_occured, props) === false) {
4515
+ IDBStorageWrapper.prototype.makeTransaction = function (mode, storeCb) {
4516
+ var storeName = this.storeName;
4517
+ var doTransaction = function (db) {
4518
+ return new PromisePolyfill(function (resolve, reject) {
4519
+ var transaction = db.transaction(storeName, mode);
4520
+ transaction.oncomplete = function () {
4521
+ resolve(transaction);
4522
+ };
4523
+ transaction.onabort = transaction.onerror = function () {
4524
+ reject(transaction.error);
4525
+ };
4526
+
4527
+ storeCb(transaction.objectStore(storeName));
4528
+ });
4529
+ };
4530
+
4531
+ return this.dbPromise
4532
+ .then(doTransaction)
4533
+ .catch(function (err) {
4534
+ if (err && err['name'] === 'InvalidStateError') {
4535
+ // try reopening the DB if the connection is closed
4536
+ this.dbPromise = this._openDb();
4537
+ return this.dbPromise.then(doTransaction);
4538
+ } else {
4539
+ return PromisePolyfill.reject(err);
4540
+ }
4541
+ }.bind(this));
4542
+ };
4543
+
4544
+ IDBStorageWrapper.prototype.setItem = function (key, value) {
4545
+ return this.makeTransaction('readwrite', function (objectStore) {
4546
+ objectStore.put(value, key);
4547
+ });
4548
+ };
4549
+
4550
+ IDBStorageWrapper.prototype.getItem = function (key) {
4551
+ var req;
4552
+ return this.makeTransaction('readonly', function (objectStore) {
4553
+ req = objectStore.get(key);
4554
+ }).then(function () {
4555
+ return req.result;
4556
+ });
4557
+ };
4558
+
4559
+ IDBStorageWrapper.prototype.removeItem = function (key) {
4560
+ return this.makeTransaction('readwrite', function (objectStore) {
4561
+ objectStore.delete(key);
4562
+ });
4563
+ };
4564
+
4565
+ IDBStorageWrapper.prototype.getAll = function () {
4566
+ var req;
4567
+ return this.makeTransaction('readonly', function (objectStore) {
4568
+ req = objectStore.getAll();
4569
+ }).then(function () {
4570
+ return req.result;
4571
+ });
4572
+ };
4573
+
4574
+ /**
4575
+ * @param {import('./session-recording').SerializedRecording} serializedRecording
4576
+ * @returns {boolean}
4577
+ */
4578
+ var isRecordingExpired = function(serializedRecording) {
4579
+ var now = Date.now();
4580
+ return !serializedRecording || now > serializedRecording['maxExpires'] || now > serializedRecording['idleExpires'];
4581
+ };
4582
+
4583
+ /* eslint camelcase: "off" */
4584
+
4585
+
4586
+ /**
4587
+ * RecorderManager: manages session recording initialization, lifecycle and state
4588
+ * @constructor
4589
+ */
4590
+ var RecorderManager = function(initOptions) {
4591
+ // TODO - Passing in mixpanel instance as it is still needed for recorder creation
4592
+ // but ideally we should be able to remove this dependency.
4593
+ this.mixpanelInstance = initOptions.mixpanelInstance;
4594
+
4595
+ this.getMpConfig = initOptions.getConfigFunc;
4596
+ this.getTabId = initOptions.getTabIdFunc;
4597
+ this.reportError = initOptions.reportErrorFunc;
4598
+ this.getDistinctId = initOptions.getDistinctIdFunc;
4599
+ this.loadExtraBundle = initOptions.loadExtraBundle;
4600
+ this.recorderSrc = initOptions.recorderSrc;
4601
+ this.targetingSrc = initOptions.targetingSrc;
4602
+ this.libBasePath = initOptions.libBasePath;
4603
+
4604
+ this._recorder = null;
4605
+ };
4606
+
4607
+ RecorderManager.prototype.shouldLoadRecorder = function() {
4608
+ if (this.getMpConfig('disable_persistence')) {
4609
+ console.log('Load recorder check skipped due to disable_persistence config');
4610
+ return PromisePolyfill.resolve(false);
4611
+ }
4612
+
4613
+ var recording_registry_idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
4614
+ var tab_id = this.getTabId();
4615
+ return recording_registry_idb.init()
4616
+ .then(function () {
4617
+ return recording_registry_idb.getAll();
4618
+ })
4619
+ .then(function (recordings) {
4620
+ for (var i = 0; i < recordings.length; i++) {
4621
+ // if there are expired recordings in the registry, we should load the recorder to flush them
4622
+ // if there's a recording for this tab id, we should load the recorder to continue the recording
4623
+ if (isRecordingExpired(recordings[i]) || recordings[i]['tabId'] === tab_id) {
4624
+ return true;
4625
+ }
4626
+ }
4627
+ return false;
4628
+ })
4629
+ .catch(_.bind(function (err) {
4630
+ this.reportError('Error checking recording registry', err);
4631
+ return false;
4632
+ }, this));
4633
+ };
4634
+
4635
+ RecorderManager.prototype.checkAndStartSessionRecording = function(force_start, rate) {
4636
+ if (!win['MutationObserver']) {
4637
+ console.critical('Browser does not support MutationObserver; skipping session recording');
4638
+ return PromisePolyfill.resolve();
4639
+ }
4640
+
4641
+ var loadRecorder = _.bind(function(startNewIfInactive) {
4642
+ return new PromisePolyfill(_.bind(function(resolve) {
4643
+ var handleLoadedRecorder = safewrap(_.bind(function() {
4644
+ this._recorder = this._recorder || new win[RECORDER_GLOBAL_NAME](this.mixpanelInstance);
4645
+ this._recorder['resumeRecording'](startNewIfInactive);
4646
+ resolve();
4647
+ }, this));
4648
+
4649
+ if (_.isUndefined(win[RECORDER_GLOBAL_NAME])) {
4650
+ var recorderSrc = this.recorderSrc || (this.libBasePath + RECORDER_FILENAME);
4651
+ this.loadExtraBundle(recorderSrc, handleLoadedRecorder);
4652
+ } else {
4653
+ handleLoadedRecorder();
4654
+ }
4655
+ }, this));
4656
+ }, this);
4657
+
4658
+ /**
4659
+ * If the user is sampled or start_session_recording is called, we always load the recorder since it's guaranteed a recording should start.
4660
+ * 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.
4661
+ */
4662
+ var effective_rate = _.isUndefined(rate) ? this.getMpConfig('record_sessions_percent') : rate;
4663
+ var is_sampled = effective_rate > 0 && Math.random() * 100 <= effective_rate;
4664
+ if (force_start || is_sampled) {
4665
+ return loadRecorder(true);
4666
+ } else {
4667
+ return this.shouldLoadRecorder()
4668
+ .then(_.bind(function (shouldLoad) {
4669
+ if (shouldLoad) {
4670
+ return loadRecorder(false);
4671
+ }
4672
+ return PromisePolyfill.resolve();
4673
+ }, this));
4674
+ }
4675
+ };
4676
+
4677
+ RecorderManager.prototype.isRecording = function() {
4678
+ // Safety check: ensure isRecording method exists (older CDN builds may not have it)
4679
+ if (!this._recorder || !_.isFunction(this._recorder['isRecording'])) {
4680
+ return false;
4681
+ }
4682
+ try {
4683
+ return this._recorder['isRecording']();
4684
+ } catch (e) {
4685
+ this.reportError('Error checking if recording is active', e);
4686
+ return false;
4687
+ }
4688
+ };
4689
+
4690
+ RecorderManager.prototype.startRecordingOnEvent = function(event_name, properties) {
4691
+ var isRecording = this.isRecording();
4692
+ var recordingTriggerEvents = this.getMpConfig('recording_event_triggers');
4693
+
4694
+ if (!isRecording && recordingTriggerEvents) {
4695
+ var trigger = recordingTriggerEvents[event_name];
4696
+ if (trigger && typeof trigger['percentage'] === 'number') {
4697
+ var newRate = trigger['percentage'];
4698
+ var propertyFilters = trigger['property_filters'];
4699
+ if (propertyFilters && !_.isEmptyObject(propertyFilters)) {
4700
+ var targetingSrc = this.targetingSrc || (this.libBasePath + TARGETING_FILENAME);
4701
+ getTargetingPromise(this.loadExtraBundle, targetingSrc)
4702
+ .then(function(targeting) {
4703
+ try {
4704
+ var result = targeting['eventMatchesCriteria'](
4705
+ event_name,
4706
+ properties,
4707
+ {
4708
+ 'event_name': event_name,
4709
+ 'property_filters': propertyFilters
4710
+ }
4711
+ );
4712
+ if (result['matches']) {
4713
+ this.checkAndStartSessionRecording(false, newRate);
4714
+ }
4715
+ } catch (err) {
4716
+ console.critical('Could not parse recording event trigger properties logic:', err);
4717
+ }
4718
+ }.bind(this)).catch(function(err) {
4719
+ console.critical('Failed to load targeting library:', err);
4720
+ });
4721
+ } else {
4722
+ this.checkAndStartSessionRecording(false, newRate);
4723
+ }
4724
+ }
4725
+ }
4726
+ };
4727
+
4728
+ RecorderManager.prototype.stopSessionRecording = function() {
4729
+ if (this._recorder) {
4730
+ return this._recorder['stopRecording']();
4731
+ }
4732
+ return PromisePolyfill.resolve();
4733
+ };
4734
+
4735
+ RecorderManager.prototype.pauseSessionRecording = function() {
4736
+ if (this._recorder) {
4737
+ return this._recorder['pauseRecording']();
4738
+ }
4739
+ return PromisePolyfill.resolve();
4740
+ };
4741
+
4742
+ RecorderManager.prototype.resumeSessionRecording = function() {
4743
+ if (this._recorder) {
4744
+ return this._recorder['resumeRecording']();
4745
+ }
4746
+ return PromisePolyfill.resolve();
4747
+ };
4748
+
4749
+ RecorderManager.prototype.isRecordingHeatmapData = function() {
4750
+ return this.getSessionReplayId() && this.getMpConfig('record_heatmap_data');
4751
+ };
4752
+
4753
+ RecorderManager.prototype.getSessionRecordingProperties = function() {
4754
+ var props = {};
4755
+ var replay_id = this.getSessionReplayId();
4756
+ if (replay_id) {
4757
+ props['$mp_replay_id'] = replay_id;
4758
+ }
4759
+ return props;
4760
+ };
4761
+
4762
+ RecorderManager.prototype.getSessionReplayUrl = function() {
4763
+ var replay_url = null;
4764
+ var replay_id = this.getSessionReplayId();
4765
+ if (replay_id) {
4766
+ var query_params = _.HTTPBuildQuery({
4767
+ 'replay_id': replay_id,
4768
+ 'distinct_id': this.getDistinctId(),
4769
+ 'token': this.getMpConfig('token')
4770
+ });
4771
+ replay_url = 'https://mixpanel.com/projects/replay-redirect?' + query_params;
4772
+ }
4773
+ return replay_url;
4774
+ };
4775
+
4776
+ RecorderManager.prototype.getSessionReplayId = function() {
4777
+ var replay_id = null;
4778
+ if (this._recorder) {
4779
+ replay_id = this._recorder['replayId'];
4780
+ }
4781
+ return replay_id || null;
4782
+ };
4783
+
4784
+ // "private" public method to reach into the recorder in test cases
4785
+ RecorderManager.prototype.getRecorder = function() {
4786
+ return this._recorder;
4787
+ };
4788
+
4789
+ safewrapClass(RecorderManager);
4790
+
4791
+ /* eslint camelcase: "off" */
4792
+
4793
+
4794
+ /**
4795
+ * DomTracker Object
4796
+ * @constructor
4797
+ */
4798
+ var DomTracker = function() {};
4799
+
4800
+
4801
+ // interface
4802
+ DomTracker.prototype.create_properties = function() {};
4803
+ DomTracker.prototype.event_handler = function() {};
4804
+ DomTracker.prototype.after_track_handler = function() {};
4805
+
4806
+ DomTracker.prototype.init = function(mixpanel_instance) {
4807
+ this.mp = mixpanel_instance;
4808
+ return this;
4809
+ };
4810
+
4811
+ /**
4812
+ * @param {Object|string} query
4813
+ * @param {string} event_name
4814
+ * @param {Object=} properties
4815
+ * @param {function=} user_callback
4816
+ */
4817
+ DomTracker.prototype.track = function(query, event_name, properties, user_callback) {
4818
+ var that = this;
4819
+ var elements = _.dom_query(query);
4820
+
4821
+ if (elements.length === 0) {
4822
+ console.error('The DOM query (' + query + ') returned 0 elements');
4823
+ return;
4824
+ }
4825
+
4826
+ _.each(elements, function(element) {
4827
+ _.register_event(element, this.override_event, function(e) {
4828
+ var options = {};
4829
+ var props = that.create_properties(properties, this);
4830
+ var timeout = that.mp.get_config('track_links_timeout');
4831
+
4832
+ that.event_handler(e, this, options);
4833
+
4834
+ // in case the mixpanel servers don't get back to us in time
4835
+ window.setTimeout(that.track_callback(user_callback, props, options, true), timeout);
4836
+
4837
+ // fire the tracking event
4838
+ that.mp.track(event_name, props, that.track_callback(user_callback, props, options));
4839
+ });
4840
+ }, this);
4841
+
4842
+ return true;
4843
+ };
4844
+
4845
+ /**
4846
+ * @param {function} user_callback
4847
+ * @param {Object} props
4848
+ * @param {boolean=} timeout_occured
4849
+ */
4850
+ DomTracker.prototype.track_callback = function(user_callback, props, options, timeout_occured) {
4851
+ timeout_occured = timeout_occured || false;
4852
+ var that = this;
4853
+
4854
+ return function() {
4855
+ // options is referenced from both callbacks, so we can have
4856
+ // a 'lock' of sorts to ensure only one fires
4857
+ if (options.callback_fired) { return; }
4858
+ options.callback_fired = true;
4859
+
4860
+ if (user_callback && user_callback(timeout_occured, props) === false) {
4244
4861
  // user can prevent the default functionality by
4245
4862
  // returning false from their callback
4246
4863
  return;
@@ -6692,133 +7309,6 @@
6692
7309
  return timestamp;
6693
7310
  };
6694
7311
 
6695
- var MIXPANEL_DB_NAME = 'mixpanelBrowserDb';
6696
-
6697
- var RECORDING_EVENTS_STORE_NAME = 'mixpanelRecordingEvents';
6698
- var RECORDING_REGISTRY_STORE_NAME = 'mixpanelRecordingRegistry';
6699
-
6700
- // note: increment the version number when adding new object stores
6701
- var DB_VERSION = 1;
6702
- var OBJECT_STORES = [RECORDING_EVENTS_STORE_NAME, RECORDING_REGISTRY_STORE_NAME];
6703
-
6704
- /**
6705
- * @type {import('./wrapper').StorageWrapper}
6706
- */
6707
- var IDBStorageWrapper = function (storeName) {
6708
- /**
6709
- * @type {Promise<IDBDatabase>|null}
6710
- */
6711
- this.dbPromise = null;
6712
- this.storeName = storeName;
6713
- };
6714
-
6715
- IDBStorageWrapper.prototype._openDb = function () {
6716
- return new PromisePolyfill(function (resolve, reject) {
6717
- var openRequest = win.indexedDB.open(MIXPANEL_DB_NAME, DB_VERSION);
6718
- openRequest['onerror'] = function () {
6719
- reject(openRequest.error);
6720
- };
6721
-
6722
- openRequest['onsuccess'] = function () {
6723
- resolve(openRequest.result);
6724
- };
6725
-
6726
- openRequest['onupgradeneeded'] = function (ev) {
6727
- var db = ev.target.result;
6728
-
6729
- OBJECT_STORES.forEach(function (storeName) {
6730
- db.createObjectStore(storeName);
6731
- });
6732
- };
6733
- });
6734
- };
6735
-
6736
- IDBStorageWrapper.prototype.init = function () {
6737
- if (!win.indexedDB) {
6738
- return PromisePolyfill.reject('indexedDB is not supported in this browser');
6739
- }
6740
-
6741
- if (!this.dbPromise) {
6742
- this.dbPromise = this._openDb();
6743
- }
6744
-
6745
- return this.dbPromise
6746
- .then(function (dbOrError) {
6747
- if (dbOrError instanceof win['IDBDatabase']) {
6748
- return PromisePolyfill.resolve();
6749
- } else {
6750
- return PromisePolyfill.reject(dbOrError);
6751
- }
6752
- });
6753
- };
6754
-
6755
- IDBStorageWrapper.prototype.isInitialized = function () {
6756
- return !!this.dbPromise;
6757
- };
6758
-
6759
- /**
6760
- * @param {IDBTransactionMode} mode
6761
- * @param {function(IDBObjectStore): void} storeCb
6762
- */
6763
- IDBStorageWrapper.prototype.makeTransaction = function (mode, storeCb) {
6764
- var storeName = this.storeName;
6765
- var doTransaction = function (db) {
6766
- return new PromisePolyfill(function (resolve, reject) {
6767
- var transaction = db.transaction(storeName, mode);
6768
- transaction.oncomplete = function () {
6769
- resolve(transaction);
6770
- };
6771
- transaction.onabort = transaction.onerror = function () {
6772
- reject(transaction.error);
6773
- };
6774
-
6775
- storeCb(transaction.objectStore(storeName));
6776
- });
6777
- };
6778
-
6779
- return this.dbPromise
6780
- .then(doTransaction)
6781
- .catch(function (err) {
6782
- if (err && err['name'] === 'InvalidStateError') {
6783
- // try reopening the DB if the connection is closed
6784
- this.dbPromise = this._openDb();
6785
- return this.dbPromise.then(doTransaction);
6786
- } else {
6787
- return PromisePolyfill.reject(err);
6788
- }
6789
- }.bind(this));
6790
- };
6791
-
6792
- IDBStorageWrapper.prototype.setItem = function (key, value) {
6793
- return this.makeTransaction('readwrite', function (objectStore) {
6794
- objectStore.put(value, key);
6795
- });
6796
- };
6797
-
6798
- IDBStorageWrapper.prototype.getItem = function (key) {
6799
- var req;
6800
- return this.makeTransaction('readonly', function (objectStore) {
6801
- req = objectStore.get(key);
6802
- }).then(function () {
6803
- return req.result;
6804
- });
6805
- };
6806
-
6807
- IDBStorageWrapper.prototype.removeItem = function (key) {
6808
- return this.makeTransaction('readwrite', function (objectStore) {
6809
- objectStore.delete(key);
6810
- });
6811
- };
6812
-
6813
- IDBStorageWrapper.prototype.getAll = function () {
6814
- var req;
6815
- return this.makeTransaction('readonly', function (objectStore) {
6816
- req = objectStore.getAll();
6817
- }).then(function () {
6818
- return req.result;
6819
- });
6820
- };
6821
-
6822
7312
  /* eslint camelcase: "off" */
6823
7313
 
6824
7314
  /*
@@ -6953,12 +7443,17 @@
6953
7443
  'record_collect_fonts': false,
6954
7444
  'record_console': true,
6955
7445
  'record_heatmap_data': false,
7446
+ 'recording_event_triggers': {},
6956
7447
  'record_idle_timeout_ms': 30 * 60 * 1000, // 30 minutes
6957
7448
  'record_mask_inputs': true,
6958
7449
  'record_max_ms': MAX_RECORDING_MS,
6959
7450
  'record_min_ms': 0,
7451
+ 'record_network': false,
7452
+ 'record_network_options': {},
6960
7453
  'record_sessions_percent': 0,
6961
- 'recorder_src': 'https://cdn.mxpnl.com/libs/mixpanel-recorder.min.js',
7454
+ 'recorder_src': null,
7455
+ 'targeting_src': null,
7456
+ 'lib_base_path': 'https://cdn.mxpnl.com/libs/',
6962
7457
  'remote_settings_mode': SETTING_DISABLED // 'strict', 'fallback', 'disabled'
6963
7458
  };
6964
7459
 
@@ -7112,6 +7607,19 @@
7112
7607
  'callback_fn': ((name === PRIMARY_INSTANCE_NAME) ? name : PRIMARY_INSTANCE_NAME + '.' + name) + '._jsc'
7113
7608
  }));
7114
7609
 
7610
+ this.recorderManager = new RecorderManager({
7611
+ mixpanelInstance: this,
7612
+ getConfigFunc: _.bind(this.get_config, this),
7613
+ setConfigFunc: _.bind(this.set_config, this),
7614
+ getTabIdFunc: _.bind(this.get_tab_id, this),
7615
+ reportErrorFunc: _.bind(this.report_error, this),
7616
+ getDistinctIdFunc: _.bind(this.get_distinct_id, this),
7617
+ recorderSrc: this.get_config('recorder_src'),
7618
+ targetingSrc: this.get_config('targeting_src'),
7619
+ libBasePath: this.get_config('lib_base_path'),
7620
+ loadExtraBundle: load_extra_bundle
7621
+ });
7622
+
7115
7623
  this['_jsc'] = NOOP_FUNC;
7116
7624
 
7117
7625
  this.__dom_loaded_queue = [];
@@ -7188,7 +7696,9 @@
7188
7696
  getConfigFunc: _.bind(this.get_config, this),
7189
7697
  setConfigFunc: _.bind(this.set_config, this),
7190
7698
  getPropertyFunc: _.bind(this.get_property, this),
7191
- trackingFunc: _.bind(this.track, this)
7699
+ trackingFunc: _.bind(this.track, this),
7700
+ loadExtraBundle: load_extra_bundle,
7701
+ targetingSrc: this.get_config('targeting_src') || (this.get_config('lib_base_path') + TARGETING_FILENAME)
7192
7702
  });
7193
7703
  this.flags.init();
7194
7704
  this['flags'] = this.flags;
@@ -7201,11 +7711,11 @@
7201
7711
  // Based on remote_settings_mode, fetch remote settings and then start session recording if applicable
7202
7712
  var mode = this.get_config('remote_settings_mode');
7203
7713
  if (mode === SETTING_STRICT || mode === SETTING_FALLBACK) {
7204
- this._fetch_remote_settings(mode).then(_.bind(function() {
7205
- this._check_and_start_session_recording();
7714
+ this.__session_recording_init_promise = this._fetch_remote_settings(mode).then(_.bind(function() {
7715
+ return this._check_and_start_session_recording();
7206
7716
  }, this));
7207
7717
  } else {
7208
- this._check_and_start_session_recording();
7718
+ this.__session_recording_init_promise = this._check_and_start_session_recording();
7209
7719
  }
7210
7720
  };
7211
7721
 
@@ -7249,132 +7759,50 @@
7249
7759
  return this.tab_id || null;
7250
7760
  };
7251
7761
 
7252
- MixpanelLib.prototype._should_load_recorder = function () {
7253
- if (this.get_config('disable_persistence')) {
7254
- console.log('Load recorder check skipped due to disable_persistence config');
7255
- return Promise.resolve(false);
7256
- }
7257
-
7258
- var recording_registry_idb = new IDBStorageWrapper(RECORDING_REGISTRY_STORE_NAME);
7259
- var tab_id = this.get_tab_id();
7260
- return recording_registry_idb.init()
7261
- .then(function () {
7262
- return recording_registry_idb.getAll();
7263
- })
7264
- .then(function (recordings) {
7265
- for (var i = 0; i < recordings.length; i++) {
7266
- // if there are expired recordings in the registry, we should load the recorder to flush them
7267
- // if there's a recording for this tab id, we should load the recorder to continue the recording
7268
- if (isRecordingExpired(recordings[i]) || recordings[i]['tabId'] === tab_id) {
7269
- return true;
7270
- }
7271
- }
7272
- return false;
7273
- })
7274
- .catch(_.bind(function (err) {
7275
- this.report_error('Error checking recording registry', err);
7276
- }, this));
7277
- };
7278
-
7279
7762
  MixpanelLib.prototype._check_and_start_session_recording = addOptOutCheckMixpanelLib(function(force_start) {
7280
- if (!win['MutationObserver']) {
7281
- console.critical('Browser does not support MutationObserver; skipping session recording');
7282
- return;
7283
- }
7284
-
7285
- var loadRecorder = _.bind(function(startNewIfInactive) {
7286
- var handleLoadedRecorder = _.bind(function() {
7287
- this._recorder = this._recorder || new win['__mp_recorder'](this);
7288
- this._recorder['resumeRecording'](startNewIfInactive);
7289
- }, this);
7290
-
7291
- if (_.isUndefined(win['__mp_recorder'])) {
7292
- load_extra_bundle(this.get_config('recorder_src'), handleLoadedRecorder);
7293
- } else {
7294
- handleLoadedRecorder();
7295
- }
7296
- }, this);
7297
-
7298
- /**
7299
- * If the user is sampled or start_session_recording is called, we always load the recorder since it's guaranteed a recording should start.
7300
- * 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.
7301
- */
7302
- var is_sampled = this.get_config('record_sessions_percent') > 0 && Math.random() * 100 <= this.get_config('record_sessions_percent');
7303
- if (force_start || is_sampled) {
7304
- loadRecorder(true);
7305
- } else {
7306
- this._should_load_recorder()
7307
- .then(function (shouldLoad) {
7308
- if (shouldLoad) {
7309
- loadRecorder(false);
7310
- }
7311
- });
7312
- }
7763
+ return this.recorderManager.checkAndStartSessionRecording(force_start);
7313
7764
  });
7314
7765
 
7766
+ MixpanelLib.prototype._start_recording_on_event = function(event_name, properties) {
7767
+ return this.recorderManager.startRecordingOnEvent(event_name, properties);
7768
+ };
7769
+
7315
7770
  MixpanelLib.prototype.start_session_recording = function () {
7316
- this._check_and_start_session_recording(true);
7771
+ return this._check_and_start_session_recording(true);
7317
7772
  };
7318
7773
 
7319
7774
  MixpanelLib.prototype.stop_session_recording = function () {
7320
- if (this._recorder) {
7321
- return this._recorder['stopRecording']();
7322
- }
7323
- return Promise.resolve();
7775
+ return this.recorderManager.stopSessionRecording();
7324
7776
  };
7325
7777
 
7326
7778
  MixpanelLib.prototype.pause_session_recording = function () {
7327
- if (this._recorder) {
7328
- return this._recorder['pauseRecording']();
7329
- }
7330
- return Promise.resolve();
7779
+ return this.recorderManager.pauseSessionRecording();
7331
7780
  };
7332
7781
 
7333
7782
  MixpanelLib.prototype.resume_session_recording = function () {
7334
- if (this._recorder) {
7335
- return this._recorder['resumeRecording']();
7336
- }
7337
- return Promise.resolve();
7783
+ return this.recorderManager.resumeSessionRecording();
7338
7784
  };
7339
7785
 
7340
7786
  MixpanelLib.prototype.is_recording_heatmap_data = function () {
7341
- return this._get_session_replay_id() && this.get_config('record_heatmap_data');
7787
+ return this.recorderManager.isRecordingHeatmapData();
7342
7788
  };
7343
7789
 
7344
7790
  MixpanelLib.prototype.get_session_recording_properties = function () {
7345
- var props = {};
7346
- var replay_id = this._get_session_replay_id();
7347
- if (replay_id) {
7348
- props['$mp_replay_id'] = replay_id;
7349
- }
7350
- return props;
7791
+ return this.recorderManager.getSessionRecordingProperties();
7351
7792
  };
7352
7793
 
7353
7794
  MixpanelLib.prototype.get_session_replay_url = function () {
7354
- var replay_url = null;
7355
- var replay_id = this._get_session_replay_id();
7356
- if (replay_id) {
7357
- var query_params = _.HTTPBuildQuery({
7358
- 'replay_id': replay_id,
7359
- 'distinct_id': this.get_distinct_id(),
7360
- 'token': this.get_config('token')
7361
- });
7362
- replay_url = 'https://mixpanel.com/projects/replay-redirect?' + query_params;
7363
- }
7364
- return replay_url;
7365
- };
7366
-
7367
- MixpanelLib.prototype._get_session_replay_id = function () {
7368
- var replay_id = null;
7369
- if (this._recorder) {
7370
- replay_id = this._recorder['replayId'];
7371
- }
7372
- return replay_id || null;
7795
+ return this.recorderManager.getSessionReplayUrl();
7373
7796
  };
7374
7797
 
7375
7798
  // "private" public method to reach into the recorder in test cases
7376
7799
  MixpanelLib.prototype.__get_recorder = function () {
7377
- return this._recorder;
7800
+ return this.recorderManager.getRecorder();
7801
+ };
7802
+
7803
+ // "private" public method to get session recording init promise in test cases
7804
+ MixpanelLib.prototype.__get_recording_init_promise = function () {
7805
+ return this.__session_recording_init_promise;
7378
7806
  };
7379
7807
 
7380
7808
  // Private methods
@@ -7632,6 +8060,7 @@
7632
8060
  };
7633
8061
 
7634
8062
  MixpanelLib.prototype._fetch_remote_settings = function(mode) {
8063
+ var self = this;
7635
8064
  var disableRecordingIfStrict = function() {
7636
8065
  if (mode === 'strict') {
7637
8066
  self.set_config({'record_sessions_percent': 0});
@@ -7652,7 +8081,6 @@
7652
8081
  };
7653
8082
  var query_string = _.HTTPBuildQuery(request_params);
7654
8083
  var full_url = settings_endpoint + '?' + query_string;
7655
- var self = this;
7656
8084
 
7657
8085
  var abortController = new AbortController();
7658
8086
  var timeout_id = setTimeout(function() {
@@ -7844,6 +8272,34 @@
7844
8272
  this._execute_array([item]);
7845
8273
  };
7846
8274
 
8275
+ /**
8276
+ * Enables events on the Mixpanel object. If passed no arguments,
8277
+ * this function enable tracking of all events. If passed an
8278
+ * array of event names, those events will be enabled, but other
8279
+ * existing disabled events will continue to be not tracked.
8280
+ *
8281
+ * @param {Array} [events] An array of event names to enable
8282
+ */
8283
+ MixpanelLib.prototype.enable = function(events) {
8284
+ var keys, new_disabled_events, i, j;
8285
+
8286
+ if (typeof(events) === 'undefined') {
8287
+ this._flags.disable_all_events = false;
8288
+ } else {
8289
+ keys = {};
8290
+ new_disabled_events = [];
8291
+ for (i = 0; i < events.length; i++) {
8292
+ keys[events[i]] = true;
8293
+ }
8294
+ for (j = 0; j < this.__disabled_events.length; j++) {
8295
+ if (!keys[this.__disabled_events[j]]) {
8296
+ new_disabled_events.push(this.__disabled_events[j]);
8297
+ }
8298
+ }
8299
+ this.__disabled_events = new_disabled_events;
8300
+ }
8301
+ };
8302
+
7847
8303
  /**
7848
8304
  * Disable events on the Mixpanel object. If passed no arguments,
7849
8305
  * this function disables tracking of any event. If passed an
@@ -8017,6 +8473,8 @@
8017
8473
  this.report_error('Invalid value for property_blacklist config: ' + property_blacklist);
8018
8474
  }
8019
8475
 
8476
+ this._start_recording_on_event(event_name, properties);
8477
+
8020
8478
  var data = {
8021
8479
  'event': event_name,
8022
8480
  'properties': properties
@@ -8030,6 +8488,11 @@
8030
8488
  send_request_options: options
8031
8489
  }, callback);
8032
8490
 
8491
+ // Check for first-time event matches
8492
+ if (this.flags && this.flags.checkFirstTimeEvents) {
8493
+ this.flags.checkFirstTimeEvents(event_name, properties);
8494
+ }
8495
+
8033
8496
  return ret;
8034
8497
  });
8035
8498
 
@@ -9220,6 +9683,7 @@
9220
9683
  // MixpanelLib Exports
9221
9684
  MixpanelLib.prototype['init'] = MixpanelLib.prototype.init;
9222
9685
  MixpanelLib.prototype['reset'] = MixpanelLib.prototype.reset;
9686
+ MixpanelLib.prototype['enable'] = MixpanelLib.prototype.enable;
9223
9687
  MixpanelLib.prototype['disable'] = MixpanelLib.prototype.disable;
9224
9688
  MixpanelLib.prototype['time_event'] = MixpanelLib.prototype.time_event;
9225
9689
  MixpanelLib.prototype['track'] = MixpanelLib.prototype.track;
@@ -9263,6 +9727,7 @@
9263
9727
 
9264
9728
  // Exports intended only for testing
9265
9729
  MixpanelLib.prototype['__get_recorder'] = MixpanelLib.prototype.__get_recorder;
9730
+ MixpanelLib.prototype['__get_recording_init_promise'] = MixpanelLib.prototype.__get_recording_init_promise;
9266
9731
 
9267
9732
  // MixpanelPersistence Exports
9268
9733
  MixpanelPersistence.prototype['properties'] = MixpanelPersistence.prototype.properties;