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

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -3
  2. package/README.md +33 -7
  3. package/dist/browser-analytics.cjs +2 -2
  4. package/dist/browser-analytics.cjs.map +1 -1
  5. package/dist/browser-analytics.d.ts +15 -2
  6. package/dist/browser-analytics.esm.js +590 -109
  7. package/dist/browser-analytics.esm.js.map +1 -1
  8. package/dist/browser-analytics.iife.js +2 -2
  9. package/dist/browser-analytics.iife.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/events/identify/identify.robustness.test.ts +112 -13
  12. package/src/events/identify/identify.ts +84 -34
  13. package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
  14. package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
  15. package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
  16. package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
  17. package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
  18. package/src/events/sharedSchemas/tripSchema.ts +3 -8
  19. package/src/index.ts +1 -0
  20. package/src/init.botDetection.test.ts +187 -0
  21. package/src/init.test.ts +1 -0
  22. package/src/init.ts +15 -2
  23. package/src/services/botDetectionRelay.test.ts +3 -2
  24. package/src/services/deferredBotDetection.ts +60 -0
  25. package/src/services/fingerprint.test.ts +190 -0
  26. package/src/services/fingerprint.ts +141 -13
  27. package/src/services/fingerprint.wait.integration.test.ts +129 -0
  28. package/src/services/mixpanel.test.ts +119 -73
  29. package/src/services/mixpanel.ts +82 -23
  30. package/src/services/trackingLifecycle.ts +54 -0
  31. package/src/services/validator/validator.test.ts +79 -0
  32. package/src/track.readiness.test.ts +131 -0
  33. package/src/track.recovery.test.ts +339 -0
  34. package/src/track.storage.test.ts +142 -0
  35. package/src/track.test.ts +130 -5
  36. package/src/track.ts +110 -25
  37. package/src/util/pendingWork.test.ts +164 -0
  38. package/src/util/pendingWork.ts +148 -0
  39. package/src/util/snapshotEventData.ts +15 -0
@@ -0,0 +1,187 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fingerprintService from '@/services/fingerprint';
3
+ import { trackEvent } from '@/track';
4
+ import init from './init';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ track: vi.fn(),
8
+ observation: vi.fn(),
9
+ fingerprintInit: vi.fn(),
10
+ visitorData: vi.fn(),
11
+ geolocationInit: vi.fn(),
12
+ }));
13
+
14
+ vi.mock('@fingerprintjs/fingerprintjs-pro-spa', () => ({
15
+ FingerprintJSPro: {
16
+ defaultEndpoint: 'https://fp.example.test',
17
+ defaultScriptUrlPattern: 'https://fp.example.test/agent',
18
+ },
19
+ FpjsClient: vi.fn().mockImplementation(() => ({
20
+ init: mocks.fingerprintInit,
21
+ getVisitorData: mocks.visitorData,
22
+ })),
23
+ }));
24
+ vi.mock('@/services/mixpanel', () => ({
25
+ default: {
26
+ init: vi.fn().mockResolvedValue(undefined),
27
+ validateConfiguration: vi.fn(),
28
+ setPeopleProperties: vi.fn(),
29
+ isReady: vi.fn().mockReturnValue(true),
30
+ getMixpanelDistinctId: vi.fn().mockReturnValue('distinct-id'),
31
+ track: mocks.track,
32
+ },
33
+ }));
34
+ vi.mock('@/services/geolocation', () => ({
35
+ default: {
36
+ init: mocks.geolocationInit,
37
+ getCoordinates: vi.fn().mockReturnValue(null),
38
+ getGeolocationSource: vi.fn().mockReturnValue(null),
39
+ },
40
+ }));
41
+ vi.mock('@reservamos/js-api-client/core', () => ({
42
+ default: {
43
+ setConfig: vi.fn(),
44
+ fingerprint: { sendObservation: mocks.observation },
45
+ },
46
+ }));
47
+
48
+ const search = {
49
+ 'Departure': '2026-10-14',
50
+ 'Departure Delta': 1,
51
+ 'Origin': 'A',
52
+ 'Origin Terminal': 'A',
53
+ 'Destination': 'B',
54
+ 'Destination Terminal': 'B',
55
+ 'Route': 'A-B',
56
+ 'Passengers': 1,
57
+ 'Bus count': 1,
58
+ 'product': 'web',
59
+ };
60
+ const purchase = {
61
+ 'Trips': [
62
+ {
63
+ 'Departure Arrival': '2026-10-15',
64
+ 'Departure Destination': 'B',
65
+ 'Departure Destination Terminal': 'B',
66
+ 'Departure Line': 'Line',
67
+ 'Departure Origin': 'A',
68
+ 'Departure Origin Terminal': 'A',
69
+ 'Departure Price': 100,
70
+ 'Departure Route': 'A-B',
71
+ 'Departure Stops': 1,
72
+ 'Departure Time': '2026-10-14',
73
+ 'Departure Transport Type': 'bus',
74
+ 'Departure Transporter': 'Line',
75
+ 'Recommended Trip': false,
76
+ },
77
+ ],
78
+ 'Passenger Count': 1,
79
+ 'Trip Count': 1,
80
+ 'Total': 100,
81
+ 'product': 'web',
82
+ };
83
+
84
+ // Keep tracking, validation, Fingerprint readiness and the relay service real.
85
+ describe('Bot Detection from Tracker Ready', () => {
86
+ beforeEach(() => {
87
+ vi.useFakeTimers();
88
+ vi.clearAllMocks();
89
+ let request = 0;
90
+ mocks.visitorData.mockImplementation(async () => ({
91
+ visitorId: 'fresh-visitor',
92
+ requestId: `request-${++request}`,
93
+ confidence: { score: 0.99 },
94
+ visitorFound: true,
95
+ cacheHit: false,
96
+ }));
97
+ mocks.observation.mockResolvedValue(true);
98
+ localStorage.clear();
99
+ Reflect.deleteProperty(window, 'fpClient');
100
+ Reflect.deleteProperty(window, 'fingerprintConfig');
101
+ });
102
+
103
+ afterEach(() => {
104
+ vi.clearAllTimers();
105
+ vi.useRealTimers();
106
+ });
107
+
108
+ it.each([null, 'cached-visitor'])(
109
+ 'submits original events with cache %s and defers one observation per event',
110
+ async (visitorId) => {
111
+ if (visitorId) {
112
+ localStorage.setItem(
113
+ 'default_fingerprint_cache',
114
+ JSON.stringify({
115
+ fingerprint: visitorId,
116
+ expiresAt: Date.now() + 60_000,
117
+ }),
118
+ );
119
+ }
120
+ let finishFingerprint!: () => void;
121
+ let finishGeolocation!: () => void;
122
+ mocks.fingerprintInit.mockReturnValue(
123
+ new Promise<void>((resolve) => {
124
+ finishFingerprint = resolve;
125
+ }),
126
+ );
127
+ mocks.geolocationInit.mockReturnValue(
128
+ new Promise<void>((resolve) => {
129
+ finishGeolocation = resolve;
130
+ }),
131
+ );
132
+ let tracked!: Promise<unknown[]>;
133
+ window.addEventListener(
134
+ 'Tracker Ready',
135
+ () => {
136
+ expect(fingerprintService.isFingerprintReady()).toBe(false);
137
+ expect(mocks.fingerprintInit).not.toHaveBeenCalled();
138
+ tracked = Promise.all([
139
+ trackEvent('Search', search),
140
+ trackEvent('Payment Attempt', {
141
+ ...purchase,
142
+ 'Payment Type': 'credit_card',
143
+ }),
144
+ trackEvent('Purchase Attempt', purchase),
145
+ ]);
146
+ },
147
+ { once: true },
148
+ );
149
+
150
+ const initializing = init({
151
+ mixpanelToken: 'token',
152
+ identificationKey: 'key',
153
+ });
154
+ await vi.advanceTimersByTimeAsync(0);
155
+ await tracked;
156
+ expect(mocks.track.mock.calls.map(([name]) => name)).toEqual([
157
+ 'Search',
158
+ 'Payment Attempt',
159
+ 'Purchase Attempt',
160
+ ]);
161
+ for (const [, properties] of mocks.track.mock.calls) {
162
+ expect(properties['User Fingerprint']).toBe(visitorId ?? '');
163
+ }
164
+ expect(mocks.visitorData).not.toHaveBeenCalled();
165
+ expect(mocks.observation).not.toHaveBeenCalled();
166
+
167
+ finishFingerprint();
168
+ finishGeolocation();
169
+ await initializing;
170
+ await vi.advanceTimersByTimeAsync(5000);
171
+ expect(fingerprintService.isFingerprintReady()).toBe(true);
172
+ expect(mocks.track).toHaveBeenCalledTimes(3);
173
+ expect(mocks.visitorData).toHaveBeenCalledTimes(3);
174
+ expect(mocks.observation).toHaveBeenCalledTimes(3);
175
+ expect(
176
+ new Set(
177
+ mocks.observation.mock.calls.map(([payload]) => payload.requestId),
178
+ ).size,
179
+ ).toBe(3);
180
+ for (const [payload] of mocks.observation.mock.calls) {
181
+ expect(payload.distinctId).toBe('distinct-id');
182
+ expect(payload.visitorId).toBe('fresh-visitor');
183
+ }
184
+ expect(vi.getTimerCount()).toBe(0);
185
+ },
186
+ );
187
+ });
package/src/init.test.ts CHANGED
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
20
20
  vi.mock('@/services/mixpanel', () => ({
21
21
  default: {
22
22
  init: mocks.mixpanel,
23
+ validateConfiguration: vi.fn(),
23
24
  getMixpanelDistinctId: mocks.distinctId,
24
25
  isReady: mocks.ready,
25
26
  track: mocks.track,
package/src/init.ts CHANGED
@@ -10,6 +10,7 @@ import validator, { InitConfigSchema } from '@/services/validator';
10
10
  * Configuration object for initializing the tracking library.
11
11
  */
12
12
  export type InitConfig = z.infer<typeof InitConfigSchema>;
13
+ let initialization = 0;
13
14
 
14
15
  /**
15
16
  * Dispatches a 'Tracker Ready' event to the window object.
@@ -40,7 +41,11 @@ async function init(config: InitConfig) {
40
41
  appName,
41
42
  } = config;
42
43
 
44
+ mixpanelService.validateConfiguration(mixpanelToken, mixpanelProxyUrl);
45
+ const attempt = ++initialization;
46
+ const isCurrent = () => attempt === initialization;
43
47
  await mixpanelService.init(mixpanelToken, debug, mixpanelProxyUrl);
48
+ if (!isCurrent()) return;
44
49
 
45
50
  configService.setEnvironment(isSandbox ? 'sandbox' : 'prod');
46
51
  if (appName) {
@@ -50,12 +55,20 @@ async function init(config: InitConfig) {
50
55
 
51
56
  // Dispatch the 'Tracker Ready' event
52
57
  onLoaded();
58
+ if (!isCurrent()) return;
53
59
 
54
60
  await Promise.all([
55
61
  initializeOptionalStage('fingerprint', () =>
56
- fingerprintService.initFingerprint(identificationKey, identifyProxyUrl),
62
+ isCurrent()
63
+ ? fingerprintService.initFingerprint(
64
+ identificationKey,
65
+ identifyProxyUrl,
66
+ )
67
+ : Promise.resolve(),
68
+ ),
69
+ initializeOptionalStage('geolocation', () =>
70
+ isCurrent() ? geolocationService.init() : Promise.resolve(),
57
71
  ),
58
- initializeOptionalStage('geolocation', () => geolocationService.init()),
59
72
  ]);
60
73
  }
61
74
 
@@ -50,7 +50,7 @@ describe('botDetectionRelayService', () => {
50
50
 
51
51
  expect(fetchMock).toHaveBeenCalledWith(
52
52
  'https://data-lake.reservamossaas.com/api/v1/fingerprint/bot-detection',
53
- {
53
+ expect.objectContaining({
54
54
  method: 'POST',
55
55
  headers: {
56
56
  'Content-Type': 'application/json',
@@ -64,7 +64,8 @@ describe('botDetectionRelayService', () => {
64
64
  eventName: 'Fingerprint Requested',
65
65
  }),
66
66
  signal: expect.any(AbortSignal),
67
- },
67
+ keepalive: true,
68
+ }),
68
69
  );
69
70
  });
70
71
 
@@ -0,0 +1,60 @@
1
+ import botDetectionRelayService from '@/services/botDetectionRelay';
2
+ import fingerprintService from '@/services/fingerprint';
3
+ import trackingLifecycle from '@/services/trackingLifecycle';
4
+ import { createPendingWorkQueue } from '@/util/pendingWork';
5
+
6
+ const observations = createPendingWorkQueue({
7
+ isReady: () => fingerprintService.isFingerprintReady(),
8
+ subscribe(notify) {
9
+ const stopReady = fingerprintService.onReady(notify);
10
+ const stopIdentity = trackingLifecycle.onChange(notify);
11
+ return () => {
12
+ stopReady();
13
+ stopIdentity();
14
+ };
15
+ },
16
+ maxEntries: 100,
17
+ maxBytes: 1024 * 1024,
18
+ maxActive: 3,
19
+ lifetime: 10000,
20
+ wait: 0,
21
+ stage: 'fingerprint',
22
+ });
23
+
24
+ /** Defers only observation work; this function never submits a Mixpanel event. */
25
+ export function deferBotDetection(
26
+ eventName: string,
27
+ distinctId: string | null,
28
+ isCurrent: () => boolean,
29
+ ): void {
30
+ void observations.enqueue({
31
+ eventName,
32
+ bytes: new Blob([eventName, distinctId ?? '']).size,
33
+ isCurrent,
34
+ async run(isActive) {
35
+ let stage: 'fingerprint' | 'relay' = 'fingerprint';
36
+ try {
37
+ const identification = await fingerprintService.identifyVisitor(true, {
38
+ isActive,
39
+ report: () => undefined,
40
+ });
41
+ if (!isActive() || !identification.requestId) return;
42
+ stage = 'relay';
43
+ await botDetectionRelayService.relay({
44
+ requestId: identification.requestId,
45
+ visitorId: identification.visitorId,
46
+ ...(distinctId && { distinctId }),
47
+ eventName,
48
+ });
49
+ } catch {
50
+ if (isActive()) {
51
+ console.error('Bot Detection diagnostic', {
52
+ eventName,
53
+ stage,
54
+ reason: 'failed',
55
+ });
56
+ }
57
+ }
58
+ },
59
+ });
60
+ }
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import trackingLifecycle from '@/services/trackingLifecycle';
2
3
  import fingerprintService, { getFingerprintForIdentify } from './fingerprint';
3
4
 
4
5
  const visitorData = {
@@ -95,6 +96,24 @@ describe('fingerprintService', () => {
95
96
  expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
96
97
  });
97
98
 
99
+ it('does not announce readiness when an invalidated agent finishes loading late', async () => {
100
+ let finish!: () => void;
101
+ initMock.mockReturnValue(
102
+ new Promise<void>((resolve) => {
103
+ finish = resolve;
104
+ }),
105
+ );
106
+ const notify = vi.fn();
107
+ const stop = fingerprintService.onReady(notify);
108
+ const pending = fingerprintService.initFingerprint('old-key');
109
+ fingerprintService.invalidate();
110
+ finish();
111
+ await pending;
112
+ expect(fingerprintService.isFingerprintReady()).toBe(false);
113
+ expect(notify).not.toHaveBeenCalled();
114
+ stop();
115
+ });
116
+
98
117
  it('suppresses identify-specific cache and profile effects after its scope closes', async () => {
99
118
  let finish: (value: typeof visitorData) => void = () => undefined;
100
119
  getVisitorDataMock.mockReturnValue(
@@ -378,3 +397,174 @@ describe('fingerprintService', () => {
378
397
  consoleError.mockRestore();
379
398
  });
380
399
  });
400
+
401
+ describe('bounded fingerprint waiting', () => {
402
+ beforeEach(async () => {
403
+ vi.useFakeTimers();
404
+ localStorage.clear();
405
+ initMock.mockResolvedValue(undefined);
406
+ getVisitorDataMock.mockReset().mockResolvedValue(visitorData);
407
+ clientMock.mockImplementation(() => ({
408
+ init: initMock,
409
+ getVisitorData: getVisitorDataMock,
410
+ }));
411
+ await fingerprintService.initFingerprint('wait-key');
412
+ });
413
+ afterEach(() => {
414
+ vi.clearAllTimers();
415
+ vi.useRealTimers();
416
+ vi.restoreAllMocks();
417
+ localStorage.clear();
418
+ });
419
+
420
+ it('returns cache without a provider request', async () => {
421
+ localStorage.setItem(
422
+ 'default_fingerprint_cache',
423
+ JSON.stringify({ fingerprint: 'cached', expiresAt: Date.now() + 1000 }),
424
+ );
425
+ await expect(fingerprintService.waitForFingerprintId()).resolves.toBe(
426
+ 'cached',
427
+ );
428
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
429
+ });
430
+ it('joins a fresh request already in flight and serves concurrent waiters', async () => {
431
+ let finish!: (value: typeof visitorData) => void;
432
+ getVisitorDataMock.mockReturnValue(
433
+ new Promise((resolve) => {
434
+ finish = resolve;
435
+ }),
436
+ );
437
+ const existing = fingerprintService.identifyVisitor(true);
438
+ const first = fingerprintService.waitForFingerprintId();
439
+ const second = fingerprintService.waitForFingerprintId();
440
+ finish(visitorData);
441
+ await expect(Promise.all([first, second])).resolves.toEqual([
442
+ 'visitor-123',
443
+ 'visitor-123',
444
+ ]);
445
+ await existing;
446
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
447
+ await vi.advanceTimersByTimeAsync(0);
448
+ expect(vi.getTimerCount()).toBe(0);
449
+ });
450
+ it('waits for an agent still loading, then identifies only once', async () => {
451
+ let finish!: () => void;
452
+ initMock.mockReturnValue(
453
+ new Promise<void>((resolve) => {
454
+ finish = resolve;
455
+ }),
456
+ );
457
+ const initialization = fingerprintService.initFingerprint('late-key');
458
+ const first = fingerprintService.waitForFingerprintId();
459
+ const second = fingerprintService.waitForFingerprintId();
460
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
461
+ finish();
462
+ await initialization;
463
+ await expect(Promise.all([first, second])).resolves.toEqual([
464
+ 'visitor-123',
465
+ 'visitor-123',
466
+ ]);
467
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
468
+ expect(getVisitorDataMock).toHaveBeenCalledWith({}, false);
469
+ await vi.advanceTimersByTimeAsync(0);
470
+ expect(vi.getTimerCount()).toBe(0);
471
+ });
472
+ it('resolves null when the pending agent fails to load', async () => {
473
+ let fail!: (error: Error) => void;
474
+ initMock.mockReturnValue(
475
+ new Promise((_resolve, reject) => {
476
+ fail = reject;
477
+ }),
478
+ );
479
+ const initialization = fingerprintService.initFingerprint('blocked-key');
480
+ const pending = fingerprintService.waitForFingerprintId();
481
+ const rejected = expect(initialization).rejects.toThrow('blocked');
482
+ fail(new Error('blocked'));
483
+ await rejected;
484
+ await expect(pending).resolves.toBeNull();
485
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
486
+ });
487
+ it.each([-1, Infinity, NaN])(
488
+ 'resolves null for invalid budget %s',
489
+ async (budget) => {
490
+ await expect(
491
+ fingerprintService.waitForFingerprintId(budget),
492
+ ).resolves.toBeNull();
493
+ expect(getVisitorDataMock).not.toHaveBeenCalled();
494
+ },
495
+ );
496
+ it('keeps a late successful result after the caller times out without another lookup', async () => {
497
+ let finish!: (value: typeof visitorData) => void;
498
+ getVisitorDataMock.mockReturnValue(
499
+ new Promise((resolve) => {
500
+ finish = resolve;
501
+ }),
502
+ );
503
+ setPeoplePropertiesMock.mockClear();
504
+ const pending = fingerprintService.waitForFingerprintId(2000);
505
+ await vi.advanceTimersByTimeAsync(2000);
506
+ await expect(pending).resolves.toBeNull();
507
+ await vi.advanceTimersByTimeAsync(500);
508
+ finish(visitorData);
509
+ await vi.advanceTimersByTimeAsync(0);
510
+ expect(fingerprintService.getCachedFingerprint()).toBe('visitor-123');
511
+ expect(setPeoplePropertiesMock).toHaveBeenCalledWith({
512
+ 'Visitor Found': true,
513
+ });
514
+ await expect(fingerprintService.waitForFingerprintId()).resolves.toBe(
515
+ 'visitor-123',
516
+ );
517
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
518
+ expect(vi.getTimerCount()).toBe(0);
519
+ });
520
+ it('suppresses a timed-out request after its user lifecycle changes', async () => {
521
+ let finish!: (value: typeof visitorData) => void;
522
+ getVisitorDataMock.mockReturnValue(
523
+ new Promise((resolve) => {
524
+ finish = resolve;
525
+ }),
526
+ );
527
+ setPeoplePropertiesMock.mockClear();
528
+ const pending = fingerprintService.waitForFingerprintId(2000);
529
+ await vi.advanceTimersByTimeAsync(2000);
530
+ await expect(pending).resolves.toBeNull();
531
+ trackingLifecycle.identify('replacement-user-after-timeout');
532
+ finish(visitorData);
533
+ await vi.advanceTimersByTimeAsync(0);
534
+ expect(fingerprintService.getCachedFingerprint()).toBeNull();
535
+ expect(setPeoplePropertiesMock).not.toHaveBeenCalled();
536
+ expect(getVisitorDataMock).toHaveBeenCalledTimes(1);
537
+ expect(vi.getTimerCount()).toBe(0);
538
+ });
539
+ it('resolves null at its timeout and consumes late rejection', async () => {
540
+ let fail!: (error: Error) => void;
541
+ getVisitorDataMock.mockReturnValue(
542
+ new Promise((_resolve, reject) => {
543
+ fail = reject;
544
+ }),
545
+ );
546
+ const pending = fingerprintService.waitForFingerprintId(100);
547
+ await vi.advanceTimersByTimeAsync(100);
548
+ await expect(pending).resolves.toBeNull();
549
+ fail(new Error('offline'));
550
+ await vi.advanceTimersByTimeAsync(0);
551
+ await vi.advanceTimersByTimeAsync(0);
552
+ expect(vi.getTimerCount()).toBe(0);
553
+ });
554
+ it('cancels when initialization changes and ignores an old result', async () => {
555
+ let finish!: (value: typeof visitorData) => void;
556
+ getVisitorDataMock.mockReturnValue(
557
+ new Promise((resolve) => {
558
+ finish = resolve;
559
+ }),
560
+ );
561
+ const pending = fingerprintService.waitForFingerprintId();
562
+ await fingerprintService.initFingerprint('replacement');
563
+ await expect(pending).resolves.toBeNull();
564
+ finish(visitorData);
565
+ await vi.advanceTimersByTimeAsync(0);
566
+ expect(fingerprintService.getCachedFingerprint()).toBeNull();
567
+ await vi.advanceTimersByTimeAsync(0);
568
+ expect(vi.getTimerCount()).toBe(0);
569
+ });
570
+ });