@reservamos/browser-analytics 1.1.8-alpha.2 → 1.1.8-alpha.3
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 +4 -0
- package/README.md +7 -1
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +8 -8
- package/dist/browser-analytics.esm.js +308 -118
- 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/identify/identify.recovery.test.ts +367 -0
- package/src/events/identify/identify.ts +5 -18
- package/src/init.ts +10 -1
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +18 -2
- package/src/services/mixpanel.batch.test.ts +108 -0
- package/src/services/mixpanel.ts +22 -5
- package/src/services/profileEnrichment.ts +258 -0
package/package.json
CHANGED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { InitConfig } from '@/init';
|
|
2
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
profile: vi.fn(),
|
|
6
|
+
people: vi.fn(),
|
|
7
|
+
restore: vi.fn(),
|
|
8
|
+
identify: vi.fn(),
|
|
9
|
+
user: 'user-a',
|
|
10
|
+
apiBaseUrl: 'https://core.test',
|
|
11
|
+
appName: '',
|
|
12
|
+
fingerprint: null as string | null,
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('@reservamos/js-api-client/core', () => ({
|
|
15
|
+
default: {
|
|
16
|
+
setConfig: vi.fn(),
|
|
17
|
+
profiles: { createAnonymousProfile: mocks.profile },
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@/services/mixpanel', async () => {
|
|
21
|
+
const lifecycle = (await import('@/services/trackingLifecycle')).default;
|
|
22
|
+
return {
|
|
23
|
+
default: {
|
|
24
|
+
init: vi.fn(async () => lifecycle.beginInitialization()),
|
|
25
|
+
validateConfiguration: vi.fn(),
|
|
26
|
+
isReady: () => true,
|
|
27
|
+
getMixpanelDistinctId: () => mocks.user,
|
|
28
|
+
identify: mocks.identify.mockImplementation((user: string) => {
|
|
29
|
+
mocks.user = user;
|
|
30
|
+
lifecycle.identify(user);
|
|
31
|
+
}),
|
|
32
|
+
setPeopleProperties: mocks.people,
|
|
33
|
+
restorePeopleProperties: mocks.restore,
|
|
34
|
+
attachProperty: vi.fn(),
|
|
35
|
+
track: vi.fn(),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
vi.mock('@/services/fingerprint', () => ({
|
|
40
|
+
getFingerprintForIdentify: vi.fn().mockResolvedValue(''),
|
|
41
|
+
default: {
|
|
42
|
+
initFingerprint: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
getCachedFingerprint: () => mocks.fingerprint,
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
vi.mock('@/services/geolocation', () => ({
|
|
47
|
+
default: {
|
|
48
|
+
init: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
getCoordinates: () => null,
|
|
50
|
+
getGeolocationSource: () => null,
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
vi.mock('@/services/config', () => ({
|
|
54
|
+
default: {
|
|
55
|
+
setEnvironment: vi.fn(),
|
|
56
|
+
setAppName: vi.fn(),
|
|
57
|
+
getCoreAPIConfig: () => ({
|
|
58
|
+
baseUrl: mocks.apiBaseUrl,
|
|
59
|
+
defaultHeaders: { 'x-app-name': mocks.appName },
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const config = { mixpanelToken: 'recovery-project', identificationKey: 'test' };
|
|
65
|
+
let init: typeof import('@/init').default;
|
|
66
|
+
let identify: typeof import('./identify').default;
|
|
67
|
+
|
|
68
|
+
async function loadDestination(destinationConfig: InitConfig = config) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
vi.clearAllTimers();
|
|
71
|
+
vi.setSystemTime(now);
|
|
72
|
+
vi.resetModules();
|
|
73
|
+
init = (await import('@/init')).default;
|
|
74
|
+
identify = (await import('./identify')).default;
|
|
75
|
+
await init(destinationConfig);
|
|
76
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
vi.resetModules();
|
|
82
|
+
sessionStorage.clear();
|
|
83
|
+
localStorage.clear();
|
|
84
|
+
mocks.user = 'user-a';
|
|
85
|
+
mocks.apiBaseUrl = 'https://core.test';
|
|
86
|
+
mocks.appName = '';
|
|
87
|
+
mocks.fingerprint = null;
|
|
88
|
+
mocks.profile.mockReset();
|
|
89
|
+
mocks.people
|
|
90
|
+
.mockReset()
|
|
91
|
+
.mockImplementation((_props, _report, accepted?: () => void) =>
|
|
92
|
+
accepted?.(),
|
|
93
|
+
);
|
|
94
|
+
mocks.restore
|
|
95
|
+
.mockReset()
|
|
96
|
+
.mockImplementation((_user, _props, _report, accepted?: () => void) =>
|
|
97
|
+
accepted?.(),
|
|
98
|
+
);
|
|
99
|
+
mocks.identify.mockReset();
|
|
100
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
101
|
+
init = (await import('@/init')).default;
|
|
102
|
+
identify = (await import('./identify')).default;
|
|
103
|
+
await init(config);
|
|
104
|
+
});
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.clearAllTimers();
|
|
107
|
+
vi.useRealTimers();
|
|
108
|
+
vi.restoreAllMocks();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('recovers a lost profile response on destination init without consumer identify', async () => {
|
|
112
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
113
|
+
await identify('user-a', {
|
|
114
|
+
email: 'a@example.com',
|
|
115
|
+
firstName: 'Original name',
|
|
116
|
+
customField: 'Not persisted',
|
|
117
|
+
});
|
|
118
|
+
expect(sessionStorage.length).toBe(1);
|
|
119
|
+
const stored = sessionStorage.getItem(sessionStorage.key(0) || '') || '';
|
|
120
|
+
expect(stored).toContain('a@example.com');
|
|
121
|
+
expect(stored).not.toContain('Not persisted');
|
|
122
|
+
|
|
123
|
+
mocks.profile.mockResolvedValueOnce({ id: 'profile-a' });
|
|
124
|
+
await loadDestination();
|
|
125
|
+
|
|
126
|
+
expect(mocks.profile).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(mocks.restore).toHaveBeenCalledWith(
|
|
128
|
+
'user-a',
|
|
129
|
+
{ reservamos_one_id: 'profile-a' },
|
|
130
|
+
expect.any(Function),
|
|
131
|
+
expect.any(Function),
|
|
132
|
+
);
|
|
133
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(sessionStorage.length).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps a resolved numeric profile ID until people acceptance and drops the contact identifier', async () => {
|
|
138
|
+
mocks.people.mockImplementation(() => undefined);
|
|
139
|
+
mocks.profile.mockResolvedValue({ id: 42 });
|
|
140
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
141
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
142
|
+
const stored = sessionStorage.getItem(sessionStorage.key(0) || '') || '';
|
|
143
|
+
expect(JSON.parse(stored)).toEqual(
|
|
144
|
+
expect.objectContaining({ profileId: 42 }),
|
|
145
|
+
);
|
|
146
|
+
expect(stored).not.toContain('a@example.com');
|
|
147
|
+
await loadDestination();
|
|
148
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(mocks.restore).toHaveBeenCalledWith(
|
|
150
|
+
'user-a',
|
|
151
|
+
{ reservamos_one_id: 42 },
|
|
152
|
+
expect.any(Function),
|
|
153
|
+
expect.any(Function),
|
|
154
|
+
);
|
|
155
|
+
expect(sessionStorage.length).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('does not resume an intent for a different persisted user', async () => {
|
|
159
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
160
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
161
|
+
mocks.user = 'user-b';
|
|
162
|
+
await loadDestination();
|
|
163
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(mocks.restore).not.toHaveBeenCalled();
|
|
165
|
+
expect(sessionStorage.length).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it.each(['project', 'proxy', 'core', 'application'])(
|
|
169
|
+
'does not resume an intent with a different %s',
|
|
170
|
+
async (change) => {
|
|
171
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
172
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
173
|
+
const destination: InitConfig = { ...config };
|
|
174
|
+
if (change === 'project') destination.mixpanelToken = 'other-project';
|
|
175
|
+
if (change === 'proxy')
|
|
176
|
+
destination.mixpanelProxyUrl = 'https://other-proxy.test';
|
|
177
|
+
if (change === 'core') mocks.apiBaseUrl = 'https://other-core.test';
|
|
178
|
+
if (change === 'application') mocks.appName = 'other-app';
|
|
179
|
+
await loadDestination(destination);
|
|
180
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
181
|
+
expect(mocks.restore).not.toHaveBeenCalled();
|
|
182
|
+
expect(sessionStorage.length).toBe(0);
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it('recovers a phone-only profile and preserves its original user identifier', async () => {
|
|
187
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
188
|
+
await identify('user-a', { phone: '+525500000000' });
|
|
189
|
+
mocks.profile.mockResolvedValueOnce({ id: 'phone-profile' });
|
|
190
|
+
await loadDestination();
|
|
191
|
+
expect(mocks.profile.mock.calls[1][0]).toEqual(
|
|
192
|
+
expect.objectContaining({
|
|
193
|
+
identifier_key: 'phone',
|
|
194
|
+
identifier_value: '+525500000000',
|
|
195
|
+
identifiers: [{ key: 'distinct_id', value: 'user-a' }],
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
expect(mocks.restore).toHaveBeenCalledWith(
|
|
199
|
+
'user-a',
|
|
200
|
+
{ reservamos_one_id: 'phone-profile' },
|
|
201
|
+
expect.any(Function),
|
|
202
|
+
expect.any(Function),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('bounds a stalled request without retrying until another initialization', async () => {
|
|
207
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
208
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
209
|
+
await vi.advanceTimersByTimeAsync(3100);
|
|
210
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
211
|
+
expect(sessionStorage.length).toBe(1);
|
|
212
|
+
mocks.profile.mockResolvedValueOnce({ id: 'recovered-profile' });
|
|
213
|
+
await loadDestination();
|
|
214
|
+
expect(mocks.profile).toHaveBeenCalledTimes(2);
|
|
215
|
+
expect(sessionStorage.length).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it.each(['user-a', 'user-b'])(
|
|
219
|
+
'does not let a superseded response overwrite or clear a replacement intent for %s',
|
|
220
|
+
async (nextUser) => {
|
|
221
|
+
let finish!: (profile: { id: string }) => void;
|
|
222
|
+
mocks.profile.mockReturnValueOnce(
|
|
223
|
+
new Promise((resolve) => {
|
|
224
|
+
finish = resolve;
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
await identify('user-a', { email: 'old@example.com' });
|
|
228
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
229
|
+
await identify(nextUser, { email: 'new@example.com' });
|
|
230
|
+
finish({ id: 'old-profile' });
|
|
231
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
232
|
+
expect(
|
|
233
|
+
mocks.people.mock.calls.some(
|
|
234
|
+
([properties]) => properties.reservamos_one_id === 'old-profile',
|
|
235
|
+
),
|
|
236
|
+
).toBe(false);
|
|
237
|
+
const entry = JSON.parse(
|
|
238
|
+
sessionStorage.getItem(sessionStorage.key(0) || '') || '{}',
|
|
239
|
+
);
|
|
240
|
+
expect(entry.userId).toBe(nextUser);
|
|
241
|
+
expect(entry.request.identifier_value).toBe('new@example.com');
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
it('rejects expired recovery work on initialization', async () => {
|
|
246
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
247
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
248
|
+
vi.setSystemTime(Date.now() + 10 * 60 * 1000 + 1);
|
|
249
|
+
await loadDestination();
|
|
250
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(sessionStorage.length).toBe(0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it.each(['{invalid', JSON.stringify({ version: 99 }), 'x'.repeat(4097)])(
|
|
255
|
+
'discards invalid persisted work without requesting a profile',
|
|
256
|
+
async (entry) => {
|
|
257
|
+
sessionStorage.setItem('reservamos_pending_profile_v1', entry);
|
|
258
|
+
await loadDestination();
|
|
259
|
+
expect(mocks.profile).not.toHaveBeenCalled();
|
|
260
|
+
expect(sessionStorage.length).toBe(0);
|
|
261
|
+
expect(mocks.restore).not.toHaveBeenCalled();
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
it('limits failed profile HTTP attempts across page initialization', async () => {
|
|
266
|
+
mocks.profile.mockRejectedValue(new Error('private backend error'));
|
|
267
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
268
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
269
|
+
await loadDestination();
|
|
270
|
+
await loadDestination();
|
|
271
|
+
await loadDestination();
|
|
272
|
+
expect(mocks.profile).toHaveBeenCalledTimes(3);
|
|
273
|
+
expect(sessionStorage.length).toBe(0);
|
|
274
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
275
|
+
'private backend error',
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('preserves normal enrichment when session storage is blocked', async () => {
|
|
280
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
|
|
281
|
+
this: Storage,
|
|
282
|
+
) {
|
|
283
|
+
if (this === sessionStorage)
|
|
284
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
285
|
+
});
|
|
286
|
+
mocks.profile.mockResolvedValue({ id: 'profile-a' });
|
|
287
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
288
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
289
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(mocks.people).toHaveBeenCalledWith(
|
|
291
|
+
{ reservamos_one_id: 'profile-a' },
|
|
292
|
+
expect.any(Function),
|
|
293
|
+
expect.any(Function),
|
|
294
|
+
);
|
|
295
|
+
expect(sessionStorage.length).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('replays the complete original Core request with unchanged details and identifiers', async () => {
|
|
299
|
+
mocks.fingerprint = 'original-fingerprint';
|
|
300
|
+
mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
|
|
301
|
+
await identify('user-a', {
|
|
302
|
+
email: 'new@example.com',
|
|
303
|
+
phone: '+525500000001',
|
|
304
|
+
cpf: '12345678900',
|
|
305
|
+
firstName: 'Original',
|
|
306
|
+
lastName: 'Name',
|
|
307
|
+
});
|
|
308
|
+
const original = mocks.profile.mock.calls[0][0];
|
|
309
|
+
expect(original.details).toEqual({ firstName: 'Original', lastName: 'Name' });
|
|
310
|
+
expect(original.identifiers).toContainEqual({
|
|
311
|
+
key: 'phone',
|
|
312
|
+
value: '+525500000001',
|
|
313
|
+
});
|
|
314
|
+
expect(original.identifiers).toContainEqual({
|
|
315
|
+
key: 'fingerprint',
|
|
316
|
+
value: 'original-fingerprint',
|
|
317
|
+
});
|
|
318
|
+
mocks.fingerprint = 'destination-fingerprint';
|
|
319
|
+
mocks.profile.mockImplementationOnce(async (request) => ({
|
|
320
|
+
id: request.identifiers.some(
|
|
321
|
+
(identifier: { key: string; value: string }) =>
|
|
322
|
+
identifier.key === 'phone' && identifier.value === '+525500000001',
|
|
323
|
+
)
|
|
324
|
+
? 'strong-phone-profile'
|
|
325
|
+
: 'weak-user-profile',
|
|
326
|
+
}));
|
|
327
|
+
await loadDestination();
|
|
328
|
+
expect(mocks.profile.mock.calls[1][0]).toEqual(original);
|
|
329
|
+
expect(mocks.restore).toHaveBeenCalledWith(
|
|
330
|
+
'user-a',
|
|
331
|
+
{ reservamos_one_id: 'strong-phone-profile' },
|
|
332
|
+
expect.any(Function),
|
|
333
|
+
expect.any(Function),
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('removes superseded recovery intent when replacing its storage record fails', async () => {
|
|
338
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
339
|
+
await identify('user-a', { email: 'old@example.com' });
|
|
340
|
+
const setItem = Storage.prototype.setItem;
|
|
341
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
|
|
342
|
+
this: Storage,
|
|
343
|
+
key,
|
|
344
|
+
value,
|
|
345
|
+
) {
|
|
346
|
+
if (this === sessionStorage) throw new Error('write unavailable');
|
|
347
|
+
return setItem.call(this, key, value);
|
|
348
|
+
});
|
|
349
|
+
await identify('user-a', { email: 'new@example.com' });
|
|
350
|
+
expect(sessionStorage.length).toBe(0);
|
|
351
|
+
await loadDestination();
|
|
352
|
+
expect(mocks.profile).toHaveBeenCalledTimes(2);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('does not retry when the attempt count cannot be persisted', async () => {
|
|
356
|
+
mocks.profile.mockReturnValue(new Promise(() => undefined));
|
|
357
|
+
await identify('user-a', { email: 'a@example.com' });
|
|
358
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
359
|
+
throw new Error('blocked');
|
|
360
|
+
});
|
|
361
|
+
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
|
362
|
+
throw new Error('blocked');
|
|
363
|
+
});
|
|
364
|
+
await loadDestination();
|
|
365
|
+
await loadDestination();
|
|
366
|
+
expect(mocks.profile).toHaveBeenCalledTimes(1);
|
|
367
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { IdentifyReporter } from '@/util/identifyAttempt';
|
|
2
|
-
import { createAnonymousProfileForIdentify } from '@/profiles/createAnonymousProfile/createAnonymousProfile';
|
|
3
2
|
import configService from '@/services/config';
|
|
4
3
|
import { getFingerprintForIdentify } from '@/services/fingerprint';
|
|
5
4
|
import geolocationService from '@/services/geolocation';
|
|
6
5
|
import mixpanelService from '@/services/mixpanel';
|
|
6
|
+
import profileEnrichment from '@/services/profileEnrichment';
|
|
7
7
|
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
8
8
|
import validatorService from '@/services/validator';
|
|
9
9
|
import {
|
|
@@ -120,13 +120,10 @@ async function enrichProfileAndLocation(
|
|
|
120
120
|
isActive: () => boolean,
|
|
121
121
|
report: IdentifyReporter,
|
|
122
122
|
): Promise<void> {
|
|
123
|
-
const profile = await
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
isActive()
|
|
128
|
-
? createAnonymousProfileForIdentify({ ...properties }, context.report)
|
|
129
|
-
: Promise.resolve(undefined),
|
|
123
|
+
const profile = await profileEnrichment.enrich(
|
|
124
|
+
userId,
|
|
125
|
+
properties,
|
|
126
|
+
isActive,
|
|
130
127
|
report,
|
|
131
128
|
);
|
|
132
129
|
if (!isActive()) return;
|
|
@@ -134,16 +131,6 @@ async function enrichProfileAndLocation(
|
|
|
134
131
|
...properties,
|
|
135
132
|
...(profile && { reservamos_one_id: profile.id }),
|
|
136
133
|
};
|
|
137
|
-
if (profile) {
|
|
138
|
-
try {
|
|
139
|
-
mixpanelService.setPeopleProperties(
|
|
140
|
-
{ reservamos_one_id: profile.id },
|
|
141
|
-
report,
|
|
142
|
-
);
|
|
143
|
-
} catch {
|
|
144
|
-
report('profile', 'failed');
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
134
|
void identifyWithLocation(userId, enriched, report);
|
|
148
135
|
}
|
|
149
136
|
|
package/src/init.ts
CHANGED
|
@@ -4,6 +4,7 @@ import configService from '@/services/config';
|
|
|
4
4
|
import fingerprintService from '@/services/fingerprint';
|
|
5
5
|
import geolocationService from '@/services/geolocation';
|
|
6
6
|
import mixpanelService from '@/services/mixpanel';
|
|
7
|
+
import profileEnrichment from '@/services/profileEnrichment';
|
|
7
8
|
import validator, { InitConfigSchema } from '@/services/validator';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -51,12 +52,20 @@ async function init(config: InitConfig) {
|
|
|
51
52
|
if (appName) {
|
|
52
53
|
configService.setAppName(appName);
|
|
53
54
|
}
|
|
54
|
-
|
|
55
|
+
const coreConfig = configService.getCoreAPIConfig();
|
|
56
|
+
coreApi.setConfig(coreConfig);
|
|
55
57
|
|
|
56
58
|
// Dispatch the 'Tracker Ready' event
|
|
57
59
|
onLoaded();
|
|
58
60
|
if (!isCurrent()) return;
|
|
59
61
|
|
|
62
|
+
profileEnrichment.initialize({
|
|
63
|
+
project: mixpanelToken,
|
|
64
|
+
proxy: mixpanelProxyUrl,
|
|
65
|
+
apiBaseUrl: coreConfig.baseUrl,
|
|
66
|
+
appName: coreConfig.defaultHeaders?.['x-app-name'],
|
|
67
|
+
});
|
|
68
|
+
|
|
60
69
|
await Promise.all([
|
|
61
70
|
initializeOptionalStage('fingerprint', () =>
|
|
62
71
|
isCurrent()
|
|
@@ -87,6 +87,16 @@ async function requestAnonymousProfile(
|
|
|
87
87
|
payload: CreateAnonymousProfileProps,
|
|
88
88
|
report?: IdentifyReporter,
|
|
89
89
|
): Promise<AnonymousProfile | undefined> {
|
|
90
|
+
return requestPreparedAnonymousProfile(
|
|
91
|
+
prepareAnonymousProfileForIdentify(payload, report),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Captures exactly the normalized Core request, including its original identifiers. */
|
|
96
|
+
export function prepareAnonymousProfileForIdentify(
|
|
97
|
+
payload: CreateAnonymousProfileProps,
|
|
98
|
+
report?: IdentifyReporter,
|
|
99
|
+
): CreateAnonymousProfilePayload {
|
|
90
100
|
validatorService.validateProps(payload, CreateAnonymousProfileSchema);
|
|
91
101
|
|
|
92
102
|
const identifiers: AnonymousIdentifier[] = [];
|
|
@@ -108,8 +118,14 @@ async function requestAnonymousProfile(
|
|
|
108
118
|
identifiers.push({ key: 'fingerprint', value: userFingerprintId });
|
|
109
119
|
if (distinctId) identifiers.push({ key: 'distinct_id', value: distinctId });
|
|
110
120
|
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
return getAnonymousProfilePayload(payload, identifiers);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Sends a captured request without rebuilding identifiers from destination state. */
|
|
125
|
+
export function requestPreparedAnonymousProfile(
|
|
126
|
+
payload: CreateAnonymousProfilePayload,
|
|
127
|
+
): Promise<AnonymousProfile> {
|
|
128
|
+
return coreApi.profiles.createAnonymousProfile(payload);
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
export default createAnonymousProfile;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import mixpanel from 'mixpanel-browser';
|
|
2
|
+
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
|
|
3
|
+
import mixpanelService from './mixpanel';
|
|
4
|
+
|
|
5
|
+
interface BatchPayload {
|
|
6
|
+
event?: string;
|
|
7
|
+
properties?: Record<string, unknown>;
|
|
8
|
+
$distinct_id?: string;
|
|
9
|
+
$set?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Keep the real identity handling, batching, storage, and timers; replace HTTP only.
|
|
13
|
+
const transport = mixpanel as unknown as {
|
|
14
|
+
_send_request(
|
|
15
|
+
url: string,
|
|
16
|
+
data: { data: string },
|
|
17
|
+
options: object,
|
|
18
|
+
callback?: (response: number) => void,
|
|
19
|
+
): boolean;
|
|
20
|
+
stop_batch_senders(): void;
|
|
21
|
+
_flags: { identify_called: boolean };
|
|
22
|
+
};
|
|
23
|
+
const batches: { url: string; payload: BatchPayload[] }[] = [];
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
localStorage.clear();
|
|
28
|
+
await mixpanelService.init('one-second-batching-token');
|
|
29
|
+
vi.spyOn(transport, '_send_request').mockImplementation(
|
|
30
|
+
(url, data, _options, callback) => {
|
|
31
|
+
if (url.endsWith('/track/') || url.endsWith('/engage/')) {
|
|
32
|
+
batches.push({
|
|
33
|
+
url,
|
|
34
|
+
payload: JSON.parse(data.data) as BatchPayload[],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
callback?.(1);
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
transport.stop_batch_senders();
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
vi.clearAllTimers();
|
|
47
|
+
vi.useRealTimers();
|
|
48
|
+
localStorage.clear();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('batches native identity and people updates within one second', async () => {
|
|
52
|
+
mixpanelService.identify('redirect-user', { $email: 'redirect@example.com' });
|
|
53
|
+
|
|
54
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
55
|
+
expect(batches).toEqual([]);
|
|
56
|
+
|
|
57
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
58
|
+
expect(batches).toHaveLength(2);
|
|
59
|
+
expect(batches.find(({ url }) => url.endsWith('/track/'))?.payload).toEqual([
|
|
60
|
+
expect.objectContaining({
|
|
61
|
+
event: '$identify',
|
|
62
|
+
properties: expect.objectContaining({ distinct_id: 'redirect-user' }),
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
expect(
|
|
66
|
+
batches.find(({ url }) => url.endsWith('/engage/'))?.payload,
|
|
67
|
+
).toContainEqual(
|
|
68
|
+
expect.objectContaining({
|
|
69
|
+
$distinct_id: 'redirect-user',
|
|
70
|
+
$set: expect.objectContaining({ $email: 'redirect@example.com' }),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('restores people readiness for the unchanged user without another native identity event', async () => {
|
|
76
|
+
mixpanelService.identify('restored-user', {});
|
|
77
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
78
|
+
batches.length = 0;
|
|
79
|
+
transport._flags.identify_called = false;
|
|
80
|
+
const accepted = vi.fn();
|
|
81
|
+
const report = vi.fn();
|
|
82
|
+
|
|
83
|
+
mixpanelService.restorePeopleProperties(
|
|
84
|
+
'other-user',
|
|
85
|
+
{ reservamos_one_id: 'wrong' },
|
|
86
|
+
report,
|
|
87
|
+
accepted,
|
|
88
|
+
);
|
|
89
|
+
expect(accepted).not.toHaveBeenCalled();
|
|
90
|
+
|
|
91
|
+
mixpanelService.restorePeopleProperties(
|
|
92
|
+
'restored-user',
|
|
93
|
+
{ reservamos_one_id: 42 },
|
|
94
|
+
report,
|
|
95
|
+
accepted,
|
|
96
|
+
);
|
|
97
|
+
expect(accepted).toHaveBeenCalledTimes(1);
|
|
98
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
99
|
+
expect(batches).toHaveLength(1);
|
|
100
|
+
expect(batches[0].url).toContain('/engage/');
|
|
101
|
+
expect(batches[0].payload).toContainEqual(
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
$distinct_id: 'restored-user',
|
|
104
|
+
$set: expect.objectContaining({ reservamos_one_id: 42 }),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
expect(report).not.toHaveBeenCalled();
|
|
108
|
+
});
|
package/src/services/mixpanel.ts
CHANGED
|
@@ -59,6 +59,7 @@ function init(
|
|
|
59
59
|
return new Promise<void>((resolve) => {
|
|
60
60
|
mixpanel.init(mixpanelToken, {
|
|
61
61
|
debug,
|
|
62
|
+
batch_flush_interval_ms: 1000,
|
|
62
63
|
loaded: () => {
|
|
63
64
|
if (attempt !== initialization) {
|
|
64
65
|
resolve();
|
|
@@ -131,32 +132,47 @@ function attachProperty(key: string, value: unknown): void {
|
|
|
131
132
|
function setPeopleProperties(
|
|
132
133
|
properties: Record<string, unknown>,
|
|
133
134
|
report?: IdentifyReporter,
|
|
135
|
+
onAccepted?: () => void,
|
|
134
136
|
): void {
|
|
135
137
|
if (!isReady()) {
|
|
136
138
|
return;
|
|
137
139
|
}
|
|
138
140
|
|
|
139
|
-
writePeopleProperties(properties, report);
|
|
141
|
+
writePeopleProperties(properties, report, onAccepted);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Restores people readiness only when the native persisted identity still matches. */
|
|
145
|
+
function restorePeopleProperties(
|
|
146
|
+
userId: string,
|
|
147
|
+
properties: Record<string, unknown>,
|
|
148
|
+
report: IdentifyReporter,
|
|
149
|
+
onAccepted: () => void,
|
|
150
|
+
): void {
|
|
151
|
+
if (!isReady() || mixpanel.get_distinct_id() !== userId) return;
|
|
152
|
+
mixpanel.identify(userId);
|
|
153
|
+
writePeopleProperties(properties, report, onAccepted);
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
function writePeopleProperties(
|
|
143
157
|
properties: Record<string, unknown>,
|
|
144
158
|
report?: IdentifyReporter,
|
|
159
|
+
onAccepted?: () => void,
|
|
145
160
|
): void {
|
|
146
|
-
if (!report) {
|
|
161
|
+
if (!report && !onAccepted) {
|
|
147
162
|
mixpanel.people.set(properties);
|
|
148
163
|
return;
|
|
149
164
|
}
|
|
150
|
-
const observer = observeIdentifyResult('people', report);
|
|
165
|
+
const observer = report ? observeIdentifyResult('people', report) : undefined;
|
|
151
166
|
try {
|
|
152
167
|
mixpanel.people.set(properties, (response) => {
|
|
153
168
|
const status = typeof response === 'number' ? response : response?.status;
|
|
154
|
-
|
|
169
|
+
if (status === 1) onAccepted?.();
|
|
170
|
+
observer?.complete(
|
|
155
171
|
status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
|
|
156
172
|
);
|
|
157
173
|
});
|
|
158
174
|
} catch {
|
|
159
|
-
observer
|
|
175
|
+
observer?.complete('failed');
|
|
160
176
|
}
|
|
161
177
|
}
|
|
162
178
|
|
|
@@ -224,6 +240,7 @@ const mixpanelService = {
|
|
|
224
240
|
identify,
|
|
225
241
|
attachProperty,
|
|
226
242
|
setPeopleProperties,
|
|
243
|
+
restorePeopleProperties,
|
|
227
244
|
getMixpanelDistinctId,
|
|
228
245
|
};
|
|
229
246
|
|