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
@@ -1,15 +1,36 @@
1
1
  import { _, console_with_prefix, generateTraceparent, safewrapClass } from '../utils'; // eslint-disable-line camelcase
2
2
  import { window } from '../window';
3
- import Config from '../config';
3
+ import { Config, TARGETING_GLOBAL_NAME } from '../config';
4
+ import {
5
+ getTargetingPromise
6
+ } from '../targeting/loader';
4
7
 
5
8
  var logger = console_with_prefix('flags');
6
-
7
9
  var FLAGS_CONFIG_KEY = 'flags';
8
10
 
9
11
  var CONFIG_CONTEXT = 'context';
10
12
  var CONFIG_DEFAULTS = {};
11
13
  CONFIG_DEFAULTS[CONFIG_CONTEXT] = {};
12
14
 
15
+ /**
16
+ * Generate a unique key for a pending first-time event
17
+ * @param {string} flagKey - The flag key
18
+ * @param {string} firstTimeEventHash - The first_time_event_hash from the pending event definition
19
+ * @returns {string} Composite key in format "flagKey:firstTimeEventHash"
20
+ */
21
+ var getPendingEventKey = function(flagKey, firstTimeEventHash) {
22
+ return flagKey + ':' + firstTimeEventHash;
23
+ };
24
+
25
+ /**
26
+ * Extract the flag key from a pending event key
27
+ * @param {string} eventKey - The composite event key in format "flagKey:firstTimeEventHash"
28
+ * @returns {string} The flag key portion
29
+ */
30
+ var getFlagKeyFromPendingEventKey = function(eventKey) {
31
+ return eventKey.split(':')[0];
32
+ };
33
+
13
34
  /**
14
35
  * FeatureFlagManager: support for Mixpanel's feature flagging product
15
36
  * @constructor
@@ -21,6 +42,8 @@ var FeatureFlagManager = function(initOptions) {
21
42
  this.setMpConfig = initOptions.setConfigFunc;
22
43
  this.getMpProperty = initOptions.getPropertyFunc;
23
44
  this.track = initOptions.trackingFunc;
45
+ this.loadExtraBundle = initOptions.loadExtraBundle || function() {};
46
+ this.targetingSrc = initOptions.targetingSrc || '';
24
47
  };
25
48
 
26
49
  FeatureFlagManager.prototype.init = function() {
@@ -33,6 +56,8 @@ FeatureFlagManager.prototype.init = function() {
33
56
  this.fetchFlags();
34
57
 
35
58
  this.trackedFeatures = new Set();
59
+ this.pendingFirstTimeEvents = {};
60
+ this.activatedFirstTimeEvents = {};
36
61
  };
37
62
 
38
63
  FeatureFlagManager.prototype.getFullConfig = function() {
@@ -113,17 +138,78 @@ FeatureFlagManager.prototype.fetchFlags = function() {
113
138
  throw new Error('No flags in API response');
114
139
  }
115
140
  var flags = new Map();
141
+ var pendingFirstTimeEvents = {};
142
+
143
+ // Process flags from response
116
144
  _.each(responseFlags, function(data, key) {
117
- flags.set(key, {
118
- 'key': data['variant_key'],
119
- 'value': data['variant_value'],
120
- 'experiment_id': data['experiment_id'],
121
- 'is_experiment_active': data['is_experiment_active'],
122
- 'is_qa_tester': data['is_qa_tester']
145
+ // Check if this flag has any activated first-time events this session
146
+ var hasActivatedEvent = false;
147
+ var prefix = key + ':';
148
+ _.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
149
+ if (eventKey.startsWith(prefix)) {
150
+ hasActivatedEvent = true;
151
+ }
123
152
  });
124
- });
153
+
154
+ if (hasActivatedEvent) {
155
+ // Preserve the activated variant, don't overwrite with server's current variant
156
+ var currentFlag = this.flags && this.flags.get(key);
157
+ if (currentFlag) {
158
+ flags.set(key, currentFlag);
159
+ }
160
+ } else {
161
+ // Use server's current variant
162
+ flags.set(key, {
163
+ 'key': data['variant_key'],
164
+ 'value': data['variant_value'],
165
+ 'experiment_id': data['experiment_id'],
166
+ 'is_experiment_active': data['is_experiment_active'],
167
+ 'is_qa_tester': data['is_qa_tester']
168
+ });
169
+ }
170
+ }, this);
171
+
172
+ // Process top-level pending_first_time_events array
173
+ var topLevelDefinitions = responseBody['pending_first_time_events'];
174
+ if (topLevelDefinitions && topLevelDefinitions.length > 0) {
175
+ _.each(topLevelDefinitions, function(def) {
176
+ var flagKey = def['flag_key'];
177
+ var eventKey = getPendingEventKey(flagKey, def['first_time_event_hash']);
178
+
179
+ // Skip if this specific event has already been activated this session
180
+ if (this.activatedFirstTimeEvents[eventKey]) {
181
+ return;
182
+ }
183
+
184
+ // Store pending event definition using composite key
185
+ pendingFirstTimeEvents[eventKey] = {
186
+ 'flag_key': flagKey,
187
+ 'flag_id': def['flag_id'],
188
+ 'project_id': def['project_id'],
189
+ 'first_time_event_hash': def['first_time_event_hash'],
190
+ 'event_name': def['event_name'],
191
+ 'property_filters': def['property_filters'],
192
+ 'pending_variant': def['pending_variant']
193
+ };
194
+ }, this);
195
+ }
196
+
197
+ // Preserve any activated orphaned flags (flags that were activated but are no longer in response)
198
+ if (this.activatedFirstTimeEvents) {
199
+ _.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
200
+ var flagKey = getFlagKeyFromPendingEventKey(eventKey);
201
+ if (activated && !flags.has(flagKey) && this.flags && this.flags.has(flagKey)) {
202
+ // Keep the activated flag even though it's not in the new response
203
+ flags.set(flagKey, this.flags.get(flagKey));
204
+ }
205
+ }, this);
206
+ }
207
+
125
208
  this.flags = flags;
209
+ this.pendingFirstTimeEvents = pendingFirstTimeEvents;
126
210
  this._traceparent = traceparent;
211
+
212
+ this._loadTargetingIfNeeded();
127
213
  }.bind(this)).catch(function(error) {
128
214
  this.markFetchComplete();
129
215
  logger.error(error);
@@ -147,6 +233,177 @@ FeatureFlagManager.prototype.markFetchComplete = function() {
147
233
  this._fetchInProgressStartTime = null;
148
234
  };
149
235
 
236
+ /**
237
+ * Proactively load targeting bundle if any pending events have property filters
238
+ */
239
+ FeatureFlagManager.prototype._loadTargetingIfNeeded = function() {
240
+ var hasPropertyFilters = false;
241
+ _.each(this.pendingFirstTimeEvents, function(evt) {
242
+ if (evt['property_filters'] && !_.isEmptyObject(evt['property_filters'])) {
243
+ hasPropertyFilters = true;
244
+ }
245
+ });
246
+
247
+ if (hasPropertyFilters) {
248
+ this.getTargeting().then(function() {
249
+ logger.log('targeting loaded for property filter evaluation');
250
+ });
251
+ }
252
+ };
253
+
254
+ /**
255
+ * Get the targeting library (initializes if not already loaded)
256
+ * This method is primarily for testing - production code should rely on automatic loading
257
+ * @returns {Promise} Promise that resolves with targeting library
258
+ */
259
+ FeatureFlagManager.prototype.getTargeting = function() {
260
+ return getTargetingPromise(
261
+ this.loadExtraBundle.bind(this),
262
+ this.targetingSrc
263
+ ).catch(function(error) {
264
+ logger.error('Failed to load targeting: ' + error);
265
+ }.bind(this));
266
+ };
267
+
268
+ /**
269
+ * Check if a tracked event matches any pending first-time events and activate the corresponding flag variant
270
+ * @param {string} eventName - The name of the event being tracked
271
+ * @param {Object} properties - Event properties to evaluate against property filters
272
+ *
273
+ * When a match is found (event name matches and property filters pass), this method:
274
+ * - Switches the flag to the pending variant
275
+ * - Marks the event as activated for this session
276
+ * - Records the activation via the API (fire-and-forget)
277
+ */
278
+ FeatureFlagManager.prototype.checkFirstTimeEvents = function(eventName, properties) {
279
+ if (!this.pendingFirstTimeEvents || _.isEmptyObject(this.pendingFirstTimeEvents)) {
280
+ return;
281
+ }
282
+
283
+ // Check if targeting promise exists (either bundled or async loaded)
284
+ if (window[TARGETING_GLOBAL_NAME] && _.isFunction(window[TARGETING_GLOBAL_NAME].then)) {
285
+ window[TARGETING_GLOBAL_NAME].then(function(library) {
286
+ this._processFirstTimeEventCheck(eventName, properties, library);
287
+ }.bind(this)).catch(function() {
288
+ // If targeting failed to load, process with null
289
+ // Events without property filters will still match
290
+ this._processFirstTimeEventCheck(eventName, properties, null);
291
+ }.bind(this));
292
+ } else {
293
+ // No targeting available, process with null
294
+ // Events without property filters will still match
295
+ this._processFirstTimeEventCheck(eventName, properties, null);
296
+ }
297
+ };
298
+
299
+ /**
300
+ * Internal method to process first-time event checks with loaded targeting library
301
+ * @param {string} eventName - The name of the event being tracked
302
+ * @param {Object} properties - Event properties to evaluate against property filters
303
+ * @param {Object} targeting - The loaded targeting library
304
+ */
305
+ FeatureFlagManager.prototype._processFirstTimeEventCheck = function(eventName, properties, targeting) {
306
+ _.each(this.pendingFirstTimeEvents, function(pendingEvent, eventKey) {
307
+ if (this.activatedFirstTimeEvents[eventKey]) {
308
+ return;
309
+ }
310
+
311
+ var flagKey = pendingEvent['flag_key'];
312
+
313
+ // Use targeting module to check if event matches
314
+ var matchResult;
315
+
316
+ // If no targeting library and event has property filters, skip it
317
+ if (!targeting && pendingEvent['property_filters'] && !_.isEmptyObject(pendingEvent['property_filters'])) {
318
+ logger.warn('Skipping event check for "' + flagKey + '" - property filters require targeting library');
319
+ return;
320
+ }
321
+
322
+ // For simple events (no property filters), just check event name
323
+ if (!targeting) {
324
+ matchResult = {
325
+ matches: eventName === pendingEvent['event_name'],
326
+ error: null
327
+ };
328
+ } else {
329
+ var criteria = {
330
+ 'event_name': pendingEvent['event_name'],
331
+ 'property_filters': pendingEvent['property_filters']
332
+ };
333
+ matchResult = targeting['eventMatchesCriteria'](
334
+ eventName,
335
+ properties,
336
+ criteria
337
+ );
338
+ }
339
+
340
+ if (matchResult.error) {
341
+ logger.error('Error checking first-time event for flag "' + flagKey + '": ' + matchResult.error);
342
+ return;
343
+ }
344
+
345
+ if (!matchResult.matches) {
346
+ return;
347
+ }
348
+
349
+ logger.log('First-time event matched for flag "' + flagKey + '": ' + eventName);
350
+
351
+ var newVariant = {
352
+ 'key': pendingEvent['pending_variant']['variant_key'],
353
+ 'value': pendingEvent['pending_variant']['variant_value'],
354
+ 'experiment_id': pendingEvent['pending_variant']['experiment_id'],
355
+ 'is_experiment_active': pendingEvent['pending_variant']['is_experiment_active']
356
+ };
357
+
358
+ this.flags.set(flagKey, newVariant);
359
+ this.activatedFirstTimeEvents[eventKey] = true;
360
+
361
+ this.recordFirstTimeEvent(
362
+ pendingEvent['flag_id'],
363
+ pendingEvent['project_id'],
364
+ pendingEvent['first_time_event_hash']
365
+ );
366
+ }, this);
367
+ };
368
+
369
+ FeatureFlagManager.prototype.getFirstTimeEventApiRoute = function(flagId) {
370
+ // Construct URL: {api_host}/flags/{flagId}/first-time-events
371
+ return this.getFullApiRoute() + '/' + flagId + '/first-time-events';
372
+ };
373
+
374
+ FeatureFlagManager.prototype.recordFirstTimeEvent = function(flagId, projectId, firstTimeEventHash) {
375
+ var distinctId = this.getMpProperty('distinct_id');
376
+ var traceparent = generateTraceparent();
377
+
378
+ // Build URL with query string parameters
379
+ var searchParams = new URLSearchParams();
380
+ searchParams.set('mp_lib', 'web');
381
+ searchParams.set('$lib_version', Config.LIB_VERSION);
382
+ var url = this.getFirstTimeEventApiRoute(flagId) + '?' + searchParams.toString();
383
+
384
+ var payload = {
385
+ 'distinct_id': distinctId,
386
+ 'project_id': projectId,
387
+ 'first_time_event_hash': firstTimeEventHash
388
+ };
389
+
390
+ logger.log('Recording first-time event for flag: ' + flagId);
391
+
392
+ // Fire-and-forget POST request
393
+ this.fetch.call(window, url, {
394
+ 'method': 'POST',
395
+ 'headers': {
396
+ 'Content-Type': 'application/json',
397
+ 'Authorization': 'Basic ' + btoa(this.getMpConfig('token') + ':'),
398
+ 'traceparent': traceparent
399
+ },
400
+ 'body': JSON.stringify(payload)
401
+ }).catch(function(error) {
402
+ // Silent failure - cohort sync will catch up
403
+ logger.error('Failed to record first-time event for flag ' + flagId + ': ' + error);
404
+ });
405
+ };
406
+
150
407
  FeatureFlagManager.prototype.getVariant = function(featureName, fallback) {
151
408
  if (!this.fetchPromise) {
152
409
  return new Promise(function(resolve) {
@@ -265,4 +522,7 @@ FeatureFlagManager.prototype['update_context'] = FeatureFlagManager.prototype.up
265
522
  // Deprecated method
266
523
  FeatureFlagManager.prototype['get_feature_data'] = FeatureFlagManager.prototype.getFeatureData;
267
524
 
525
+ // Exports intended only for testing
526
+ FeatureFlagManager.prototype['getTargeting'] = FeatureFlagManager.prototype.getTargeting;
527
+
268
528
  export { FeatureFlagManager };
package/src/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { RulesLogic } from 'json-logic-js';
2
+
1
3
  export type Persistence = "cookie" | "localStorage";
2
4
 
3
5
  export type ApiPayloadFormat = "base64" | "json";
@@ -8,6 +10,16 @@ export type Query = string | Element | Element[];
8
10
 
9
11
  export type RemoteSettingType = "disabled" | "fallback" | "strict";
10
12
 
13
+
14
+ export interface EventTriggerProps {
15
+ percentage: number;
16
+ property_filters?: RulesLogic;
17
+ }
18
+
19
+ export interface RecordingEventTriggers {
20
+ [eventName: string]: EventTriggerProps;
21
+ }
22
+
11
23
  export interface Dict {
12
24
  [key: string]: any;
13
25
  }
@@ -225,6 +237,9 @@ export interface Config {
225
237
  recorder_src: string;
226
238
  record_block_class: string | RegExp;
227
239
  record_block_selector: string;
240
+ record_console: boolean;
241
+ record_network: boolean;
242
+ record_network_options: NetworkRecordOptions;
228
243
  record_collect_fonts: boolean;
229
244
  record_idle_timeout_ms: number;
230
245
  record_inline_images: boolean;
@@ -239,6 +254,7 @@ export interface Config {
239
254
  record_max_ms: number;
240
255
  record_sessions_percent: number;
241
256
  record_canvas: boolean;
257
+ recording_event_triggers: RecordingEventTriggers;
242
258
  record_heatmap_data: boolean;
243
259
  remote_settings_mode: RemoteSettingType;
244
260
  hooks: {
@@ -518,5 +534,57 @@ export function get_session_recording_properties():
518
534
  | { $mp_replay_id?: string }
519
535
  | {};
520
536
 
537
+ // Network Recording Plugin Types
538
+ export type InitiatorType =
539
+ | 'audio'
540
+ | 'beacon'
541
+ | 'body'
542
+ | 'css'
543
+ | 'early-hint'
544
+ | 'embed'
545
+ | 'fetch'
546
+ | 'frame'
547
+ | 'iframe'
548
+ | 'icon'
549
+ | 'image'
550
+ | 'img'
551
+ | 'input'
552
+ | 'link'
553
+ | 'navigation'
554
+ | 'object'
555
+ | 'ping'
556
+ | 'script'
557
+ | 'track'
558
+ | 'video'
559
+ | 'xmlhttprequest';
560
+
561
+ export interface NetworkRequest {
562
+ url: string;
563
+ method?: string;
564
+ initiatorType: InitiatorType;
565
+ status?: number;
566
+ startTime: number;
567
+ endTime: number;
568
+ timeOrigin: number;
569
+ requestHeaders?: Record<string, string>;
570
+ requestBody?: string;
571
+ responseHeaders?: Record<string, string>;
572
+ responseBody?: string;
573
+ }
574
+
575
+ export interface NetworkRecordOptions {
576
+ initiatorTypes?: InitiatorType[];
577
+ ignoreRequestUrls?: string[];
578
+ ignoreRequestFn?: (data: NetworkRequest) => boolean;
579
+ recordHeaders?: { request: string[]; response: string[] };
580
+ recordBodyUrls?: { request: string[]; response: string[] };
581
+ recordInitialRequests?: boolean;
582
+ }
583
+
584
+ export interface NetworkData {
585
+ requests: NetworkRequest[];
586
+ isInitial?: boolean;
587
+ }
588
+
521
589
  declare const mixpanel: OverridedMixpanel;
522
590
  export default mixpanel;
@@ -1,5 +1,6 @@
1
1
  /* eslint camelcase: "off" */
2
2
  import '../recorder';
3
+ import '../targeting';
3
4
 
4
5
  import { init_as_module } from '../mixpanel-core';
5
6
  import { loadNoop } from './bundle-loaders';