@reservamos/browser-analytics 1.1.6 → 1.1.7-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +58 -10
- package/dist/browser-analytics.cjs +3 -3
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +55 -55
- package/dist/browser-analytics.esm.js +2257 -1991
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +3 -3
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +14 -12
- package/src/events/identify/identify.robustness.test.ts +220 -0
- package/src/events/identify/identify.test.ts +6 -4
- package/src/events/identify/identify.ts +84 -42
- package/src/index.ts +2 -0
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.identify.test.ts +56 -0
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +39 -12
- package/src/services/botDetectionRelay.test.ts +127 -0
- package/src/services/botDetectionRelay.ts +64 -0
- package/src/services/fingerprint.test.ts +362 -0
- package/src/services/fingerprint.ts +99 -18
- package/src/services/mixpanel.test.ts +160 -0
- package/src/services/mixpanel.ts +32 -6
- package/src/track.test.ts +156 -0
- package/src/track.ts +24 -5
- package/src/types/botDetection.ts +16 -0
- package/src/util/identifyAttempt.ts +139 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import type { FingerprintIdentification } from '@/types/botDetection';
|
|
2
|
+
import type { IdentifyStageContext } from '@/util/identifyAttempt';
|
|
1
3
|
import {
|
|
2
4
|
FingerprintJSPro,
|
|
3
5
|
FpjsClient,
|
|
4
6
|
} from '@fingerprintjs/fingerprintjs-pro-spa';
|
|
7
|
+
import mixpanelService from '@/services/mixpanel';
|
|
5
8
|
|
|
6
9
|
declare global {
|
|
7
10
|
interface Window {
|
|
@@ -19,6 +22,13 @@ const DEFAULT_CACHE_TIME_IN_DAYS = 7;
|
|
|
19
22
|
const PROXY_QUERY_PARAM =
|
|
20
23
|
'apiKey=<apiKey>&version=<version>&loaderVersion=<loaderVersion>';
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* True only after `fpClient.init()` succeeds. `window.fpClient` exists as soon as
|
|
27
|
+
* the client is constructed, but that instance still uses a stub agent that throws
|
|
28
|
+
* "FPJSAgent hasn't loaded yet" until init completes.
|
|
29
|
+
*/
|
|
30
|
+
let fingerprintAgentReady = false;
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Calculates the expiration date based on the cache time.
|
|
24
34
|
* @param {number} cacheTimeInDays - The cache time in days.
|
|
@@ -37,6 +47,10 @@ const getExpirationDate = (cacheTimeInDays: number): number => {
|
|
|
37
47
|
* @returns {string | null} - The cached fingerprint or null if it is expired or not found.
|
|
38
48
|
*/
|
|
39
49
|
const getCachedFingerprint = (): string | null => {
|
|
50
|
+
return readCachedFingerprint();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function readCachedFingerprint(context?: IdentifyStageContext): string | null {
|
|
40
54
|
const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig;
|
|
41
55
|
|
|
42
56
|
try {
|
|
@@ -47,9 +61,10 @@ const getCachedFingerprint = (): string | null => {
|
|
|
47
61
|
localStorage.removeItem(cacheName);
|
|
48
62
|
return null;
|
|
49
63
|
} catch {
|
|
64
|
+
context?.report('cache', 'unavailable');
|
|
50
65
|
return null;
|
|
51
66
|
}
|
|
52
|
-
}
|
|
67
|
+
}
|
|
53
68
|
|
|
54
69
|
/**
|
|
55
70
|
* Sets the cached fingerprint in local storage.
|
|
@@ -102,18 +117,26 @@ async function initFingerprint(
|
|
|
102
117
|
customScriptEndpointArray.unshift(buildProxyUrl(proxyUrl));
|
|
103
118
|
}
|
|
104
119
|
|
|
120
|
+
fingerprintAgentReady = false;
|
|
105
121
|
window.fingerprintConfig = {};
|
|
106
|
-
|
|
122
|
+
const client = new FpjsClient({
|
|
107
123
|
loadOptions: {
|
|
108
124
|
apiKey,
|
|
109
125
|
endpoint: customEndpointArray,
|
|
110
126
|
scriptUrlPattern: customScriptEndpointArray,
|
|
111
127
|
},
|
|
112
128
|
});
|
|
129
|
+
window.fpClient = client;
|
|
113
130
|
|
|
114
131
|
try {
|
|
115
|
-
await
|
|
132
|
+
await client.init();
|
|
133
|
+
if (window.fpClient === client) {
|
|
134
|
+
fingerprintAgentReady = true;
|
|
135
|
+
}
|
|
116
136
|
} catch (error) {
|
|
137
|
+
if (window.fpClient === client) {
|
|
138
|
+
fingerprintAgentReady = false;
|
|
139
|
+
}
|
|
117
140
|
console.error('Error initializing identification service:', error);
|
|
118
141
|
throw error;
|
|
119
142
|
}
|
|
@@ -121,11 +144,26 @@ async function initFingerprint(
|
|
|
121
144
|
|
|
122
145
|
/**
|
|
123
146
|
* Retrieves the fingerprint, first checking the cache and then querying the identification service if not found.
|
|
147
|
+
* Returns an empty string when the Fingerprint agent has not loaded, so callers like identify() can continue.
|
|
124
148
|
* @param {boolean} cacheOnly - Optional flag to retrieve the fingerprint only from the cache.
|
|
125
|
-
* @returns {Promise<string>} A promise that resolves with the fingerprint.
|
|
126
|
-
* @throws {Error} Throws an error if retrieval fails.
|
|
149
|
+
* @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when unavailable.
|
|
150
|
+
* @throws {Error} Throws an error if retrieval fails after the agent has loaded.
|
|
127
151
|
*/
|
|
128
152
|
async function getFingerprint(cacheOnly = false): Promise<string> {
|
|
153
|
+
return readFingerprint(cacheOnly);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Internal identify path with late-effect suppression and sanitized diagnostics. */
|
|
157
|
+
export function getFingerprintForIdentify(
|
|
158
|
+
context: IdentifyStageContext,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
return readFingerprint(false, context);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function readFingerprint(
|
|
164
|
+
cacheOnly: boolean,
|
|
165
|
+
context?: IdentifyStageContext,
|
|
166
|
+
): Promise<string> {
|
|
129
167
|
if (!window.fingerprintConfig) {
|
|
130
168
|
if (cacheOnly) {
|
|
131
169
|
return '';
|
|
@@ -134,44 +172,87 @@ async function getFingerprint(cacheOnly = false): Promise<string> {
|
|
|
134
172
|
throw new Error('Fingerprint configuration is not initialized.');
|
|
135
173
|
}
|
|
136
174
|
|
|
137
|
-
const cachedFingerprint =
|
|
175
|
+
const cachedFingerprint = readCachedFingerprint(context);
|
|
138
176
|
if (cachedFingerprint) {
|
|
139
177
|
return cachedFingerprint;
|
|
140
178
|
}
|
|
141
179
|
|
|
142
|
-
if (cacheOnly) {
|
|
180
|
+
if (cacheOnly || !isFingerprintReady()) {
|
|
143
181
|
return '';
|
|
144
182
|
}
|
|
145
183
|
|
|
184
|
+
const { visitorId } = await requestIdentification(false, context);
|
|
185
|
+
return visitorId;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Requests detailed visitor identification data from Fingerprint.
|
|
190
|
+
* @param {boolean} ignoreCache - Whether the Fingerprint SPA cache must be bypassed.
|
|
191
|
+
* @returns {Promise<FingerprintIdentification>} Request-scoped identification data.
|
|
192
|
+
* @throws {Error} If the identification service is not ready or retrieval fails.
|
|
193
|
+
*/
|
|
194
|
+
async function identifyVisitor(
|
|
195
|
+
ignoreCache = false,
|
|
196
|
+
): Promise<FingerprintIdentification> {
|
|
197
|
+
return requestIdentification(ignoreCache);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function requestIdentification(
|
|
201
|
+
ignoreCache: boolean,
|
|
202
|
+
context?: IdentifyStageContext,
|
|
203
|
+
): Promise<FingerprintIdentification> {
|
|
146
204
|
if (!isFingerprintReady()) {
|
|
147
205
|
throw new Error('Identification service is not initialized.');
|
|
148
206
|
}
|
|
149
207
|
|
|
150
208
|
try {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
209
|
+
const result = await window.fpClient.getVisitorData({}, ignoreCache);
|
|
210
|
+
if (!context || context.isActive()) {
|
|
211
|
+
try {
|
|
212
|
+
setCachedFingerprint(result.visitorId);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (!context) throw error;
|
|
215
|
+
context.report('cache', 'unavailable');
|
|
216
|
+
}
|
|
217
|
+
if (typeof result.visitorFound === 'boolean') {
|
|
218
|
+
try {
|
|
219
|
+
mixpanelService.setPeopleProperties({
|
|
220
|
+
'Visitor Found': result.visitorFound,
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (context) context.report('fingerprint', 'failed');
|
|
224
|
+
else console.error('Error updating Mixpanel visitor profile:', error);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
visitorId: result.visitorId,
|
|
231
|
+
requestId: result.requestId,
|
|
232
|
+
...(typeof result.visitorFound === 'boolean'
|
|
233
|
+
? { visitorFound: result.visitorFound }
|
|
234
|
+
: {}),
|
|
235
|
+
confidenceScore: result.confidence.score,
|
|
236
|
+
cacheHit: result.cacheHit,
|
|
237
|
+
};
|
|
155
238
|
} catch (error) {
|
|
156
|
-
console.error('Error retrieving fingerprint:', error);
|
|
239
|
+
if (!context) console.error('Error retrieving fingerprint:', error);
|
|
157
240
|
throw error;
|
|
158
241
|
}
|
|
159
242
|
}
|
|
160
243
|
|
|
161
244
|
/**
|
|
162
|
-
* Checks if the
|
|
163
|
-
*
|
|
164
|
-
* This function verifies whether the identification service has been initialized
|
|
165
|
-
* and is ready to use by checking if the `fpClient` instance exists on the window object.
|
|
166
|
-
* @returns {boolean} Returns true if the identification service is ready, otherwise false.
|
|
245
|
+
* Checks if the Fingerprint agent finished loading and can identify visitors.
|
|
246
|
+
* @returns {boolean} True only after `initFingerprint()` has successfully loaded the agent.
|
|
167
247
|
*/
|
|
168
248
|
function isFingerprintReady(): boolean {
|
|
169
|
-
return window.fpClient !== undefined;
|
|
249
|
+
return window.fpClient !== undefined && fingerprintAgentReady;
|
|
170
250
|
}
|
|
171
251
|
|
|
172
252
|
const fingerprintService = {
|
|
173
253
|
initFingerprint,
|
|
174
254
|
getFingerprint,
|
|
255
|
+
identifyVisitor,
|
|
175
256
|
isFingerprintReady,
|
|
176
257
|
getCachedFingerprint,
|
|
177
258
|
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import mixpanel from 'mixpanel-browser';
|
|
2
|
+
import { version as mixpanelVersion } from 'mixpanel-browser/package.json';
|
|
3
|
+
import {
|
|
4
|
+
afterAll,
|
|
5
|
+
afterEach,
|
|
6
|
+
beforeAll,
|
|
7
|
+
describe,
|
|
8
|
+
expect,
|
|
9
|
+
it,
|
|
10
|
+
vi,
|
|
11
|
+
} from 'vitest';
|
|
12
|
+
import { version } from '../../package.json';
|
|
13
|
+
import mixpanelService from './mixpanel';
|
|
14
|
+
|
|
15
|
+
interface TransportData {
|
|
16
|
+
event?: string;
|
|
17
|
+
properties?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Intercept transport only: identify, track, register, and property merging stay real.
|
|
21
|
+
const transport = mixpanel as unknown as {
|
|
22
|
+
_track_or_batch(options: { data: TransportData }): TransportData;
|
|
23
|
+
stop_batch_senders(): void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe('Mixpanel SDK version metadata', () => {
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
localStorage.clear();
|
|
29
|
+
await mixpanelService.init('test-sdk-version-token');
|
|
30
|
+
transport.stop_batch_senders();
|
|
31
|
+
mixpanel.set_config({ disable_persistence: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
vi.clearAllTimers();
|
|
37
|
+
vi.useRealTimers();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
transport.stop_batch_senders();
|
|
42
|
+
localStorage.clear();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('registers SDK metadata and propagates it to the native $identify event', () => {
|
|
46
|
+
expect(mixpanel.get_property('Event Source')).toBe('sdk');
|
|
47
|
+
expect(mixpanel.get_property('Reservamos Version')).toBe(version);
|
|
48
|
+
|
|
49
|
+
const send = vi
|
|
50
|
+
.spyOn(transport, '_track_or_batch')
|
|
51
|
+
.mockImplementation(({ data }) => data);
|
|
52
|
+
|
|
53
|
+
mixpanelService.identify('sdk-version-user', {});
|
|
54
|
+
const events = send.mock.calls
|
|
55
|
+
.map(([options]) => options.data)
|
|
56
|
+
.filter((data) => data.event);
|
|
57
|
+
|
|
58
|
+
expect(events).toHaveLength(1);
|
|
59
|
+
expect(events[0]).toEqual({
|
|
60
|
+
event: '$identify',
|
|
61
|
+
properties: expect.objectContaining({
|
|
62
|
+
'Event Source': 'sdk',
|
|
63
|
+
'Reservamos Version': version,
|
|
64
|
+
'$lib_version': mixpanelVersion,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
send.mockClear();
|
|
69
|
+
mixpanelService.identify('sdk-version-user', {});
|
|
70
|
+
expect(send.mock.calls.some(([options]) => options.data.event)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('keeps the SDK version authoritative for SDK-tracked events', () => {
|
|
74
|
+
const send = vi
|
|
75
|
+
.spyOn(transport, '_track_or_batch')
|
|
76
|
+
.mockImplementation(({ data }) => data);
|
|
77
|
+
|
|
78
|
+
mixpanelService.track('Test', { 'Reservamos Version': 'caller-version' });
|
|
79
|
+
|
|
80
|
+
expect(send.mock.calls[0][0].data).toEqual({
|
|
81
|
+
event: 'Test',
|
|
82
|
+
properties: expect.objectContaining({
|
|
83
|
+
'Event Source': 'sdk',
|
|
84
|
+
'Reservamos Version': version,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('still sets people properties when the SDK distinct ID cache is blocked', () => {
|
|
90
|
+
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
91
|
+
({ data }) => data,
|
|
92
|
+
);
|
|
93
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
94
|
+
throw new DOMException('Blocked', 'SecurityError');
|
|
95
|
+
});
|
|
96
|
+
const people = vi
|
|
97
|
+
.spyOn(mixpanel.people, 'set')
|
|
98
|
+
.mockImplementation(() => undefined);
|
|
99
|
+
mixpanelService.identify('cache-blocked-user', {});
|
|
100
|
+
expect(people).toHaveBeenCalledWith({});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it.each([
|
|
104
|
+
1,
|
|
105
|
+
0,
|
|
106
|
+
{ status: 1, error: null },
|
|
107
|
+
{ status: 0, error: 'private-id' },
|
|
108
|
+
-1,
|
|
109
|
+
])(
|
|
110
|
+
'observes the people callback %j with controlled diagnostics',
|
|
111
|
+
(response) => {
|
|
112
|
+
vi.useFakeTimers();
|
|
113
|
+
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
114
|
+
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
115
|
+
({ data }) => data,
|
|
116
|
+
);
|
|
117
|
+
let callback: ((response: unknown) => void) | undefined;
|
|
118
|
+
vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
|
|
119
|
+
callback = args[1] as (response: unknown) => void;
|
|
120
|
+
});
|
|
121
|
+
const report = vi.fn();
|
|
122
|
+
const schedule = vi.spyOn(globalThis, 'setTimeout');
|
|
123
|
+
const cancel = vi.spyOn(globalThis, 'clearTimeout');
|
|
124
|
+
mixpanelService.identify('callback-user', {}, report);
|
|
125
|
+
expect(schedule).toHaveBeenCalledWith(expect.any(Function), 5000);
|
|
126
|
+
const timeout = schedule.mock.results.at(-1)?.value;
|
|
127
|
+
callback?.(response);
|
|
128
|
+
expect(cancel).toHaveBeenCalledWith(timeout);
|
|
129
|
+
const status = typeof response === 'number' ? response : response.status;
|
|
130
|
+
if (status === 1) expect(report).not.toHaveBeenCalled();
|
|
131
|
+
else
|
|
132
|
+
expect(report).toHaveBeenCalledWith(
|
|
133
|
+
'people',
|
|
134
|
+
status === 0 ? 'failed' : 'unconfirmed',
|
|
135
|
+
undefined,
|
|
136
|
+
);
|
|
137
|
+
vi.advanceTimersByTime(5000);
|
|
138
|
+
expect(report).toHaveBeenCalledTimes(status === 1 ? 0 : 1);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it('reports missing people callback as unobserved and ignores a late result', () => {
|
|
143
|
+
vi.useFakeTimers();
|
|
144
|
+
vi.spyOn(mixpanel, 'identify').mockImplementation(() => undefined);
|
|
145
|
+
vi.spyOn(transport, '_track_or_batch').mockImplementation(
|
|
146
|
+
({ data }) => data,
|
|
147
|
+
);
|
|
148
|
+
let callback: ((response: unknown) => void) | undefined;
|
|
149
|
+
vi.spyOn(mixpanel.people, 'set').mockImplementation((...args) => {
|
|
150
|
+
callback = args[1] as (response: unknown) => void;
|
|
151
|
+
});
|
|
152
|
+
const report = vi.fn();
|
|
153
|
+
mixpanelService.identify('callback-user', {}, report);
|
|
154
|
+
vi.advanceTimersByTime(5000);
|
|
155
|
+
callback?.(0);
|
|
156
|
+
expect(report).toHaveBeenCalledOnce();
|
|
157
|
+
expect(report).toHaveBeenCalledWith('people', 'unobserved', undefined);
|
|
158
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
});
|
package/src/services/mixpanel.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { IdentifyReporter } from '@/util/identifyAttempt';
|
|
1
2
|
import mixpanel from 'mixpanel-browser';
|
|
3
|
+
import { observeIdentifyResult } from '@/util/identifyAttempt';
|
|
2
4
|
import { version } from '../../package.json';
|
|
3
5
|
|
|
4
6
|
declare global {
|
|
@@ -10,10 +12,11 @@ declare global {
|
|
|
10
12
|
const MIXPANEL_DISTINCT_ID_CACHE_KEY = 'mp_distinct_id';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
*
|
|
15
|
+
* SDK metadata sent on every Mixpanel event, including native identification events.
|
|
14
16
|
*/
|
|
15
|
-
const
|
|
17
|
+
const SDK_SUPER_PROPERTIES = {
|
|
16
18
|
'Event Source': 'sdk',
|
|
19
|
+
'Reservamos Version': version,
|
|
17
20
|
} as const;
|
|
18
21
|
|
|
19
22
|
/**
|
|
@@ -33,7 +36,7 @@ function init(
|
|
|
33
36
|
debug,
|
|
34
37
|
loaded: () => {
|
|
35
38
|
window.mixpanel = mixpanel;
|
|
36
|
-
mixpanel.register({ ...
|
|
39
|
+
mixpanel.register({ ...SDK_SUPER_PROPERTIES });
|
|
37
40
|
resolve();
|
|
38
41
|
},
|
|
39
42
|
...(proxyUrl && { api_host: proxyUrl }),
|
|
@@ -48,10 +51,32 @@ function init(
|
|
|
48
51
|
* @returns {void}
|
|
49
52
|
* @throws {Error} If Mixpanel is not initialized (handled internally).
|
|
50
53
|
*/
|
|
51
|
-
function identify(
|
|
54
|
+
function identify(
|
|
55
|
+
userId: string,
|
|
56
|
+
properties: Record<string, unknown>,
|
|
57
|
+
report?: IdentifyReporter,
|
|
58
|
+
): void {
|
|
52
59
|
mixpanel.identify(userId);
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
try {
|
|
61
|
+
updateCachedDistinctId(userId);
|
|
62
|
+
} catch {
|
|
63
|
+
report?.('cache', 'unavailable');
|
|
64
|
+
}
|
|
65
|
+
if (!report) {
|
|
66
|
+
mixpanel.people.set(properties);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const observer = observeIdentifyResult('people', report);
|
|
70
|
+
try {
|
|
71
|
+
mixpanel.people.set(properties, (response) => {
|
|
72
|
+
const status = typeof response === 'number' ? response : response?.status;
|
|
73
|
+
observer.complete(
|
|
74
|
+
status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
observer.complete('failed');
|
|
79
|
+
}
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
/**
|
|
@@ -100,6 +125,7 @@ function track(eventName: string, properties: Record<string, unknown>): void {
|
|
|
100
125
|
if (!isReady()) {
|
|
101
126
|
return;
|
|
102
127
|
}
|
|
128
|
+
// Event properties override superproperties; keep the SDK version authoritative.
|
|
103
129
|
mixpanel.track(eventName, { ...properties, 'Reservamos Version': version });
|
|
104
130
|
}
|
|
105
131
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import botDetectionRelayService from '@/services/botDetectionRelay';
|
|
3
|
+
import fingerprintService from '@/services/fingerprint';
|
|
4
|
+
import mixpanelService from '@/services/mixpanel';
|
|
5
|
+
import { trackEvent } from './track';
|
|
6
|
+
|
|
7
|
+
vi.mock('@/services/botDetectionRelay', () => ({
|
|
8
|
+
default: {
|
|
9
|
+
isEnabledFor: vi.fn(),
|
|
10
|
+
relay: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('@/services/fingerprint', () => ({
|
|
15
|
+
default: {
|
|
16
|
+
getFingerprint: vi.fn(),
|
|
17
|
+
identifyVisitor: vi.fn(),
|
|
18
|
+
isFingerprintReady: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('@/services/geolocation', () => ({
|
|
23
|
+
default: {
|
|
24
|
+
getCoordinates: vi.fn().mockReturnValue(null),
|
|
25
|
+
getGeolocationSource: vi.fn().mockReturnValue(null),
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('@/services/mixpanel', () => ({
|
|
30
|
+
default: {
|
|
31
|
+
isReady: vi.fn().mockReturnValue(true),
|
|
32
|
+
getMixpanelDistinctId: vi.fn().mockReturnValue('distinct-123'),
|
|
33
|
+
track: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock('@/services/validator', () => ({
|
|
38
|
+
default: {
|
|
39
|
+
parseEventProps: vi.fn(),
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
describe('trackEvent Bot Detection relay', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
vi.mocked(mixpanelService.isReady).mockReturnValue(true);
|
|
47
|
+
vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
|
|
48
|
+
'distinct-123',
|
|
49
|
+
);
|
|
50
|
+
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses a fresh identification and relays a configured event', async () => {
|
|
54
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
|
|
55
|
+
vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue({
|
|
56
|
+
visitorId: 'visitor-123',
|
|
57
|
+
requestId: 'request-123',
|
|
58
|
+
visitorFound: true,
|
|
59
|
+
confidenceScore: 0.99,
|
|
60
|
+
cacheHit: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await trackEvent('Payment Attempt', { 'Payment Type': 'credit_card' });
|
|
64
|
+
|
|
65
|
+
expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
|
|
66
|
+
expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
|
|
67
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Payment Attempt', {
|
|
68
|
+
'User Fingerprint': 'visitor-123',
|
|
69
|
+
'Payment Type': 'credit_card',
|
|
70
|
+
});
|
|
71
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
72
|
+
requestId: 'request-123',
|
|
73
|
+
visitorId: 'visitor-123',
|
|
74
|
+
distinctId: 'distinct-123',
|
|
75
|
+
eventName: 'Payment Attempt',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reports a failed fresh identification without tracking or relaying the configured event', async () => {
|
|
80
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
|
|
81
|
+
vi.mocked(fingerprintService.identifyVisitor).mockRejectedValue(
|
|
82
|
+
new Error('Fingerprint identification failed'),
|
|
83
|
+
);
|
|
84
|
+
const eventProperties = { 'Payment Type': 'credit_card' };
|
|
85
|
+
|
|
86
|
+
await trackEvent('Payment Attempt', eventProperties);
|
|
87
|
+
|
|
88
|
+
expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
|
|
89
|
+
expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
|
|
90
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
91
|
+
expect(mixpanelService.track).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Track Event Error', {
|
|
93
|
+
'Failed Event Name': 'Payment Attempt',
|
|
94
|
+
'Error Message': 'Fingerprint identification failed',
|
|
95
|
+
'Validation Errors': [],
|
|
96
|
+
'Event Payload': eventProperties,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('preserves existing fingerprint behavior for an unconfigured event', async () => {
|
|
101
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
|
|
102
|
+
vi.mocked(fingerprintService.getFingerprint).mockResolvedValue(
|
|
103
|
+
'cached-visitor',
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await trackEvent('Payment Attempt', { 'Payment Type': 'credit_card' });
|
|
107
|
+
|
|
108
|
+
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(true);
|
|
109
|
+
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
110
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Payment Attempt', {
|
|
111
|
+
'User Fingerprint': 'cached-visitor',
|
|
112
|
+
'Payment Type': 'credit_card',
|
|
113
|
+
});
|
|
114
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('uses a fresh identification and relays Search', async () => {
|
|
118
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
|
|
119
|
+
vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue({
|
|
120
|
+
visitorId: 'visitor-123',
|
|
121
|
+
requestId: 'search-request-123',
|
|
122
|
+
visitorFound: true,
|
|
123
|
+
confidenceScore: 0.99,
|
|
124
|
+
cacheHit: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await trackEvent('Search', {});
|
|
128
|
+
|
|
129
|
+
expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
|
|
130
|
+
expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
|
|
131
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Search', {
|
|
132
|
+
'User Fingerprint': 'visitor-123',
|
|
133
|
+
});
|
|
134
|
+
expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
|
|
135
|
+
requestId: 'search-request-123',
|
|
136
|
+
visitorId: 'visitor-123',
|
|
137
|
+
distinctId: 'distinct-123',
|
|
138
|
+
eventName: 'Search',
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('tracks Search without a relay when Fingerprint is not ready', async () => {
|
|
143
|
+
vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
|
|
144
|
+
vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
|
|
145
|
+
vi.mocked(fingerprintService.getFingerprint).mockResolvedValue('');
|
|
146
|
+
|
|
147
|
+
await trackEvent('Search', {});
|
|
148
|
+
|
|
149
|
+
expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
|
|
150
|
+
expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(false);
|
|
151
|
+
expect(mixpanelService.track).toHaveBeenCalledWith('Search', {
|
|
152
|
+
'User Fingerprint': '',
|
|
153
|
+
});
|
|
154
|
+
expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/track.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
EventData,
|
|
4
4
|
EventMetadata,
|
|
5
5
|
} from './types/eventData';
|
|
6
|
+
import botDetectionRelayService from '@/services/botDetectionRelay';
|
|
6
7
|
import fingerprintService from '@/services/fingerprint';
|
|
7
8
|
import geolocationService from '@/services/geolocation';
|
|
8
9
|
import mixpanelService from '@/services/mixpanel';
|
|
@@ -117,12 +118,21 @@ export async function trackEvent(
|
|
|
117
118
|
|
|
118
119
|
try {
|
|
119
120
|
validator.parseEventProps(eventName, eventProperties);
|
|
120
|
-
const cacheOnly = !FP_TRIGGER_EVENTS.includes(eventName);
|
|
121
|
-
|
|
122
121
|
// Call the Mixpanel service to store the distinct ID in the cache
|
|
123
|
-
mixpanelService.getMixpanelDistinctId();
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
const distinctId = mixpanelService.getMixpanelDistinctId();
|
|
123
|
+
const botDetectionEnabled =
|
|
124
|
+
botDetectionRelayService.isEnabledFor(eventName);
|
|
125
|
+
let fingerprint: string;
|
|
126
|
+
let fingerprintRequestId: string | undefined;
|
|
127
|
+
|
|
128
|
+
if (botDetectionEnabled && fingerprintService.isFingerprintReady()) {
|
|
129
|
+
const identification = await fingerprintService.identifyVisitor(true);
|
|
130
|
+
fingerprint = identification.visitorId;
|
|
131
|
+
fingerprintRequestId = identification.requestId;
|
|
132
|
+
} else {
|
|
133
|
+
const cacheOnly = !FP_TRIGGER_EVENTS.includes(eventName);
|
|
134
|
+
fingerprint = await fingerprintService.getFingerprint(cacheOnly);
|
|
135
|
+
}
|
|
126
136
|
const defaultCoordinates: Record<string, string | number> = {};
|
|
127
137
|
const coordinates = geolocationService.getCoordinates();
|
|
128
138
|
const geolocationSource = geolocationService.getGeolocationSource();
|
|
@@ -148,6 +158,15 @@ export async function trackEvent(
|
|
|
148
158
|
};
|
|
149
159
|
|
|
150
160
|
mixpanelService.track(eventName, properties);
|
|
161
|
+
|
|
162
|
+
if (fingerprintRequestId) {
|
|
163
|
+
void botDetectionRelayService.relay({
|
|
164
|
+
requestId: fingerprintRequestId,
|
|
165
|
+
visitorId: fingerprint,
|
|
166
|
+
...(distinctId && { distinctId }),
|
|
167
|
+
eventName,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
151
170
|
} catch (error) {
|
|
152
171
|
console.error(`Error tracking event '${eventName}':`, error);
|
|
153
172
|
trackEventError(eventName, error, eventProperties);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface FingerprintIdentification {
|
|
2
|
+
visitorId: string;
|
|
3
|
+
requestId: string;
|
|
4
|
+
visitorFound?: boolean;
|
|
5
|
+
confidenceScore: number;
|
|
6
|
+
cacheHit: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface BotDetectionRelayPayload {
|
|
10
|
+
requestId: string;
|
|
11
|
+
visitorId: string;
|
|
12
|
+
distinctId?: string;
|
|
13
|
+
eventName: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type { BotDetectionRelayPayload, FingerprintIdentification };
|