@reservamos/browser-analytics 1.1.8-alpha.0 → 1.1.8-alpha.2

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 (39) hide show
  1. package/CHANGELOG.md +21 -3
  2. package/README.md +33 -7
  3. package/dist/browser-analytics.cjs +2 -2
  4. package/dist/browser-analytics.cjs.map +1 -1
  5. package/dist/browser-analytics.d.ts +15 -2
  6. package/dist/browser-analytics.esm.js +590 -109
  7. package/dist/browser-analytics.esm.js.map +1 -1
  8. package/dist/browser-analytics.iife.js +2 -2
  9. package/dist/browser-analytics.iife.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/events/identify/identify.robustness.test.ts +112 -13
  12. package/src/events/identify/identify.ts +84 -34
  13. package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
  14. package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
  15. package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
  16. package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
  17. package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
  18. package/src/events/sharedSchemas/tripSchema.ts +3 -8
  19. package/src/index.ts +1 -0
  20. package/src/init.botDetection.test.ts +187 -0
  21. package/src/init.test.ts +1 -0
  22. package/src/init.ts +15 -2
  23. package/src/services/botDetectionRelay.test.ts +3 -2
  24. package/src/services/deferredBotDetection.ts +60 -0
  25. package/src/services/fingerprint.test.ts +190 -0
  26. package/src/services/fingerprint.ts +141 -13
  27. package/src/services/fingerprint.wait.integration.test.ts +129 -0
  28. package/src/services/mixpanel.test.ts +119 -73
  29. package/src/services/mixpanel.ts +82 -23
  30. package/src/services/trackingLifecycle.ts +54 -0
  31. package/src/services/validator/validator.test.ts +79 -0
  32. package/src/track.readiness.test.ts +131 -0
  33. package/src/track.recovery.test.ts +339 -0
  34. package/src/track.storage.test.ts +142 -0
  35. package/src/track.test.ts +130 -5
  36. package/src/track.ts +110 -25
  37. package/src/util/pendingWork.test.ts +164 -0
  38. package/src/util/pendingWork.ts +148 -0
  39. package/src/util/snapshotEventData.ts +15 -0
@@ -1,5 +1,6 @@
1
1
  import type { IdentifyReporter } from '@/util/identifyAttempt';
2
2
  import mixpanel from 'mixpanel-browser';
3
+ import trackingLifecycle from '@/services/trackingLifecycle';
3
4
  import { observeIdentifyResult } from '@/util/identifyAttempt';
4
5
  import { version } from '../../package.json';
5
6
 
@@ -11,6 +12,19 @@ declare global {
11
12
 
12
13
  const MIXPANEL_DISTINCT_ID_CACHE_KEY = 'mp_distinct_id';
13
14
 
15
+ const readinessSubscribers = new Set<() => void>();
16
+ let initializing = false;
17
+ let initialization = 0;
18
+ let initializedConfig: { token: string; proxyUrl?: string } | undefined;
19
+
20
+ /** Subscribes to internal Mixpanel readiness; callers own unsubscription. */
21
+ function onReady(callback: () => void): () => void {
22
+ readinessSubscribers.add(callback);
23
+ return () => {
24
+ readinessSubscribers.delete(callback);
25
+ };
26
+ }
27
+
14
28
  /**
15
29
  * SDK metadata sent on every Mixpanel event, including native identification events.
16
30
  */
@@ -31,12 +45,30 @@ function init(
31
45
  debug = false,
32
46
  proxyUrl?: string,
33
47
  ): Promise<void> {
48
+ validateConfiguration(mixpanelToken, proxyUrl);
49
+ const attempt = ++initialization;
50
+ initializing = true;
51
+ trackingLifecycle.beginInitialization();
52
+ // The installed singleton does not call loaded again after its first init.
53
+ if (initializedConfig && window.mixpanel === mixpanel) {
54
+ mixpanel.set_config({ debug });
55
+ initializing = false;
56
+ readinessSubscribers.forEach((notify) => notify());
57
+ return Promise.resolve();
58
+ }
34
59
  return new Promise<void>((resolve) => {
35
60
  mixpanel.init(mixpanelToken, {
36
61
  debug,
37
62
  loaded: () => {
63
+ if (attempt !== initialization) {
64
+ resolve();
65
+ return;
66
+ }
38
67
  window.mixpanel = mixpanel;
39
68
  mixpanel.register({ ...SDK_SUPER_PROPERTIES });
69
+ initializedConfig = { token: mixpanelToken, proxyUrl };
70
+ initializing = false;
71
+ readinessSubscribers.forEach((notify) => notify());
40
72
  resolve();
41
73
  },
42
74
  ...(proxyUrl && { api_host: proxyUrl }),
@@ -44,6 +76,20 @@ function init(
44
76
  });
45
77
  }
46
78
 
79
+ /** Rejects unsupported singleton reconfiguration before changing the active session. */
80
+ function validateConfiguration(token: string, proxyUrl?: string): void {
81
+ if (
82
+ initializedConfig &&
83
+ window.mixpanel === mixpanel &&
84
+ (initializedConfig.token !== token ||
85
+ initializedConfig.proxyUrl !== proxyUrl)
86
+ ) {
87
+ throw new Error(
88
+ 'Changing the Mixpanel project or proxy requires a new page session.',
89
+ );
90
+ }
91
+ }
92
+
47
93
  /**
48
94
  * Identifies a user in Mixpanel and sets their properties.
49
95
  * @param {string} userId - The unique identifier for the user.
@@ -56,23 +102,10 @@ function identify(
56
102
  properties: Record<string, unknown>,
57
103
  report?: IdentifyReporter,
58
104
  ): void {
105
+ trackingLifecycle.identify(userId);
59
106
  mixpanel.identify(userId);
60
107
  if (!updateCachedDistinctId(userId)) report?.('cache', 'unavailable');
61
- if (!report) {
62
- mixpanel.people.set(properties);
63
- return;
64
- }
65
- const observer = observeIdentifyResult('people', report);
66
- try {
67
- mixpanel.people.set(properties, (response) => {
68
- const status = typeof response === 'number' ? response : response?.status;
69
- observer.complete(
70
- status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
71
- );
72
- });
73
- } catch {
74
- observer.complete('failed');
75
- }
108
+ writePeopleProperties(properties, report);
76
109
  }
77
110
 
78
111
  /**
@@ -95,12 +128,36 @@ function attachProperty(key: string, value: unknown): void {
95
128
  * Sets people properties for the current Mixpanel user without changing their distinct ID.
96
129
  * @param {Record<string, unknown>} properties - Profile properties to set on the current user.
97
130
  */
98
- function setPeopleProperties(properties: Record<string, unknown>): void {
131
+ function setPeopleProperties(
132
+ properties: Record<string, unknown>,
133
+ report?: IdentifyReporter,
134
+ ): void {
99
135
  if (!isReady()) {
100
136
  return;
101
137
  }
102
138
 
103
- mixpanel.people.set(properties);
139
+ writePeopleProperties(properties, report);
140
+ }
141
+
142
+ function writePeopleProperties(
143
+ properties: Record<string, unknown>,
144
+ report?: IdentifyReporter,
145
+ ): void {
146
+ if (!report) {
147
+ mixpanel.people.set(properties);
148
+ return;
149
+ }
150
+ const observer = observeIdentifyResult('people', report);
151
+ try {
152
+ mixpanel.people.set(properties, (response) => {
153
+ const status = typeof response === 'number' ? response : response?.status;
154
+ observer.complete(
155
+ status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
156
+ );
157
+ });
158
+ } catch {
159
+ observer.complete('failed');
160
+ }
104
161
  }
105
162
 
106
163
  /**
@@ -108,7 +165,7 @@ function setPeopleProperties(properties: Record<string, unknown>): void {
108
165
  * @returns {boolean} Returns true if the Mixpanel tracker is ready, otherwise false.
109
166
  */
110
167
  function isReady(): boolean {
111
- return window.mixpanel !== undefined;
168
+ return !initializing && window.mixpanel !== undefined;
112
169
  }
113
170
  /**
114
171
  * Tracks an event in Mixpanel with the given name and properties.
@@ -126,13 +183,12 @@ function track(eventName: string, properties: Record<string, unknown>): void {
126
183
  }
127
184
 
128
185
  /**
129
- * Retrieves the distinct ID from Mixpanel.
130
- * This function checks if Mixpanel is initialized and returns the current user's
131
- * distinct ID. If Mixpanel is not initialized, it returns null.
132
- * @returns {string | null} The distinct ID if available, or null if Mixpanel is not initialized.
186
+ * Retrieves the live Mixpanel distinct ID first, caching it on a best-effort basis.
187
+ * Falls back to the SDK cache only when no live ID is available.
188
+ * @returns {string | null} The live or cached distinct ID, or null when neither is available.
133
189
  */
134
190
  export const getMixpanelDistinctId = (): string | null => {
135
- const distinctId = mixpanel?.get_distinct_id?.();
191
+ const distinctId = isReady() ? mixpanel.get_distinct_id() : null;
136
192
  if (distinctId) {
137
193
  updateCachedDistinctId(distinctId);
138
194
  return distinctId;
@@ -148,6 +204,7 @@ export const getMixpanelDistinctId = (): string | null => {
148
204
  * Updates the cached Mixpanel distinct ID.
149
205
  * Should be called when identifying a user to ensure cache stays in sync.
150
206
  * @param {string} distinctId - The distinct ID to cache
207
+ * @returns {boolean} Whether the cache write succeeded.
151
208
  */
152
209
  function updateCachedDistinctId(distinctId: string): boolean {
153
210
  try {
@@ -160,7 +217,9 @@ function updateCachedDistinctId(distinctId: string): boolean {
160
217
 
161
218
  const mixpanelService = {
162
219
  init,
220
+ validateConfiguration,
163
221
  isReady,
222
+ onReady,
164
223
  track,
165
224
  identify,
166
225
  attachProperty,
@@ -0,0 +1,54 @@
1
+ let generation = 0;
2
+ let initialized = false;
3
+ let userId: string | undefined;
4
+ const subscribers = new Set<() => void>();
5
+ const initializationSubscribers = new Set<() => void>();
6
+
7
+ function invalidate(): void {
8
+ generation++;
9
+ subscribers.forEach((notify) => notify());
10
+ }
11
+
12
+ /** Pre-init events may join the first initialization; later configurations replace them. */
13
+ function beginInitialization(): void {
14
+ if (initialized) invalidate();
15
+ initialized = true;
16
+ userId = undefined;
17
+ initializationSubscribers.forEach((notify) => notify());
18
+ }
19
+
20
+ /** Invalidates retained work before the provider changes the active user. */
21
+ function identify(nextUserId: string): void {
22
+ if (userId !== nextUserId) {
23
+ userId = nextUserId;
24
+ invalidate();
25
+ }
26
+ }
27
+
28
+ /** Captures a predicate shared by waiting work and late provider effects. */
29
+ function capture(): () => boolean {
30
+ const captured = generation;
31
+ return () => captured === generation;
32
+ }
33
+
34
+ function onChange(notify: () => void): () => void {
35
+ subscribers.add(notify);
36
+ return () => {
37
+ subscribers.delete(notify);
38
+ };
39
+ }
40
+
41
+ function onInitialization(notify: () => void): () => void {
42
+ initializationSubscribers.add(notify);
43
+ return () => {
44
+ initializationSubscribers.delete(notify);
45
+ };
46
+ }
47
+
48
+ export default {
49
+ beginInitialization,
50
+ identify,
51
+ capture,
52
+ onChange,
53
+ onInitialization,
54
+ };
@@ -36,6 +36,85 @@ describe('InitConfigSchema', () => {
36
36
  });
37
37
 
38
38
  describe('parseEventProps', () => {
39
+ const checkout = {
40
+ 'Trips': [
41
+ {
42
+ 'Departure Arrival': '2026-11-01T12:00:00',
43
+ 'Departure Destination': 'Puebla',
44
+ 'Departure Destination Terminal': 'CAPU',
45
+ 'Departure Line': 'Bus line',
46
+ 'Departure Origin': 'Mexico City',
47
+ 'Departure Origin Terminal': 'TAPO',
48
+ 'Departure Price': 100,
49
+ 'Departure Route': 'Mexico City-Puebla',
50
+ 'Departure Stops': 0,
51
+ 'Departure Time': '2026-11-01T10:00:00',
52
+ 'Departure Transport Type': 'bus',
53
+ 'Departure Transporter': 'Carrier',
54
+ 'Recommended Trip': false,
55
+ },
56
+ ],
57
+ 'Passenger Count': 1,
58
+ 'Trip Count': 1,
59
+ 'Total': 100,
60
+ 'product': 'web',
61
+ };
62
+
63
+ describe.each(['Purchase Attempt', 'Payment Attempt'])(
64
+ '%s stops',
65
+ (eventName) => {
66
+ const properties = {
67
+ ...checkout,
68
+ ...(eventName === 'Payment Attempt' && {
69
+ 'Payment Type': 'credit_card',
70
+ }),
71
+ };
72
+
73
+ it.each([0, 1, 2])('accepts %i departure and return stops', (stops) => {
74
+ expect(() =>
75
+ validatorService.parseEventProps(eventName, {
76
+ ...properties,
77
+ Trips: [
78
+ {
79
+ ...checkout.Trips[0],
80
+ 'Departure Stops': stops,
81
+ 'Return Stops': stops,
82
+ },
83
+ ],
84
+ }),
85
+ ).not.toThrow();
86
+ });
87
+
88
+ it.each(['Departure Stops', 'Return Stops'])(
89
+ 'rejects invalid %s without coercion',
90
+ (field) => {
91
+ for (const stops of [0.5, '0', null, NaN]) {
92
+ expect(() =>
93
+ validatorService.parseEventProps(eventName, {
94
+ ...properties,
95
+ Trips: [{ ...checkout.Trips[0], [field]: stops }],
96
+ }),
97
+ ).toThrow();
98
+ }
99
+ },
100
+ );
101
+
102
+ it('requires departure stops and keeps return stops optional', () => {
103
+ const { 'Departure Stops': omitted, ...trip } = checkout.Trips[0];
104
+ expect(omitted).toBe(0);
105
+ expect(() =>
106
+ validatorService.parseEventProps(eventName, {
107
+ ...properties,
108
+ Trips: [trip],
109
+ }),
110
+ ).toThrow();
111
+ expect(() =>
112
+ validatorService.parseEventProps(eventName, properties),
113
+ ).not.toThrow();
114
+ });
115
+ },
116
+ );
117
+
39
118
  /**
40
119
  * This test supposes that unknownEvent is not a known event.
41
120
  * And that the customEventSchema does not support objects as values for a field.
@@ -0,0 +1,131 @@
1
+ import type { Mock } from 'vitest';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import mixpanelService from '@/services/mixpanel';
4
+ import { trackEvent } from './track';
5
+
6
+ const transport = vi.hoisted(() => ({
7
+ loaded: undefined as (() => void) | undefined,
8
+ register: vi.fn(),
9
+ track: vi.fn(),
10
+ }));
11
+
12
+ // Keep the actual Mixpanel service and its readiness notification. Only the
13
+ // browser client and unrelated enrichment are replaced at their boundaries.
14
+ vi.mock('mixpanel-browser', () => ({
15
+ default: {
16
+ init: vi.fn((_token: string, config: { loaded: () => void }) => {
17
+ transport.loaded = config.loaded;
18
+ }),
19
+ register: transport.register,
20
+ track: transport.track,
21
+ get_distinct_id: vi.fn().mockReturnValue('distinct-123'),
22
+ },
23
+ }));
24
+ vi.mock('@/services/botDetectionRelay', () => ({
25
+ default: { isEnabledFor: vi.fn().mockReturnValue(false) },
26
+ }));
27
+ vi.mock('@/services/fingerprint', () => ({
28
+ default: {
29
+ getFingerprint: vi.fn().mockResolvedValue(''),
30
+ getCachedFingerprint: vi.fn().mockReturnValue(null),
31
+ },
32
+ }));
33
+ vi.mock('@/services/geolocation', () => ({
34
+ default: {
35
+ getCoordinates: vi.fn().mockReturnValue(null),
36
+ getGeolocationSource: vi.fn().mockReturnValue(null),
37
+ },
38
+ }));
39
+ vi.mock('@/services/validator', () => ({
40
+ default: { parseEventProps: vi.fn() },
41
+ }));
42
+
43
+ describe('tracking with the actual Mixpanel readiness service', () => {
44
+ beforeEach(() => {
45
+ vi.useFakeTimers();
46
+ vi.clearAllMocks();
47
+ Reflect.deleteProperty(window, 'mixpanel');
48
+ transport.loaded = undefined;
49
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
50
+ });
51
+
52
+ afterEach(() => {
53
+ vi.restoreAllMocks();
54
+ vi.clearAllTimers();
55
+ vi.useRealTimers();
56
+ Reflect.deleteProperty(window, 'mixpanel');
57
+ localStorage.clear();
58
+ });
59
+
60
+ function observeUnsubscriptions() {
61
+ const subscribe = mixpanelService.onReady;
62
+ const unsubscribers: Mock<[], void>[] = [];
63
+ vi.spyOn(mixpanelService, 'onReady').mockImplementation((callback) => {
64
+ const unsubscribe = vi.fn(subscribe(callback));
65
+ unsubscribers.push(unsubscribe);
66
+ return unsubscribe;
67
+ });
68
+ return unsubscribers;
69
+ }
70
+
71
+ it('releases early Search on loaded before later View Results without Tracker Ready', async () => {
72
+ const unsubscribers = observeUnsubscriptions();
73
+ const remove = vi.spyOn(window, 'removeEventListener');
74
+ const dispatch = vi.spyOn(window, 'dispatchEvent');
75
+ const earlySearch = trackEvent('Search', { source: 'early' });
76
+ const initialization = mixpanelService.init('test-token');
77
+ await vi.advanceTimersByTimeAsync(1000);
78
+ expect(transport.track).not.toHaveBeenCalled();
79
+
80
+ transport.loaded?.();
81
+ await initialization;
82
+ await earlySearch;
83
+ await vi.advanceTimersByTimeAsync(0);
84
+ expect(
85
+ vi.mocked(transport.register).mock.invocationCallOrder[0],
86
+ ).toBeLessThan(vi.mocked(transport.track).mock.invocationCallOrder[0]);
87
+ expect(transport.track).toHaveBeenCalledWith(
88
+ 'Search',
89
+ expect.objectContaining({ source: 'early' }),
90
+ );
91
+ expect(unsubscribers[0]).toHaveBeenCalledOnce();
92
+ expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
93
+ expect(vi.getTimerCount()).toBe(0);
94
+
95
+ await vi.advanceTimersByTimeAsync(1000);
96
+ await trackEvent('View Results', { source: 'later' });
97
+ expect(vi.mocked(transport.track).mock.calls.map(([name]) => name)).toEqual(
98
+ ['Search', 'View Results'],
99
+ );
100
+ expect(
101
+ dispatch.mock.calls.some(([event]) => event.type === 'Tracker Ready'),
102
+ ).toBe(false);
103
+ transport.loaded?.();
104
+ expect(transport.track).toHaveBeenCalledTimes(2);
105
+ expect(unsubscribers[0]).toHaveBeenCalledOnce();
106
+ expect(console.error).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it('unsubscribes on expiry and does not replay on late Mixpanel loaded', async () => {
110
+ const unsubscribers = observeUnsubscriptions();
111
+ const initialization = mixpanelService.init('test-token');
112
+ const pending = trackEvent('Search', { secret: 'private-payload' });
113
+ await vi.advanceTimersByTimeAsync(60000);
114
+ await pending;
115
+ expect(unsubscribers[0]).toHaveBeenCalledOnce();
116
+ expect(vi.getTimerCount()).toBe(0);
117
+ expect(console.error).toHaveBeenCalledWith('Tracking recovery diagnostic', {
118
+ eventName: 'Search',
119
+ stage: 'readiness',
120
+ reason: 'timeout',
121
+ });
122
+
123
+ transport.loaded?.();
124
+ await initialization;
125
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
126
+ await Promise.resolve();
127
+ expect(transport.track).not.toHaveBeenCalled();
128
+ expect(unsubscribers[0]).toHaveBeenCalledOnce();
129
+ expect(vi.getTimerCount()).toBe(0);
130
+ });
131
+ });