@reservamos/browser-analytics 1.1.6 → 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.
- package/CHANGELOG.md +13 -0
- package/README.md +58 -10
- package/dist/browser-analytics.cjs +3 -3
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +55 -55
- package/dist/browser-analytics.esm.js +2257 -1991
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +3 -3
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +14 -12
- package/src/events/identify/identify.robustness.test.ts +220 -0
- package/src/events/identify/identify.test.ts +6 -4
- package/src/events/identify/identify.ts +84 -42
- package/src/index.ts +2 -0
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.identify.test.ts +56 -0
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +39 -12
- package/src/services/botDetectionRelay.test.ts +127 -0
- package/src/services/botDetectionRelay.ts +64 -0
- package/src/services/fingerprint.test.ts +362 -0
- package/src/services/fingerprint.ts +99 -18
- package/src/services/mixpanel.test.ts +160 -0
- package/src/services/mixpanel.ts +32 -6
- package/src/track.test.ts +156 -0
- package/src/track.ts +24 -5
- package/src/types/botDetection.ts +16 -0
- package/src/util/identifyAttempt.ts +139 -0
package/package.json
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reservamos/browser-analytics",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7-alpha.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
|
|
7
7
|
},
|
|
8
|
-
"main": "./dist/
|
|
9
|
-
"module": "./dist/
|
|
8
|
+
"main": "./dist/browser-analytics.cjs",
|
|
9
|
+
"module": "./dist/browser-analytics.esm.js",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
12
12
|
"require": "./dist/browser-analytics.cjs",
|
|
13
13
|
"import": "./dist/browser-analytics.esm.js",
|
|
14
14
|
"types": "./dist/browser-analytics.d.ts"
|
|
15
15
|
},
|
|
16
|
-
"./dist
|
|
17
|
-
"import": "./dist/",
|
|
18
|
-
"require": "./dist/",
|
|
19
|
-
"types": "./dist/"
|
|
20
|
-
}
|
|
16
|
+
"./dist/*": "./dist/*"
|
|
21
17
|
},
|
|
22
18
|
"bugs": {
|
|
23
19
|
"url": "https://github.com/reservamos/reservamos-browser-analytics/issues"
|
|
@@ -32,7 +28,7 @@
|
|
|
32
28
|
"package.json"
|
|
33
29
|
],
|
|
34
30
|
"homepage": "https://github.com/reservamos/reservamos-browser-analytics#readme",
|
|
35
|
-
"jsdelivr": "./dist/
|
|
31
|
+
"jsdelivr": "./dist/browser-analytics.iife.js",
|
|
36
32
|
"keywords": [
|
|
37
33
|
"analytics",
|
|
38
34
|
"browser"
|
|
@@ -49,21 +45,27 @@
|
|
|
49
45
|
"build": "tsc && vite build",
|
|
50
46
|
"postbuild": "dts-bundle-generator --config ./dts-bundle-generator.config.ts",
|
|
51
47
|
"test": "vitest",
|
|
48
|
+
"test:run": "vitest --run",
|
|
52
49
|
"test:coverage": "vitest --coverage",
|
|
53
50
|
"format": "prettier . --write",
|
|
51
|
+
"format:check": "node scripts/check-format.mjs",
|
|
52
|
+
"lint": "eslint src/ scripts/",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"validate": "./validate.sh",
|
|
55
|
+
"validate:full": "./validate.sh --full",
|
|
54
56
|
"upgrade": "upgradeps",
|
|
55
57
|
"release": "npm run build && np"
|
|
56
58
|
},
|
|
57
59
|
"type": "module",
|
|
58
|
-
"types": "./dist/
|
|
60
|
+
"types": "./dist/browser-analytics.d.ts",
|
|
59
61
|
"typesVersions": {
|
|
60
62
|
"*": {
|
|
61
63
|
"*": [
|
|
62
|
-
"./dist/
|
|
64
|
+
"./dist/browser-analytics.d.ts"
|
|
63
65
|
]
|
|
64
66
|
}
|
|
65
67
|
},
|
|
66
|
-
"typings": "./dist/
|
|
68
|
+
"typings": "./dist/browser-analytics.d.ts",
|
|
67
69
|
"dependencies": {
|
|
68
70
|
"@fingerprintjs/fingerprintjs-pro-spa": "^1.3.3",
|
|
69
71
|
"mixpanel-browser": "^2.55.1",
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import identify from './identify';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
config: vi.fn(),
|
|
6
|
+
coordinates: vi.fn(),
|
|
7
|
+
profile: vi.fn(),
|
|
8
|
+
ready: vi.fn(),
|
|
9
|
+
identify: vi.fn(),
|
|
10
|
+
attach: vi.fn(),
|
|
11
|
+
fingerprint: vi.fn(),
|
|
12
|
+
validate: vi.fn(),
|
|
13
|
+
error: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('@/services/config', () => ({
|
|
16
|
+
default: { getCoreAPIConfig: mocks.config },
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('@/services/geolocation', () => ({
|
|
19
|
+
default: { getCoordinates: mocks.coordinates },
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('@/profiles/createAnonymousProfile/createAnonymousProfile', () => ({
|
|
22
|
+
createAnonymousProfileForIdentify: mocks.profile,
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('@/services/mixpanel', () => ({
|
|
25
|
+
default: {
|
|
26
|
+
isReady: mocks.ready,
|
|
27
|
+
identify: mocks.identify,
|
|
28
|
+
attachProperty: mocks.attach,
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('@/services/fingerprint', () => ({
|
|
32
|
+
getFingerprintForIdentify: mocks.fingerprint,
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('@/services/validator', () => ({
|
|
35
|
+
default: { parseIdentifyProps: mocks.validate },
|
|
36
|
+
}));
|
|
37
|
+
vi.mock('@/track', () => ({ trackEventError: mocks.error }));
|
|
38
|
+
|
|
39
|
+
describe('identify bounded local attempt', () => {
|
|
40
|
+
const fetchMock = vi.fn();
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.useFakeTimers();
|
|
43
|
+
Object.values(mocks).forEach((mock) => mock.mockReset());
|
|
44
|
+
fetchMock.mockReset().mockResolvedValue({ ok: true, status: 200 });
|
|
45
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
46
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
47
|
+
mocks.ready.mockReturnValue(true);
|
|
48
|
+
mocks.profile.mockResolvedValue(undefined);
|
|
49
|
+
mocks.fingerprint.mockResolvedValue('');
|
|
50
|
+
mocks.coordinates.mockReturnValue(null);
|
|
51
|
+
mocks.config.mockReturnValue({
|
|
52
|
+
baseUrl: 'https://example.test/api',
|
|
53
|
+
defaultHeaders: { 'x-app-name': 'reservamos-web' },
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.clearAllTimers();
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
vi.unstubAllGlobals();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('awaits the local profile and includes its ID before identifying', async () => {
|
|
64
|
+
let finish: (value: { id: string }) => void = () => undefined;
|
|
65
|
+
mocks.profile.mockReturnValue(
|
|
66
|
+
new Promise((resolve) => {
|
|
67
|
+
finish = resolve;
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
let finished = false;
|
|
71
|
+
const pending = identify('user', { email: 'test@example.com' }).then(() => {
|
|
72
|
+
finished = true;
|
|
73
|
+
});
|
|
74
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
75
|
+
expect(finished).toBe(false);
|
|
76
|
+
expect(mocks.identify).not.toHaveBeenCalled();
|
|
77
|
+
finish({ id: 'profile' });
|
|
78
|
+
await pending;
|
|
79
|
+
expect(mocks.identify).toHaveBeenCalledWith(
|
|
80
|
+
'user',
|
|
81
|
+
expect.objectContaining({ reservamos_one_id: 'profile' }),
|
|
82
|
+
expect.any(Function),
|
|
83
|
+
);
|
|
84
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('runs readiness once and removes its listener', async () => {
|
|
88
|
+
mocks.ready.mockReturnValue(false);
|
|
89
|
+
const remove = vi.spyOn(window, 'removeEventListener');
|
|
90
|
+
const pending = identify('user');
|
|
91
|
+
mocks.ready.mockReturnValue(true);
|
|
92
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
93
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
94
|
+
await pending;
|
|
95
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(remove).toHaveBeenCalledWith('Tracker Ready', expect.any(Function));
|
|
97
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('expires readiness without a diagnostic listener or late execution', async () => {
|
|
101
|
+
mocks.ready.mockReturnValue(false);
|
|
102
|
+
const add = vi.spyOn(window, 'addEventListener');
|
|
103
|
+
const pending = identify('private-id');
|
|
104
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
105
|
+
await pending;
|
|
106
|
+
mocks.ready.mockReturnValue(true);
|
|
107
|
+
window.dispatchEvent(new CustomEvent('Tracker Ready'));
|
|
108
|
+
expect(mocks.identify).not.toHaveBeenCalled();
|
|
109
|
+
expect(mocks.error).not.toHaveBeenCalled();
|
|
110
|
+
expect(
|
|
111
|
+
add.mock.calls.filter(([name]) => name === 'Tracker Ready'),
|
|
112
|
+
).toHaveLength(1);
|
|
113
|
+
expect(console.error).toHaveBeenCalledWith('Identify diagnostic', {
|
|
114
|
+
stage: 'readiness',
|
|
115
|
+
reason: 'timeout',
|
|
116
|
+
});
|
|
117
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('continues after profile timeout and ignores its late result', async () => {
|
|
121
|
+
let finish: (value: { id: string }) => void = () => undefined;
|
|
122
|
+
mocks.profile.mockReturnValue(
|
|
123
|
+
new Promise((resolve) => {
|
|
124
|
+
finish = resolve;
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
const properties = { email: 'test@example.com' };
|
|
128
|
+
const pending = identify('user', properties);
|
|
129
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
130
|
+
await pending;
|
|
131
|
+
finish({ id: 'late-profile' });
|
|
132
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
133
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(properties).not.toHaveProperty('reservamos_one_id');
|
|
135
|
+
expect(mocks.error).toHaveBeenCalledWith('Identify', expect.any(Error), {
|
|
136
|
+
stage: 'profile',
|
|
137
|
+
reason: 'timeout',
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('continues after profile rejection with controlled diagnostics', async () => {
|
|
142
|
+
mocks.profile.mockRejectedValue(new Error('private@example.com'));
|
|
143
|
+
await identify('private-id');
|
|
144
|
+
expect(mocks.identify).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(mocks.error).toHaveBeenCalledWith(
|
|
146
|
+
'Identify',
|
|
147
|
+
expect.objectContaining({ message: 'Identify profile: failed' }),
|
|
148
|
+
{ stage: 'profile', reason: 'failed' },
|
|
149
|
+
);
|
|
150
|
+
expect(console.error).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('expires fingerprint and consumes its late rejection', async () => {
|
|
154
|
+
let reject: (reason: Error) => void = () => undefined;
|
|
155
|
+
mocks.fingerprint.mockReturnValue(
|
|
156
|
+
new Promise((_resolve, fail) => {
|
|
157
|
+
reject = fail;
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
const pending = identify('user');
|
|
161
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
162
|
+
await pending;
|
|
163
|
+
reject(new Error('private-id'));
|
|
164
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
165
|
+
expect(mocks.attach).not.toHaveBeenCalled();
|
|
166
|
+
expect(mocks.error).toHaveBeenCalledTimes(1);
|
|
167
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('starts geolocation without awaiting fetch and aborts at its deadline', async () => {
|
|
171
|
+
mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
|
|
172
|
+
fetchMock.mockReturnValue(new Promise(() => undefined));
|
|
173
|
+
await identify('user');
|
|
174
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
175
|
+
'https://example.test/api/v1/datalake/identify',
|
|
176
|
+
expect.objectContaining({
|
|
177
|
+
headers: expect.objectContaining({ 'x-app-name': 'reservamos-web' }),
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
const signal = (fetchMock.mock.calls[0][1] as RequestInit).signal;
|
|
181
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
182
|
+
expect(signal?.aborted).toBe(true);
|
|
183
|
+
expect(mocks.error).toHaveBeenCalledWith('Identify', expect.any(Error), {
|
|
184
|
+
stage: 'datalake',
|
|
185
|
+
reason: 'timeout',
|
|
186
|
+
});
|
|
187
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it.each(['http', 'rejection'])(
|
|
191
|
+
'observes datalake %s without personal data',
|
|
192
|
+
async (outcome) => {
|
|
193
|
+
mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
|
|
194
|
+
if (outcome === 'http')
|
|
195
|
+
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
|
196
|
+
else fetchMock.mockRejectedValue(new Error('private@example.com'));
|
|
197
|
+
await identify('private-id', { email: 'private@example.com' });
|
|
198
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
199
|
+
expect(mocks.error).toHaveBeenCalledWith(
|
|
200
|
+
'Identify',
|
|
201
|
+
expect.any(Error),
|
|
202
|
+
outcome === 'http'
|
|
203
|
+
? { stage: 'datalake', reason: 'http_error', httpStatus: 500 }
|
|
204
|
+
: { stage: 'datalake', reason: 'failed' },
|
|
205
|
+
);
|
|
206
|
+
expect(JSON.stringify(mocks.error.mock.calls)).not.toContain('private');
|
|
207
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
it('retains the geolocation finally attempt on validation failure', async () => {
|
|
212
|
+
mocks.validate.mockImplementation(() => {
|
|
213
|
+
throw new Error('private@example.com');
|
|
214
|
+
});
|
|
215
|
+
mocks.coordinates.mockReturnValue({ lat: 1, long: 2 });
|
|
216
|
+
await identify('user');
|
|
217
|
+
expect(mocks.identify).not.toHaveBeenCalled();
|
|
218
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -17,8 +17,8 @@ vi.mock('@/services/geolocation', () => ({
|
|
|
17
17
|
},
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
|
-
vi.mock('@/profiles/createAnonymousProfile', () => ({
|
|
21
|
-
|
|
20
|
+
vi.mock('@/profiles/createAnonymousProfile/createAnonymousProfile', () => ({
|
|
21
|
+
createAnonymousProfileForIdentify: createAnonymousProfileMock,
|
|
22
22
|
}));
|
|
23
23
|
|
|
24
24
|
vi.mock('@/services/mixpanel', () => ({
|
|
@@ -31,6 +31,7 @@ vi.mock('@/services/mixpanel', () => ({
|
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
33
|
vi.mock('@/services/fingerprint', () => ({
|
|
34
|
+
getFingerprintForIdentify: vi.fn().mockResolvedValue(null),
|
|
34
35
|
default: {
|
|
35
36
|
getFingerprint: vi.fn().mockResolvedValue(null),
|
|
36
37
|
},
|
|
@@ -63,8 +64,8 @@ describe('identify datalake request', () => {
|
|
|
63
64
|
getCoreAPIConfigMock.mockReturnValue({
|
|
64
65
|
baseUrl: 'https://data-lake.reservamossaas.com/api',
|
|
65
66
|
defaultHeaders: {
|
|
66
|
-
Authorization: 'Bearer test-token',
|
|
67
|
-
Origin: 'https://example.com',
|
|
67
|
+
'Authorization': 'Bearer test-token',
|
|
68
|
+
'Origin': 'https://example.com',
|
|
68
69
|
'x-app-name': 'reservamos-web',
|
|
69
70
|
},
|
|
70
71
|
});
|
|
@@ -74,6 +75,7 @@ describe('identify datalake request', () => {
|
|
|
74
75
|
|
|
75
76
|
afterEach(() => {
|
|
76
77
|
vi.restoreAllMocks();
|
|
78
|
+
vi.unstubAllGlobals();
|
|
77
79
|
});
|
|
78
80
|
|
|
79
81
|
it('sends x-app-name header on datalake identify request', async () => {
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { IdentifyReporter } from '@/util/identifyAttempt';
|
|
2
|
+
import { createAnonymousProfileForIdentify } from '@/profiles/createAnonymousProfile/createAnonymousProfile';
|
|
2
3
|
import configService from '@/services/config';
|
|
4
|
+
import { getFingerprintForIdentify } from '@/services/fingerprint';
|
|
3
5
|
import geolocationService from '@/services/geolocation';
|
|
6
|
+
import mixpanelService from '@/services/mixpanel';
|
|
4
7
|
import validatorService from '@/services/validator';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
import {
|
|
9
|
+
createIdentifyReporter,
|
|
10
|
+
observeIdentifyResult,
|
|
11
|
+
waitForIdentifyReady,
|
|
12
|
+
waitForIdentifyStage,
|
|
13
|
+
} from '@/util/identifyAttempt';
|
|
8
14
|
import { DefaultProperties } from './identifySchema';
|
|
9
15
|
|
|
10
16
|
const KNOWN_PROPERTIES = ['firstName', 'lastName', 'email', 'phone'];
|
|
@@ -51,7 +57,9 @@ function mapProperties(properties: UserProperties): Record<string, unknown> {
|
|
|
51
57
|
const identifyWithLocation = async (
|
|
52
58
|
userId: string,
|
|
53
59
|
properties: UserProperties,
|
|
60
|
+
report: IdentifyReporter,
|
|
54
61
|
) => {
|
|
62
|
+
let observer: ReturnType<typeof observeIdentifyResult> | undefined;
|
|
55
63
|
try {
|
|
56
64
|
const coordinates = geolocationService.getCoordinates();
|
|
57
65
|
|
|
@@ -69,25 +77,38 @@ const identifyWithLocation = async (
|
|
|
69
77
|
delete properties.lastName;
|
|
70
78
|
|
|
71
79
|
const coreApiConfig = configService.getCoreAPIConfig();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
observer = observeIdentifyResult('datalake', report, () =>
|
|
82
|
+
controller.abort(),
|
|
83
|
+
);
|
|
84
|
+
const response = await fetch(
|
|
85
|
+
`${coreApiConfig.baseUrl}/v1/datalake/identify`,
|
|
86
|
+
{
|
|
87
|
+
method: 'POST',
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
'Authorization': coreApiConfig.defaultHeaders?.Authorization || '',
|
|
92
|
+
'Origin': coreApiConfig.defaultHeaders?.Origin || '',
|
|
93
|
+
...(coreApiConfig.defaultHeaders?.['x-app-name']
|
|
94
|
+
? { 'x-app-name': coreApiConfig.defaultHeaders['x-app-name'] }
|
|
95
|
+
: {}),
|
|
86
96
|
},
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
profile_params: {
|
|
99
|
+
...properties,
|
|
100
|
+
distinct_id: userId,
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
observer.complete(
|
|
106
|
+
response.ok ? undefined : 'http_error',
|
|
107
|
+
response.ok ? undefined : response.status,
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
if (observer) observer.complete('failed');
|
|
111
|
+
else report('datalake', 'failed');
|
|
91
112
|
}
|
|
92
113
|
};
|
|
93
114
|
|
|
@@ -95,39 +116,60 @@ const identifyWithLocation = async (
|
|
|
95
116
|
* Identifies a user with given properties and fingerprint.
|
|
96
117
|
* @param {string} userId - The unique identifier for the user.
|
|
97
118
|
* @param {UserProperties} properties - Optional user properties.
|
|
98
|
-
* @
|
|
99
|
-
* @returns {Promise<void>}
|
|
119
|
+
* @returns {Promise<void>} Resolves after a bounded local attempt, not confirmed remote delivery.
|
|
100
120
|
*/
|
|
101
121
|
async function identify(
|
|
102
122
|
userId: string,
|
|
103
123
|
properties: UserProperties = {},
|
|
104
124
|
): Promise<void> {
|
|
105
|
-
|
|
125
|
+
const report = createIdentifyReporter();
|
|
126
|
+
if (!(await waitForIdentifyReady(report))) return;
|
|
127
|
+
try {
|
|
106
128
|
try {
|
|
107
129
|
validatorService.parseIdentifyProps(properties);
|
|
108
|
-
|
|
109
130
|
if (!userId) {
|
|
110
|
-
|
|
111
|
-
|
|
131
|
+
report('validation', 'invalid_input');
|
|
132
|
+
return;
|
|
112
133
|
}
|
|
113
|
-
|
|
134
|
+
} catch {
|
|
135
|
+
report('validation', 'invalid_input');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const anonymousProfile = await waitForIdentifyStage(
|
|
139
|
+
'profile',
|
|
140
|
+
3000,
|
|
141
|
+
(context) =>
|
|
142
|
+
createAnonymousProfileForIdentify({ ...properties }, context.report),
|
|
143
|
+
report,
|
|
144
|
+
);
|
|
114
145
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
146
|
+
if (anonymousProfile) {
|
|
147
|
+
properties.reservamos_one_id = anonymousProfile.id;
|
|
148
|
+
}
|
|
149
|
+
const mappedProps = mapProperties(properties);
|
|
150
|
+
try {
|
|
151
|
+
mixpanelService.identify(userId, mappedProps, report);
|
|
152
|
+
} catch {
|
|
153
|
+
report('mixpanel', 'failed');
|
|
154
|
+
}
|
|
155
|
+
const fingerprint = await waitForIdentifyStage(
|
|
156
|
+
'fingerprint',
|
|
157
|
+
2000,
|
|
158
|
+
getFingerprintForIdentify,
|
|
159
|
+
report,
|
|
160
|
+
);
|
|
161
|
+
if (fingerprint) {
|
|
162
|
+
try {
|
|
122
163
|
mixpanelService.attachProperty(FINGERPRINT_PROPERTY, fingerprint);
|
|
164
|
+
} catch {
|
|
165
|
+
report('fingerprint', 'failed');
|
|
123
166
|
}
|
|
124
|
-
} catch (error) {
|
|
125
|
-
console.error('Error identifying user', error);
|
|
126
|
-
trackEventError('Identify', error, { userId, properties });
|
|
127
|
-
} finally {
|
|
128
|
-
identifyWithLocation(userId, properties);
|
|
129
167
|
}
|
|
130
|
-
}
|
|
168
|
+
} catch {
|
|
169
|
+
report('mixpanel', 'failed');
|
|
170
|
+
} finally {
|
|
171
|
+
void identifyWithLocation(userId, properties, report);
|
|
172
|
+
}
|
|
131
173
|
}
|
|
132
174
|
|
|
133
175
|
export default identify;
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { PurchaseConfirmationProps } from '@/events/purchaseConfirmation';
|
|
|
11
11
|
import type { SearchProps } from '@/events/search';
|
|
12
12
|
import type { SeatChangeProps } from '@/events/seatChange';
|
|
13
13
|
import type { ViewResultsProps } from '@/events/viewResults';
|
|
14
|
+
import type { InitConfig } from '@/init';
|
|
14
15
|
import type { CreateAnonymousProfileProps } from '@/profiles/createAnonymousProfile';
|
|
15
16
|
import type { CreateFrequentPassengersSchemaProps } from '@/recommendations/createFrequentPassengers';
|
|
16
17
|
import type { CreateRecommendedSeatsSchemaProps } from '@/recommendations/createRecommendedSeats';
|
|
@@ -103,6 +104,7 @@ export type {
|
|
|
103
104
|
DeleteFrequentPassengerProps,
|
|
104
105
|
GeolocationSelectedProps,
|
|
105
106
|
PurchaseConfirmationProps,
|
|
107
|
+
InitConfig,
|
|
106
108
|
};
|
|
107
109
|
|
|
108
110
|
export default analytics;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import coreApi from '@reservamos/js-api-client/core';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import fingerprintService from '@/services/fingerprint';
|
|
4
|
+
import mixpanelService from '@/services/mixpanel';
|
|
5
|
+
import createAnonymousProfile, {
|
|
6
|
+
createAnonymousProfileForIdentify,
|
|
7
|
+
} from './createAnonymousProfile';
|
|
8
|
+
|
|
9
|
+
describe('anonymous profile internal identify path', () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('propagates API failure without a raw log, while the public wrapper still returns undefined', async () => {
|
|
15
|
+
vi.spyOn(fingerprintService, 'getCachedFingerprint').mockReturnValue(null);
|
|
16
|
+
vi.spyOn(mixpanelService, 'getMixpanelDistinctId').mockReturnValue(null);
|
|
17
|
+
const error = new Error('private@example.com');
|
|
18
|
+
vi.spyOn(coreApi.profiles, 'createAnonymousProfile').mockRejectedValue(
|
|
19
|
+
error,
|
|
20
|
+
);
|
|
21
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
22
|
+
await expect(
|
|
23
|
+
createAnonymousProfileForIdentify({ email: 'test@example.com' }, vi.fn()),
|
|
24
|
+
).rejects.toBe(error);
|
|
25
|
+
expect(log).not.toHaveBeenCalled();
|
|
26
|
+
await expect(
|
|
27
|
+
createAnonymousProfile({ email: 'test@example.com' }),
|
|
28
|
+
).resolves.toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('omits unreadable optional identifiers and still requests a profile', async () => {
|
|
32
|
+
const error = new Error('private-id');
|
|
33
|
+
vi.spyOn(fingerprintService, 'getCachedFingerprint').mockImplementation(
|
|
34
|
+
() => {
|
|
35
|
+
throw error;
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
vi.spyOn(mixpanelService, 'getMixpanelDistinctId').mockImplementation(
|
|
39
|
+
() => {
|
|
40
|
+
throw error;
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
const create = vi
|
|
44
|
+
.spyOn(coreApi.profiles, 'createAnonymousProfile')
|
|
45
|
+
.mockResolvedValue({ id: 'profile-123' });
|
|
46
|
+
const report = vi.fn();
|
|
47
|
+
await createAnonymousProfileForIdentify(
|
|
48
|
+
{ email: 'test@example.com' },
|
|
49
|
+
report,
|
|
50
|
+
);
|
|
51
|
+
expect(create).toHaveBeenCalledWith(
|
|
52
|
+
expect.objectContaining({ identifiers: [] }),
|
|
53
|
+
);
|
|
54
|
+
expect(report).toHaveBeenCalledWith('cache', 'unavailable');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
CreateAnonymousProfilePayload,
|
|
5
5
|
IdentifierKey,
|
|
6
6
|
} from '@reservamos/js-api-client/core';
|
|
7
|
+
import type { IdentifyReporter } from '@/util/identifyAttempt';
|
|
7
8
|
import type { CreateAnonymousProfileProps } from './createAnonymousProfileSchema';
|
|
8
9
|
import coreApi from '@reservamos/js-api-client/core';
|
|
9
10
|
import fingerprintService from '@/services/fingerprint';
|
|
@@ -67,22 +68,48 @@ async function createAnonymousProfile(
|
|
|
67
68
|
payload: CreateAnonymousProfileProps,
|
|
68
69
|
): Promise<AnonymousProfile | undefined> {
|
|
69
70
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const identifiers: AnonymousIdentifier[] = [];
|
|
73
|
-
const userFingerprintId = fingerprintService.getCachedFingerprint();
|
|
74
|
-
const distinctId = mixpanelService.getMixpanelDistinctId();
|
|
75
|
-
|
|
76
|
-
if (userFingerprintId)
|
|
77
|
-
identifiers.push({ key: 'fingerprint', value: userFingerprintId });
|
|
78
|
-
if (distinctId) identifiers.push({ key: 'distinct_id', value: distinctId });
|
|
79
|
-
|
|
80
|
-
const dataPayload = getAnonymousProfilePayload(payload, identifiers);
|
|
81
|
-
return await coreApi.profiles.createAnonymousProfile(dataPayload);
|
|
71
|
+
return await requestAnonymousProfile(payload);
|
|
82
72
|
} catch (error) {
|
|
83
73
|
console.error('Could not create anonymous profile:', error);
|
|
84
74
|
return undefined;
|
|
85
75
|
}
|
|
86
76
|
}
|
|
87
77
|
|
|
78
|
+
/** Internal identify path: failures propagate to its bounded, sanitized observer. */
|
|
79
|
+
export function createAnonymousProfileForIdentify(
|
|
80
|
+
payload: CreateAnonymousProfileProps,
|
|
81
|
+
report: IdentifyReporter,
|
|
82
|
+
): Promise<AnonymousProfile | undefined> {
|
|
83
|
+
return requestAnonymousProfile(payload, report);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function requestAnonymousProfile(
|
|
87
|
+
payload: CreateAnonymousProfileProps,
|
|
88
|
+
report?: IdentifyReporter,
|
|
89
|
+
): Promise<AnonymousProfile | undefined> {
|
|
90
|
+
validatorService.validateProps(payload, CreateAnonymousProfileSchema);
|
|
91
|
+
|
|
92
|
+
const identifiers: AnonymousIdentifier[] = [];
|
|
93
|
+
const readIdentifier = (read: () => string | null) => {
|
|
94
|
+
if (!report) return read();
|
|
95
|
+
try {
|
|
96
|
+
return read();
|
|
97
|
+
} catch {
|
|
98
|
+
report('cache', 'unavailable');
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const userFingerprintId = readIdentifier(
|
|
103
|
+
fingerprintService.getCachedFingerprint,
|
|
104
|
+
);
|
|
105
|
+
const distinctId = readIdentifier(mixpanelService.getMixpanelDistinctId);
|
|
106
|
+
|
|
107
|
+
if (userFingerprintId)
|
|
108
|
+
identifiers.push({ key: 'fingerprint', value: userFingerprintId });
|
|
109
|
+
if (distinctId) identifiers.push({ key: 'distinct_id', value: distinctId });
|
|
110
|
+
|
|
111
|
+
const dataPayload = getAnonymousProfilePayload(payload, identifiers);
|
|
112
|
+
return await coreApi.profiles.createAnonymousProfile(dataPayload);
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
export default createAnonymousProfile;
|