@reservamos/browser-analytics 1.1.7-alpha.0 → 1.1.7
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 +7 -1
- package/README.md +35 -0
- 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 +1138 -844
- 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 +3 -3
- 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 +154 -0
- package/src/services/botDetectionRelay.ts +54 -0
- package/src/services/fingerprint.test.ts +250 -35
- package/src/services/fingerprint.ts +89 -12
- 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 {
|
|
@@ -18,6 +21,12 @@ const DEFAULT_CACHE_NAME = 'default_fingerprint_cache';
|
|
|
18
21
|
const DEFAULT_CACHE_TIME_IN_DAYS = 7;
|
|
19
22
|
const PROXY_QUERY_PARAM =
|
|
20
23
|
'apiKey=<apiKey>&version=<version>&loaderVersion=<loaderVersion>';
|
|
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
|
+
*/
|
|
21
30
|
let fingerprintAgentReady = false;
|
|
22
31
|
|
|
23
32
|
/**
|
|
@@ -38,6 +47,10 @@ const getExpirationDate = (cacheTimeInDays: number): number => {
|
|
|
38
47
|
* @returns {string | null} - The cached fingerprint or null if it is expired or not found.
|
|
39
48
|
*/
|
|
40
49
|
const getCachedFingerprint = (): string | null => {
|
|
50
|
+
return readCachedFingerprint();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function readCachedFingerprint(context?: IdentifyStageContext): string | null {
|
|
41
54
|
const { cacheName = DEFAULT_CACHE_NAME } = window.fingerprintConfig;
|
|
42
55
|
|
|
43
56
|
try {
|
|
@@ -48,9 +61,10 @@ const getCachedFingerprint = (): string | null => {
|
|
|
48
61
|
localStorage.removeItem(cacheName);
|
|
49
62
|
return null;
|
|
50
63
|
} catch {
|
|
64
|
+
context?.report('cache', 'unavailable');
|
|
51
65
|
return null;
|
|
52
66
|
}
|
|
53
|
-
}
|
|
67
|
+
}
|
|
54
68
|
|
|
55
69
|
/**
|
|
56
70
|
* Sets the cached fingerprint in local storage.
|
|
@@ -130,11 +144,26 @@ async function initFingerprint(
|
|
|
130
144
|
|
|
131
145
|
/**
|
|
132
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.
|
|
133
148
|
* @param {boolean} cacheOnly - Optional flag to retrieve the fingerprint only from the cache.
|
|
134
|
-
* @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when
|
|
149
|
+
* @returns {Promise<string>} A promise that resolves with the fingerprint, or an empty string when unavailable.
|
|
135
150
|
* @throws {Error} Throws an error if retrieval fails after the agent has loaded.
|
|
136
151
|
*/
|
|
137
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> {
|
|
138
167
|
if (!window.fingerprintConfig) {
|
|
139
168
|
if (cacheOnly) {
|
|
140
169
|
return '';
|
|
@@ -143,7 +172,7 @@ async function getFingerprint(cacheOnly = false): Promise<string> {
|
|
|
143
172
|
throw new Error('Fingerprint configuration is not initialized.');
|
|
144
173
|
}
|
|
145
174
|
|
|
146
|
-
const cachedFingerprint =
|
|
175
|
+
const cachedFingerprint = readCachedFingerprint(context);
|
|
147
176
|
if (cachedFingerprint) {
|
|
148
177
|
return cachedFingerprint;
|
|
149
178
|
}
|
|
@@ -152,22 +181,69 @@ async function getFingerprint(cacheOnly = false): Promise<string> {
|
|
|
152
181
|
return '';
|
|
153
182
|
}
|
|
154
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> {
|
|
204
|
+
if (!isFingerprintReady()) {
|
|
205
|
+
throw new Error('Identification service is not initialized.');
|
|
206
|
+
}
|
|
207
|
+
|
|
155
208
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
};
|
|
160
238
|
} catch (error) {
|
|
161
|
-
console.error('Error retrieving fingerprint:', error);
|
|
239
|
+
if (!context) console.error('Error retrieving fingerprint:', error);
|
|
162
240
|
throw error;
|
|
163
241
|
}
|
|
164
242
|
}
|
|
165
243
|
|
|
166
244
|
/**
|
|
167
|
-
* Checks if the
|
|
168
|
-
*
|
|
169
|
-
* This function verifies that the Fingerprint agent loaded successfully.
|
|
170
|
-
* @returns {boolean} Returns true only after the agent has loaded.
|
|
245
|
+
* Checks if the Fingerprint agent finished loading and can identify visitors.
|
|
246
|
+
* @returns {boolean} True only after `initFingerprint()` has successfully loaded the agent.
|
|
171
247
|
*/
|
|
172
248
|
function isFingerprintReady(): boolean {
|
|
173
249
|
return window.fpClient !== undefined && fingerprintAgentReady;
|
|
@@ -176,6 +252,7 @@ function isFingerprintReady(): boolean {
|
|
|
176
252
|
const fingerprintService = {
|
|
177
253
|
initFingerprint,
|
|
178
254
|
getFingerprint,
|
|
255
|
+
identifyVisitor,
|
|
179
256
|
isFingerprintReady,
|
|
180
257
|
getCachedFingerprint,
|
|
181
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 };
|