mixpanel-browser 2.74.0 → 2.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.github/workflows/unit-tests.yml +1 -1
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +2 -2
  4. package/dist/mixpanel-core.cjs.js +318 -20
  5. package/dist/mixpanel-recorder.js +127 -15
  6. package/dist/mixpanel-recorder.min.js +1 -1
  7. package/dist/mixpanel-recorder.min.js.map +1 -1
  8. package/dist/mixpanel-targeting.js +2576 -0
  9. package/dist/mixpanel-targeting.min.js +2 -0
  10. package/dist/mixpanel-targeting.min.js.map +1 -0
  11. package/dist/mixpanel-with-async-modules.cjs.d.ts +522 -0
  12. package/dist/mixpanel-with-async-modules.cjs.js +9700 -0
  13. package/dist/mixpanel-with-async-recorder.cjs.js +318 -20
  14. package/dist/mixpanel-with-recorder.js +435 -26
  15. package/dist/mixpanel-with-recorder.min.js +1 -1
  16. package/dist/mixpanel.amd.js +1020 -28
  17. package/dist/mixpanel.cjs.js +1020 -28
  18. package/dist/mixpanel.globals.js +318 -20
  19. package/dist/mixpanel.min.js +179 -172
  20. package/dist/mixpanel.module.js +1020 -28
  21. package/dist/mixpanel.umd.js +1020 -28
  22. package/dist/rrweb-bundled.js +119 -5
  23. package/dist/rrweb-compiled.js +116 -5
  24. package/package.json +4 -3
  25. package/rollup.config.mjs +34 -2
  26. package/src/config.js +1 -1
  27. package/src/flags/index.js +269 -8
  28. package/src/globals.js +14 -0
  29. package/src/loaders/loader-module.js +1 -0
  30. package/src/mixpanel-core.js +12 -3
  31. package/src/recorder/index.js +2 -1
  32. package/src/targeting/event-matcher.js +97 -0
  33. package/src/targeting/index.js +11 -0
  34. package/src/targeting/loader.js +36 -0
  35. package/src/utils.js +1 -8
  36. package/.claude/settings.local.json +0 -12
  37. /package/src/loaders/{loader-module-with-async-recorder.js → loader-module-with-async-modules.js} +0 -0
@@ -3,7 +3,7 @@
3
3
 
4
4
  var Config = {
5
5
  DEBUG: false,
6
- LIB_VERSION: '2.74.0'
6
+ LIB_VERSION: '2.75.0'
7
7
  };
8
8
 
9
9
  // since es6 imports are static and we run unit tests from the console, window won't be defined when importing this file
@@ -594,15 +594,8 @@
594
594
  return toString.call(obj) === '[object Array]';
595
595
  };
596
596
 
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
597
  _.isFunction = function(f) {
601
- try {
602
- return /^\s*\bfunction\b/.test(f);
603
- } catch (x) {
604
- return false;
605
- }
598
+ return typeof f === 'function';
606
599
  };
607
600
 
608
601
  _.isArguments = function(obj) {
@@ -2160,6 +2153,16 @@
2160
2153
  return !serializedRecording || now > serializedRecording['maxExpires'] || now > serializedRecording['idleExpires'];
2161
2154
  };
2162
2155
 
2156
+ /**
2157
+ * Shared global window property names used across modules
2158
+ */
2159
+
2160
+ // Targeting library global (used by flags and targeting modules)
2161
+ var TARGETING_GLOBAL_NAME = '__mp_targeting';
2162
+
2163
+ // Recorder library global (used by recorder and mixpanel-core)
2164
+ var RECORDER_GLOBAL_NAME = '__mp_recorder';
2165
+
2163
2166
  // stateless utils
2164
2167
  // mostly from https://github.com/mixpanel/mixpanel-js/blob/989ada50f518edab47b9c4fd9535f9fbd5ec5fc0/src/autotrack-utils.js
2165
2168
 
@@ -3908,14 +3911,62 @@
3908
3911
  // TODO integrate error_reporter from mixpanel instance
3909
3912
  safewrapClass(Autocapture);
3910
3913
 
3911
- var logger$3 = console_with_prefix('flags');
3914
+ /**
3915
+ * Get the promise-based targeting loader
3916
+ * @param {Function} loadExtraBundle - Function to load external bundle (callback-based)
3917
+ * @param {string} targetingSrc - URL to targeting bundle
3918
+ * @returns {Promise} Promise that resolves with targeting library
3919
+ */
3920
+ var getTargetingPromise = function(loadExtraBundle, targetingSrc) {
3921
+ // Return existing promise if already initialized or loading
3922
+ if (win[TARGETING_GLOBAL_NAME] && typeof win[TARGETING_GLOBAL_NAME].then === 'function') {
3923
+ return win[TARGETING_GLOBAL_NAME];
3924
+ }
3925
+
3926
+ // Create loading promise and set it as the global immediately
3927
+ // This makes minified build behavior consistent with dev/CJS builds
3928
+ win[TARGETING_GLOBAL_NAME] = new Promise(function (resolve) {
3929
+ loadExtraBundle(targetingSrc, resolve);
3930
+ }).then(function () {
3931
+ var p = win[TARGETING_GLOBAL_NAME];
3932
+ if (p && typeof p.then === 'function') {
3933
+ return p;
3934
+ }
3935
+ throw new Error('targeting failed to load');
3936
+ }).catch(function (err) {
3937
+ delete win[TARGETING_GLOBAL_NAME];
3938
+ throw err;
3939
+ });
3940
+
3941
+ return win[TARGETING_GLOBAL_NAME];
3942
+ };
3912
3943
 
3944
+ var logger$3 = console_with_prefix('flags');
3913
3945
  var FLAGS_CONFIG_KEY = 'flags';
3914
3946
 
3915
3947
  var CONFIG_CONTEXT = 'context';
3916
3948
  var CONFIG_DEFAULTS = {};
3917
3949
  CONFIG_DEFAULTS[CONFIG_CONTEXT] = {};
3918
3950
 
3951
+ /**
3952
+ * Generate a unique key for a pending first-time event
3953
+ * @param {string} flagKey - The flag key
3954
+ * @param {string} firstTimeEventHash - The first_time_event_hash from the pending event definition
3955
+ * @returns {string} Composite key in format "flagKey:firstTimeEventHash"
3956
+ */
3957
+ var getPendingEventKey = function(flagKey, firstTimeEventHash) {
3958
+ return flagKey + ':' + firstTimeEventHash;
3959
+ };
3960
+
3961
+ /**
3962
+ * Extract the flag key from a pending event key
3963
+ * @param {string} eventKey - The composite event key in format "flagKey:firstTimeEventHash"
3964
+ * @returns {string} The flag key portion
3965
+ */
3966
+ var getFlagKeyFromPendingEventKey = function(eventKey) {
3967
+ return eventKey.split(':')[0];
3968
+ };
3969
+
3919
3970
  /**
3920
3971
  * FeatureFlagManager: support for Mixpanel's feature flagging product
3921
3972
  * @constructor
@@ -3927,6 +3978,8 @@
3927
3978
  this.setMpConfig = initOptions.setConfigFunc;
3928
3979
  this.getMpProperty = initOptions.getPropertyFunc;
3929
3980
  this.track = initOptions.trackingFunc;
3981
+ this.loadExtraBundle = initOptions.loadExtraBundle || function() {};
3982
+ this.targetingSrc = initOptions.targetingSrc || '';
3930
3983
  };
3931
3984
 
3932
3985
  FeatureFlagManager.prototype.init = function() {
@@ -3939,6 +3992,8 @@
3939
3992
  this.fetchFlags();
3940
3993
 
3941
3994
  this.trackedFeatures = new Set();
3995
+ this.pendingFirstTimeEvents = {};
3996
+ this.activatedFirstTimeEvents = {};
3942
3997
  };
3943
3998
 
3944
3999
  FeatureFlagManager.prototype.getFullConfig = function() {
@@ -4019,17 +4074,78 @@
4019
4074
  throw new Error('No flags in API response');
4020
4075
  }
4021
4076
  var flags = new Map();
4077
+ var pendingFirstTimeEvents = {};
4078
+
4079
+ // Process flags from response
4022
4080
  _.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']
4081
+ // Check if this flag has any activated first-time events this session
4082
+ var hasActivatedEvent = false;
4083
+ var prefix = key + ':';
4084
+ _.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
4085
+ if (eventKey.startsWith(prefix)) {
4086
+ hasActivatedEvent = true;
4087
+ }
4029
4088
  });
4030
- });
4089
+
4090
+ if (hasActivatedEvent) {
4091
+ // Preserve the activated variant, don't overwrite with server's current variant
4092
+ var currentFlag = this.flags && this.flags.get(key);
4093
+ if (currentFlag) {
4094
+ flags.set(key, currentFlag);
4095
+ }
4096
+ } else {
4097
+ // Use server's current variant
4098
+ flags.set(key, {
4099
+ 'key': data['variant_key'],
4100
+ 'value': data['variant_value'],
4101
+ 'experiment_id': data['experiment_id'],
4102
+ 'is_experiment_active': data['is_experiment_active'],
4103
+ 'is_qa_tester': data['is_qa_tester']
4104
+ });
4105
+ }
4106
+ }, this);
4107
+
4108
+ // Process top-level pending_first_time_events array
4109
+ var topLevelDefinitions = responseBody['pending_first_time_events'];
4110
+ if (topLevelDefinitions && topLevelDefinitions.length > 0) {
4111
+ _.each(topLevelDefinitions, function(def) {
4112
+ var flagKey = def['flag_key'];
4113
+ var eventKey = getPendingEventKey(flagKey, def['first_time_event_hash']);
4114
+
4115
+ // Skip if this specific event has already been activated this session
4116
+ if (this.activatedFirstTimeEvents[eventKey]) {
4117
+ return;
4118
+ }
4119
+
4120
+ // Store pending event definition using composite key
4121
+ pendingFirstTimeEvents[eventKey] = {
4122
+ 'flag_key': flagKey,
4123
+ 'flag_id': def['flag_id'],
4124
+ 'project_id': def['project_id'],
4125
+ 'first_time_event_hash': def['first_time_event_hash'],
4126
+ 'event_name': def['event_name'],
4127
+ 'property_filters': def['property_filters'],
4128
+ 'pending_variant': def['pending_variant']
4129
+ };
4130
+ }, this);
4131
+ }
4132
+
4133
+ // Preserve any activated orphaned flags (flags that were activated but are no longer in response)
4134
+ if (this.activatedFirstTimeEvents) {
4135
+ _.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
4136
+ var flagKey = getFlagKeyFromPendingEventKey(eventKey);
4137
+ if (activated && !flags.has(flagKey) && this.flags && this.flags.has(flagKey)) {
4138
+ // Keep the activated flag even though it's not in the new response
4139
+ flags.set(flagKey, this.flags.get(flagKey));
4140
+ }
4141
+ }, this);
4142
+ }
4143
+
4031
4144
  this.flags = flags;
4145
+ this.pendingFirstTimeEvents = pendingFirstTimeEvents;
4032
4146
  this._traceparent = traceparent;
4147
+
4148
+ this._loadTargetingIfNeeded();
4033
4149
  }.bind(this)).catch(function(error) {
4034
4150
  this.markFetchComplete();
4035
4151
  logger$3.error(error);
@@ -4053,6 +4169,177 @@
4053
4169
  this._fetchInProgressStartTime = null;
4054
4170
  };
4055
4171
 
4172
+ /**
4173
+ * Proactively load targeting bundle if any pending events have property filters
4174
+ */
4175
+ FeatureFlagManager.prototype._loadTargetingIfNeeded = function() {
4176
+ var hasPropertyFilters = false;
4177
+ _.each(this.pendingFirstTimeEvents, function(evt) {
4178
+ if (evt['property_filters'] && !_.isEmptyObject(evt['property_filters'])) {
4179
+ hasPropertyFilters = true;
4180
+ }
4181
+ });
4182
+
4183
+ if (hasPropertyFilters) {
4184
+ this.getTargeting().then(function() {
4185
+ logger$3.log('targeting loaded for property filter evaluation');
4186
+ });
4187
+ }
4188
+ };
4189
+
4190
+ /**
4191
+ * Get the targeting library (initializes if not already loaded)
4192
+ * This method is primarily for testing - production code should rely on automatic loading
4193
+ * @returns {Promise} Promise that resolves with targeting library
4194
+ */
4195
+ FeatureFlagManager.prototype.getTargeting = function() {
4196
+ return getTargetingPromise(
4197
+ this.loadExtraBundle.bind(this),
4198
+ this.targetingSrc
4199
+ ).catch(function(error) {
4200
+ logger$3.error('Failed to load targeting: ' + error);
4201
+ }.bind(this));
4202
+ };
4203
+
4204
+ /**
4205
+ * Check if a tracked event matches any pending first-time events and activate the corresponding flag variant
4206
+ * @param {string} eventName - The name of the event being tracked
4207
+ * @param {Object} properties - Event properties to evaluate against property filters
4208
+ *
4209
+ * When a match is found (event name matches and property filters pass), this method:
4210
+ * - Switches the flag to the pending variant
4211
+ * - Marks the event as activated for this session
4212
+ * - Records the activation via the API (fire-and-forget)
4213
+ */
4214
+ FeatureFlagManager.prototype.checkFirstTimeEvents = function(eventName, properties) {
4215
+ if (!this.pendingFirstTimeEvents || _.isEmptyObject(this.pendingFirstTimeEvents)) {
4216
+ return;
4217
+ }
4218
+
4219
+ // Check if targeting promise exists (either bundled or async loaded)
4220
+ if (win[TARGETING_GLOBAL_NAME] && _.isFunction(win[TARGETING_GLOBAL_NAME].then)) {
4221
+ win[TARGETING_GLOBAL_NAME].then(function(library) {
4222
+ this._processFirstTimeEventCheck(eventName, properties, library);
4223
+ }.bind(this)).catch(function() {
4224
+ // If targeting failed to load, process with null
4225
+ // Events without property filters will still match
4226
+ this._processFirstTimeEventCheck(eventName, properties, null);
4227
+ }.bind(this));
4228
+ } else {
4229
+ // No targeting available, process with null
4230
+ // Events without property filters will still match
4231
+ this._processFirstTimeEventCheck(eventName, properties, null);
4232
+ }
4233
+ };
4234
+
4235
+ /**
4236
+ * Internal method to process first-time event checks with loaded targeting library
4237
+ * @param {string} eventName - The name of the event being tracked
4238
+ * @param {Object} properties - Event properties to evaluate against property filters
4239
+ * @param {Object} targeting - The loaded targeting library
4240
+ */
4241
+ FeatureFlagManager.prototype._processFirstTimeEventCheck = function(eventName, properties, targeting) {
4242
+ _.each(this.pendingFirstTimeEvents, function(pendingEvent, eventKey) {
4243
+ if (this.activatedFirstTimeEvents[eventKey]) {
4244
+ return;
4245
+ }
4246
+
4247
+ var flagKey = pendingEvent['flag_key'];
4248
+
4249
+ // Use targeting module to check if event matches
4250
+ var matchResult;
4251
+
4252
+ // If no targeting library and event has property filters, skip it
4253
+ if (!targeting && pendingEvent['property_filters'] && !_.isEmptyObject(pendingEvent['property_filters'])) {
4254
+ logger$3.warn('Skipping event check for "' + flagKey + '" - property filters require targeting library');
4255
+ return;
4256
+ }
4257
+
4258
+ // For simple events (no property filters), just check event name
4259
+ if (!targeting) {
4260
+ matchResult = {
4261
+ matches: eventName === pendingEvent['event_name'],
4262
+ error: null
4263
+ };
4264
+ } else {
4265
+ var criteria = {
4266
+ 'event_name': pendingEvent['event_name'],
4267
+ 'property_filters': pendingEvent['property_filters']
4268
+ };
4269
+ matchResult = targeting['eventMatchesCriteria'](
4270
+ eventName,
4271
+ properties,
4272
+ criteria
4273
+ );
4274
+ }
4275
+
4276
+ if (matchResult.error) {
4277
+ logger$3.error('Error checking first-time event for flag "' + flagKey + '": ' + matchResult.error);
4278
+ return;
4279
+ }
4280
+
4281
+ if (!matchResult.matches) {
4282
+ return;
4283
+ }
4284
+
4285
+ logger$3.log('First-time event matched for flag "' + flagKey + '": ' + eventName);
4286
+
4287
+ var newVariant = {
4288
+ 'key': pendingEvent['pending_variant']['variant_key'],
4289
+ 'value': pendingEvent['pending_variant']['variant_value'],
4290
+ 'experiment_id': pendingEvent['pending_variant']['experiment_id'],
4291
+ 'is_experiment_active': pendingEvent['pending_variant']['is_experiment_active']
4292
+ };
4293
+
4294
+ this.flags.set(flagKey, newVariant);
4295
+ this.activatedFirstTimeEvents[eventKey] = true;
4296
+
4297
+ this.recordFirstTimeEvent(
4298
+ pendingEvent['flag_id'],
4299
+ pendingEvent['project_id'],
4300
+ pendingEvent['first_time_event_hash']
4301
+ );
4302
+ }, this);
4303
+ };
4304
+
4305
+ FeatureFlagManager.prototype.getFirstTimeEventApiRoute = function(flagId) {
4306
+ // Construct URL: {api_host}/flags/{flagId}/first-time-events
4307
+ return this.getFullApiRoute() + '/' + flagId + '/first-time-events';
4308
+ };
4309
+
4310
+ FeatureFlagManager.prototype.recordFirstTimeEvent = function(flagId, projectId, firstTimeEventHash) {
4311
+ var distinctId = this.getMpProperty('distinct_id');
4312
+ var traceparent = generateTraceparent();
4313
+
4314
+ // Build URL with query string parameters
4315
+ var searchParams = new URLSearchParams();
4316
+ searchParams.set('mp_lib', 'web');
4317
+ searchParams.set('$lib_version', Config.LIB_VERSION);
4318
+ var url = this.getFirstTimeEventApiRoute(flagId) + '?' + searchParams.toString();
4319
+
4320
+ var payload = {
4321
+ 'distinct_id': distinctId,
4322
+ 'project_id': projectId,
4323
+ 'first_time_event_hash': firstTimeEventHash
4324
+ };
4325
+
4326
+ logger$3.log('Recording first-time event for flag: ' + flagId);
4327
+
4328
+ // Fire-and-forget POST request
4329
+ this.fetch.call(win, url, {
4330
+ 'method': 'POST',
4331
+ 'headers': {
4332
+ 'Content-Type': 'application/json',
4333
+ 'Authorization': 'Basic ' + btoa(this.getMpConfig('token') + ':'),
4334
+ 'traceparent': traceparent
4335
+ },
4336
+ 'body': JSON.stringify(payload)
4337
+ }).catch(function(error) {
4338
+ // Silent failure - cohort sync will catch up
4339
+ logger$3.error('Failed to record first-time event for flag ' + flagId + ': ' + error);
4340
+ });
4341
+ };
4342
+
4056
4343
  FeatureFlagManager.prototype.getVariant = function(featureName, fallback) {
4057
4344
  if (!this.fetchPromise) {
4058
4345
  return new Promise(function(resolve) {
@@ -4171,6 +4458,9 @@
4171
4458
  // Deprecated method
4172
4459
  FeatureFlagManager.prototype['get_feature_data'] = FeatureFlagManager.prototype.getFeatureData;
4173
4460
 
4461
+ // Exports intended only for testing
4462
+ FeatureFlagManager.prototype['getTargeting'] = FeatureFlagManager.prototype.getTargeting;
4463
+
4174
4464
  /* eslint camelcase: "off" */
4175
4465
 
4176
4466
 
@@ -6959,6 +7249,7 @@
6959
7249
  'record_min_ms': 0,
6960
7250
  'record_sessions_percent': 0,
6961
7251
  'recorder_src': 'https://cdn.mxpnl.com/libs/mixpanel-recorder.min.js',
7252
+ 'targeting_src': 'https://cdn.mxpnl.com/libs/mixpanel-targeting.min.js',
6962
7253
  'remote_settings_mode': SETTING_DISABLED // 'strict', 'fallback', 'disabled'
6963
7254
  };
6964
7255
 
@@ -7188,7 +7479,9 @@
7188
7479
  getConfigFunc: _.bind(this.get_config, this),
7189
7480
  setConfigFunc: _.bind(this.set_config, this),
7190
7481
  getPropertyFunc: _.bind(this.get_property, this),
7191
- trackingFunc: _.bind(this.track, this)
7482
+ trackingFunc: _.bind(this.track, this),
7483
+ loadExtraBundle: load_extra_bundle,
7484
+ targetingSrc: this.get_config('targeting_src')
7192
7485
  });
7193
7486
  this.flags.init();
7194
7487
  this['flags'] = this.flags;
@@ -7284,11 +7577,11 @@
7284
7577
 
7285
7578
  var loadRecorder = _.bind(function(startNewIfInactive) {
7286
7579
  var handleLoadedRecorder = _.bind(function() {
7287
- this._recorder = this._recorder || new win['__mp_recorder'](this);
7580
+ this._recorder = this._recorder || new win[RECORDER_GLOBAL_NAME](this);
7288
7581
  this._recorder['resumeRecording'](startNewIfInactive);
7289
7582
  }, this);
7290
7583
 
7291
- if (_.isUndefined(win['__mp_recorder'])) {
7584
+ if (_.isUndefined(win[RECORDER_GLOBAL_NAME])) {
7292
7585
  load_extra_bundle(this.get_config('recorder_src'), handleLoadedRecorder);
7293
7586
  } else {
7294
7587
  handleLoadedRecorder();
@@ -8030,6 +8323,11 @@
8030
8323
  send_request_options: options
8031
8324
  }, callback);
8032
8325
 
8326
+ // Check for first-time event matches
8327
+ if (this.flags && this.flags.checkFirstTimeEvents) {
8328
+ this.flags.checkFirstTimeEvents(event_name, properties);
8329
+ }
8330
+
8033
8331
  return ret;
8034
8332
  });
8035
8333