@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.
- package/CHANGELOG.md +19 -2
- package/README.md +32 -4
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +23 -10
- package/dist/browser-analytics.esm.js +466 -154
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +2 -2
- package/src/events/identify/identify.recovery.test.ts +367 -0
- package/src/events/identify/identify.robustness.test.ts +112 -13
- package/src/events/identify/identify.ts +72 -35
- package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
- package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
- package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
- package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
- package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
- package/src/index.ts +1 -0
- package/src/init.ts +10 -1
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +18 -2
- package/src/services/botDetectionRelay.test.ts +3 -2
- package/src/services/fingerprint.test.ts +172 -0
- package/src/services/fingerprint.ts +106 -5
- package/src/services/fingerprint.wait.integration.test.ts +129 -0
- package/src/services/mixpanel.batch.test.ts +108 -0
- package/src/services/mixpanel.test.ts +77 -73
- package/src/services/mixpanel.ts +45 -18
- package/src/services/profileEnrichment.ts +258 -0
- package/src/track.storage.test.ts +27 -12
|
@@ -1,9 +1,10 @@
|
|
|
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
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
7
8
|
import validatorService from '@/services/validator';
|
|
8
9
|
import {
|
|
9
10
|
createIdentifyReporter,
|
|
@@ -15,6 +16,7 @@ import { DefaultProperties } from './identifySchema';
|
|
|
15
16
|
|
|
16
17
|
const KNOWN_PROPERTIES = ['firstName', 'lastName', 'email', 'phone'];
|
|
17
18
|
const FINGERPRINT_PROPERTY = 'Known Fingerprints';
|
|
19
|
+
let latestAttempt = 0;
|
|
18
20
|
|
|
19
21
|
export interface UserProperties
|
|
20
22
|
extends DefaultProperties,
|
|
@@ -112,21 +114,74 @@ const identifyWithLocation = async (
|
|
|
112
114
|
}
|
|
113
115
|
};
|
|
114
116
|
|
|
117
|
+
async function enrichProfileAndLocation(
|
|
118
|
+
userId: string,
|
|
119
|
+
properties: UserProperties,
|
|
120
|
+
isActive: () => boolean,
|
|
121
|
+
report: IdentifyReporter,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const profile = await profileEnrichment.enrich(
|
|
124
|
+
userId,
|
|
125
|
+
properties,
|
|
126
|
+
isActive,
|
|
127
|
+
report,
|
|
128
|
+
);
|
|
129
|
+
if (!isActive()) return;
|
|
130
|
+
const enriched = {
|
|
131
|
+
...properties,
|
|
132
|
+
...(profile && { reservamos_one_id: profile.id }),
|
|
133
|
+
};
|
|
134
|
+
void identifyWithLocation(userId, enriched, report);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function enrichFingerprint(
|
|
138
|
+
isActive: () => boolean,
|
|
139
|
+
report: IdentifyReporter,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const fingerprint = await waitForIdentifyStage(
|
|
142
|
+
'fingerprint',
|
|
143
|
+
2000,
|
|
144
|
+
(context) =>
|
|
145
|
+
!isActive()
|
|
146
|
+
? Promise.resolve('')
|
|
147
|
+
: getFingerprintForIdentify({
|
|
148
|
+
isActive: () => context.isActive() && isActive(),
|
|
149
|
+
report: context.report,
|
|
150
|
+
}),
|
|
151
|
+
report,
|
|
152
|
+
);
|
|
153
|
+
if (!fingerprint || !isActive()) return;
|
|
154
|
+
try {
|
|
155
|
+
mixpanelService.attachProperty(FINGERPRINT_PROPERTY, fingerprint);
|
|
156
|
+
} catch {
|
|
157
|
+
report('fingerprint', 'failed');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
115
161
|
/**
|
|
116
|
-
*
|
|
162
|
+
* Applies identity and initial properties before optional enrichment.
|
|
117
163
|
* @param {string} userId - The unique identifier for the user.
|
|
118
|
-
* @param {UserProperties} properties - Optional user properties.
|
|
119
|
-
* @returns {Promise<void>} Resolves after
|
|
164
|
+
* @param {UserProperties} properties - Optional user properties; never mutated.
|
|
165
|
+
* @returns {Promise<void>} Resolves after the local identity submission, not enrichment or remote delivery.
|
|
120
166
|
*/
|
|
121
167
|
async function identify(
|
|
122
168
|
userId: string,
|
|
123
169
|
properties: UserProperties = {},
|
|
124
170
|
): Promise<void> {
|
|
171
|
+
const attempt = ++latestAttempt;
|
|
172
|
+
const input = { ...properties };
|
|
125
173
|
const report = createIdentifyReporter();
|
|
126
|
-
|
|
174
|
+
const initialLifecycle = trackingLifecycle.capture();
|
|
175
|
+
if (
|
|
176
|
+
!(await waitForIdentifyReady(report)) ||
|
|
177
|
+
attempt !== latestAttempt ||
|
|
178
|
+
!initialLifecycle()
|
|
179
|
+
)
|
|
180
|
+
return;
|
|
181
|
+
let submitted = false;
|
|
127
182
|
try {
|
|
128
183
|
try {
|
|
129
|
-
validatorService.parseIdentifyProps(
|
|
184
|
+
validatorService.parseIdentifyProps(input);
|
|
130
185
|
if (!userId) {
|
|
131
186
|
report('validation', 'invalid_input');
|
|
132
187
|
return;
|
|
@@ -135,40 +190,22 @@ async function identify(
|
|
|
135
190
|
report('validation', 'invalid_input');
|
|
136
191
|
return;
|
|
137
192
|
}
|
|
138
|
-
const anonymousProfile = await waitForIdentifyStage(
|
|
139
|
-
'profile',
|
|
140
|
-
3000,
|
|
141
|
-
(context) =>
|
|
142
|
-
createAnonymousProfileForIdentify({ ...properties }, context.report),
|
|
143
|
-
report,
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
if (anonymousProfile) {
|
|
147
|
-
properties.reservamos_one_id = anonymousProfile.id;
|
|
148
|
-
}
|
|
149
|
-
const mappedProps = mapProperties(properties);
|
|
150
193
|
try {
|
|
151
|
-
mixpanelService.identify(userId,
|
|
194
|
+
mixpanelService.identify(userId, mapProperties(input), report);
|
|
152
195
|
} catch {
|
|
153
196
|
report('mixpanel', 'failed');
|
|
197
|
+
return;
|
|
154
198
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
mixpanelService.attachProperty(FINGERPRINT_PROPERTY, fingerprint);
|
|
164
|
-
} catch {
|
|
165
|
-
report('fingerprint', 'failed');
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
} catch {
|
|
169
|
-
report('mixpanel', 'failed');
|
|
199
|
+
const currentLifecycle = trackingLifecycle.capture();
|
|
200
|
+
const isActive = () => attempt === latestAttempt && currentLifecycle();
|
|
201
|
+
const scopedReport: IdentifyReporter = (...args) => {
|
|
202
|
+
if (isActive()) report(...args);
|
|
203
|
+
};
|
|
204
|
+
submitted = true;
|
|
205
|
+
void enrichProfileAndLocation(userId, input, isActive, scopedReport);
|
|
206
|
+
void enrichFingerprint(isActive, scopedReport);
|
|
170
207
|
} finally {
|
|
171
|
-
void identifyWithLocation(userId,
|
|
208
|
+
if (!submitted) void identifyWithLocation(userId, { ...input }, report);
|
|
172
209
|
}
|
|
173
210
|
}
|
|
174
211
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import paymentAttemptSchema from '@/events/paymentAttempt/paymentAttemptSchema';
|
|
3
|
+
import validatorService from '@/services/validator';
|
|
4
|
+
import passengersCreatedSchema from './passengersCreatedSchema';
|
|
5
|
+
|
|
6
|
+
const trip = {
|
|
7
|
+
'Departure Arrival': '2026-10-20T14:30:00',
|
|
8
|
+
'Departure Destination': 'Destination City',
|
|
9
|
+
'Departure Destination Terminal': 'Destination Terminal',
|
|
10
|
+
'Departure Line': 'Synthetic Line',
|
|
11
|
+
'Departure Origin': 'Origin City',
|
|
12
|
+
'Departure Origin Terminal': 'Origin Terminal',
|
|
13
|
+
'Departure Price': 450.5,
|
|
14
|
+
'Departure Route': 'Origin City - Destination City',
|
|
15
|
+
'Departure Stops': 1,
|
|
16
|
+
'Departure Time': '2026-10-20T08:00:00',
|
|
17
|
+
'Departure Transport Type': 'bus',
|
|
18
|
+
'Departure Transporter': 'Synthetic Transporter',
|
|
19
|
+
'Recommended Trip': false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const passenger = {
|
|
23
|
+
'Passenger Birthdate': '1990-01-01',
|
|
24
|
+
'Passenger Seat': '12',
|
|
25
|
+
'Passenger Name': 'Synthetic Passenger',
|
|
26
|
+
'Passenger Document Type': 'passport',
|
|
27
|
+
'Passenger Document Id': 'X0000000',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const basePayload = {
|
|
31
|
+
'Trips': [trip],
|
|
32
|
+
'Passenger Count': 1,
|
|
33
|
+
'Trip Count': 1,
|
|
34
|
+
'Total': 450.5,
|
|
35
|
+
'product': 'web',
|
|
36
|
+
'Passengers': [passenger],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const paymentAttemptPayload = { ...basePayload, 'Payment Type': 'card' };
|
|
40
|
+
|
|
41
|
+
const without = (source: object, key: string) =>
|
|
42
|
+
Object.fromEntries(Object.entries(source).filter(([name]) => name !== key));
|
|
43
|
+
|
|
44
|
+
describe('passengersCreatedSchema', () => {
|
|
45
|
+
it('accepts a valid payload without Coupon', () => {
|
|
46
|
+
expect(passengersCreatedSchema.parse(basePayload)).toEqual(basePayload);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('accepts a valid payload with Coupon', () => {
|
|
50
|
+
const payload = { ...basePayload, Coupon: 'PROMO10' };
|
|
51
|
+
|
|
52
|
+
expect(passengersCreatedSchema.parse(payload)).toEqual(payload);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each([10, true, null, {}])('rejects Coupon given as %j', (coupon) => {
|
|
56
|
+
expect(
|
|
57
|
+
passengersCreatedSchema.safeParse({ ...basePayload, Coupon: coupon })
|
|
58
|
+
.success,
|
|
59
|
+
).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects an empty Coupon', () => {
|
|
63
|
+
expect(
|
|
64
|
+
passengersCreatedSchema.safeParse({ ...basePayload, Coupon: '' }).success,
|
|
65
|
+
).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps rejecting keys outside the schema', () => {
|
|
69
|
+
const result = passengersCreatedSchema.safeParse({
|
|
70
|
+
...basePayload,
|
|
71
|
+
Insurance: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.success).toBe(false);
|
|
75
|
+
expect(result.error?.issues[0]?.code).toBe('unrecognized_keys');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('accepts the payload shape previously rejected for its Coupon key', () => {
|
|
79
|
+
expect(() =>
|
|
80
|
+
validatorService.parseEventProps('Passengers Created', {
|
|
81
|
+
...basePayload,
|
|
82
|
+
Coupon: 'PROMO10',
|
|
83
|
+
}),
|
|
84
|
+
).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects a trip without Departure Arrival', () => {
|
|
88
|
+
expect(
|
|
89
|
+
passengersCreatedSchema.safeParse({
|
|
90
|
+
...basePayload,
|
|
91
|
+
Trips: [without(trip, 'Departure Arrival')],
|
|
92
|
+
}).success,
|
|
93
|
+
).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('checkout events with an empty passenger list', () => {
|
|
98
|
+
it.each([
|
|
99
|
+
['Passengers Created', passengersCreatedSchema, basePayload],
|
|
100
|
+
['Payment Attempt', paymentAttemptSchema, paymentAttemptPayload],
|
|
101
|
+
] as const)(
|
|
102
|
+
'%s rejects an empty Passengers list',
|
|
103
|
+
(_name, schema, payload) => {
|
|
104
|
+
const result = schema.safeParse({ ...payload, Passengers: [] });
|
|
105
|
+
|
|
106
|
+
expect(result.success).toBe(false);
|
|
107
|
+
expect(result.error?.issues.map((issue) => issue.message)).toContain(
|
|
108
|
+
'Passengers must have at least 1 items',
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
it.each([
|
|
114
|
+
['Passengers Created', passengersCreatedSchema, basePayload],
|
|
115
|
+
['Payment Attempt', paymentAttemptSchema, paymentAttemptPayload],
|
|
116
|
+
] as const)(
|
|
117
|
+
'%s accepts an omitted Passengers list',
|
|
118
|
+
(_name, schema, payload) => {
|
|
119
|
+
expect(schema.safeParse(without(payload, 'Passengers')).success).toBe(
|
|
120
|
+
true,
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Coupon parity with Payment Attempt', () => {
|
|
127
|
+
it.each(['PROMO10', '', 10])(
|
|
128
|
+
'gives the same verdict for Coupon %j',
|
|
129
|
+
(coupon) => {
|
|
130
|
+
const passengersCreated = passengersCreatedSchema.safeParse({
|
|
131
|
+
...basePayload,
|
|
132
|
+
Coupon: coupon,
|
|
133
|
+
});
|
|
134
|
+
const paymentAttempt = paymentAttemptSchema.safeParse({
|
|
135
|
+
...paymentAttemptPayload,
|
|
136
|
+
Coupon: coupon,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(passengersCreated.success).toBe(paymentAttempt.success);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
arrayField,
|
|
4
|
+
intField,
|
|
5
|
+
numberField,
|
|
6
|
+
stringField,
|
|
7
|
+
} from '@/util/primitiveFields';
|
|
3
8
|
import productValidation from '@/util/productValidation';
|
|
4
9
|
import passengerSchema from '../sharedSchemas/passengerSchema';
|
|
5
10
|
import tripSchema from '../sharedSchemas/tripSchema';
|
|
@@ -12,6 +17,7 @@ const passengersCreatedSchema = z
|
|
|
12
17
|
'Total': numberField('Total'),
|
|
13
18
|
'product': productValidation,
|
|
14
19
|
'Passengers': arrayField(passengerSchema, 'Passengers', 1).optional(),
|
|
20
|
+
'Coupon': stringField('Coupon').optional(),
|
|
15
21
|
})
|
|
16
22
|
.strict();
|
|
17
23
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import analytics from '@/index';
|
|
3
|
+
import mixpanelService from '@/services/mixpanel';
|
|
4
|
+
|
|
5
|
+
vi.mock('@/services/mixpanel', () => ({
|
|
6
|
+
default: {
|
|
7
|
+
isReady: vi.fn().mockReturnValue(true),
|
|
8
|
+
track: vi.fn(),
|
|
9
|
+
getMixpanelDistinctId: vi.fn().mockReturnValue('identified-user'),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
localStorage.clear();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('exposes the asynchronous wait through the public analytics facade', async () => {
|
|
19
|
+
localStorage.setItem(
|
|
20
|
+
'default_fingerprint_cache',
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
fingerprint: 'cached-visitor',
|
|
23
|
+
expiresAt: Date.now() + 60000,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
await expect(analytics.identifiers.waitForFingerprintId()).resolves.toBe(
|
|
27
|
+
'cached-visitor',
|
|
28
|
+
);
|
|
29
|
+
expect(analytics.identifiers.getFingerprintId()).toBe('cached-visitor');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('validates and submits confirmation product through the real tracking pipeline', async () => {
|
|
33
|
+
analytics.track.purchaseConfirmation({
|
|
34
|
+
'Operation Id': 'ORDER-1',
|
|
35
|
+
'Payment Status': 'confirmed',
|
|
36
|
+
'product': 'web',
|
|
37
|
+
});
|
|
38
|
+
await vi.waitFor(() =>
|
|
39
|
+
expect(mixpanelService.track).toHaveBeenCalledWith(
|
|
40
|
+
'Purchase Confirmation Viewed',
|
|
41
|
+
expect.objectContaining({
|
|
42
|
+
'Operation Id': 'ORDER-1',
|
|
43
|
+
'Payment Status': 'confirmed',
|
|
44
|
+
'product': 'web',
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
@@ -28,6 +28,22 @@ describe('purchaseConfirmationSchema', () => {
|
|
|
28
28
|
expect(purchaseConfirmationSchema.parse(payload)).toEqual(payload);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
it.each(['confirmed', 'pending_pix'] as const)(
|
|
32
|
+
'preserves optional product for %s',
|
|
33
|
+
(status) => {
|
|
34
|
+
const payload = {
|
|
35
|
+
'Operation Id': 'MOB-123',
|
|
36
|
+
'Payment Status': status,
|
|
37
|
+
'product': 'web',
|
|
38
|
+
};
|
|
39
|
+
expect(purchaseConfirmationSchema.parse(payload)).toEqual(payload);
|
|
40
|
+
expect(
|
|
41
|
+
purchaseConfirmationSchema.safeParse({ ...payload, product: 'invalid' })
|
|
42
|
+
.success,
|
|
43
|
+
).toBe(false);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
31
47
|
it('rejects a payload missing Operation Id', () => {
|
|
32
48
|
expect(() =>
|
|
33
49
|
purchaseConfirmationSchema.parse({ 'Payment Status': 'confirmed' }),
|
|
@@ -81,6 +97,7 @@ describe('trackPurchaseConfirmation', () => {
|
|
|
81
97
|
const eventData = {
|
|
82
98
|
'Operation Id': 'MOB-123',
|
|
83
99
|
'Payment Status': 'confirmed' as const,
|
|
100
|
+
'product': 'web' as const,
|
|
84
101
|
};
|
|
85
102
|
const meta = { source: 'checkout' };
|
|
86
103
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import productValidation from '@/util/productValidation';
|
|
2
3
|
|
|
3
4
|
const purchaseConfirmationSchema = z
|
|
4
5
|
.object({
|
|
@@ -6,6 +7,7 @@ const purchaseConfirmationSchema = z
|
|
|
6
7
|
'Payment Status': z.enum(['confirmed', 'pending_pix']),
|
|
7
8
|
'Payment Type': z.string().optional(),
|
|
8
9
|
'Total': z.number().optional(),
|
|
10
|
+
'product': productValidation.optional(),
|
|
9
11
|
})
|
|
10
12
|
.strict();
|
|
11
13
|
|
package/src/index.ts
CHANGED
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;
|
|
@@ -50,7 +50,7 @@ describe('botDetectionRelayService', () => {
|
|
|
50
50
|
|
|
51
51
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
52
52
|
'https://data-lake.reservamossaas.com/api/v1/fingerprint/bot-detection',
|
|
53
|
-
{
|
|
53
|
+
expect.objectContaining({
|
|
54
54
|
method: 'POST',
|
|
55
55
|
headers: {
|
|
56
56
|
'Content-Type': 'application/json',
|
|
@@ -64,7 +64,8 @@ describe('botDetectionRelayService', () => {
|
|
|
64
64
|
eventName: 'Fingerprint Requested',
|
|
65
65
|
}),
|
|
66
66
|
signal: expect.any(AbortSignal),
|
|
67
|
-
|
|
67
|
+
keepalive: true,
|
|
68
|
+
}),
|
|
68
69
|
);
|
|
69
70
|
});
|
|
70
71
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
2
3
|
import fingerprintService, { getFingerprintForIdentify } from './fingerprint';
|
|
3
4
|
|
|
4
5
|
const visitorData = {
|
|
@@ -396,3 +397,174 @@ describe('fingerprintService', () => {
|
|
|
396
397
|
consoleError.mockRestore();
|
|
397
398
|
});
|
|
398
399
|
});
|
|
400
|
+
|
|
401
|
+
describe('bounded fingerprint waiting', () => {
|
|
402
|
+
beforeEach(async () => {
|
|
403
|
+
vi.useFakeTimers();
|
|
404
|
+
localStorage.clear();
|
|
405
|
+
initMock.mockResolvedValue(undefined);
|
|
406
|
+
getVisitorDataMock.mockReset().mockResolvedValue(visitorData);
|
|
407
|
+
clientMock.mockImplementation(() => ({
|
|
408
|
+
init: initMock,
|
|
409
|
+
getVisitorData: getVisitorDataMock,
|
|
410
|
+
}));
|
|
411
|
+
await fingerprintService.initFingerprint('wait-key');
|
|
412
|
+
});
|
|
413
|
+
afterEach(() => {
|
|
414
|
+
vi.clearAllTimers();
|
|
415
|
+
vi.useRealTimers();
|
|
416
|
+
vi.restoreAllMocks();
|
|
417
|
+
localStorage.clear();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('returns cache without a provider request', async () => {
|
|
421
|
+
localStorage.setItem(
|
|
422
|
+
'default_fingerprint_cache',
|
|
423
|
+
JSON.stringify({ fingerprint: 'cached', expiresAt: Date.now() + 1000 }),
|
|
424
|
+
);
|
|
425
|
+
await expect(fingerprintService.waitForFingerprintId()).resolves.toBe(
|
|
426
|
+
'cached',
|
|
427
|
+
);
|
|
428
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
429
|
+
});
|
|
430
|
+
it('joins a fresh request already in flight and serves concurrent waiters', async () => {
|
|
431
|
+
let finish!: (value: typeof visitorData) => void;
|
|
432
|
+
getVisitorDataMock.mockReturnValue(
|
|
433
|
+
new Promise((resolve) => {
|
|
434
|
+
finish = resolve;
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
const existing = fingerprintService.identifyVisitor(true);
|
|
438
|
+
const first = fingerprintService.waitForFingerprintId();
|
|
439
|
+
const second = fingerprintService.waitForFingerprintId();
|
|
440
|
+
finish(visitorData);
|
|
441
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
442
|
+
'visitor-123',
|
|
443
|
+
'visitor-123',
|
|
444
|
+
]);
|
|
445
|
+
await existing;
|
|
446
|
+
expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
|
|
447
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
448
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
449
|
+
});
|
|
450
|
+
it('waits for an agent still loading, then identifies only once', async () => {
|
|
451
|
+
let finish!: () => void;
|
|
452
|
+
initMock.mockReturnValue(
|
|
453
|
+
new Promise<void>((resolve) => {
|
|
454
|
+
finish = resolve;
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
const initialization = fingerprintService.initFingerprint('late-key');
|
|
458
|
+
const first = fingerprintService.waitForFingerprintId();
|
|
459
|
+
const second = fingerprintService.waitForFingerprintId();
|
|
460
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
461
|
+
finish();
|
|
462
|
+
await initialization;
|
|
463
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
464
|
+
'visitor-123',
|
|
465
|
+
'visitor-123',
|
|
466
|
+
]);
|
|
467
|
+
expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
|
|
468
|
+
expect(getVisitorDataMock).toHaveBeenCalledWith({}, false);
|
|
469
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
470
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
471
|
+
});
|
|
472
|
+
it('resolves null when the pending agent fails to load', async () => {
|
|
473
|
+
let fail!: (error: Error) => void;
|
|
474
|
+
initMock.mockReturnValue(
|
|
475
|
+
new Promise((_resolve, reject) => {
|
|
476
|
+
fail = reject;
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
const initialization = fingerprintService.initFingerprint('blocked-key');
|
|
480
|
+
const pending = fingerprintService.waitForFingerprintId();
|
|
481
|
+
const rejected = expect(initialization).rejects.toThrow('blocked');
|
|
482
|
+
fail(new Error('blocked'));
|
|
483
|
+
await rejected;
|
|
484
|
+
await expect(pending).resolves.toBeNull();
|
|
485
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
it.each([-1, Infinity, NaN])(
|
|
488
|
+
'resolves null for invalid budget %s',
|
|
489
|
+
async (budget) => {
|
|
490
|
+
await expect(
|
|
491
|
+
fingerprintService.waitForFingerprintId(budget),
|
|
492
|
+
).resolves.toBeNull();
|
|
493
|
+
expect(getVisitorDataMock).not.toHaveBeenCalled();
|
|
494
|
+
},
|
|
495
|
+
);
|
|
496
|
+
it('keeps a late successful result after the caller times out without another lookup', async () => {
|
|
497
|
+
let finish!: (value: typeof visitorData) => void;
|
|
498
|
+
getVisitorDataMock.mockReturnValue(
|
|
499
|
+
new Promise((resolve) => {
|
|
500
|
+
finish = resolve;
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
setPeoplePropertiesMock.mockClear();
|
|
504
|
+
const pending = fingerprintService.waitForFingerprintId(2000);
|
|
505
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
506
|
+
await expect(pending).resolves.toBeNull();
|
|
507
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
508
|
+
finish(visitorData);
|
|
509
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
510
|
+
expect(fingerprintService.getCachedFingerprint()).toBe('visitor-123');
|
|
511
|
+
expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
|
|
512
|
+
'Visitor Found': true,
|
|
513
|
+
});
|
|
514
|
+
await expect(fingerprintService.waitForFingerprintId()).resolves.toBe(
|
|
515
|
+
'visitor-123',
|
|
516
|
+
);
|
|
517
|
+
expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
|
|
518
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
519
|
+
});
|
|
520
|
+
it('suppresses a timed-out request after its user lifecycle changes', async () => {
|
|
521
|
+
let finish!: (value: typeof visitorData) => void;
|
|
522
|
+
getVisitorDataMock.mockReturnValue(
|
|
523
|
+
new Promise((resolve) => {
|
|
524
|
+
finish = resolve;
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
setPeoplePropertiesMock.mockClear();
|
|
528
|
+
const pending = fingerprintService.waitForFingerprintId(2000);
|
|
529
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
530
|
+
await expect(pending).resolves.toBeNull();
|
|
531
|
+
trackingLifecycle.identify('replacement-user-after-timeout');
|
|
532
|
+
finish(visitorData);
|
|
533
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
534
|
+
expect(fingerprintService.getCachedFingerprint()).toBeNull();
|
|
535
|
+
expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
|
|
536
|
+
expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
|
|
537
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
538
|
+
});
|
|
539
|
+
it('resolves null at its timeout and consumes late rejection', async () => {
|
|
540
|
+
let fail!: (error: Error) => void;
|
|
541
|
+
getVisitorDataMock.mockReturnValue(
|
|
542
|
+
new Promise((_resolve, reject) => {
|
|
543
|
+
fail = reject;
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
const pending = fingerprintService.waitForFingerprintId(100);
|
|
547
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
548
|
+
await expect(pending).resolves.toBeNull();
|
|
549
|
+
fail(new Error('offline'));
|
|
550
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
551
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
552
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
553
|
+
});
|
|
554
|
+
it('cancels when initialization changes and ignores an old result', async () => {
|
|
555
|
+
let finish!: (value: typeof visitorData) => void;
|
|
556
|
+
getVisitorDataMock.mockReturnValue(
|
|
557
|
+
new Promise((resolve) => {
|
|
558
|
+
finish = resolve;
|
|
559
|
+
}),
|
|
560
|
+
);
|
|
561
|
+
const pending = fingerprintService.waitForFingerprintId();
|
|
562
|
+
await fingerprintService.initFingerprint('replacement');
|
|
563
|
+
await expect(pending).resolves.toBeNull();
|
|
564
|
+
finish(visitorData);
|
|
565
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
566
|
+
expect(fingerprintService.getCachedFingerprint()).toBeNull();
|
|
567
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
568
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
});
|