@reservamos/browser-analytics 1.1.7-alpha.0 → 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.
@@ -0,0 +1,127 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import configService from '@/services/config';
3
+ import botDetectionRelayService from './botDetectionRelay';
4
+
5
+ vi.mock('@/services/config', () => ({
6
+ default: {
7
+ getCoreAPIConfig: vi.fn(),
8
+ },
9
+ }));
10
+
11
+ const payload = {
12
+ requestId: 'request-123',
13
+ visitorId: 'visitor-123',
14
+ distinctId: 'distinct-123',
15
+ eventName: 'Payment Attempt',
16
+ };
17
+
18
+ const coreApiConfig = {
19
+ baseUrl: 'https://data-lake.reservamossaas.com/api',
20
+ defaultHeaders: {
21
+ 'Authorization': 'Bearer test-token',
22
+ 'Origin': 'https://example.com',
23
+ 'x-app-name': 'reservamos-web',
24
+ },
25
+ };
26
+
27
+ describe('botDetectionRelayService', () => {
28
+ beforeEach(() => {
29
+ vi.unstubAllGlobals();
30
+ vi.mocked(configService.getCoreAPIConfig).mockReturnValue(coreApiConfig);
31
+ });
32
+
33
+ it('enables only the SDK-owned Bot Detection events', () => {
34
+ expect(botDetectionRelayService.isEnabledFor('Search')).toBe(true);
35
+ expect(botDetectionRelayService.isEnabledFor('Payment Attempt')).toBe(true);
36
+ expect(botDetectionRelayService.isEnabledFor('Purchase Attempt')).toBe(
37
+ true,
38
+ );
39
+ expect(botDetectionRelayService.isEnabledFor('View Results')).toBe(false);
40
+ expect(
41
+ botDetectionRelayService.isEnabledFor('Purchase Confirmation Viewed'),
42
+ ).toBe(false);
43
+ });
44
+
45
+ it('posts the payload to the Core API fingerprint path with keepalive', async () => {
46
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
47
+ vi.stubGlobal('fetch', fetchMock);
48
+
49
+ await botDetectionRelayService.relay(payload);
50
+
51
+ expect(fetchMock).toHaveBeenCalledWith(
52
+ 'https://data-lake.reservamossaas.com/api/v1/fingerprint/bot-detection',
53
+ {
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ 'x-app-name': 'reservamos-web',
58
+ },
59
+ body: JSON.stringify(payload),
60
+ keepalive: true,
61
+ },
62
+ );
63
+ });
64
+
65
+ it('omits x-app-name when it is not in the core API config', async () => {
66
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
67
+ vi.stubGlobal('fetch', fetchMock);
68
+ vi.mocked(configService.getCoreAPIConfig).mockReturnValue({
69
+ baseUrl: 'https://datalake-api-dev.reservamossaas.com/api',
70
+ defaultHeaders: {
71
+ Authorization: 'Bearer test-token',
72
+ Origin: 'https://example.com',
73
+ },
74
+ });
75
+
76
+ await botDetectionRelayService.relay(payload);
77
+
78
+ expect(fetchMock).toHaveBeenCalledWith(
79
+ 'https://datalake-api-dev.reservamossaas.com/api/v1/fingerprint/bot-detection',
80
+ expect.objectContaining({
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ },
84
+ }),
85
+ );
86
+ });
87
+
88
+ it('contains rejected requests and reports the failure', async () => {
89
+ const error = new Error('Network unavailable');
90
+ const consoleError = vi
91
+ .spyOn(console, 'error')
92
+ .mockImplementation(() => undefined);
93
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error));
94
+
95
+ await expect(
96
+ botDetectionRelayService.relay(payload),
97
+ ).resolves.toBeUndefined();
98
+ expect(consoleError).toHaveBeenCalledWith(
99
+ 'Error relaying Fingerprint identification:',
100
+ error,
101
+ );
102
+
103
+ consoleError.mockRestore();
104
+ });
105
+
106
+ it('contains non-success responses and reports their status', async () => {
107
+ const consoleError = vi
108
+ .spyOn(console, 'error')
109
+ .mockImplementation(() => undefined);
110
+ vi.stubGlobal(
111
+ 'fetch',
112
+ vi.fn().mockResolvedValue({ ok: false, status: 503 }),
113
+ );
114
+
115
+ await expect(
116
+ botDetectionRelayService.relay(payload),
117
+ ).resolves.toBeUndefined();
118
+ expect(consoleError).toHaveBeenCalledWith(
119
+ 'Error relaying Fingerprint identification:',
120
+ expect.objectContaining({
121
+ message: 'Bot Detection relay failed with status 503',
122
+ }),
123
+ );
124
+
125
+ consoleError.mockRestore();
126
+ });
127
+ });
@@ -0,0 +1,64 @@
1
+ import type { BotDetectionRelayPayload } from '@/types/botDetection';
2
+ import configService from '@/services/config';
3
+
4
+ const BOT_DETECTION_RELAY_PATH = '/v1/fingerprint/bot-detection';
5
+
6
+ /**
7
+ * Events that require a fresh Fingerprint identification and Core API relay.
8
+ * Owned by the SDK so consumers do not choose which events participate.
9
+ */
10
+ const BOT_DETECTION_EVENTS = [
11
+ 'Search',
12
+ 'Payment Attempt',
13
+ 'Purchase Attempt',
14
+ ] as const;
15
+
16
+ /**
17
+ * Checks whether an event is eligible for Bot Detection.
18
+ * @param {string} eventName - Mixpanel event name.
19
+ * @returns {boolean} Whether the event should obtain and relay a fresh identification.
20
+ */
21
+ function isEnabledFor(eventName: string): boolean {
22
+ return (BOT_DETECTION_EVENTS as readonly string[]).includes(eventName);
23
+ }
24
+
25
+ /**
26
+ * Relays Fingerprint identification context to Core API.
27
+ * Failures are contained so they cannot affect an event already submitted to Mixpanel.
28
+ * @param {BotDetectionRelayPayload} payload - Request correlation data.
29
+ * @returns {Promise<void>} Resolves after the relay attempt completes.
30
+ */
31
+ async function relay(payload: BotDetectionRelayPayload): Promise<void> {
32
+ try {
33
+ const coreApiConfig = configService.getCoreAPIConfig();
34
+ const response = await fetch(
35
+ `${coreApiConfig.baseUrl}${BOT_DETECTION_RELAY_PATH}`,
36
+ {
37
+ method: 'POST',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ ...(coreApiConfig.defaultHeaders?.['x-app-name']
41
+ ? { 'x-app-name': coreApiConfig.defaultHeaders['x-app-name'] }
42
+ : {}),
43
+ },
44
+ body: JSON.stringify(payload),
45
+ keepalive: true,
46
+ },
47
+ );
48
+
49
+ if (!response.ok) {
50
+ throw new Error(
51
+ `Bot Detection relay failed with status ${response.status}`,
52
+ );
53
+ }
54
+ } catch (error) {
55
+ console.error('Error relaying Fingerprint identification:', error);
56
+ }
57
+ }
58
+
59
+ const botDetectionRelayService = {
60
+ isEnabledFor,
61
+ relay,
62
+ };
63
+
64
+ export default botDetectionRelayService;
@@ -1,66 +1,279 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import fingerprintService from './fingerprint';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fingerprintService, { getFingerprintForIdentify } from './fingerprint';
3
3
 
4
- const { initMock, getVisitorDataMock } = vi.hoisted(() => ({
5
- initMock: vi.fn(),
6
- getVisitorDataMock: vi.fn(),
7
- }));
4
+ const visitorData = {
5
+ visitorId: 'visitor-123',
6
+ requestId: 'request-123',
7
+ visitorFound: true,
8
+ confidence: { score: 0.99 },
9
+ cacheHit: false,
10
+ };
11
+
12
+ const { getVisitorDataMock, initMock, setPeoplePropertiesMock, clientMock } =
13
+ vi.hoisted(() => ({
14
+ clientMock: vi.fn(),
15
+ getVisitorDataMock: vi.fn(),
16
+ initMock: vi.fn(),
17
+ setPeoplePropertiesMock: vi.fn(),
18
+ }));
8
19
 
9
20
  vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
10
21
  FingerprintJSPro: {
11
22
  defaultEndpoint: 'https://fpjscdn.net',
12
23
  defaultScriptUrlPattern: 'https://fpjscdn.net/agent',
13
24
  },
14
- FpjsClient: vi.fn().mockImplementation(() => ({
15
- init: initMock,
16
- getVisitorData: getVisitorDataMock,
17
- })),
25
+ FpjsClient: clientMock,
18
26
  }));
19
27
 
20
- describe('fingerprintService readiness', () => {
21
- beforeEach(() => {
22
- localStorage.clear();
23
- initMock.mockReset();
28
+ vi.mock('@/services/mixpanel', () => ({
29
+ default: { setPeopleProperties: setPeoplePropertiesMock },
30
+ }));
31
+
32
+ describe('fingerprintService', () => {
33
+ let storage: Record<string, string>;
34
+
35
+ beforeEach(async () => {
36
+ clientMock.mockImplementation(() => ({
37
+ init: initMock,
38
+ getVisitorData: getVisitorDataMock,
39
+ }));
40
+ storage = {};
41
+ vi.stubGlobal('localStorage', {
42
+ getItem: (key: string) => storage[key] ?? null,
43
+ setItem: (key: string, value: string) => {
44
+ storage[key] = value;
45
+ },
46
+ removeItem: (key: string) => {
47
+ storage = Object.fromEntries(
48
+ Object.entries(storage).filter(([entryKey]) => entryKey !== key),
49
+ );
50
+ },
51
+ clear: () => {
52
+ storage = {};
53
+ },
54
+ });
24
55
  getVisitorDataMock.mockReset();
56
+ initMock.mockReset();
57
+ setPeoplePropertiesMock.mockReset();
58
+ getVisitorDataMock.mockResolvedValue(visitorData);
59
+ initMock.mockResolvedValue(undefined);
60
+ await fingerprintService.initFingerprint('test-api-key');
25
61
  });
26
62
 
27
- it('does not identify with a client whose agent failed to load', async () => {
28
- const initError = new Error('Agent script blocked');
63
+ afterEach(() => {
64
+ vi.restoreAllMocks();
65
+ vi.unstubAllGlobals();
66
+ });
67
+
68
+ it('omits unavailable Visitor Found metadata without blocking identification', async () => {
69
+ getVisitorDataMock.mockResolvedValue({
70
+ ...visitorData,
71
+ visitorFound: undefined,
72
+ });
73
+ const result = await fingerprintService.identifyVisitor();
74
+ expect(result.visitorId).toBe('visitor-123');
75
+ expect(result.requestId).toBe('request-123');
76
+ expect(result).not.toHaveProperty('visitorFound');
77
+ expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('suppresses identify-specific cache and profile effects after its scope closes', async () => {
81
+ let finish: (value: typeof visitorData) => void = () => undefined;
82
+ getVisitorDataMock.mockReturnValue(
83
+ new Promise((resolve) => {
84
+ finish = resolve;
85
+ }),
86
+ );
87
+ let active = true;
88
+ const pending = getFingerprintForIdentify({
89
+ isActive: () => active,
90
+ report: vi.fn(),
91
+ });
92
+ active = false;
93
+ finish(visitorData);
94
+ await pending;
95
+ expect(fingerprintService.getCachedFingerprint()).toBeNull();
96
+ expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('continues the identify path when fingerprint cache writes fail', async () => {
100
+ vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
101
+ throw new Error('private-id');
102
+ });
103
+ const report = vi.fn();
104
+ await expect(
105
+ getFingerprintForIdentify({ isActive: () => true, report }),
106
+ ).resolves.toBe('visitor-123');
107
+ expect(report).toHaveBeenCalledWith('cache', 'unavailable');
108
+ expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
109
+ 'Visitor Found': true,
110
+ });
111
+ });
112
+
113
+ it('sanitizes errors only on the identify-specific fingerprint path', async () => {
114
+ const error = new Error('private-id');
115
+ getVisitorDataMock.mockRejectedValue(error);
29
116
  const consoleError = vi
30
117
  .spyOn(console, 'error')
31
118
  .mockImplementation(() => undefined);
32
- initMock.mockRejectedValue(initError);
33
- getVisitorDataMock.mockRejectedValue(
34
- new Error(
35
- "FPJSAgent hasn't loaded yet. Make sure to call the init() method first.",
36
- ),
119
+ await expect(
120
+ getFingerprintForIdentify({ isActive: () => true, report: vi.fn() }),
121
+ ).rejects.toBe(error);
122
+ expect(consoleError).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('returns request-scoped identification data and bypasses the SPA cache', async () => {
126
+ await expect(fingerprintService.identifyVisitor(true)).resolves.toEqual({
127
+ visitorId: 'visitor-123',
128
+ requestId: 'request-123',
129
+ visitorFound: true,
130
+ confidenceScore: 0.99,
131
+ cacheHit: false,
132
+ });
133
+
134
+ expect(getVisitorDataMock).toHaveBeenCalledWith({}, true);
135
+ expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
136
+ 'Visitor Found': true,
137
+ });
138
+ });
139
+
140
+ it('writes a false visitor match to the Mixpanel profile', async () => {
141
+ getVisitorDataMock.mockResolvedValue({
142
+ ...visitorData,
143
+ visitorFound: false,
144
+ });
145
+
146
+ await expect(
147
+ fingerprintService.identifyVisitor(true),
148
+ ).resolves.toMatchObject({ visitorFound: false });
149
+ expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
150
+ 'Visitor Found': false,
151
+ });
152
+ expect(setPeoplePropertiesMock).toHaveBeenCalledTimes(1);
153
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ it('keeps identification data when the Mixpanel profile update fails', async () => {
157
+ const error = new Error('Mixpanel unavailable');
158
+ const consoleError = vi
159
+ .spyOn(console, 'error')
160
+ .mockImplementation(() => undefined);
161
+ setPeoplePropertiesMock.mockImplementation(() => {
162
+ throw error;
163
+ });
164
+
165
+ await expect(fingerprintService.identifyVisitor(true)).resolves.toEqual({
166
+ visitorId: 'visitor-123',
167
+ requestId: 'request-123',
168
+ visitorFound: true,
169
+ confidenceScore: 0.99,
170
+ cacheHit: false,
171
+ });
172
+ expect(fingerprintService.getCachedFingerprint()).toBe('visitor-123');
173
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
174
+ expect(consoleError).toHaveBeenCalledWith(
175
+ 'Error updating Mixpanel visitor profile:',
176
+ error,
177
+ );
178
+
179
+ consoleError.mockRestore();
180
+ });
181
+
182
+ it('preserves the visitor-only SDK cache contract', async () => {
183
+ await fingerprintService.identifyVisitor(true);
184
+
185
+ expect(fingerprintService.getCachedFingerprint()).toBe('visitor-123');
186
+ await expect(fingerprintService.getFingerprint(true)).resolves.toBe(
187
+ 'visitor-123',
37
188
  );
189
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
190
+ expect(setPeoplePropertiesMock).toHaveBeenCalledTimes(1);
191
+ });
192
+
193
+ it('does not reuse a legacy cached visitor ID as a request-scoped result', async () => {
194
+ localStorage.setItem(
195
+ 'default_fingerprint_cache',
196
+ JSON.stringify({
197
+ fingerprint: 'legacy-visitor',
198
+ expiresAt: Date.now() + 60_000,
199
+ }),
200
+ );
201
+
202
+ await expect(
203
+ fingerprintService.identifyVisitor(true),
204
+ ).resolves.toMatchObject({
205
+ visitorId: 'visitor-123',
206
+ requestId: 'request-123',
207
+ });
208
+ expect(getVisitorDataMock).toHaveBeenCalledWith({}, true);
209
+ });
210
+
211
+ it('reports and rethrows identification errors', async () => {
212
+ const error = new Error('Fingerprint unavailable');
213
+ const consoleError = vi
214
+ .spyOn(console, 'error')
215
+ .mockImplementation(() => undefined);
216
+ getVisitorDataMock.mockRejectedValue(error);
217
+
218
+ await expect(fingerprintService.identifyVisitor(true)).rejects.toThrow(
219
+ 'Fingerprint unavailable',
220
+ );
221
+ expect(consoleError).toHaveBeenCalledWith(
222
+ 'Error retrieving fingerprint:',
223
+ error,
224
+ );
225
+ expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
226
+
227
+ consoleError.mockRestore();
228
+ });
229
+
230
+ it('does not treat a constructed client as ready until the agent loads', async () => {
231
+ const consoleError = vi
232
+ .spyOn(console, 'error')
233
+ .mockImplementation(() => undefined);
234
+ const initError = new Error('Agent script blocked');
235
+ initMock.mockRejectedValue(initError);
38
236
 
39
237
  await expect(
40
- fingerprintService.initFingerprint('test-key'),
238
+ fingerprintService.initFingerprint('test-api-key'),
41
239
  ).rejects.toThrow('Agent script blocked');
42
240
  expect(fingerprintService.isFingerprintReady()).toBe(false);
43
- await expect(fingerprintService.getFingerprint()).resolves.toBe('');
44
- expect(getVisitorDataMock).not.toHaveBeenCalled();
45
241
  expect(consoleError).toHaveBeenCalledWith(
46
242
  'Error initializing identification service:',
47
243
  initError,
48
244
  );
49
245
 
246
+ await expect(fingerprintService.getFingerprint()).resolves.toBe('');
247
+ await expect(fingerprintService.identifyVisitor(true)).rejects.toThrow(
248
+ 'Identification service is not initialized.',
249
+ );
250
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
251
+
50
252
  consoleError.mockRestore();
51
253
  });
52
254
 
53
- it('identifies after the agent loads successfully', async () => {
54
- initMock.mockResolvedValue(undefined);
55
- getVisitorDataMock.mockResolvedValue({ visitorId: 'visitor-123' });
56
-
57
- await fingerprintService.initFingerprint('test-key');
255
+ it('returns a cached visitor ID when the agent failed to load', async () => {
256
+ localStorage.setItem(
257
+ 'default_fingerprint_cache',
258
+ JSON.stringify({
259
+ fingerprint: 'cached-visitor',
260
+ expiresAt: Date.now() + 60_000,
261
+ }),
262
+ );
263
+ const consoleError = vi
264
+ .spyOn(console, 'error')
265
+ .mockImplementation(() => undefined);
266
+ initMock.mockRejectedValue(new Error('Agent script blocked'));
58
267
 
59
- expect(fingerprintService.isFingerprintReady()).toBe(true);
268
+ await expect(
269
+ fingerprintService.initFingerprint('test-api-key'),
270
+ ).rejects.toThrow('Agent script blocked');
60
271
  await expect(fingerprintService.getFingerprint()).resolves.toBe(
61
- 'visitor-123',
272
+ 'cached-visitor',
62
273
  );
63
- expect(getVisitorDataMock).toHaveBeenCalledOnce();
274
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
275
+
276
+ consoleError.mockRestore();
64
277
  });
65
278
 
66
279
  it('waits for the agent to load before identifying', async () => {
@@ -71,9 +284,8 @@ describe('fingerprintService readiness', () => {
71
284
  finishInit = resolve;
72
285
  }),
73
286
  );
74
- getVisitorDataMock.mockResolvedValue({ visitorId: 'visitor-123' });
75
287
 
76
- const init = fingerprintService.initFingerprint('test-key');
288
+ const init = fingerprintService.initFingerprint('test-api-key');
77
289
 
78
290
  expect(fingerprintService.isFingerprintReady()).toBe(false);
79
291
  await expect(fingerprintService.getFingerprint()).resolves.toBe('');
@@ -127,7 +339,10 @@ describe('fingerprintService readiness', () => {
127
339
  }),
128
340
  )
129
341
  .mockResolvedValueOnce(undefined);
130
- getVisitorDataMock.mockResolvedValue({ visitorId: 'new-visitor' });
342
+ getVisitorDataMock.mockResolvedValue({
343
+ ...visitorData,
344
+ visitorId: 'new-visitor',
345
+ });
131
346
  const consoleError = vi
132
347
  .spyOn(console, 'error')
133
348
  .mockImplementation(() => undefined);
@@ -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 the agent is unavailable.
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 = getCachedFingerprint();
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 fpClient = window.fpClient;
157
- const { visitorId: fingerprint } = await fpClient.getVisitorData();
158
- setCachedFingerprint(fingerprint);
159
- return fingerprint;
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 identification service is ready.
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
  };