@reservamos/browser-analytics 1.1.7 → 1.1.8-alpha.0

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/src/track.test.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import botDetectionRelayService from '@/services/botDetectionRelay';
3
3
  import fingerprintService from '@/services/fingerprint';
4
4
  import mixpanelService from '@/services/mixpanel';
5
- import { trackEvent } from './track';
5
+ import validator from '@/services/validator';
6
+ import { trackEvent, trackEventError, tryTrackEvent } from './track';
6
7
 
7
8
  vi.mock('@/services/botDetectionRelay', () => ({
8
9
  default: {
@@ -14,6 +15,7 @@ vi.mock('@/services/botDetectionRelay', () => ({
14
15
  vi.mock('@/services/fingerprint', () => ({
15
16
  default: {
16
17
  getFingerprint: vi.fn(),
18
+ getCachedFingerprint: vi.fn(),
17
19
  identifyVisitor: vi.fn(),
18
20
  isFingerprintReady: vi.fn(),
19
21
  },
@@ -42,115 +44,357 @@ vi.mock('@/services/validator', () => ({
42
44
 
43
45
  describe('trackEvent Bot Detection relay', () => {
44
46
  beforeEach(() => {
45
- vi.clearAllMocks();
47
+ vi.resetAllMocks();
46
48
  vi.mocked(mixpanelService.isReady).mockReturnValue(true);
47
49
  vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
48
50
  'distinct-123',
49
51
  );
50
- vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(true);
51
- });
52
-
53
- it('uses a fresh identification and relays a configured event', async () => {
54
52
  vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(true);
53
+ vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(true);
54
+ vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(
55
+ 'cached-visitor',
56
+ );
55
57
  vi.mocked(fingerprintService.identifyVisitor).mockResolvedValue({
56
- visitorId: 'visitor-123',
57
- requestId: 'request-123',
58
- visitorFound: true,
58
+ visitorId: 'fresh-visitor',
59
+ requestId: 'fresh-request',
59
60
  confidenceScore: 0.99,
60
61
  cacheHit: false,
61
62
  });
63
+ vi.mocked(botDetectionRelayService.relay).mockResolvedValue(undefined);
64
+ });
62
65
 
63
- await trackEvent('Payment Attempt', { 'Payment Type': 'credit_card' });
66
+ describe.each(['Search', 'Payment Attempt', 'Purchase Attempt'])(
67
+ '%s',
68
+ (eventName) => {
69
+ it.each(['cached-visitor', null])(
70
+ 'submits with cached identity %s before starting identification',
71
+ async (cachedVisitor) => {
72
+ vi.mocked(fingerprintService.getCachedFingerprint).mockReturnValue(
73
+ cachedVisitor,
74
+ );
75
+ vi.mocked(fingerprintService.identifyVisitor).mockImplementation(
76
+ () => {
77
+ expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
78
+ 'User Fingerprint': cachedVisitor ?? '',
79
+ 'Payment Type': 'credit_card',
80
+ 'Checkout Source': 'web',
81
+ });
82
+ return Promise.resolve({
83
+ visitorId: 'fresh-visitor',
84
+ requestId: 'fresh-request',
85
+ confidenceScore: 0.99,
86
+ cacheHit: false,
87
+ });
88
+ },
89
+ );
64
90
 
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',
91
+ const completion = trackEvent(
92
+ eventName,
93
+ { 'Payment Type': 'credit_card' },
94
+ { 'Checkout Source': 'web' },
95
+ );
96
+
97
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
98
+ await completion;
99
+ expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
100
+ expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
101
+ await vi.waitFor(() =>
102
+ expect(botDetectionRelayService.relay).toHaveBeenCalledOnce(),
103
+ );
104
+ expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
105
+ requestId: 'fresh-request',
106
+ visitorId: 'fresh-visitor',
107
+ distinctId: 'distinct-123',
108
+ eventName,
109
+ });
110
+ },
111
+ );
112
+
113
+ it('completes tracking while fresh identification is still pending', async () => {
114
+ let resolveIdentification!: (
115
+ value: Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>,
116
+ ) => void;
117
+ vi.mocked(fingerprintService.identifyVisitor).mockReturnValue(
118
+ new Promise((resolve) => {
119
+ resolveIdentification = resolve;
120
+ }),
121
+ );
122
+ const onComplete = vi.fn();
123
+ const completion = trackEvent(eventName, {}).then(onComplete);
124
+ // A pending remote operation must not delay submission or completion.
125
+ await Promise.resolve();
126
+ await Promise.resolve();
127
+ expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
128
+ 'User Fingerprint': 'cached-visitor',
129
+ });
130
+ expect(onComplete).toHaveBeenCalledOnce();
131
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
132
+ resolveIdentification({
133
+ visitorId: 'late-visitor',
134
+ requestId: 'late-request',
135
+ confidenceScore: 0.99,
136
+ cacheHit: false,
137
+ });
138
+ await completion;
139
+ await vi.waitFor(() =>
140
+ expect(botDetectionRelayService.relay).toHaveBeenCalledOnce(),
141
+ );
142
+ expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
143
+ requestId: 'late-request',
144
+ visitorId: 'late-visitor',
145
+ distinctId: 'distinct-123',
146
+ eventName,
147
+ });
148
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it('preserves the original event when identification rejects', async () => {
152
+ vi.mocked(fingerprintService.identifyVisitor).mockRejectedValue(
153
+ new Error('Identification failed'),
154
+ );
155
+ await trackEvent(eventName, {});
156
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
157
+ expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
158
+ 'User Fingerprint': 'cached-visitor',
159
+ });
160
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('submits without identity if reading the cache throws', async () => {
164
+ vi.mocked(fingerprintService.getCachedFingerprint).mockImplementation(
165
+ () => {
166
+ throw new Error('Cache unavailable');
167
+ },
168
+ );
169
+ await trackEvent(eventName, {});
170
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
171
+ expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
172
+ 'User Fingerprint': '',
173
+ });
174
+ });
175
+
176
+ it('submits without starting identification when the agent is unavailable', async () => {
177
+ vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
178
+ await trackEvent(eventName, {});
179
+ expect(mixpanelService.track).toHaveBeenCalledWith(eventName, {
180
+ 'User Fingerprint': 'cached-visitor',
181
+ });
182
+ expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
183
+ expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
184
+ });
185
+ },
186
+ );
187
+
188
+ it('relays the original distinct ID even if identity changes while identification is pending', async () => {
189
+ let resolveIdentification!: (
190
+ value: Awaited<ReturnType<typeof fingerprintService.identifyVisitor>>,
191
+ ) => void;
192
+ vi.mocked(fingerprintService.identifyVisitor).mockReturnValue(
193
+ new Promise((resolve) => {
194
+ resolveIdentification = resolve;
195
+ }),
196
+ );
197
+ await trackEvent('Payment Attempt', {});
198
+ vi.mocked(mixpanelService.getMixpanelDistinctId).mockReturnValue(
199
+ 'new-distinct-id',
200
+ );
201
+ resolveIdentification({
202
+ visitorId: 'fresh-visitor',
203
+ requestId: 'fresh-request',
204
+ confidenceScore: 0.99,
205
+ cacheHit: false,
76
206
  });
207
+ await vi.waitFor(() =>
208
+ expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
209
+ requestId: 'fresh-request',
210
+ visitorId: 'fresh-visitor',
211
+ distinctId: 'distinct-123',
212
+ eventName: 'Payment Attempt',
213
+ }),
214
+ );
77
215
  });
78
216
 
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'),
217
+ it('contains an unexpected background relay rejection', async () => {
218
+ vi.mocked(botDetectionRelayService.relay).mockRejectedValue(
219
+ new Error('Relay failed'),
83
220
  );
84
- const eventProperties = { 'Payment Type': 'credit_card' };
221
+ await trackEvent('Payment Attempt', {});
222
+ await Promise.resolve();
223
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
224
+ expect(mixpanelService.track).toHaveBeenCalledWith('Payment Attempt', {
225
+ 'User Fingerprint': 'cached-visitor',
226
+ });
227
+ });
85
228
 
86
- await trackEvent('Payment Attempt', eventProperties);
229
+ it('retains tracking error behavior for invalid event properties', async () => {
230
+ vi.mocked(validator.parseEventProps).mockImplementationOnce(() => {
231
+ throw new Error('Invalid event properties');
232
+ });
233
+ await trackEvent('Payment Attempt', {});
234
+ expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
235
+ expect(mixpanelService.track).toHaveBeenCalledTimes(1);
236
+ expect(mixpanelService.track).toHaveBeenCalledWith(
237
+ 'Track Event Error',
238
+ expect.objectContaining({ 'Failed Event Name': 'Payment Attempt' }),
239
+ );
240
+ });
87
241
 
88
- expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
89
- expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
242
+ it('does not start bot detection when submitting the original event throws', async () => {
243
+ vi.mocked(mixpanelService.track).mockImplementationOnce(() => {
244
+ throw new Error('Tracking failed');
245
+ });
246
+ await trackEvent('Payment Attempt', {});
247
+ expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
90
248
  expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it('submits View Results with an empty fingerprint if identification rejects', async () => {
252
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
253
+ vi.mocked(fingerprintService.getFingerprint).mockRejectedValue(
254
+ new Error('Fingerprint failed'),
255
+ );
256
+ await trackEvent('View Results', { 'Results Count': 2 }, { source: 'web' });
257
+ expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(false);
91
258
  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,
259
+ expect(mixpanelService.track).toHaveBeenCalledWith('View Results', {
260
+ 'User Fingerprint': '',
261
+ 'Results Count': 2,
262
+ 'source': 'web',
97
263
  });
98
264
  });
99
265
 
100
- it('preserves existing fingerprint behavior for an unconfigured event', async () => {
266
+ it('preserves existing fingerprint behavior for events outside bot detection', async () => {
101
267
  vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
102
268
  vi.mocked(fingerprintService.getFingerprint).mockResolvedValue(
103
269
  'cached-visitor',
104
270
  );
105
-
106
- await trackEvent('Payment Attempt', { 'Payment Type': 'credit_card' });
107
-
271
+ await trackEvent('Seat Change', {});
108
272
  expect(fingerprintService.getFingerprint).toHaveBeenCalledWith(true);
273
+ expect(fingerprintService.getCachedFingerprint).not.toHaveBeenCalled();
109
274
  expect(fingerprintService.identifyVisitor).not.toHaveBeenCalled();
110
- expect(mixpanelService.track).toHaveBeenCalledWith('Payment Attempt', {
275
+ expect(mixpanelService.track).toHaveBeenCalledWith('Seat Change', {
111
276
  'User Fingerprint': 'cached-visitor',
112
- 'Payment Type': 'credit_card',
113
277
  });
114
278
  expect(botDetectionRelayService.relay).not.toHaveBeenCalled();
115
279
  });
280
+ });
116
281
 
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
- });
282
+ describe('bounded tracking readiness', () => {
283
+ beforeEach(() => {
284
+ vi.useFakeTimers();
285
+ vi.resetAllMocks();
286
+ vi.mocked(mixpanelService.isReady).mockReturnValue(false);
287
+ vi.mocked(botDetectionRelayService.isEnabledFor).mockReturnValue(false);
288
+ vi.mocked(fingerprintService.getFingerprint).mockResolvedValue('');
289
+ vi.mocked(fingerprintService.isFingerprintReady).mockReturnValue(false);
290
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
291
+ });
292
+ afterEach(() => {
293
+ vi.clearAllTimers();
294
+ vi.useRealTimers();
295
+ vi.restoreAllMocks();
296
+ });
297
+
298
+ it('submits at the deadline if Mixpanel loaded without a Tracker Ready signal', async () => {
299
+ const pending = trackEvent('Payment Attempt', { source: 'checkout' });
300
+ await vi.advanceTimersByTimeAsync(1000);
301
+ vi.mocked(mixpanelService.isReady).mockReturnValue(true);
302
+ await vi.advanceTimersByTimeAsync(4000);
303
+ await pending;
304
+ expect(mixpanelService.track).toHaveBeenCalledWith(
305
+ 'Payment Attempt',
306
+ expect.objectContaining({ source: 'checkout' }),
307
+ );
308
+ expect(console.error).not.toHaveBeenCalled();
309
+ expect(vi.getTimerCount()).toBe(0);
310
+ });
126
311
 
127
- await trackEvent('Search', {});
312
+ it('holds multiple events until readiness and submits each once', async () => {
313
+ const remove = vi.spyOn(window, 'removeEventListener');
314
+ const first = trackEvent('Payment Attempt', { source: 'first' });
315
+ const second = trackEvent('Purchase Attempt', { source: 'second' });
316
+ void first.catch(() => undefined);
317
+ void second.catch(() => undefined);
318
+ expect(mixpanelService.track).not.toHaveBeenCalled();
319
+ vi.mocked(mixpanelService.isReady).mockReturnValue(true);
320
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
321
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
322
+ await expect(Promise.all([first, second])).resolves.toEqual([
323
+ undefined,
324
+ undefined,
325
+ ]);
326
+ expect(mixpanelService.track).toHaveBeenCalledTimes(2);
327
+ expect(mixpanelService.track).toHaveBeenCalledWith(
328
+ 'Payment Attempt',
329
+ expect.objectContaining({ source: 'first' }),
330
+ );
331
+ expect(mixpanelService.track).toHaveBeenCalledWith(
332
+ 'Purchase Attempt',
333
+ expect.objectContaining({ source: 'second' }),
334
+ );
335
+ expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
336
+ expect(vi.getTimerCount()).toBe(0);
337
+ });
128
338
 
129
- expect(fingerprintService.identifyVisitor).toHaveBeenCalledWith(true);
130
- expect(fingerprintService.getFingerprint).not.toHaveBeenCalled();
131
- expect(mixpanelService.track).toHaveBeenCalledWith('Search', {
132
- 'User Fingerprint': 'visitor-123',
339
+ it('expires cleanly, reports a controlled diagnostic, and ignores late readiness', async () => {
340
+ const remove = vi.spyOn(window, 'removeEventListener');
341
+ const pending = trackEvent('Payment Attempt', {
342
+ secret: 'private-payload',
133
343
  });
134
- expect(botDetectionRelayService.relay).toHaveBeenCalledWith({
135
- requestId: 'search-request-123',
136
- visitorId: 'visitor-123',
137
- distinctId: 'distinct-123',
138
- eventName: 'Search',
344
+ void pending.catch(() => undefined);
345
+ await vi.advanceTimersByTimeAsync(5000);
346
+ await expect(pending).resolves.toBeUndefined();
347
+ expect(console.error).toHaveBeenCalledWith('Tracking diagnostic', {
348
+ eventName: 'Payment Attempt',
349
+ stage: 'readiness',
350
+ reason: 'timeout',
139
351
  });
352
+ expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
353
+ 'private-payload',
354
+ );
355
+ expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
356
+ expect(vi.getTimerCount()).toBe(0);
357
+ vi.mocked(mixpanelService.isReady).mockReturnValue(true);
358
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
359
+ await Promise.resolve();
360
+ expect(mixpanelService.track).not.toHaveBeenCalled();
140
361
  });
141
362
 
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('');
363
+ it('does not execute when a readiness signal arrives before Mixpanel is ready', async () => {
364
+ const callback = vi.fn().mockResolvedValue(undefined);
365
+ const pending = tryTrackEvent(callback);
366
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
367
+ await Promise.resolve();
368
+ expect(callback).not.toHaveBeenCalled();
369
+ vi.mocked(mixpanelService.isReady).mockReturnValue(true);
370
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
371
+ await pending;
372
+ expect(callback).toHaveBeenCalledOnce();
373
+ expect(vi.getTimerCount()).toBe(0);
374
+ });
146
375
 
147
- await trackEvent('Search', {});
376
+ it('consumes callback rejection after readiness', async () => {
377
+ const callback = vi
378
+ .fn()
379
+ .mockRejectedValue(new Error('private-callback-error'));
380
+ const pending = tryTrackEvent(callback);
381
+ vi.mocked(mixpanelService.isReady).mockReturnValue(true);
382
+ window.dispatchEvent(new CustomEvent('Tracker Ready'));
383
+ await expect(pending).resolves.toBeUndefined();
384
+ expect(callback).toHaveBeenCalledOnce();
385
+ expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
386
+ 'private-callback-error',
387
+ );
388
+ expect(vi.getTimerCount()).toBe(0);
389
+ });
148
390
 
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();
391
+ it('expires a queued tracking diagnostic without creating another listener', async () => {
392
+ const add = vi.spyOn(window, 'addEventListener');
393
+ trackEventError('Payment Attempt', new Error('failed'), {});
394
+ await vi.advanceTimersByTimeAsync(5000);
395
+ expect(
396
+ add.mock.calls.filter(([name]) => name === 'Tracker Ready'),
397
+ ).toHaveLength(1);
398
+ expect(vi.getTimerCount()).toBe(0);
155
399
  });
156
400
  });
package/src/track.ts CHANGED
@@ -89,12 +89,42 @@ export function trackEventError(
89
89
  });
90
90
  }
91
91
 
92
- export async function tryTrackEvent(trackEventCallback: () => Promise<void>) {
93
- if (mixpanelService.isReady()) {
92
+ /** Waits for one bounded attempt, then consumes callback failures. */
93
+ export async function tryTrackEvent(
94
+ trackEventCallback: () => Promise<void>,
95
+ eventName = 'Track Event Error',
96
+ ): Promise<void> {
97
+ if (!mixpanelService.isReady()) {
98
+ const ready = await new Promise<boolean>((resolve) => {
99
+ const finish = (isReady: boolean) => {
100
+ clearTimeout(timer);
101
+ window.removeEventListener('Tracker Ready', onReady);
102
+ resolve(isReady);
103
+ };
104
+ const onReady = () => {
105
+ if (mixpanelService.isReady()) finish(true);
106
+ };
107
+ const timer = setTimeout(() => finish(mixpanelService.isReady()), 5000);
108
+ window.addEventListener('Tracker Ready', onReady);
109
+ onReady();
110
+ });
111
+ if (!ready) {
112
+ console.error('Tracking diagnostic', {
113
+ eventName,
114
+ stage: 'readiness',
115
+ reason: 'timeout',
116
+ });
117
+ return;
118
+ }
119
+ }
120
+
121
+ try {
94
122
  await trackEventCallback();
95
- } else {
96
- window.addEventListener('Tracker Ready', async () => {
97
- await trackEventCallback();
123
+ } catch {
124
+ console.error('Tracking diagnostic', {
125
+ eventName,
126
+ stage: 'submission',
127
+ reason: 'failed',
98
128
  });
99
129
  }
100
130
  }
@@ -105,33 +135,50 @@ export async function tryTrackEvent(trackEventCallback: () => Promise<void>) {
105
135
  * @param {string} eventName - The name of the event to track.
106
136
  * @param {object} eventProperties - The properties of the event to track.
107
137
  * @param {EventMetadata} meta - Additional metadata to include in the event.
108
- * @throws {Error} Throws an error if Mixpanel or Fingerprint is not ready.
138
+ * Waits up to five seconds for Mixpanel readiness before event submission.
139
+ * Optional fingerprint failure does not discard an otherwise valid event.
109
140
  */
110
- export async function trackEvent(
141
+ export function trackEvent(
111
142
  eventName: string,
112
143
  eventProperties: object,
113
144
  meta: EventMetadata = {},
114
145
  ): Promise<void> {
115
- if (!mixpanelService.isReady()) {
116
- throw new Error('Mixpanel is not initialized.');
117
- }
146
+ return tryTrackEvent(
147
+ () => submitEvent(eventName, eventProperties, meta),
148
+ eventName,
149
+ );
150
+ }
118
151
 
152
+ async function submitEvent(
153
+ eventName: string,
154
+ eventProperties: object,
155
+ meta: EventMetadata,
156
+ ): Promise<void> {
119
157
  try {
120
158
  validator.parseEventProps(eventName, eventProperties);
121
159
  // Call the Mixpanel service to store the distinct ID in the cache
122
160
  const distinctId = mixpanelService.getMixpanelDistinctId();
123
161
  const botDetectionEnabled =
124
162
  botDetectionRelayService.isEnabledFor(eventName);
125
- let fingerprint: string;
126
- let fingerprintRequestId: string | undefined;
163
+ let fingerprint = '';
127
164
 
128
- if (botDetectionEnabled && fingerprintService.isFingerprintReady()) {
129
- const identification = await fingerprintService.identifyVisitor(true);
130
- fingerprint = identification.visitorId;
131
- fingerprintRequestId = identification.requestId;
165
+ if (botDetectionEnabled) {
166
+ try {
167
+ fingerprint = fingerprintService.getCachedFingerprint() ?? '';
168
+ } catch (error) {
169
+ console.error('Error reading cached fingerprint:', error);
170
+ }
132
171
  } else {
133
172
  const cacheOnly = !FP_TRIGGER_EVENTS.includes(eventName);
134
- fingerprint = await fingerprintService.getFingerprint(cacheOnly);
173
+ try {
174
+ fingerprint = await fingerprintService.getFingerprint(cacheOnly);
175
+ } catch {
176
+ console.error('Tracking diagnostic', {
177
+ eventName,
178
+ stage: 'fingerprint',
179
+ reason: 'failed',
180
+ });
181
+ }
135
182
  }
136
183
  const defaultCoordinates: Record<string, string | number> = {};
137
184
  const coordinates = geolocationService.getCoordinates();
@@ -159,13 +206,8 @@ export async function trackEvent(
159
206
 
160
207
  mixpanelService.track(eventName, properties);
161
208
 
162
- if (fingerprintRequestId) {
163
- void botDetectionRelayService.relay({
164
- requestId: fingerprintRequestId,
165
- visitorId: fingerprint,
166
- ...(distinctId && { distinctId }),
167
- eventName,
168
- });
209
+ if (botDetectionEnabled) {
210
+ void identifyAndRelayFingerprint(eventName, distinctId);
169
211
  }
170
212
  } catch (error) {
171
213
  console.error(`Error tracking event '${eventName}':`, error);
@@ -173,6 +215,28 @@ export async function trackEvent(
173
215
  }
174
216
  }
175
217
 
218
+ /** Identifies and relays only after the original event has been submitted. */
219
+ async function identifyAndRelayFingerprint(
220
+ eventName: string,
221
+ distinctId: string | null,
222
+ ): Promise<void> {
223
+ try {
224
+ if (!fingerprintService.isFingerprintReady()) return;
225
+
226
+ const identification = await fingerprintService.identifyVisitor(true);
227
+ if (!identification.requestId) return;
228
+
229
+ await botDetectionRelayService.relay({
230
+ requestId: identification.requestId,
231
+ visitorId: identification.visitorId,
232
+ ...(distinctId && { distinctId }),
233
+ eventName,
234
+ });
235
+ } catch (error) {
236
+ console.error(`Error running Bot Detection for '${eventName}':`, error);
237
+ }
238
+ }
239
+
176
240
  /**
177
241
  * Extracts UTM and other tracking parameters from the current URL that match predefined fields.
178
242
  * @returns {Record<string, string>} An object containing matched tracking parameters.