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