@reservamos/browser-analytics 1.1.8-alpha.0 → 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 +6 -1
- package/README.md +8 -4
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.esm.js +417 -58
- 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 +1 -1
- package/src/events/sharedSchemas/tripSchema.ts +3 -8
- package/src/init.botDetection.test.ts +187 -0
- package/src/init.test.ts +1 -0
- package/src/init.ts +15 -2
- package/src/services/deferredBotDetection.ts +60 -0
- package/src/services/fingerprint.test.ts +18 -0
- package/src/services/fingerprint.ts +36 -9
- package/src/services/mixpanel.test.ts +42 -0
- package/src/services/mixpanel.ts +54 -5
- 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 +130 -5
- package/src/track.ts +110 -25
- package/src/util/pendingWork.test.ts +164 -0
- package/src/util/pendingWork.ts +148 -0
- package/src/util/snapshotEventData.ts +15 -0
package/package.json
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
-
dateField,
|
|
4
|
-
intField,
|
|
5
|
-
numberField,
|
|
6
|
-
stringField,
|
|
7
|
-
} from '@/util/primitiveFields';
|
|
2
|
+
import { dateField, numberField, stringField } from '@/util/primitiveFields';
|
|
8
3
|
|
|
9
4
|
const tripSchema = z.object({
|
|
10
5
|
'Departure Arrival': dateField('Departure Arrival'),
|
|
@@ -17,7 +12,7 @@ const tripSchema = z.object({
|
|
|
17
12
|
'Departure Origin Terminal': stringField('Departure Origin Terminal'),
|
|
18
13
|
'Departure Price': numberField('Departure Price'),
|
|
19
14
|
'Departure Route': stringField('Departure Route'),
|
|
20
|
-
'Departure Stops':
|
|
15
|
+
'Departure Stops': z.number().int(),
|
|
21
16
|
'Departure Time': dateField('Departure Time'),
|
|
22
17
|
'Departure Transport Type': stringField('Departure Transport Type'),
|
|
23
18
|
'Departure Transporter': stringField('Departure Transporter'),
|
|
@@ -31,7 +26,7 @@ const tripSchema = z.object({
|
|
|
31
26
|
'Return Origin Terminal': stringField('Return Origin Terminal').optional(),
|
|
32
27
|
'Return Price': numberField('Return Price').optional(),
|
|
33
28
|
'Return Route': stringField('Return Route').optional(),
|
|
34
|
-
'Return Stops':
|
|
29
|
+
'Return Stops': z.number().int().optional(),
|
|
35
30
|
'Return Time': dateField('Return Time').optional(),
|
|
36
31
|
'Return Transport Type': stringField('Return Transport Type').optional(),
|
|
37
32
|
'Return Transporter': stringField('Return Transporter').optional(),
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fingerprintService from '@/services/fingerprint';
|
|
3
|
+
import { trackEvent } from '@/track';
|
|
4
|
+
import init from './init';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
track: vi.fn(),
|
|
8
|
+
observation: vi.fn(),
|
|
9
|
+
fingerprintInit: vi.fn(),
|
|
10
|
+
visitorData: vi.fn(),
|
|
11
|
+
geolocationInit: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
|
|
15
|
+
FingerprintJSPro: {
|
|
16
|
+
defaultEndpoint: 'https://fp.example.test',
|
|
17
|
+
defaultScriptUrlPattern: 'https://fp.example.test/agent',
|
|
18
|
+
},
|
|
19
|
+
FpjsClient: vi.fn().mockImplementation(() => ({
|
|
20
|
+
init: mocks.fingerprintInit,
|
|
21
|
+
getVisitorData: mocks.visitorData,
|
|
22
|
+
})),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('@/services/mixpanel', () => ({
|
|
25
|
+
default: {
|
|
26
|
+
init: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
validateConfiguration: vi.fn(),
|
|
28
|
+
setPeopleProperties: vi.fn(),
|
|
29
|
+
isReady: vi.fn().mockReturnValue(true),
|
|
30
|
+
getMixpanelDistinctId: vi.fn().mockReturnValue('distinct-id'),
|
|
31
|
+
track: mocks.track,
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('@/services/geolocation', () => ({
|
|
35
|
+
default: {
|
|
36
|
+
init: mocks.geolocationInit,
|
|
37
|
+
getCoordinates: vi.fn().mockReturnValue(null),
|
|
38
|
+
getGeolocationSource: vi.fn().mockReturnValue(null),
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
vi.mock('@reservamos/js-api-client/core', () => ({
|
|
42
|
+
default: {
|
|
43
|
+
setConfig: vi.fn(),
|
|
44
|
+
fingerprint: { sendObservation: mocks.observation },
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const search = {
|
|
49
|
+
'Departure': '2026-10-14',
|
|
50
|
+
'Departure Delta': 1,
|
|
51
|
+
'Origin': 'A',
|
|
52
|
+
'Origin Terminal': 'A',
|
|
53
|
+
'Destination': 'B',
|
|
54
|
+
'Destination Terminal': 'B',
|
|
55
|
+
'Route': 'A-B',
|
|
56
|
+
'Passengers': 1,
|
|
57
|
+
'Bus count': 1,
|
|
58
|
+
'product': 'web',
|
|
59
|
+
};
|
|
60
|
+
const purchase = {
|
|
61
|
+
'Trips': [
|
|
62
|
+
{
|
|
63
|
+
'Departure Arrival': '2026-10-15',
|
|
64
|
+
'Departure Destination': 'B',
|
|
65
|
+
'Departure Destination Terminal': 'B',
|
|
66
|
+
'Departure Line': 'Line',
|
|
67
|
+
'Departure Origin': 'A',
|
|
68
|
+
'Departure Origin Terminal': 'A',
|
|
69
|
+
'Departure Price': 100,
|
|
70
|
+
'Departure Route': 'A-B',
|
|
71
|
+
'Departure Stops': 1,
|
|
72
|
+
'Departure Time': '2026-10-14',
|
|
73
|
+
'Departure Transport Type': 'bus',
|
|
74
|
+
'Departure Transporter': 'Line',
|
|
75
|
+
'Recommended Trip': false,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
'Passenger Count': 1,
|
|
79
|
+
'Trip Count': 1,
|
|
80
|
+
'Total': 100,
|
|
81
|
+
'product': 'web',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Keep tracking, validation, Fingerprint readiness and the relay service real.
|
|
85
|
+
describe('Bot Detection from Tracker Ready', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.useFakeTimers();
|
|
88
|
+
vi.clearAllMocks();
|
|
89
|
+
let request = 0;
|
|
90
|
+
mocks.visitorData.mockImplementation(async () => ({
|
|
91
|
+
visitorId: 'fresh-visitor',
|
|
92
|
+
requestId: `request-${++request}`,
|
|
93
|
+
confidence: { score: 0.99 },
|
|
94
|
+
visitorFound: true,
|
|
95
|
+
cacheHit: false,
|
|
96
|
+
}));
|
|
97
|
+
mocks.observation.mockResolvedValue(true);
|
|
98
|
+
localStorage.clear();
|
|
99
|
+
Reflect.deleteProperty(window, 'fpClient');
|
|
100
|
+
Reflect.deleteProperty(window, 'fingerprintConfig');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
vi.clearAllTimers();
|
|
105
|
+
vi.useRealTimers();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it.each([null, 'cached-visitor'])(
|
|
109
|
+
'submits original events with cache %s and defers one observation per event',
|
|
110
|
+
async (visitorId) => {
|
|
111
|
+
if (visitorId) {
|
|
112
|
+
localStorage.setItem(
|
|
113
|
+
'default_fingerprint_cache',
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
fingerprint: visitorId,
|
|
116
|
+
expiresAt: Date.now() + 60_000,
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
let finishFingerprint!: () => void;
|
|
121
|
+
let finishGeolocation!: () => void;
|
|
122
|
+
mocks.fingerprintInit.mockReturnValue(
|
|
123
|
+
new Promise<void>((resolve) => {
|
|
124
|
+
finishFingerprint = resolve;
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
mocks.geolocationInit.mockReturnValue(
|
|
128
|
+
new Promise<void>((resolve) => {
|
|
129
|
+
finishGeolocation = resolve;
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
let tracked!: Promise<unknown[]>;
|
|
133
|
+
window.addEventListener(
|
|
134
|
+
'Tracker Ready',
|
|
135
|
+
() => {
|
|
136
|
+
expect(fingerprintService.isFingerprintReady()).toBe(false);
|
|
137
|
+
expect(mocks.fingerprintInit).not.toHaveBeenCalled();
|
|
138
|
+
tracked = Promise.all([
|
|
139
|
+
trackEvent('Search', search),
|
|
140
|
+
trackEvent('Payment Attempt', {
|
|
141
|
+
...purchase,
|
|
142
|
+
'Payment Type': 'credit_card',
|
|
143
|
+
}),
|
|
144
|
+
trackEvent('Purchase Attempt', purchase),
|
|
145
|
+
]);
|
|
146
|
+
},
|
|
147
|
+
{ once: true },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const initializing = init({
|
|
151
|
+
mixpanelToken: 'token',
|
|
152
|
+
identificationKey: 'key',
|
|
153
|
+
});
|
|
154
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
155
|
+
await tracked;
|
|
156
|
+
expect(mocks.track.mock.calls.map(([name]) => name)).toEqual([
|
|
157
|
+
'Search',
|
|
158
|
+
'Payment Attempt',
|
|
159
|
+
'Purchase Attempt',
|
|
160
|
+
]);
|
|
161
|
+
for (const [, properties] of mocks.track.mock.calls) {
|
|
162
|
+
expect(properties['User Fingerprint']).toBe(visitorId ?? '');
|
|
163
|
+
}
|
|
164
|
+
expect(mocks.visitorData).not.toHaveBeenCalled();
|
|
165
|
+
expect(mocks.observation).not.toHaveBeenCalled();
|
|
166
|
+
|
|
167
|
+
finishFingerprint();
|
|
168
|
+
finishGeolocation();
|
|
169
|
+
await initializing;
|
|
170
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
171
|
+
expect(fingerprintService.isFingerprintReady()).toBe(true);
|
|
172
|
+
expect(mocks.track).toHaveBeenCalledTimes(3);
|
|
173
|
+
expect(mocks.visitorData).toHaveBeenCalledTimes(3);
|
|
174
|
+
expect(mocks.observation).toHaveBeenCalledTimes(3);
|
|
175
|
+
expect(
|
|
176
|
+
new Set(
|
|
177
|
+
mocks.observation.mock.calls.map(([payload]) => payload.requestId),
|
|
178
|
+
).size,
|
|
179
|
+
).toBe(3);
|
|
180
|
+
for (const [payload] of mocks.observation.mock.calls) {
|
|
181
|
+
expect(payload.distinctId).toBe('distinct-id');
|
|
182
|
+
expect(payload.visitorId).toBe('fresh-visitor');
|
|
183
|
+
}
|
|
184
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
});
|
package/src/init.test.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -10,6 +10,7 @@ import validator, { InitConfigSchema } from '@/services/validator';
|
|
|
10
10
|
* Configuration object for initializing the tracking library.
|
|
11
11
|
*/
|
|
12
12
|
export type InitConfig = z.infer<typeof InitConfigSchema>;
|
|
13
|
+
let initialization = 0;
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Dispatches a 'Tracker Ready' event to the window object.
|
|
@@ -40,7 +41,11 @@ async function init(config: InitConfig) {
|
|
|
40
41
|
appName,
|
|
41
42
|
} = config;
|
|
42
43
|
|
|
44
|
+
mixpanelService.validateConfiguration(mixpanelToken, mixpanelProxyUrl);
|
|
45
|
+
const attempt = ++initialization;
|
|
46
|
+
const isCurrent = () => attempt === initialization;
|
|
43
47
|
await mixpanelService.init(mixpanelToken, debug, mixpanelProxyUrl);
|
|
48
|
+
if (!isCurrent()) return;
|
|
44
49
|
|
|
45
50
|
configService.setEnvironment(isSandbox ? 'sandbox' : 'prod');
|
|
46
51
|
if (appName) {
|
|
@@ -50,12 +55,20 @@ async function init(config: InitConfig) {
|
|
|
50
55
|
|
|
51
56
|
// Dispatch the 'Tracker Ready' event
|
|
52
57
|
onLoaded();
|
|
58
|
+
if (!isCurrent()) return;
|
|
53
59
|
|
|
54
60
|
await Promise.all([
|
|
55
61
|
initializeOptionalStage('fingerprint', () =>
|
|
56
|
-
|
|
62
|
+
isCurrent()
|
|
63
|
+
? fingerprintService.initFingerprint(
|
|
64
|
+
identificationKey,
|
|
65
|
+
identifyProxyUrl,
|
|
66
|
+
)
|
|
67
|
+
: Promise.resolve(),
|
|
68
|
+
),
|
|
69
|
+
initializeOptionalStage('geolocation', () =>
|
|
70
|
+
isCurrent() ? geolocationService.init() : Promise.resolve(),
|
|
57
71
|
),
|
|
58
|
-
initializeOptionalStage('geolocation', () => geolocationService.init()),
|
|
59
72
|
]);
|
|
60
73
|
}
|
|
61
74
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import botDetectionRelayService from '@/services/botDetectionRelay';
|
|
2
|
+
import fingerprintService from '@/services/fingerprint';
|
|
3
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
4
|
+
import { createPendingWorkQueue } from '@/util/pendingWork';
|
|
5
|
+
|
|
6
|
+
const observations = createPendingWorkQueue({
|
|
7
|
+
isReady: () => fingerprintService.isFingerprintReady(),
|
|
8
|
+
subscribe(notify) {
|
|
9
|
+
const stopReady = fingerprintService.onReady(notify);
|
|
10
|
+
const stopIdentity = trackingLifecycle.onChange(notify);
|
|
11
|
+
return () => {
|
|
12
|
+
stopReady();
|
|
13
|
+
stopIdentity();
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
maxEntries: 100,
|
|
17
|
+
maxBytes: 1024 * 1024,
|
|
18
|
+
maxActive: 3,
|
|
19
|
+
lifetime: 10000,
|
|
20
|
+
wait: 0,
|
|
21
|
+
stage: 'fingerprint',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Defers only observation work; this function never submits a Mixpanel event. */
|
|
25
|
+
export function deferBotDetection(
|
|
26
|
+
eventName: string,
|
|
27
|
+
distinctId: string | null,
|
|
28
|
+
isCurrent: () => boolean,
|
|
29
|
+
): void {
|
|
30
|
+
void observations.enqueue({
|
|
31
|
+
eventName,
|
|
32
|
+
bytes: new Blob([eventName, distinctId ?? '']).size,
|
|
33
|
+
isCurrent,
|
|
34
|
+
async run(isActive) {
|
|
35
|
+
let stage: 'fingerprint' | 'relay' = 'fingerprint';
|
|
36
|
+
try {
|
|
37
|
+
const identification = await fingerprintService.identifyVisitor(true, {
|
|
38
|
+
isActive,
|
|
39
|
+
report: () => undefined,
|
|
40
|
+
});
|
|
41
|
+
if (!isActive() || !identification.requestId) return;
|
|
42
|
+
stage = 'relay';
|
|
43
|
+
await botDetectionRelayService.relay({
|
|
44
|
+
requestId: identification.requestId,
|
|
45
|
+
visitorId: identification.visitorId,
|
|
46
|
+
...(distinctId && { distinctId }),
|
|
47
|
+
eventName,
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
if (isActive()) {
|
|
51
|
+
console.error('Bot Detection diagnostic', {
|
|
52
|
+
eventName,
|
|
53
|
+
stage,
|
|
54
|
+
reason: 'failed',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -95,6 +95,24 @@ describe('fingerprintService', () => {
|
|
|
95
95
|
expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
|
|
96
96
|
});
|
|
97
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
|
+
|
|
98
116
|
it('suppresses identify-specific cache and profile effects after its scope closes', async () => {
|
|
99
117
|
let finish: (value: typeof visitorData) => void = () => undefined;
|
|
100
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.
|
|
@@ -116,7 +133,8 @@ async function initFingerprint(
|
|
|
116
133
|
customScriptEndpointArray.unshift(buildProxyUrl(proxyUrl));
|
|
117
134
|
}
|
|
118
135
|
|
|
119
|
-
|
|
136
|
+
invalidate();
|
|
137
|
+
const attempt = initialization;
|
|
120
138
|
window.fingerprintConfig = {};
|
|
121
139
|
const client = new FpjsClient({
|
|
122
140
|
loadOptions: {
|
|
@@ -129,11 +147,12 @@ async function initFingerprint(
|
|
|
129
147
|
|
|
130
148
|
try {
|
|
131
149
|
await client.init();
|
|
132
|
-
if (window.fpClient === client) {
|
|
150
|
+
if (window.fpClient === client && attempt === initialization) {
|
|
133
151
|
fingerprintAgentReady = true;
|
|
152
|
+
readinessSubscribers.forEach((notify) => notify());
|
|
134
153
|
}
|
|
135
154
|
} catch (error) {
|
|
136
|
-
if (window.fpClient === client) {
|
|
155
|
+
if (window.fpClient === client && attempt === initialization) {
|
|
137
156
|
fingerprintAgentReady = false;
|
|
138
157
|
}
|
|
139
158
|
console.error('Error initializing identification service:', error);
|
|
@@ -148,8 +167,11 @@ async function initFingerprint(
|
|
|
148
167
|
* @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when unavailable.
|
|
149
168
|
* @throws {Error} Throws an error if retrieval fails after the agent has loaded.
|
|
150
169
|
*/
|
|
151
|
-
async function getFingerprint(
|
|
152
|
-
|
|
170
|
+
async function getFingerprint(
|
|
171
|
+
cacheOnly = false,
|
|
172
|
+
context?: IdentifyStageContext,
|
|
173
|
+
): Promise<string> {
|
|
174
|
+
return readFingerprint(cacheOnly, context);
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
/** Internal identify path with late-effect suppression and sanitized diagnostics. */
|
|
@@ -184,8 +206,9 @@ async function readFingerprint(
|
|
|
184
206
|
*/
|
|
185
207
|
async function identifyVisitor(
|
|
186
208
|
ignoreCache = false,
|
|
209
|
+
context?: IdentifyStageContext,
|
|
187
210
|
): Promise<FingerprintIdentification> {
|
|
188
|
-
return requestIdentification(ignoreCache);
|
|
211
|
+
return requestIdentification(ignoreCache, context);
|
|
189
212
|
}
|
|
190
213
|
|
|
191
214
|
async function requestIdentification(
|
|
@@ -201,9 +224,8 @@ async function requestIdentification(
|
|
|
201
224
|
if (!context || context.isActive()) {
|
|
202
225
|
try {
|
|
203
226
|
setCachedFingerprint(result.visitorId);
|
|
204
|
-
} catch
|
|
205
|
-
|
|
206
|
-
context.report('cache', 'unavailable');
|
|
227
|
+
} catch {
|
|
228
|
+
context?.report('cache', 'unavailable');
|
|
207
229
|
}
|
|
208
230
|
if (typeof result.visitorFound === 'boolean') {
|
|
209
231
|
try {
|
|
@@ -245,7 +267,12 @@ const fingerprintService = {
|
|
|
245
267
|
getFingerprint,
|
|
246
268
|
identifyVisitor,
|
|
247
269
|
isFingerprintReady,
|
|
270
|
+
onReady,
|
|
271
|
+
invalidate,
|
|
248
272
|
getCachedFingerprint,
|
|
249
273
|
};
|
|
250
274
|
|
|
275
|
+
// One module-lifetime listener invalidates the old agent before Mixpanel signals readiness.
|
|
276
|
+
trackingLifecycle.onInitialization(invalidate);
|
|
277
|
+
|
|
251
278
|
export default fingerprintService;
|
|
@@ -34,6 +34,7 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
34
34
|
|
|
35
35
|
afterEach(() => {
|
|
36
36
|
vi.restoreAllMocks();
|
|
37
|
+
vi.unstubAllGlobals();
|
|
37
38
|
vi.clearAllTimers();
|
|
38
39
|
vi.useRealTimers();
|
|
39
40
|
});
|
|
@@ -87,6 +88,21 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
87
88
|
});
|
|
88
89
|
});
|
|
89
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
|
+
|
|
90
106
|
it('still sets people properties when the SDK distinct ID cache is blocked', () => {
|
|
91
107
|
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
92
108
|
({ data }) => data,
|
|
@@ -142,6 +158,32 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
142
158
|
expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
|
|
143
159
|
});
|
|
144
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
|
+
|
|
145
187
|
it('uses the cached distinct ID when Mixpanel has no live ID', () => {
|
|
146
188
|
localStorage.setItem('mp_distinct_id', 'cached-id');
|
|
147
189
|
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
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,6 +102,7 @@ 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
108
|
if (!report) {
|
|
@@ -108,7 +155,7 @@ function setPeopleProperties(properties: Record<string, unknown>): void {
|
|
|
108
155
|
* @returns {boolean} Returns true if the Mixpanel tracker is ready, otherwise false.
|
|
109
156
|
*/
|
|
110
157
|
function isReady(): boolean {
|
|
111
|
-
return window.mixpanel !== undefined;
|
|
158
|
+
return !initializing && window.mixpanel !== undefined;
|
|
112
159
|
}
|
|
113
160
|
/**
|
|
114
161
|
* Tracks an event in Mixpanel with the given name and properties.
|
|
@@ -126,10 +173,9 @@ function track(eventName: string, properties: Record<string, unknown>): void {
|
|
|
126
173
|
}
|
|
127
174
|
|
|
128
175
|
/**
|
|
129
|
-
* Retrieves the distinct ID
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* @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.
|
|
133
179
|
*/
|
|
134
180
|
export const getMixpanelDistinctId = (): string | null => {
|
|
135
181
|
const distinctId = mixpanel?.get_distinct_id?.();
|
|
@@ -148,6 +194,7 @@ export const getMixpanelDistinctId = (): string | null => {
|
|
|
148
194
|
* Updates the cached Mixpanel distinct ID.
|
|
149
195
|
* Should be called when identifying a user to ensure cache stays in sync.
|
|
150
196
|
* @param {string} distinctId - The distinct ID to cache
|
|
197
|
+
* @returns {boolean} Whether the cache write succeeded.
|
|
151
198
|
*/
|
|
152
199
|
function updateCachedDistinctId(distinctId: string): boolean {
|
|
153
200
|
try {
|
|
@@ -160,7 +207,9 @@ function updateCachedDistinctId(distinctId: string): boolean {
|
|
|
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,
|