@reservamos/browser-analytics 1.1.7 → 1.1.8-alpha.1
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.
- package/CHANGELOG.md +13 -0
- package/README.md +16 -10
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.esm.js +500 -81
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +2 -2
- package/src/events/sharedSchemas/tripSchema.ts +3 -8
- package/src/init.botDetection.test.ts +187 -0
- package/src/init.test.ts +288 -0
- package/src/init.ts +47 -12
- package/src/services/deferredBotDetection.ts +60 -0
- package/src/services/fingerprint.test.ts +36 -0
- package/src/services/fingerprint.ts +38 -20
- package/src/services/mixpanel.test.ts +103 -0
- package/src/services/mixpanel.ts +70 -21
- package/src/services/trackingLifecycle.ts +54 -0
- package/src/services/validator/validator.test.ts +79 -0
- package/src/track.readiness.test.ts +131 -0
- package/src/track.recovery.test.ts +339 -0
- package/src/track.storage.test.ts +127 -0
- package/src/track.test.ts +435 -66
- package/src/track.ts +174 -25
- package/src/util/pendingWork.test.ts +164 -0
- package/src/util/pendingWork.ts +148 -0
- package/src/util/snapshotEventData.ts +15 -0
|
@@ -65,6 +65,24 @@ describe('fingerprintService', () => {
|
|
|
65
65
|
vi.unstubAllGlobals();
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it.each([null, 'cached-visitor'])(
|
|
69
|
+
'reads the default cache before fingerprint configuration exists: %s',
|
|
70
|
+
(visitorId) => {
|
|
71
|
+
Reflect.deleteProperty(window, 'fingerprintConfig');
|
|
72
|
+
if (visitorId) {
|
|
73
|
+
localStorage.setItem(
|
|
74
|
+
'default_fingerprint_cache',
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
fingerprint: visitorId,
|
|
77
|
+
expiresAt: Date.now() + 60_000,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
expect(fingerprintService.getCachedFingerprint()).toBe(visitorId);
|
|
82
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
68
86
|
it('omits unavailable Visitor Found metadata without blocking identification', async () => {
|
|
69
87
|
getVisitorDataMock.mockResolvedValue({
|
|
70
88
|
...visitorData,
|
|
@@ -77,6 +95,24 @@ describe('fingerprintService', () => {
|
|
|
77
95
|
expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
|
|
78
96
|
});
|
|
79
97
|
|
|
98
|
+
it('does not announce readiness when an invalidated agent finishes loading late', async () => {
|
|
99
|
+
let finish!: () => void;
|
|
100
|
+
initMock.mockReturnValue(
|
|
101
|
+
new Promise<void>((resolve) => {
|
|
102
|
+
finish = resolve;
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const notify = vi.fn();
|
|
106
|
+
const stop = fingerprintService.onReady(notify);
|
|
107
|
+
const pending = fingerprintService.initFingerprint('old-key');
|
|
108
|
+
fingerprintService.invalidate();
|
|
109
|
+
finish();
|
|
110
|
+
await pending;
|
|
111
|
+
expect(fingerprintService.isFingerprintReady()).toBe(false);
|
|
112
|
+
expect(notify).not.toHaveBeenCalled();
|
|
113
|
+
stop();
|
|
114
|
+
});
|
|
115
|
+
|
|
80
116
|
it('suppresses identify-specific cache and profile effects after its scope closes', async () => {
|
|
81
117
|
let finish: (value: typeof visitorData) => void = () => undefined;
|
|
82
118
|
getVisitorDataMock.mockReturnValue(
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
FpjsClient,
|
|
6
6
|
} from '@fingerprintjs/fingerprintjs-pro-spa';
|
|
7
7
|
import mixpanelService from '@/services/mixpanel';
|
|
8
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
8
9
|
|
|
9
10
|
declare global {
|
|
10
11
|
interface Window {
|
|
@@ -28,6 +29,22 @@ const PROXY_QUERY_PARAM =
|
|
|
28
29
|
* "FPJSAgent hasn't loaded yet" until init completes.
|
|
29
30
|
*/
|
|
30
31
|
let fingerprintAgentReady = false;
|
|
32
|
+
let initialization = 0;
|
|
33
|
+
const readinessSubscribers = new Set<() => void>();
|
|
34
|
+
|
|
35
|
+
/** Notifies deferred observations only when the actual provider agent has loaded. */
|
|
36
|
+
function onReady(notify: () => void): () => void {
|
|
37
|
+
readinessSubscribers.add(notify);
|
|
38
|
+
return () => {
|
|
39
|
+
readinessSubscribers.delete(notify);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Prevents a previous configuration or late agent load from serving a new init. */
|
|
44
|
+
function invalidate(): void {
|
|
45
|
+
initialization++;
|
|
46
|
+
fingerprintAgentReady = false;
|
|
47
|
+
}
|
|
31
48
|
|
|
32
49
|
/**
|
|
33
50
|
* Calculates the expiration date based on the cache time.
|
|
@@ -51,9 +68,8 @@ const getCachedFingerprint = (): string | null => {
|
|
|
51
68
|
};
|
|
52
69
|
|
|
53
70
|
function readCachedFingerprint(context?: IdentifyStageContext): string | null {
|
|
54
|
-
const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig;
|
|
55
|
-
|
|
56
71
|
try {
|
|
72
|
+
const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig ?? {};
|
|
57
73
|
const cachedData = JSON.parse(localStorage.getItem(cacheName) || 'null');
|
|
58
74
|
if (cachedData && cachedData.expiresAt > Date.now()) {
|
|
59
75
|
return cachedData.fingerprint;
|
|
@@ -117,7 +133,8 @@ async function initFingerprint(
|
|
|
117
133
|
customScriptEndpointArray.unshift(buildProxyUrl(proxyUrl));
|
|
118
134
|
}
|
|
119
135
|
|
|
120
|
-
|
|
136
|
+
invalidate();
|
|
137
|
+
const attempt = initialization;
|
|
121
138
|
window.fingerprintConfig = {};
|
|
122
139
|
const client = new FpjsClient({
|
|
123
140
|
loadOptions: {
|
|
@@ -130,11 +147,12 @@ async function initFingerprint(
|
|
|
130
147
|
|
|
131
148
|
try {
|
|
132
149
|
await client.init();
|
|
133
|
-
if (window.fpClient === client) {
|
|
150
|
+
if (window.fpClient === client && attempt === initialization) {
|
|
134
151
|
fingerprintAgentReady = true;
|
|
152
|
+
readinessSubscribers.forEach((notify) => notify());
|
|
135
153
|
}
|
|
136
154
|
} catch (error) {
|
|
137
|
-
if (window.fpClient === client) {
|
|
155
|
+
if (window.fpClient === client && attempt === initialization) {
|
|
138
156
|
fingerprintAgentReady = false;
|
|
139
157
|
}
|
|
140
158
|
console.error('Error initializing identification service:', error);
|
|
@@ -149,8 +167,11 @@ async function initFingerprint(
|
|
|
149
167
|
* @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when unavailable.
|
|
150
168
|
* @throws {Error} Throws an error if retrieval fails after the agent has loaded.
|
|
151
169
|
*/
|
|
152
|
-
async function getFingerprint(
|
|
153
|
-
|
|
170
|
+
async function getFingerprint(
|
|
171
|
+
cacheOnly = false,
|
|
172
|
+
context?: IdentifyStageContext,
|
|
173
|
+
): Promise<string> {
|
|
174
|
+
return readFingerprint(cacheOnly, context);
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
/** Internal identify path with late-effect suppression and sanitized diagnostics. */
|
|
@@ -164,20 +185,12 @@ async function readFingerprint(
|
|
|
164
185
|
cacheOnly: boolean,
|
|
165
186
|
context?: IdentifyStageContext,
|
|
166
187
|
): Promise<string> {
|
|
167
|
-
if (!window.fingerprintConfig) {
|
|
168
|
-
if (cacheOnly) {
|
|
169
|
-
return '';
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
throw new Error('Fingerprint configuration is not initialized.');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
188
|
const cachedFingerprint = readCachedFingerprint(context);
|
|
176
189
|
if (cachedFingerprint) {
|
|
177
190
|
return cachedFingerprint;
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
if (cacheOnly || !isFingerprintReady()) {
|
|
193
|
+
if (cacheOnly || !window.fingerprintConfig || !isFingerprintReady()) {
|
|
181
194
|
return '';
|
|
182
195
|
}
|
|
183
196
|
|
|
@@ -193,8 +206,9 @@ async function readFingerprint(
|
|
|
193
206
|
*/
|
|
194
207
|
async function identifyVisitor(
|
|
195
208
|
ignoreCache = false,
|
|
209
|
+
context?: IdentifyStageContext,
|
|
196
210
|
): Promise<FingerprintIdentification> {
|
|
197
|
-
return requestIdentification(ignoreCache);
|
|
211
|
+
return requestIdentification(ignoreCache, context);
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
async function requestIdentification(
|
|
@@ -210,9 +224,8 @@ async function requestIdentification(
|
|
|
210
224
|
if (!context || context.isActive()) {
|
|
211
225
|
try {
|
|
212
226
|
setCachedFingerprint(result.visitorId);
|
|
213
|
-
} catch
|
|
214
|
-
|
|
215
|
-
context.report('cache', 'unavailable');
|
|
227
|
+
} catch {
|
|
228
|
+
context?.report('cache', 'unavailable');
|
|
216
229
|
}
|
|
217
230
|
if (typeof result.visitorFound === 'boolean') {
|
|
218
231
|
try {
|
|
@@ -254,7 +267,12 @@ const fingerprintService = {
|
|
|
254
267
|
getFingerprint,
|
|
255
268
|
identifyVisitor,
|
|
256
269
|
isFingerprintReady,
|
|
270
|
+
onReady,
|
|
271
|
+
invalidate,
|
|
257
272
|
getCachedFingerprint,
|
|
258
273
|
};
|
|
259
274
|
|
|
275
|
+
// One module-lifetime listener invalidates the old agent before Mixpanel signals readiness.
|
|
276
|
+
trackingLifecycle.onInitialization(invalidate);
|
|
277
|
+
|
|
260
278
|
export default fingerprintService;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
it,
|
|
10
10
|
vi,
|
|
11
11
|
} from 'vitest';
|
|
12
|
+
import { trackEvent } from '@/track';
|
|
12
13
|
import { version } from '../../package.json';
|
|
13
14
|
import mixpanelService from './mixpanel';
|
|
14
15
|
|
|
@@ -33,6 +34,7 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
33
34
|
|
|
34
35
|
afterEach(() => {
|
|
35
36
|
vi.restoreAllMocks();
|
|
37
|
+
vi.unstubAllGlobals();
|
|
36
38
|
vi.clearAllTimers();
|
|
37
39
|
vi.useRealTimers();
|
|
38
40
|
});
|
|
@@ -86,6 +88,21 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
86
88
|
});
|
|
87
89
|
});
|
|
88
90
|
|
|
91
|
+
it('completes a repeated init with the installed singleton provider without waiting for another loaded callback', async () => {
|
|
92
|
+
const complete = vi.fn();
|
|
93
|
+
void mixpanelService.init('test-sdk-version-token').then(complete);
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
expect(complete).toHaveBeenCalledOnce();
|
|
96
|
+
expect(mixpanelService.isReady()).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rejects a different project before disturbing the existing provider session', () => {
|
|
100
|
+
expect(() => mixpanelService.init('different-project')).toThrow(
|
|
101
|
+
'new page session',
|
|
102
|
+
);
|
|
103
|
+
expect(mixpanelService.isReady()).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
89
106
|
it('still sets people properties when the SDK distinct ID cache is blocked', () => {
|
|
90
107
|
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
91
108
|
({ data }) => data,
|
|
@@ -100,6 +117,92 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
100
117
|
expect(people).toHaveBeenCalledWith({});
|
|
101
118
|
});
|
|
102
119
|
|
|
120
|
+
it.each(['getItem', 'setItem'] as const)(
|
|
121
|
+
'keeps the live distinct ID when SDK storage %s fails',
|
|
122
|
+
(method) => {
|
|
123
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('live-id');
|
|
124
|
+
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
|
125
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
126
|
+
});
|
|
127
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('live-id');
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it.each(['getItem', 'setItem'] as const)(
|
|
132
|
+
'submits the original event when SDK storage %s is blocked',
|
|
133
|
+
async (method) => {
|
|
134
|
+
const send = vi
|
|
135
|
+
.spyOn(transport, '_track_or_batch')
|
|
136
|
+
.mockImplementation(({ data }) => data);
|
|
137
|
+
mixpanel.identify('storage-test-id');
|
|
138
|
+
send.mockClear();
|
|
139
|
+
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
|
140
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
141
|
+
});
|
|
142
|
+
await trackEvent('Storage Failure Test', { source: 'checkout' });
|
|
143
|
+
expect(send.mock.calls.map(([options]) => options.data)).toContainEqual({
|
|
144
|
+
event: 'Storage Failure Test',
|
|
145
|
+
properties: expect.objectContaining({
|
|
146
|
+
source: 'checkout',
|
|
147
|
+
distinct_id: 'storage-test-id',
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
it('returns null when no live distinct ID is available and the cache is blocked', () => {
|
|
154
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
|
155
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
156
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
157
|
+
});
|
|
158
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('uses the live distinct ID first and null fallback when localStorage is null', () => {
|
|
162
|
+
vi.stubGlobal('localStorage', null);
|
|
163
|
+
const liveId = vi
|
|
164
|
+
.spyOn(mixpanel, 'get_distinct_id')
|
|
165
|
+
.mockReturnValue('live-id');
|
|
166
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('live-id');
|
|
167
|
+
liveId.mockReturnValue('');
|
|
168
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('preserves identify diagnostics and people updates when localStorage is null', () => {
|
|
172
|
+
vi.useFakeTimers();
|
|
173
|
+
vi.stubGlobal('localStorage', null);
|
|
174
|
+
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
175
|
+
const people = vi
|
|
176
|
+
.spyOn(mixpanel.people, 'set')
|
|
177
|
+
.mockImplementation(() => undefined);
|
|
178
|
+
const report = vi.fn();
|
|
179
|
+
mixpanelService.identify('null-storage-user', { plan: 'standard' }, report);
|
|
180
|
+
expect(report).toHaveBeenCalledWith('cache', 'unavailable');
|
|
181
|
+
expect(people).toHaveBeenCalledWith(
|
|
182
|
+
{ plan: 'standard' },
|
|
183
|
+
expect.any(Function),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('uses the cached distinct ID when Mixpanel has no live ID', () => {
|
|
188
|
+
localStorage.setItem('mp_distinct_id', 'cached-id');
|
|
189
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
|
190
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('cached-id');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('preserves identify cache diagnostics and people updates on a write failure', () => {
|
|
194
|
+
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
195
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
196
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
197
|
+
});
|
|
198
|
+
vi.spyOn(mixpanel.people, 'set').mockImplementation(() => undefined);
|
|
199
|
+
vi.useFakeTimers();
|
|
200
|
+
const report = vi.fn();
|
|
201
|
+
mixpanelService.identify('user-id', {}, report);
|
|
202
|
+
expect(report).toHaveBeenCalledWith('cache', 'unavailable');
|
|
203
|
+
expect(mixpanel.people.set).toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
103
206
|
it.each([
|
|
104
207
|
1,
|
|
105
208
|
0,
|
package/src/services/mixpanel.ts
CHANGED
|
@@ -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,12 +102,9 @@ 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
|
-
|
|
61
|
-
updateCachedDistinctId(userId);
|
|
62
|
-
} catch {
|
|
63
|
-
report?.('cache', 'unavailable');
|
|
64
|
-
}
|
|
107
|
+
if (!updateCachedDistinctId(userId)) report?.('cache', 'unavailable');
|
|
65
108
|
if (!report) {
|
|
66
109
|
mixpanel.people.set(properties);
|
|
67
110
|
return;
|
|
@@ -112,7 +155,7 @@ function setPeopleProperties(properties: Record<string, unknown>): void {
|
|
|
112
155
|
* @returns {boolean} Returns true if the Mixpanel tracker is ready, otherwise false.
|
|
113
156
|
*/
|
|
114
157
|
function isReady(): boolean {
|
|
115
|
-
return window.mixpanel !== undefined;
|
|
158
|
+
return !initializing && window.mixpanel !== undefined;
|
|
116
159
|
}
|
|
117
160
|
/**
|
|
118
161
|
* Tracks an event in Mixpanel with the given name and properties.
|
|
@@ -130,37 +173,43 @@ function track(eventName: string, properties: Record<string, unknown>): void {
|
|
|
130
173
|
}
|
|
131
174
|
|
|
132
175
|
/**
|
|
133
|
-
* Retrieves the distinct ID
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* @returns {string | null} The distinct ID if available, or null if Mixpanel is not initialized.
|
|
176
|
+
* Retrieves the live Mixpanel distinct ID first, caching it on a best-effort basis.
|
|
177
|
+
* Falls back to the SDK cache only when no live ID is available.
|
|
178
|
+
* @returns {string | null} The live or cached distinct ID, or null when neither is available.
|
|
137
179
|
*/
|
|
138
180
|
export const getMixpanelDistinctId = (): string | null => {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
if (mixpanel && mixpanel.get_distinct_id) {
|
|
142
|
-
const distinctId = mixpanel.get_distinct_id();
|
|
143
|
-
|
|
181
|
+
const distinctId = mixpanel?.get_distinct_id?.();
|
|
182
|
+
if (distinctId) {
|
|
144
183
|
updateCachedDistinctId(distinctId);
|
|
145
|
-
|
|
146
184
|
return distinctId;
|
|
147
|
-
} else if (cachedId) {
|
|
148
|
-
return cachedId;
|
|
149
185
|
}
|
|
150
|
-
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
return localStorage.getItem(MIXPANEL_DISTINCT_ID_CACHE_KEY);
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
151
192
|
};
|
|
152
193
|
/**
|
|
153
194
|
* Updates the cached Mixpanel distinct ID.
|
|
154
195
|
* Should be called when identifying a user to ensure cache stays in sync.
|
|
155
196
|
* @param {string} distinctId - The distinct ID to cache
|
|
197
|
+
* @returns {boolean} Whether the cache write succeeded.
|
|
156
198
|
*/
|
|
157
|
-
function updateCachedDistinctId(distinctId: string):
|
|
158
|
-
|
|
199
|
+
function updateCachedDistinctId(distinctId: string): boolean {
|
|
200
|
+
try {
|
|
201
|
+
localStorage.setItem(MIXPANEL_DISTINCT_ID_CACHE_KEY, distinctId);
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
159
206
|
}
|
|
160
207
|
|
|
161
208
|
const mixpanelService = {
|
|
162
209
|
init,
|
|
210
|
+
validateConfiguration,
|
|
163
211
|
isReady,
|
|
212
|
+
onReady,
|
|
164
213
|
track,
|
|
165
214
|
identify,
|
|
166
215
|
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.
|