@reservamos/browser-analytics 1.1.7 → 1.1.8-alpha.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.
- package/CHANGELOG.md +8 -0
- package/README.md +12 -10
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.esm.js +113 -53
- 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/init.test.ts +287 -0
- package/src/init.ts +34 -12
- package/src/services/fingerprint.test.ts +18 -0
- package/src/services/fingerprint.ts +2 -11
- package/src/services/mixpanel.test.ts +61 -0
- package/src/services/mixpanel.ts +16 -16
- package/src/track.test.ts +315 -71
- package/src/track.ts +88 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reservamos/browser-analytics",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8-alpha.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"validate": "./validate.sh",
|
|
55
55
|
"validate:full": "./validate.sh --full",
|
|
56
56
|
"upgrade": "upgradeps",
|
|
57
|
-
"release": "
|
|
57
|
+
"release": "gh workflow run publish.yml --ref main"
|
|
58
58
|
},
|
|
59
59
|
"type": "module",
|
|
60
60
|
"types": "./dist/browser-analytics.d.ts",
|
package/src/init.test.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import createAnonymousProfile from '@/profiles/createAnonymousProfile/createAnonymousProfile';
|
|
3
|
+
import { trackEvent } from '@/track';
|
|
4
|
+
import init from './init';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
mixpanel: vi.fn(),
|
|
8
|
+
fingerprint: vi.fn(),
|
|
9
|
+
geolocation: vi.fn(),
|
|
10
|
+
validate: vi.fn(),
|
|
11
|
+
environment: vi.fn(),
|
|
12
|
+
appName: vi.fn(),
|
|
13
|
+
config: vi.fn(),
|
|
14
|
+
core: vi.fn(),
|
|
15
|
+
distinctId: vi.fn(),
|
|
16
|
+
profile: vi.fn(),
|
|
17
|
+
track: vi.fn(),
|
|
18
|
+
ready: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@/services/mixpanel', () => ({
|
|
21
|
+
default: {
|
|
22
|
+
init: mocks.mixpanel,
|
|
23
|
+
getMixpanelDistinctId: mocks.distinctId,
|
|
24
|
+
isReady: mocks.ready,
|
|
25
|
+
track: mocks.track,
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('@/services/fingerprint', async (importOriginal) => {
|
|
29
|
+
const actual =
|
|
30
|
+
await importOriginal<typeof import('@/services/fingerprint')>();
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
default: { ...actual.default, initFingerprint: mocks.fingerprint },
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
vi.mock('@/services/geolocation', () => ({
|
|
37
|
+
default: {
|
|
38
|
+
init: mocks.geolocation,
|
|
39
|
+
getCoordinates: vi.fn().mockReturnValue(null),
|
|
40
|
+
getGeolocationSource: vi.fn().mockReturnValue(null),
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
vi.mock('@/services/validator', async (importOriginal) => {
|
|
44
|
+
const actual = await importOriginal<typeof import('@/services/validator')>();
|
|
45
|
+
return {
|
|
46
|
+
...actual,
|
|
47
|
+
default: { ...actual.default, parseInitProps: mocks.validate },
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
vi.mock('@/services/config', () => ({
|
|
51
|
+
default: {
|
|
52
|
+
setEnvironment: mocks.environment,
|
|
53
|
+
setAppName: mocks.appName,
|
|
54
|
+
getCoreAPIConfig: mocks.config,
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
vi.mock('@reservamos/js-api-client/core', () => ({
|
|
58
|
+
default: {
|
|
59
|
+
setConfig: mocks.core,
|
|
60
|
+
profiles: { createAnonymousProfile: mocks.profile },
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const config = {
|
|
65
|
+
mixpanelToken: 'test-token',
|
|
66
|
+
identificationKey: 'test-key',
|
|
67
|
+
appName: 'test-app',
|
|
68
|
+
isSandbox: true,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
describe('init optional enrichment', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.useFakeTimers();
|
|
74
|
+
Object.values(mocks).forEach((mock) => mock.mockReset());
|
|
75
|
+
localStorage.clear();
|
|
76
|
+
Reflect.deleteProperty(window, 'fingerprintConfig');
|
|
77
|
+
mocks.distinctId.mockReturnValue('live-id');
|
|
78
|
+
mocks.ready.mockReturnValue(true);
|
|
79
|
+
mocks.profile.mockResolvedValue({ id: 'profile-id' });
|
|
80
|
+
mocks.mixpanel.mockResolvedValue(undefined);
|
|
81
|
+
mocks.fingerprint.mockResolvedValue(undefined);
|
|
82
|
+
mocks.geolocation.mockResolvedValue(null);
|
|
83
|
+
mocks.config.mockReturnValue({ baseUrl: 'https://example.test' });
|
|
84
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
85
|
+
});
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.clearAllTimers();
|
|
88
|
+
vi.useRealTimers();
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('configures Core API and emits readiness while both optional stages are pending', async () => {
|
|
93
|
+
mocks.fingerprint.mockReturnValue(new Promise(() => undefined));
|
|
94
|
+
mocks.geolocation.mockReturnValue(new Promise(() => undefined));
|
|
95
|
+
const dispatch = vi
|
|
96
|
+
.spyOn(window, 'dispatchEvent')
|
|
97
|
+
.mockImplementation((event) => {
|
|
98
|
+
if (event.type === 'Tracker Ready') {
|
|
99
|
+
expect(mocks.environment).toHaveBeenCalledWith('sandbox');
|
|
100
|
+
expect(mocks.appName).toHaveBeenCalledWith('test-app');
|
|
101
|
+
expect(mocks.core).toHaveBeenCalledWith({
|
|
102
|
+
baseUrl: 'https://example.test',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
const completed = vi.fn();
|
|
108
|
+
const pending = init(config).then(completed);
|
|
109
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
110
|
+
expect(dispatch).toHaveBeenCalledWith(
|
|
111
|
+
expect.objectContaining({ type: 'Tracker Ready' }),
|
|
112
|
+
);
|
|
113
|
+
expect(mocks.fingerprint).toHaveBeenCalledOnce();
|
|
114
|
+
expect(mocks.geolocation).toHaveBeenCalledOnce();
|
|
115
|
+
expect(completed).not.toHaveBeenCalled();
|
|
116
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
117
|
+
await pending;
|
|
118
|
+
expect(completed).toHaveBeenCalledOnce();
|
|
119
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
120
|
+
'SDK initialization diagnostic',
|
|
121
|
+
{ stage: 'fingerprint', reason: 'timeout' },
|
|
122
|
+
);
|
|
123
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
124
|
+
'SDK initialization diagnostic',
|
|
125
|
+
{ stage: 'geolocation', reason: 'timeout' },
|
|
126
|
+
);
|
|
127
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it.each([null, 'cached-visitor'])(
|
|
131
|
+
'supports profile calls from readiness before fingerprint init with cache %s',
|
|
132
|
+
async (cachedId) => {
|
|
133
|
+
if (cachedId) {
|
|
134
|
+
localStorage.setItem(
|
|
135
|
+
'default_fingerprint_cache',
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
fingerprint: cachedId,
|
|
138
|
+
expiresAt: Date.now() + 60_000,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
let outcome: Promise<unknown> | undefined;
|
|
143
|
+
window.addEventListener(
|
|
144
|
+
'Tracker Ready',
|
|
145
|
+
() => {
|
|
146
|
+
outcome = createAnonymousProfile({ email: 'test@example.com' }).then(
|
|
147
|
+
(response) => ({ response }),
|
|
148
|
+
(error: unknown) => ({ error }),
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
{ once: true },
|
|
152
|
+
);
|
|
153
|
+
await init(config);
|
|
154
|
+
await expect(outcome).resolves.toEqual({
|
|
155
|
+
response: { id: 'profile-id' },
|
|
156
|
+
});
|
|
157
|
+
expect(mocks.profile).toHaveBeenCalledWith({
|
|
158
|
+
identifier_key: 'email',
|
|
159
|
+
identifier_value: 'test@example.com',
|
|
160
|
+
details: {},
|
|
161
|
+
identifiers: [
|
|
162
|
+
...(cachedId ? [{ key: 'fingerprint', value: cachedId }] : []),
|
|
163
|
+
{ key: 'distinct_id', value: 'live-id' },
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
describe.each(['Search', 'View Results'])(
|
|
170
|
+
'%s from early readiness',
|
|
171
|
+
(eventName) => {
|
|
172
|
+
it.each([null, 'cached-visitor'])(
|
|
173
|
+
'submits before Fingerprint config with cache %s',
|
|
174
|
+
async (visitorId) => {
|
|
175
|
+
if (visitorId)
|
|
176
|
+
localStorage.setItem(
|
|
177
|
+
'default_fingerprint_cache',
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
fingerprint: visitorId,
|
|
180
|
+
expiresAt: Date.now() + 60_000,
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
const properties = {
|
|
184
|
+
'Departure': '2026-10-14',
|
|
185
|
+
'Departure Delta': 0,
|
|
186
|
+
'Origin': 'A',
|
|
187
|
+
'Origin Terminal': 'A',
|
|
188
|
+
'Destination': 'B',
|
|
189
|
+
'Destination Terminal': 'B',
|
|
190
|
+
'Route': 'A-B',
|
|
191
|
+
'Passengers': 1,
|
|
192
|
+
'Bus count': 1,
|
|
193
|
+
'product': 'web',
|
|
194
|
+
};
|
|
195
|
+
let tracked: Promise<void> | undefined;
|
|
196
|
+
window.addEventListener(
|
|
197
|
+
'Tracker Ready',
|
|
198
|
+
() => {
|
|
199
|
+
tracked = trackEvent(eventName, properties);
|
|
200
|
+
},
|
|
201
|
+
{ once: true },
|
|
202
|
+
);
|
|
203
|
+
await init(config);
|
|
204
|
+
await tracked;
|
|
205
|
+
expect(mocks.track).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(mocks.track).toHaveBeenCalledWith(
|
|
207
|
+
eventName,
|
|
208
|
+
expect.objectContaining({
|
|
209
|
+
'User Fingerprint': visitorId ?? '',
|
|
210
|
+
'Route': 'A-B',
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
it('waits for required Mixpanel initialization before configuring or signaling readiness', async () => {
|
|
219
|
+
let finishMixpanel!: () => void;
|
|
220
|
+
mocks.mixpanel.mockReturnValue(
|
|
221
|
+
new Promise<void>((resolve) => {
|
|
222
|
+
finishMixpanel = resolve;
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
const dispatch = vi.spyOn(window, 'dispatchEvent');
|
|
226
|
+
const pending = init(config);
|
|
227
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
228
|
+
expect(mocks.core).not.toHaveBeenCalled();
|
|
229
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
230
|
+
finishMixpanel();
|
|
231
|
+
await pending;
|
|
232
|
+
expect(dispatch).toHaveBeenCalledOnce();
|
|
233
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it.each(['fingerprint', 'geolocation'] as const)(
|
|
237
|
+
'continues after %s rejects without leaking raw errors',
|
|
238
|
+
async (stage) => {
|
|
239
|
+
mocks[stage].mockRejectedValue(new Error('private-stage-error'));
|
|
240
|
+
const dispatch = vi.spyOn(window, 'dispatchEvent');
|
|
241
|
+
await expect(init(config)).resolves.toBeUndefined();
|
|
242
|
+
expect(dispatch).toHaveBeenCalledOnce();
|
|
243
|
+
expect(mocks.fingerprint).toHaveBeenCalledOnce();
|
|
244
|
+
expect(mocks.geolocation).toHaveBeenCalledOnce();
|
|
245
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
246
|
+
'SDK initialization diagnostic',
|
|
247
|
+
{ stage, reason: 'failed' },
|
|
248
|
+
);
|
|
249
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
250
|
+
'private-stage-error',
|
|
251
|
+
);
|
|
252
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
it('continues to geolocation if fingerprint initialization throws synchronously', async () => {
|
|
257
|
+
mocks.fingerprint.mockImplementation(() => {
|
|
258
|
+
throw new Error('private-stage-error');
|
|
259
|
+
});
|
|
260
|
+
await expect(init(config)).resolves.toBeUndefined();
|
|
261
|
+
expect(mocks.geolocation).toHaveBeenCalledOnce();
|
|
262
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
263
|
+
'SDK initialization diagnostic',
|
|
264
|
+
{ stage: 'fingerprint', reason: 'failed' },
|
|
265
|
+
);
|
|
266
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('consumes late rejection after the optional deadline without another diagnostic', async () => {
|
|
270
|
+
let rejectFingerprint!: (reason: Error) => void;
|
|
271
|
+
mocks.fingerprint.mockReturnValue(
|
|
272
|
+
new Promise<void>((_resolve, reject) => {
|
|
273
|
+
rejectFingerprint = reject;
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
const dispatch = vi.spyOn(window, 'dispatchEvent');
|
|
277
|
+
const pending = init(config);
|
|
278
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
279
|
+
await pending;
|
|
280
|
+
expect(console.error).toHaveBeenCalledTimes(1);
|
|
281
|
+
rejectFingerprint(new Error('private-late-error'));
|
|
282
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
283
|
+
expect(console.error).toHaveBeenCalledTimes(1);
|
|
284
|
+
expect(dispatch).toHaveBeenCalledOnce();
|
|
285
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
286
|
+
});
|
|
287
|
+
});
|
package/src/init.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type InitConfig = z.infer<typeof InitConfigSchema>;
|
|
|
15
15
|
* Dispatches a 'Tracker Ready' event to the window object.
|
|
16
16
|
*
|
|
17
17
|
* This function creates and dispatches a custom event named 'Tracker Ready'
|
|
18
|
-
*
|
|
18
|
+
* after Mixpanel and Core API configuration are ready; optional enrichment may still be pending.
|
|
19
19
|
*/
|
|
20
20
|
function onLoaded() {
|
|
21
21
|
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
@@ -24,6 +24,7 @@ function onLoaded() {
|
|
|
24
24
|
/**
|
|
25
25
|
* Initializes the tracking library with the provided configuration.
|
|
26
26
|
* @param {InitConfig} config - The configuration object for initialization.
|
|
27
|
+
* Optional enrichment runs concurrently, with a three-second waiting limit per stage.
|
|
27
28
|
* @throws {Error} Throws an error if the configuration is invalid.
|
|
28
29
|
*/
|
|
29
30
|
async function init(config: InitConfig) {
|
|
@@ -41,17 +42,6 @@ async function init(config: InitConfig) {
|
|
|
41
42
|
|
|
42
43
|
await mixpanelService.init(mixpanelToken, debug, mixpanelProxyUrl);
|
|
43
44
|
|
|
44
|
-
try {
|
|
45
|
-
await fingerprintService.initFingerprint(
|
|
46
|
-
identificationKey,
|
|
47
|
-
identifyProxyUrl,
|
|
48
|
-
);
|
|
49
|
-
} catch (error) {
|
|
50
|
-
console.error('Error initializing identification service:', error);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
await geolocationService.init();
|
|
54
|
-
|
|
55
45
|
configService.setEnvironment(isSandbox ? 'sandbox' : 'prod');
|
|
56
46
|
if (appName) {
|
|
57
47
|
configService.setAppName(appName);
|
|
@@ -60,6 +50,38 @@ async function init(config: InitConfig) {
|
|
|
60
50
|
|
|
61
51
|
// Dispatch the 'Tracker Ready' event
|
|
62
52
|
onLoaded();
|
|
53
|
+
|
|
54
|
+
await Promise.all([
|
|
55
|
+
initializeOptionalStage('fingerprint', () =>
|
|
56
|
+
fingerprintService.initFingerprint(identificationKey, identifyProxyUrl),
|
|
57
|
+
),
|
|
58
|
+
initializeOptionalStage('geolocation', () => geolocationService.init()),
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Bounds the wait for optional work without cancelling future enrichment. */
|
|
63
|
+
function initializeOptionalStage(
|
|
64
|
+
stage: 'fingerprint' | 'geolocation',
|
|
65
|
+
initialize: () => Promise<unknown>,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let active = true;
|
|
69
|
+
const finish = (reason?: 'timeout' | 'failed') => {
|
|
70
|
+
if (!active) return;
|
|
71
|
+
active = false;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
if (reason)
|
|
74
|
+
console.error('SDK initialization diagnostic', { stage, reason });
|
|
75
|
+
resolve();
|
|
76
|
+
};
|
|
77
|
+
const timer = setTimeout(() => finish('timeout'), 3000);
|
|
78
|
+
void Promise.resolve()
|
|
79
|
+
.then(initialize)
|
|
80
|
+
.then(
|
|
81
|
+
() => finish(),
|
|
82
|
+
() => finish('failed'),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
63
85
|
}
|
|
64
86
|
|
|
65
87
|
/**
|
|
@@ -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,
|
|
@@ -51,9 +51,8 @@ const getCachedFingerprint = (): string | null => {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
function readCachedFingerprint(context?: IdentifyStageContext): string | null {
|
|
54
|
-
const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig;
|
|
55
|
-
|
|
56
54
|
try {
|
|
55
|
+
const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig ?? {};
|
|
57
56
|
const cachedData = JSON.parse(localStorage.getItem(cacheName) || 'null');
|
|
58
57
|
if (cachedData && cachedData.expiresAt > Date.now()) {
|
|
59
58
|
return cachedData.fingerprint;
|
|
@@ -164,20 +163,12 @@ async function readFingerprint(
|
|
|
164
163
|
cacheOnly: boolean,
|
|
165
164
|
context?: IdentifyStageContext,
|
|
166
165
|
): 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
166
|
const cachedFingerprint = readCachedFingerprint(context);
|
|
176
167
|
if (cachedFingerprint) {
|
|
177
168
|
return cachedFingerprint;
|
|
178
169
|
}
|
|
179
170
|
|
|
180
|
-
if (cacheOnly || !isFingerprintReady()) {
|
|
171
|
+
if (cacheOnly || !window.fingerprintConfig || !isFingerprintReady()) {
|
|
181
172
|
return '';
|
|
182
173
|
}
|
|
183
174
|
|
|
@@ -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
|
|
|
@@ -100,6 +101,66 @@ describe('Mixpanel SDK version metadata', () => {
|
|
|
100
101
|
expect(people).toHaveBeenCalledWith({});
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
it.each(['getItem', 'setItem'] as const)(
|
|
105
|
+
'keeps the live distinct ID when SDK storage %s fails',
|
|
106
|
+
(method) => {
|
|
107
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('live-id');
|
|
108
|
+
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
|
109
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
110
|
+
});
|
|
111
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('live-id');
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
it.each(['getItem', 'setItem'] as const)(
|
|
116
|
+
'submits the original event when SDK storage %s is blocked',
|
|
117
|
+
async (method) => {
|
|
118
|
+
const send = vi
|
|
119
|
+
.spyOn(transport, '_track_or_batch')
|
|
120
|
+
.mockImplementation(({ data }) => data);
|
|
121
|
+
mixpanel.identify('storage-test-id');
|
|
122
|
+
send.mockClear();
|
|
123
|
+
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
|
124
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
125
|
+
});
|
|
126
|
+
await trackEvent('Storage Failure Test', { source: 'checkout' });
|
|
127
|
+
expect(send.mock.calls.map(([options]) => options.data)).toContainEqual({
|
|
128
|
+
event: 'Storage Failure Test',
|
|
129
|
+
properties: expect.objectContaining({
|
|
130
|
+
source: 'checkout',
|
|
131
|
+
distinct_id: 'storage-test-id',
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
it('returns null when no live distinct ID is available and the cache is blocked', () => {
|
|
138
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
|
139
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
140
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
141
|
+
});
|
|
142
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('uses the cached distinct ID when Mixpanel has no live ID', () => {
|
|
146
|
+
localStorage.setItem('mp_distinct_id', 'cached-id');
|
|
147
|
+
vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('');
|
|
148
|
+
expect(mixpanelService.getMixpanelDistinctId()).toBe('cached-id');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('preserves identify cache diagnostics and people updates on a write failure', () => {
|
|
152
|
+
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
153
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
154
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
155
|
+
});
|
|
156
|
+
vi.spyOn(mixpanel.people, 'set').mockImplementation(() => undefined);
|
|
157
|
+
vi.useFakeTimers();
|
|
158
|
+
const report = vi.fn();
|
|
159
|
+
mixpanelService.identify('user-id', {}, report);
|
|
160
|
+
expect(report).toHaveBeenCalledWith('cache', 'unavailable');
|
|
161
|
+
expect(mixpanel.people.set).toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
103
164
|
it.each([
|
|
104
165
|
1,
|
|
105
166
|
0,
|
package/src/services/mixpanel.ts
CHANGED
|
@@ -57,11 +57,7 @@ function identify(
|
|
|
57
57
|
report?: IdentifyReporter,
|
|
58
58
|
): void {
|
|
59
59
|
mixpanel.identify(userId);
|
|
60
|
-
|
|
61
|
-
updateCachedDistinctId(userId);
|
|
62
|
-
} catch {
|
|
63
|
-
report?.('cache', 'unavailable');
|
|
64
|
-
}
|
|
60
|
+
if (!updateCachedDistinctId(userId)) report?.('cache', 'unavailable');
|
|
65
61
|
if (!report) {
|
|
66
62
|
mixpanel.people.set(properties);
|
|
67
63
|
return;
|
|
@@ -136,26 +132,30 @@ function track(eventName: string, properties: Record<string, unknown>): void {
|
|
|
136
132
|
* @returns {string | null} The distinct ID if available, or null if Mixpanel is not initialized.
|
|
137
133
|
*/
|
|
138
134
|
export const getMixpanelDistinctId = (): string | null => {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
if (mixpanel && mixpanel.get_distinct_id) {
|
|
142
|
-
const distinctId = mixpanel.get_distinct_id();
|
|
143
|
-
|
|
135
|
+
const distinctId = mixpanel?.get_distinct_id?.();
|
|
136
|
+
if (distinctId) {
|
|
144
137
|
updateCachedDistinctId(distinctId);
|
|
145
|
-
|
|
146
138
|
return distinctId;
|
|
147
|
-
} else if (cachedId) {
|
|
148
|
-
return cachedId;
|
|
149
139
|
}
|
|
150
|
-
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
return localStorage.getItem(MIXPANEL_DISTINCT_ID_CACHE_KEY);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
151
146
|
};
|
|
152
147
|
/**
|
|
153
148
|
* Updates the cached Mixpanel distinct ID.
|
|
154
149
|
* Should be called when identifying a user to ensure cache stays in sync.
|
|
155
150
|
* @param {string} distinctId - The distinct ID to cache
|
|
156
151
|
*/
|
|
157
|
-
function updateCachedDistinctId(distinctId: string):
|
|
158
|
-
|
|
152
|
+
function updateCachedDistinctId(distinctId: string): boolean {
|
|
153
|
+
try {
|
|
154
|
+
localStorage.setItem(MIXPANEL_DISTINCT_ID_CACHE_KEY, distinctId);
|
|
155
|
+
return true;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
const mixpanelService = {
|