@reservamos/browser-analytics 1.1.7 → 1.1.8-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +16 -10
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.esm.js +500 -81
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +2 -2
- package/src/events/sharedSchemas/tripSchema.ts +3 -8
- package/src/init.botDetection.test.ts +187 -0
- package/src/init.test.ts +288 -0
- package/src/init.ts +47 -12
- package/src/services/deferredBotDetection.ts +60 -0
- package/src/services/fingerprint.test.ts +36 -0
- package/src/services/fingerprint.ts +38 -20
- package/src/services/mixpanel.test.ts +103 -0
- package/src/services/mixpanel.ts +70 -21
- package/src/services/trackingLifecycle.ts +54 -0
- package/src/services/validator/validator.test.ts +79 -0
- package/src/track.readiness.test.ts +131 -0
- package/src/track.recovery.test.ts +339 -0
- package/src/track.storage.test.ts +127 -0
- package/src/track.test.ts +435 -66
- package/src/track.ts +174 -25
- package/src/util/pendingWork.test.ts +164 -0
- package/src/util/pendingWork.ts +148 -0
- package/src/util/snapshotEventData.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reservamos/browser-analytics",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8-alpha.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"validate": "./validate.sh",
|
|
55
55
|
"validate:full": "./validate.sh --full",
|
|
56
56
|
"upgrade": "upgradeps",
|
|
57
|
-
"release": "
|
|
57
|
+
"release": "gh workflow run publish.yml --ref main"
|
|
58
58
|
},
|
|
59
59
|
"type": "module",
|
|
60
60
|
"types": "./dist/browser-analytics.d.ts",
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
-
dateField,
|
|
4
|
-
intField,
|
|
5
|
-
numberField,
|
|
6
|
-
stringField,
|
|
7
|
-
} from '@/util/primitiveFields';
|
|
2
|
+
import { dateField, numberField, stringField } from '@/util/primitiveFields';
|
|
8
3
|
|
|
9
4
|
const tripSchema = z.object({
|
|
10
5
|
'Departure Arrival': dateField('Departure Arrival'),
|
|
@@ -17,7 +12,7 @@ const tripSchema = z.object({
|
|
|
17
12
|
'Departure Origin Terminal': stringField('Departure Origin Terminal'),
|
|
18
13
|
'Departure Price': numberField('Departure Price'),
|
|
19
14
|
'Departure Route': stringField('Departure Route'),
|
|
20
|
-
'Departure Stops':
|
|
15
|
+
'Departure Stops': z.number().int(),
|
|
21
16
|
'Departure Time': dateField('Departure Time'),
|
|
22
17
|
'Departure Transport Type': stringField('Departure Transport Type'),
|
|
23
18
|
'Departure Transporter': stringField('Departure Transporter'),
|
|
@@ -31,7 +26,7 @@ const tripSchema = z.object({
|
|
|
31
26
|
'Return Origin Terminal': stringField('Return Origin Terminal').optional(),
|
|
32
27
|
'Return Price': numberField('Return Price').optional(),
|
|
33
28
|
'Return Route': stringField('Return Route').optional(),
|
|
34
|
-
'Return Stops':
|
|
29
|
+
'Return Stops': z.number().int().optional(),
|
|
35
30
|
'Return Time': dateField('Return Time').optional(),
|
|
36
31
|
'Return Transport Type': stringField('Return Transport Type').optional(),
|
|
37
32
|
'Return Transporter': stringField('Return Transporter').optional(),
|
|
@@ -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
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import createAnonymousProfile from '@/profiles/createAnonymousProfile/createAnonymousProfile';
|
|
3
|
+
import { trackEvent } from '@/track';
|
|
4
|
+
import init from './init';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
mixpanel: vi.fn(),
|
|
8
|
+
fingerprint: vi.fn(),
|
|
9
|
+
geolocation: vi.fn(),
|
|
10
|
+
validate: vi.fn(),
|
|
11
|
+
environment: vi.fn(),
|
|
12
|
+
appName: vi.fn(),
|
|
13
|
+
config: vi.fn(),
|
|
14
|
+
core: vi.fn(),
|
|
15
|
+
distinctId: vi.fn(),
|
|
16
|
+
profile: vi.fn(),
|
|
17
|
+
track: vi.fn(),
|
|
18
|
+
ready: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@/services/mixpanel', () => ({
|
|
21
|
+
default: {
|
|
22
|
+
init: mocks.mixpanel,
|
|
23
|
+
validateConfiguration: vi.fn(),
|
|
24
|
+
getMixpanelDistinctId: mocks.distinctId,
|
|
25
|
+
isReady: mocks.ready,
|
|
26
|
+
track: mocks.track,
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('@/services/fingerprint', async (importOriginal) => {
|
|
30
|
+
const actual =
|
|
31
|
+
await importOriginal<typeof import('@/services/fingerprint')>();
|
|
32
|
+
return {
|
|
33
|
+
...actual,
|
|
34
|
+
default: { ...actual.default, initFingerprint: mocks.fingerprint },
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
vi.mock('@/services/geolocation', () => ({
|
|
38
|
+
default: {
|
|
39
|
+
init: mocks.geolocation,
|
|
40
|
+
getCoordinates: vi.fn().mockReturnValue(null),
|
|
41
|
+
getGeolocationSource: vi.fn().mockReturnValue(null),
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('@/services/validator', async (importOriginal) => {
|
|
45
|
+
const actual = await importOriginal<typeof import('@/services/validator')>();
|
|
46
|
+
return {
|
|
47
|
+
...actual,
|
|
48
|
+
default: { ...actual.default, parseInitProps: mocks.validate },
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
vi.mock('@/services/config', () => ({
|
|
52
|
+
default: {
|
|
53
|
+
setEnvironment: mocks.environment,
|
|
54
|
+
setAppName: mocks.appName,
|
|
55
|
+
getCoreAPIConfig: mocks.config,
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
vi.mock('@reservamos/js-api-client/core', () => ({
|
|
59
|
+
default: {
|
|
60
|
+
setConfig: mocks.core,
|
|
61
|
+
profiles: { createAnonymousProfile: mocks.profile },
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const config = {
|
|
66
|
+
mixpanelToken: 'test-token',
|
|
67
|
+
identificationKey: 'test-key',
|
|
68
|
+
appName: 'test-app',
|
|
69
|
+
isSandbox: true,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
describe('init optional enrichment', () => {
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
vi.useFakeTimers();
|
|
75
|
+
Object.values(mocks).forEach((mock) => mock.mockReset());
|
|
76
|
+
localStorage.clear();
|
|
77
|
+
Reflect.deleteProperty(window, 'fingerprintConfig');
|
|
78
|
+
mocks.distinctId.mockReturnValue('live-id');
|
|
79
|
+
mocks.ready.mockReturnValue(true);
|
|
80
|
+
mocks.profile.mockResolvedValue({ id: 'profile-id' });
|
|
81
|
+
mocks.mixpanel.mockResolvedValue(undefined);
|
|
82
|
+
mocks.fingerprint.mockResolvedValue(undefined);
|
|
83
|
+
mocks.geolocation.mockResolvedValue(null);
|
|
84
|
+
mocks.config.mockReturnValue({ baseUrl: 'https://example.test' });
|
|
85
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
86
|
+
});
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.clearAllTimers();
|
|
89
|
+
vi.useRealTimers();
|
|
90
|
+
vi.restoreAllMocks();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('configures Core API and emits readiness while both optional stages are pending', async () => {
|
|
94
|
+
mocks.fingerprint.mockReturnValue(new Promise(() => undefined));
|
|
95
|
+
mocks.geolocation.mockReturnValue(new Promise(() => undefined));
|
|
96
|
+
const dispatch = vi
|
|
97
|
+
.spyOn(window, 'dispatchEvent')
|
|
98
|
+
.mockImplementation((event) => {
|
|
99
|
+
if (event.type === 'Tracker Ready') {
|
|
100
|
+
expect(mocks.environment).toHaveBeenCalledWith('sandbox');
|
|
101
|
+
expect(mocks.appName).toHaveBeenCalledWith('test-app');
|
|
102
|
+
expect(mocks.core).toHaveBeenCalledWith({
|
|
103
|
+
baseUrl: 'https://example.test',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
108
|
+
const completed = vi.fn();
|
|
109
|
+
const pending = init(config).then(completed);
|
|
110
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
111
|
+
expect(dispatch).toHaveBeenCalledWith(
|
|
112
|
+
expect.objectContaining({ type: 'Tracker Ready' }),
|
|
113
|
+
);
|
|
114
|
+
expect(mocks.fingerprint).toHaveBeenCalledOnce();
|
|
115
|
+
expect(mocks.geolocation).toHaveBeenCalledOnce();
|
|
116
|
+
expect(completed).not.toHaveBeenCalled();
|
|
117
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
118
|
+
await pending;
|
|
119
|
+
expect(completed).toHaveBeenCalledOnce();
|
|
120
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
121
|
+
'SDK initialization diagnostic',
|
|
122
|
+
{ stage: 'fingerprint', reason: 'timeout' },
|
|
123
|
+
);
|
|
124
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
125
|
+
'SDK initialization diagnostic',
|
|
126
|
+
{ stage: 'geolocation', reason: 'timeout' },
|
|
127
|
+
);
|
|
128
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it.each([null, 'cached-visitor'])(
|
|
132
|
+
'supports profile calls from readiness before fingerprint init with cache %s',
|
|
133
|
+
async (cachedId) => {
|
|
134
|
+
if (cachedId) {
|
|
135
|
+
localStorage.setItem(
|
|
136
|
+
'default_fingerprint_cache',
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
fingerprint: cachedId,
|
|
139
|
+
expiresAt: Date.now() + 60_000,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
let outcome: Promise<unknown> | undefined;
|
|
144
|
+
window.addEventListener(
|
|
145
|
+
'Tracker Ready',
|
|
146
|
+
() => {
|
|
147
|
+
outcome = createAnonymousProfile({ email: 'test@example.com' }).then(
|
|
148
|
+
(response) => ({ response }),
|
|
149
|
+
(error: unknown) => ({ error }),
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
{ once: true },
|
|
153
|
+
);
|
|
154
|
+
await init(config);
|
|
155
|
+
await expect(outcome).resolves.toEqual({
|
|
156
|
+
response: { id: 'profile-id' },
|
|
157
|
+
});
|
|
158
|
+
expect(mocks.profile).toHaveBeenCalledWith({
|
|
159
|
+
identifier_key: 'email',
|
|
160
|
+
identifier_value: 'test@example.com',
|
|
161
|
+
details: {},
|
|
162
|
+
identifiers: [
|
|
163
|
+
...(cachedId ? [{ key: 'fingerprint', value: cachedId }] : []),
|
|
164
|
+
{ key: 'distinct_id', value: 'live-id' },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
describe.each(['Search', 'View Results'])(
|
|
171
|
+
'%s from early readiness',
|
|
172
|
+
(eventName) => {
|
|
173
|
+
it.each([null, 'cached-visitor'])(
|
|
174
|
+
'submits before Fingerprint config with cache %s',
|
|
175
|
+
async (visitorId) => {
|
|
176
|
+
if (visitorId)
|
|
177
|
+
localStorage.setItem(
|
|
178
|
+
'default_fingerprint_cache',
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
fingerprint: visitorId,
|
|
181
|
+
expiresAt: Date.now() + 60_000,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
const properties = {
|
|
185
|
+
'Departure': '2026-10-14',
|
|
186
|
+
'Departure Delta': 0,
|
|
187
|
+
'Origin': 'A',
|
|
188
|
+
'Origin Terminal': 'A',
|
|
189
|
+
'Destination': 'B',
|
|
190
|
+
'Destination Terminal': 'B',
|
|
191
|
+
'Route': 'A-B',
|
|
192
|
+
'Passengers': 1,
|
|
193
|
+
'Bus count': 1,
|
|
194
|
+
'product': 'web',
|
|
195
|
+
};
|
|
196
|
+
let tracked: Promise<void> | undefined;
|
|
197
|
+
window.addEventListener(
|
|
198
|
+
'Tracker Ready',
|
|
199
|
+
() => {
|
|
200
|
+
tracked = trackEvent(eventName, properties);
|
|
201
|
+
},
|
|
202
|
+
{ once: true },
|
|
203
|
+
);
|
|
204
|
+
await init(config);
|
|
205
|
+
await tracked;
|
|
206
|
+
expect(mocks.track).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(mocks.track).toHaveBeenCalledWith(
|
|
208
|
+
eventName,
|
|
209
|
+
expect.objectContaining({
|
|
210
|
+
'User Fingerprint': visitorId ?? '',
|
|
211
|
+
'Route': 'A-B',
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
it('waits for required Mixpanel initialization before configuring or signaling readiness', async () => {
|
|
220
|
+
let finishMixpanel!: () => void;
|
|
221
|
+
mocks.mixpanel.mockReturnValue(
|
|
222
|
+
new Promise<void>((resolve) => {
|
|
223
|
+
finishMixpanel = resolve;
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
const dispatch = vi.spyOn(window, 'dispatchEvent');
|
|
227
|
+
const pending = init(config);
|
|
228
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
229
|
+
expect(mocks.core).not.toHaveBeenCalled();
|
|
230
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
231
|
+
finishMixpanel();
|
|
232
|
+
await pending;
|
|
233
|
+
expect(dispatch).toHaveBeenCalledOnce();
|
|
234
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it.each(['fingerprint', 'geolocation'] as const)(
|
|
238
|
+
'continues after %s rejects without leaking raw errors',
|
|
239
|
+
async (stage) => {
|
|
240
|
+
mocks[stage].mockRejectedValue(new Error('private-stage-error'));
|
|
241
|
+
const dispatch = vi.spyOn(window, 'dispatchEvent');
|
|
242
|
+
await expect(init(config)).resolves.toBeUndefined();
|
|
243
|
+
expect(dispatch).toHaveBeenCalledOnce();
|
|
244
|
+
expect(mocks.fingerprint).toHaveBeenCalledOnce();
|
|
245
|
+
expect(mocks.geolocation).toHaveBeenCalledOnce();
|
|
246
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
247
|
+
'SDK initialization diagnostic',
|
|
248
|
+
{ stage, reason: 'failed' },
|
|
249
|
+
);
|
|
250
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
251
|
+
'private-stage-error',
|
|
252
|
+
);
|
|
253
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
it('continues to geolocation if fingerprint initialization throws synchronously', async () => {
|
|
258
|
+
mocks.fingerprint.mockImplementation(() => {
|
|
259
|
+
throw new Error('private-stage-error');
|
|
260
|
+
});
|
|
261
|
+
await expect(init(config)).resolves.toBeUndefined();
|
|
262
|
+
expect(mocks.geolocation).toHaveBeenCalledOnce();
|
|
263
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
264
|
+
'SDK initialization diagnostic',
|
|
265
|
+
{ stage: 'fingerprint', reason: 'failed' },
|
|
266
|
+
);
|
|
267
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('consumes late rejection after the optional deadline without another diagnostic', async () => {
|
|
271
|
+
let rejectFingerprint!: (reason: Error) => void;
|
|
272
|
+
mocks.fingerprint.mockReturnValue(
|
|
273
|
+
new Promise<void>((_resolve, reject) => {
|
|
274
|
+
rejectFingerprint = reject;
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
const dispatch = vi.spyOn(window, 'dispatchEvent');
|
|
278
|
+
const pending = init(config);
|
|
279
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
280
|
+
await pending;
|
|
281
|
+
expect(console.error).toHaveBeenCalledTimes(1);
|
|
282
|
+
rejectFingerprint(new Error('private-late-error'));
|
|
283
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
284
|
+
expect(console.error).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(dispatch).toHaveBeenCalledOnce();
|
|
286
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
287
|
+
});
|
|
288
|
+
});
|
package/src/init.ts
CHANGED
|
@@ -10,12 +10,13 @@ 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.
|
|
16
17
|
*
|
|
17
18
|
* This function creates and dispatches a custom event named 'Tracker Ready'
|
|
18
|
-
*
|
|
19
|
+
* after Mixpanel and Core API configuration are ready; optional enrichment may still be pending.
|
|
19
20
|
*/
|
|
20
21
|
function onLoaded() {
|
|
21
22
|
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
@@ -24,6 +25,7 @@ function onLoaded() {
|
|
|
24
25
|
/**
|
|
25
26
|
* Initializes the tracking library with the provided configuration.
|
|
26
27
|
* @param {InitConfig} config - The configuration object for initialization.
|
|
28
|
+
* Optional enrichment runs concurrently, with a three-second waiting limit per stage.
|
|
27
29
|
* @throws {Error} Throws an error if the configuration is invalid.
|
|
28
30
|
*/
|
|
29
31
|
async function init(config: InitConfig) {
|
|
@@ -39,18 +41,11 @@ async function init(config: InitConfig) {
|
|
|
39
41
|
appName,
|
|
40
42
|
} = config;
|
|
41
43
|
|
|
44
|
+
mixpanelService.validateConfiguration(mixpanelToken, mixpanelProxyUrl);
|
|
45
|
+
const attempt = ++initialization;
|
|
46
|
+
const isCurrent = () => attempt === initialization;
|
|
42
47
|
await mixpanelService.init(mixpanelToken, debug, mixpanelProxyUrl);
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
await fingerprintService.initFingerprint(
|
|
46
|
-
identificationKey,
|
|
47
|
-
identifyProxyUrl,
|
|
48
|
-
);
|
|
49
|
-
} catch (error) {
|
|
50
|
-
console.error('Error initializing identification service:', error);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
await geolocationService.init();
|
|
48
|
+
if (!isCurrent()) return;
|
|
54
49
|
|
|
55
50
|
configService.setEnvironment(isSandbox ? 'sandbox' : 'prod');
|
|
56
51
|
if (appName) {
|
|
@@ -60,6 +55,46 @@ async function init(config: InitConfig) {
|
|
|
60
55
|
|
|
61
56
|
// Dispatch the 'Tracker Ready' event
|
|
62
57
|
onLoaded();
|
|
58
|
+
if (!isCurrent()) return;
|
|
59
|
+
|
|
60
|
+
await Promise.all([
|
|
61
|
+
initializeOptionalStage('fingerprint', () =>
|
|
62
|
+
isCurrent()
|
|
63
|
+
? fingerprintService.initFingerprint(
|
|
64
|
+
identificationKey,
|
|
65
|
+
identifyProxyUrl,
|
|
66
|
+
)
|
|
67
|
+
: Promise.resolve(),
|
|
68
|
+
),
|
|
69
|
+
initializeOptionalStage('geolocation', () =>
|
|
70
|
+
isCurrent() ? geolocationService.init() : Promise.resolve(),
|
|
71
|
+
),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Bounds the wait for optional work without cancelling future enrichment. */
|
|
76
|
+
function initializeOptionalStage(
|
|
77
|
+
stage: 'fingerprint' | 'geolocation',
|
|
78
|
+
initialize: () => Promise<unknown>,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
let active = true;
|
|
82
|
+
const finish = (reason?: 'timeout' | 'failed') => {
|
|
83
|
+
if (!active) return;
|
|
84
|
+
active = false;
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
if (reason)
|
|
87
|
+
console.error('SDK initialization diagnostic', { stage, reason });
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => finish('timeout'), 3000);
|
|
91
|
+
void Promise.resolve()
|
|
92
|
+
.then(initialize)
|
|
93
|
+
.then(
|
|
94
|
+
() => finish(),
|
|
95
|
+
() => finish('failed'),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
63
98
|
}
|
|
64
99
|
|
|
65
100
|
/**
|
|
@@ -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
|
+
}
|