@reservamos/browser-analytics 1.1.8-alpha.1 → 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.
Files changed (30) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/README.md +32 -4
  3. package/dist/browser-analytics.cjs +2 -2
  4. package/dist/browser-analytics.cjs.map +1 -1
  5. package/dist/browser-analytics.d.ts +23 -10
  6. package/dist/browser-analytics.esm.js +466 -154
  7. package/dist/browser-analytics.esm.js.map +1 -1
  8. package/dist/browser-analytics.iife.js +2 -2
  9. package/dist/browser-analytics.iife.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/events/identify/identify.recovery.test.ts +367 -0
  12. package/src/events/identify/identify.robustness.test.ts +112 -13
  13. package/src/events/identify/identify.ts +72 -35
  14. package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
  15. package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
  16. package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
  17. package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
  18. package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
  19. package/src/index.ts +1 -0
  20. package/src/init.ts +10 -1
  21. package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +18 -2
  22. package/src/services/botDetectionRelay.test.ts +3 -2
  23. package/src/services/fingerprint.test.ts +172 -0
  24. package/src/services/fingerprint.ts +106 -5
  25. package/src/services/fingerprint.wait.integration.test.ts +129 -0
  26. package/src/services/mixpanel.batch.test.ts +108 -0
  27. package/src/services/mixpanel.test.ts +77 -73
  28. package/src/services/mixpanel.ts +45 -18
  29. package/src/services/profileEnrichment.ts +258 -0
  30. package/src/track.storage.test.ts +27 -12
@@ -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();
@@ -105,21 +106,7 @@ function identify(
105
106
  trackingLifecycle.identify(userId);
106
107
  mixpanel.identify(userId);
107
108
  if (!updateCachedDistinctId(userId)) report?.('cache', 'unavailable');
108
- if (!report) {
109
- mixpanel.people.set(properties);
110
- return;
111
- }
112
- const observer = observeIdentifyResult('people', report);
113
- try {
114
- mixpanel.people.set(properties, (response) => {
115
- const status = typeof response === 'number' ? response : response?.status;
116
- observer.complete(
117
- status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
118
- );
119
- });
120
- } catch {
121
- observer.complete('failed');
122
- }
109
+ writePeopleProperties(properties, report);
123
110
  }
124
111
 
125
112
  /**
@@ -142,12 +129,51 @@ function attachProperty(key: string, value: unknown): void {
142
129
  * Sets people properties for the current Mixpanel user without changing their distinct ID.
143
130
  * @param {Record<string, unknown>} properties - Profile properties to set on the current user.
144
131
  */
145
- function setPeopleProperties(properties: Record<string, unknown>): void {
132
+ function setPeopleProperties(
133
+ properties: Record<string, unknown>,
134
+ report?: IdentifyReporter,
135
+ onAccepted?: () => void,
136
+ ): void {
146
137
  if (!isReady()) {
147
138
  return;
148
139
  }
149
140
 
150
- mixpanel.people.set(properties);
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);
154
+ }
155
+
156
+ function writePeopleProperties(
157
+ properties: Record<string, unknown>,
158
+ report?: IdentifyReporter,
159
+ onAccepted?: () => void,
160
+ ): void {
161
+ if (!report && !onAccepted) {
162
+ mixpanel.people.set(properties);
163
+ return;
164
+ }
165
+ const observer = report ? observeIdentifyResult('people', report) : undefined;
166
+ try {
167
+ mixpanel.people.set(properties, (response) => {
168
+ const status = typeof response === 'number' ? response : response?.status;
169
+ if (status === 1) onAccepted?.();
170
+ observer?.complete(
171
+ status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
172
+ );
173
+ });
174
+ } catch {
175
+ observer?.complete('failed');
176
+ }
151
177
  }
152
178
 
153
179
  /**
@@ -178,7 +204,7 @@ function track(eventName: string, properties: Record<string, unknown>): void {
178
204
  * @returns {string | null} The live or cached distinct ID, or null when neither is available.
179
205
  */
180
206
  export const getMixpanelDistinctId = (): string | null => {
181
- const distinctId = mixpanel?.get_distinct_id?.();
207
+ const distinctId = isReady() ? mixpanel.get_distinct_id() : null;
182
208
  if (distinctId) {
183
209
  updateCachedDistinctId(distinctId);
184
210
  return distinctId;
@@ -214,6 +240,7 @@ const mixpanelService = {
214
240
  identify,
215
241
  attachProperty,
216
242
  setPeopleProperties,
243
+ restorePeopleProperties,
217
244
  getMixpanelDistinctId,
218
245
  };
219
246
 
@@ -0,0 +1,258 @@
1
+ import type { IdentifyReporter } from '@/util/identifyAttempt';
2
+ import { z } from 'zod';
3
+ import {
4
+ createAnonymousProfileForIdentify,
5
+ prepareAnonymousProfileForIdentify,
6
+ requestPreparedAnonymousProfile,
7
+ } from '@/profiles/createAnonymousProfile/createAnonymousProfile';
8
+ import mixpanelService from '@/services/mixpanel';
9
+ import trackingLifecycle from '@/services/trackingLifecycle';
10
+ import {
11
+ createIdentifyReporter,
12
+ waitForIdentifyStage,
13
+ } from '@/util/identifyAttempt';
14
+
15
+ const STORAGE_KEY = 'reservamos_pending_profile_v1';
16
+ const LIFETIME_MS = 10 * 60 * 1000;
17
+ const MAX_ATTEMPTS = 3;
18
+ const MAX_BYTES = 4096;
19
+ const profileIdSchema = z.union([
20
+ z.string().min(1),
21
+ z.number().int().positive(),
22
+ ]);
23
+ const requestSchema = z
24
+ .object({
25
+ identifier_key: z.enum(['email', 'phone']),
26
+ identifier_value: z.string().min(1),
27
+ identifiers: z.array(
28
+ z.object({ key: z.string().min(1), value: z.string().min(1) }).strict(),
29
+ ),
30
+ details: z.record(z.string()),
31
+ })
32
+ .strict();
33
+ const pendingSchema = z
34
+ .object({
35
+ version: z.literal(1),
36
+ id: z.string().min(1),
37
+ userId: z.string().min(1),
38
+ scope: z.string().min(1),
39
+ expiresAt: z.number().int(),
40
+ attempts: z.number().int().min(1).max(MAX_ATTEMPTS),
41
+ request: requestSchema.optional(),
42
+ profileId: profileIdSchema.optional(),
43
+ })
44
+ .strict()
45
+ .refine((entry) => entry.request || entry.profileId);
46
+ type PendingProfile = z.infer<typeof pendingSchema>;
47
+ interface ProfileLink {
48
+ id: z.infer<typeof profileIdSchema>;
49
+ }
50
+ interface RecoveryScope {
51
+ project: string;
52
+ proxy?: string;
53
+ apiBaseUrl: string;
54
+ appName?: string;
55
+ }
56
+ let scope: string | undefined;
57
+ let generation = 0;
58
+
59
+ function remove(report: IdentifyReporter, id?: string): void {
60
+ try {
61
+ if (id) {
62
+ const stored = JSON.parse(
63
+ sessionStorage.getItem(STORAGE_KEY) || 'null',
64
+ ) as PendingProfile | null;
65
+ if (stored?.id !== id) return;
66
+ }
67
+ sessionStorage.removeItem(STORAGE_KEY);
68
+ } catch {
69
+ report('cache', 'unavailable');
70
+ }
71
+ }
72
+
73
+ function save(entry: PendingProfile, report: IdentifyReporter): boolean {
74
+ try {
75
+ const serialized = JSON.stringify(entry);
76
+ if (new Blob([serialized]).size > MAX_BYTES) {
77
+ remove(report);
78
+ report('cache', 'unavailable');
79
+ return false;
80
+ }
81
+ sessionStorage.setItem(STORAGE_KEY, serialized);
82
+ return true;
83
+ } catch {
84
+ remove(report);
85
+ report('cache', 'unavailable');
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function read(report: IdentifyReporter): PendingProfile | undefined {
91
+ try {
92
+ const serialized = sessionStorage.getItem(STORAGE_KEY);
93
+ if (!serialized) return undefined;
94
+ if (new Blob([serialized]).size > MAX_BYTES) throw new Error();
95
+ const entry = pendingSchema.parse(JSON.parse(serialized));
96
+ if (
97
+ entry.expiresAt <= Date.now() ||
98
+ entry.expiresAt > Date.now() + LIFETIME_MS
99
+ ) {
100
+ remove(report);
101
+ return undefined;
102
+ }
103
+ return entry;
104
+ } catch {
105
+ remove(report);
106
+ report('cache', 'unavailable');
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ function currentUserIs(userId: string): boolean {
112
+ return (
113
+ mixpanelService.isReady() &&
114
+ mixpanelService.getMixpanelDistinctId() === userId
115
+ );
116
+ }
117
+
118
+ async function resolveProfile(
119
+ userId: string,
120
+ properties: Record<string, string | number | boolean | undefined>,
121
+ isCurrent: () => boolean,
122
+ report: IdentifyReporter,
123
+ entry?: PendingProfile,
124
+ recovering = false,
125
+ ): Promise<ProfileLink | undefined> {
126
+ if (!isCurrent()) return undefined;
127
+ const profile =
128
+ entry?.profileId !== undefined
129
+ ? { id: entry.profileId }
130
+ : await waitForIdentifyStage(
131
+ 'profile',
132
+ 3000,
133
+ (context) =>
134
+ isCurrent()
135
+ ? entry?.request
136
+ ? requestPreparedAnonymousProfile(entry.request)
137
+ : createAnonymousProfileForIdentify(
138
+ { ...properties },
139
+ context.report,
140
+ )
141
+ : Promise.resolve(undefined),
142
+ report,
143
+ );
144
+ if (!isCurrent()) return undefined;
145
+ if (!profile) {
146
+ if (entry && entry.attempts >= MAX_ATTEMPTS) remove(report, entry.id);
147
+ return undefined;
148
+ }
149
+ const parsedId = profileIdSchema.safeParse(profile.id);
150
+ if (!parsedId.success) {
151
+ report('profile', 'failed');
152
+ return undefined;
153
+ }
154
+ const profileId = parsedId.data;
155
+ if (entry) save({ ...entry, request: undefined, profileId }, report);
156
+ const accepted = () => {
157
+ if (entry && isCurrent()) remove(report, entry.id);
158
+ };
159
+ try {
160
+ const properties = { reservamos_one_id: profileId };
161
+ if (recovering) {
162
+ mixpanelService.restorePeopleProperties(
163
+ userId,
164
+ properties,
165
+ report,
166
+ accepted,
167
+ );
168
+ } else if (entry) {
169
+ mixpanelService.setPeopleProperties(properties, report, accepted);
170
+ } else {
171
+ mixpanelService.setPeopleProperties(properties, report);
172
+ }
173
+ } catch {
174
+ report('profile', 'failed');
175
+ }
176
+ return { id: profileId };
177
+ }
178
+
179
+ /** Records the original normalized request while preserving the ordinary in-memory enrichment. */
180
+ function enrich(
181
+ userId: string,
182
+ properties: Record<string, string | number | boolean | undefined>,
183
+ isActive: () => boolean,
184
+ report: IdentifyReporter,
185
+ ): Promise<ProfileLink | undefined> {
186
+ if (!isActive()) return Promise.resolve(undefined);
187
+ const attempt = ++generation;
188
+ let request: z.infer<typeof requestSchema> | undefined;
189
+ if (scope) {
190
+ try {
191
+ const prepared = requestSchema.safeParse(
192
+ prepareAnonymousProfileForIdentify({ ...properties }, report),
193
+ );
194
+ if (prepared.success) request = prepared.data;
195
+ } catch {
196
+ // The ordinary bounded request below retains its existing validation handling.
197
+ }
198
+ }
199
+ const entry: PendingProfile | undefined =
200
+ scope && request
201
+ ? {
202
+ version: 1,
203
+ id: `${Date.now()}-${Math.random()}`,
204
+ userId,
205
+ scope,
206
+ expiresAt: Date.now() + LIFETIME_MS,
207
+ attempts: 1,
208
+ request,
209
+ }
210
+ : undefined;
211
+ if (entry) save(entry, report);
212
+ else if (scope) remove(report);
213
+ const isCurrent = () =>
214
+ attempt === generation &&
215
+ isActive() &&
216
+ (!entry || Date.now() < entry.expiresAt) &&
217
+ (!scope || currentUserIs(userId));
218
+ return resolveProfile(userId, properties, isCurrent, report, entry);
219
+ }
220
+
221
+ /** Resumes only matching tab-local work after Mixpanel and Core configuration are ready. */
222
+ function initialize(configuration: RecoveryScope): void {
223
+ scope = JSON.stringify([
224
+ configuration.project,
225
+ configuration.proxy ?? '',
226
+ configuration.apiBaseUrl,
227
+ configuration.appName ?? '',
228
+ ]);
229
+ const attempt = ++generation;
230
+ const report = createIdentifyReporter();
231
+ const entry = read(report);
232
+ if (!entry) return;
233
+ if (
234
+ entry.scope !== scope ||
235
+ !currentUserIs(entry.userId) ||
236
+ (entry.profileId === undefined && entry.attempts >= MAX_ATTEMPTS)
237
+ ) {
238
+ remove(report, entry.id);
239
+ return;
240
+ }
241
+ const lifecycle = trackingLifecycle.capture();
242
+ const isCurrent = () =>
243
+ attempt === generation &&
244
+ lifecycle() &&
245
+ Date.now() < entry.expiresAt &&
246
+ currentUserIs(entry.userId);
247
+ const resumed = {
248
+ ...entry,
249
+ attempts: entry.attempts + (entry.profileId === undefined ? 1 : 0),
250
+ };
251
+ if (entry.profileId === undefined && !save(resumed, report)) return;
252
+ const scopedReport: IdentifyReporter = (...args) => {
253
+ if (isCurrent()) report(...args);
254
+ };
255
+ void resolveProfile(entry.userId, {}, isCurrent, scopedReport, resumed, true);
256
+ }
257
+
258
+ export default { enrich, initialize };
@@ -1,4 +1,5 @@
1
1
  import coreApi from '@reservamos/js-api-client/core';
2
+ import mixpanel from 'mixpanel-browser';
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import fingerprintService from '@/services/fingerprint';
4
5
  import mixpanelService from '@/services/mixpanel';
@@ -10,7 +11,22 @@ const { getVisitorData, init, client } = vi.hoisted(() => ({
10
11
  client: vi.fn(),
11
12
  }));
12
13
 
13
- // Keep the SDK Fingerprint cache, tracking, validation, and relay real.
14
+ const { provider } = vi.hoisted(() => ({
15
+ provider: {
16
+ init: vi.fn((_token: string, config: { loaded: () => void }) =>
17
+ config.loaded(),
18
+ ),
19
+ register: vi.fn(),
20
+ set_config: vi.fn(),
21
+ identify: vi.fn(),
22
+ track: vi.fn(),
23
+ get_distinct_id: vi.fn(() => 'live-id'),
24
+ people: { set: vi.fn(), union: vi.fn() },
25
+ },
26
+ }));
27
+ vi.mock('mixpanel-browser', () => ({ default: provider }));
28
+
29
+ // Keep the SDK Fingerprint cache, Mixpanel service, tracking, validation, and relay real.
14
30
  vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
15
31
  FingerprintJSPro: {
16
32
  defaultEndpoint: 'https://fpjscdn.net',
@@ -57,6 +73,7 @@ const checkout = {
57
73
  describe('tracking with blocked SDK Fingerprint cache writes', () => {
58
74
  beforeEach(async () => {
59
75
  localStorage.clear();
76
+ await mixpanelService.init('test-storage-token');
60
77
  client.mockImplementation(() => ({ init, getVisitorData }));
61
78
  init.mockResolvedValue(undefined);
62
79
  getVisitorData.mockResolvedValue({
@@ -67,14 +84,8 @@ describe('tracking with blocked SDK Fingerprint cache writes', () => {
67
84
  cacheHit: false,
68
85
  });
69
86
  await fingerprintService.initFingerprint('test-api-key');
70
- vi.spyOn(mixpanelService, 'isReady').mockReturnValue(true);
71
- vi.spyOn(mixpanelService, 'getMixpanelDistinctId').mockReturnValue(
72
- 'live-id',
73
- );
74
- vi.spyOn(mixpanelService, 'track').mockImplementation(() => undefined);
75
- vi.spyOn(mixpanelService, 'setPeopleProperties').mockImplementation(
76
- () => undefined,
77
- );
87
+ vi.spyOn(mixpanel, 'get_distinct_id').mockReturnValue('live-id');
88
+ vi.spyOn(mixpanel.people, 'set').mockImplementation(() => undefined);
78
89
  vi.spyOn(coreApi.fingerprint, 'sendObservation').mockResolvedValue(true);
79
90
  vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
80
91
  throw new DOMException('Blocked', 'SecurityError');
@@ -96,16 +107,20 @@ describe('tracking with blocked SDK Fingerprint cache writes', () => {
96
107
  async (eventName, properties, eligible) => {
97
108
  await trackEvent(eventName, properties);
98
109
 
99
- expect(mixpanelService.track).toHaveBeenCalledOnce();
100
- expect(mixpanelService.track).toHaveBeenCalledWith(
110
+ expect(mixpanel.track).toHaveBeenCalledOnce();
111
+ expect(mixpanel.track).toHaveBeenCalledWith(
101
112
  eventName,
102
113
  expect.objectContaining({
103
114
  'User Fingerprint': expect.any(String),
104
115
  'product': 'web',
105
116
  }),
106
117
  );
118
+ /**
119
+ * Checks effects that may finish after event submission during background refresh.
120
+ * @returns {void} Throws until the expected profile and relay effects are observed.
121
+ */
107
122
  const verifyIdentificationEffects = () => {
108
- expect(mixpanelService.setPeopleProperties).toHaveBeenCalledWith({
123
+ expect(mixpanel.people.set).toHaveBeenCalledWith({
109
124
  'Visitor Found': true,
110
125
  });
111
126
  expect(coreApi.fingerprint.sendObservation).toHaveBeenCalledTimes(