@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
package/src/track.ts CHANGED
@@ -4,10 +4,14 @@ import type {
4
4
  EventMetadata,
5
5
  } from './types/eventData';
6
6
  import botDetectionRelayService from '@/services/botDetectionRelay';
7
+ import { deferBotDetection } from '@/services/deferredBotDetection';
7
8
  import fingerprintService from '@/services/fingerprint';
8
9
  import geolocationService from '@/services/geolocation';
9
10
  import mixpanelService from '@/services/mixpanel';
11
+ import trackingLifecycle from '@/services/trackingLifecycle';
10
12
  import validator from '@/services/validator';
13
+ import { createPendingWorkQueue } from '@/util/pendingWork';
14
+ import { snapshotEventData } from '@/util/snapshotEventData';
11
15
 
12
16
  /**
13
17
  * List of events that trigger the fingerprint to be sent with the event. other events will only fetch the cached fingerprint.
@@ -99,12 +103,14 @@ export async function tryTrackEvent(
99
103
  const finish = (isReady: boolean) => {
100
104
  clearTimeout(timer);
101
105
  window.removeEventListener('Tracker Ready', onReady);
106
+ unsubscribe();
102
107
  resolve(isReady);
103
108
  };
104
109
  const onReady = () => {
105
110
  if (mixpanelService.isReady()) finish(true);
106
111
  };
107
112
  const timer = setTimeout(() => finish(mixpanelService.isReady()), 5000);
113
+ const unsubscribe = mixpanelService.onReady(onReady);
108
114
  window.addEventListener('Tracker Ready', onReady);
109
115
  onReady();
110
116
  });
@@ -135,25 +141,96 @@ export async function tryTrackEvent(
135
141
  * @param {string} eventName - The name of the event to track.
136
142
  * @param {object} eventProperties - The properties of the event to track.
137
143
  * @param {EventMetadata} meta - Additional metadata to include in the event.
138
- * Waits up to five seconds for Mixpanel readiness before event submission.
139
- * Optional fingerprint failure does not discard an otherwise valid event.
144
+ * Retains unsent events for up to sixty seconds without retrying a started submission.
140
145
  */
146
+ const pendingEvents = createPendingWorkQueue({
147
+ isReady: () => mixpanelService.isReady(),
148
+ subscribe(notify) {
149
+ const stopReady = mixpanelService.onReady(notify);
150
+ const stopIdentity = trackingLifecycle.onChange(notify);
151
+ window.addEventListener('Tracker Ready', notify);
152
+ return () => {
153
+ stopReady();
154
+ stopIdentity();
155
+ window.removeEventListener('Tracker Ready', notify);
156
+ };
157
+ },
158
+ maxEntries: 100,
159
+ maxBytes: 1024 * 1024,
160
+ maxActive: 100,
161
+ lifetime: 60000,
162
+ wait: 5000,
163
+ stage: 'readiness',
164
+ });
165
+
166
+ /** Resolves within the initial five-second wait; unsent events can remain pending for 60 seconds. */
141
167
  export function trackEvent(
142
168
  eventName: string,
143
169
  eventProperties: object,
144
170
  meta: EventMetadata = {},
145
171
  ): Promise<void> {
146
- return tryTrackEvent(
147
- () => submitEvent(eventName, eventProperties, meta),
172
+ const isCurrent = trackingLifecycle.capture();
173
+ const trackingParams = extractTrackingParams();
174
+ if (mixpanelService.isReady()) {
175
+ return submitEvent(
176
+ eventName,
177
+ eventProperties,
178
+ meta,
179
+ isCurrent,
180
+ trackingParams,
181
+ );
182
+ }
183
+ try {
184
+ validator.parseEventProps(eventName, eventProperties);
185
+ } catch (error) {
186
+ trackEventError(eventName, error, eventProperties);
187
+ return Promise.resolve();
188
+ }
189
+ let snapshot: { eventProperties: object; meta: EventMetadata };
190
+ let bytes: number;
191
+ try {
192
+ const serialized = JSON.stringify(
193
+ { eventName, eventProperties, meta, trackingParams },
194
+ (_key, value: unknown) => {
195
+ if (typeof value === 'function' || typeof value === 'symbol')
196
+ throw new Error();
197
+ return value === undefined ? null : value;
198
+ },
199
+ );
200
+ bytes = new Blob([serialized]).size;
201
+ if (bytes > 1024 * 1024) throw new Error();
202
+ snapshot = snapshotEventData({ eventProperties, meta });
203
+ } catch {
204
+ console.error('Tracking recovery diagnostic', {
205
+ eventName,
206
+ stage: 'readiness',
207
+ reason: 'snapshot_unavailable',
208
+ });
209
+ return Promise.resolve();
210
+ }
211
+ return pendingEvents.enqueue({
148
212
  eventName,
149
- );
213
+ bytes,
214
+ isCurrent,
215
+ run: (isActive) =>
216
+ submitEvent(
217
+ eventName,
218
+ snapshot.eventProperties,
219
+ snapshot.meta,
220
+ isActive,
221
+ trackingParams,
222
+ ),
223
+ });
150
224
  }
151
225
 
152
226
  async function submitEvent(
153
227
  eventName: string,
154
228
  eventProperties: object,
155
229
  meta: EventMetadata,
230
+ isCurrent: () => boolean,
231
+ trackingParams: Record<string, string>,
156
232
  ): Promise<void> {
233
+ const observationContext = trackingLifecycle.capture();
157
234
  try {
158
235
  validator.parseEventProps(eventName, eventProperties);
159
236
  // Call the Mixpanel service to store the distinct ID in the cache
@@ -162,16 +239,15 @@ async function submitEvent(
162
239
  botDetectionRelayService.isEnabledFor(eventName);
163
240
  let fingerprint = '';
164
241
 
165
- if (botDetectionEnabled) {
242
+ if (botDetectionEnabled || FP_TRIGGER_EVENTS.includes(eventName)) {
166
243
  try {
167
244
  fingerprint = fingerprintService.getCachedFingerprint() ?? '';
168
245
  } catch (error) {
169
246
  console.error('Error reading cached fingerprint:', error);
170
247
  }
171
248
  } else {
172
- const cacheOnly = !FP_TRIGGER_EVENTS.includes(eventName);
173
249
  try {
174
- fingerprint = await fingerprintService.getFingerprint(cacheOnly);
250
+ fingerprint = await fingerprintService.getFingerprint(true);
175
251
  } catch {
176
252
  console.error('Tracking diagnostic', {
177
253
  eventName,
@@ -193,7 +269,6 @@ async function submitEvent(
193
269
  const defaultProperties = {
194
270
  'User Fingerprint': fingerprint,
195
271
  };
196
- const trackingParams = extractTrackingParams();
197
272
 
198
273
  const simplifiedData = flattenEventData(eventProperties);
199
274
  const properties = {
@@ -204,10 +279,22 @@ async function submitEvent(
204
279
  ...meta,
205
280
  };
206
281
 
207
- mixpanelService.track(eventName, properties);
282
+ if (!isCurrent()) return;
283
+ try {
284
+ mixpanelService.track(eventName, properties);
285
+ } catch {
286
+ console.error('Tracking diagnostic', {
287
+ eventName,
288
+ stage: 'submission',
289
+ reason: 'uncertain',
290
+ });
291
+ return;
292
+ }
208
293
 
209
294
  if (botDetectionEnabled) {
210
- void identifyAndRelayFingerprint(eventName, distinctId);
295
+ deferBotDetection(eventName, distinctId, observationContext);
296
+ } else if (FP_TRIGGER_EVENTS.includes(eventName)) {
297
+ void refreshFingerprint(eventName, observationContext);
211
298
  }
212
299
  } catch (error) {
213
300
  console.error(`Error tracking event '${eventName}':`, error);
@@ -215,25 +302,23 @@ async function submitEvent(
215
302
  }
216
303
  }
217
304
 
218
- /** Identifies and relays only after the original event has been submitted. */
219
- async function identifyAndRelayFingerprint(
305
+ /** Refreshes optional identity after submission without delaying the event. */
306
+ async function refreshFingerprint(
220
307
  eventName: string,
221
- distinctId: string | null,
308
+ isCurrent: () => boolean,
222
309
  ): Promise<void> {
310
+ const deadline = Date.now() + 10000;
223
311
  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 }),
312
+ await fingerprintService.getFingerprint(false, {
313
+ isActive: () => isCurrent() && Date.now() < deadline,
314
+ report: () => undefined,
315
+ });
316
+ } catch {
317
+ console.error('Tracking diagnostic', {
233
318
  eventName,
319
+ stage: 'fingerprint',
320
+ reason: 'failed',
234
321
  });
235
- } catch (error) {
236
- console.error(`Error running Bot Detection for '${eventName}':`, error);
237
322
  }
238
323
  }
239
324
 
@@ -0,0 +1,164 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createPendingWorkQueue } from './pendingWork';
3
+
4
+ describe('bounded pending work', () => {
5
+ let ready: boolean;
6
+ let signal: () => void;
7
+ let detach: ReturnType<typeof vi.fn>;
8
+ beforeEach(() => {
9
+ vi.useFakeTimers();
10
+ ready = false;
11
+ signal = () => undefined;
12
+ detach = vi.fn();
13
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
14
+ });
15
+ afterEach(() => {
16
+ vi.clearAllTimers();
17
+ vi.useRealTimers();
18
+ vi.restoreAllMocks();
19
+ });
20
+ function queue(maxActive = 1) {
21
+ return createPendingWorkQueue({
22
+ isReady: () => ready,
23
+ subscribe: (notify) => {
24
+ signal = notify;
25
+ return detach;
26
+ },
27
+ maxEntries: 2,
28
+ maxBytes: 10,
29
+ maxActive,
30
+ lifetime: 60000,
31
+ wait: 5000,
32
+ stage: 'readiness',
33
+ });
34
+ }
35
+ const work = (run: () => void | Promise<void>, bytes = 1) => ({
36
+ eventName: 'Test',
37
+ bytes,
38
+ isCurrent: () => true,
39
+ run,
40
+ });
41
+
42
+ it('resolves the foreground wait but retains unsent work for readiness at 5.2 seconds', async () => {
43
+ const run = vi.fn();
44
+ const pending = queue().enqueue(work(run));
45
+ await vi.advanceTimersByTimeAsync(5000);
46
+ await expect(pending).resolves.toBeUndefined();
47
+ expect(run).not.toHaveBeenCalled();
48
+ await vi.advanceTimersByTimeAsync(200);
49
+ ready = true;
50
+ signal();
51
+ signal();
52
+ await vi.advanceTimersByTimeAsync(0);
53
+ expect(run).toHaveBeenCalledOnce();
54
+ expect(detach).toHaveBeenCalledOnce();
55
+ expect(vi.getTimerCount()).toBe(0);
56
+ });
57
+
58
+ it('never retries after a provider accepts and throws, including reentrant readiness', async () => {
59
+ const q = queue();
60
+ const run = vi.fn(() => {
61
+ signal();
62
+ throw new Error('accepted then threw');
63
+ });
64
+ const pending = q.enqueue(work(run));
65
+ ready = true;
66
+ signal();
67
+ signal();
68
+ await pending;
69
+ await vi.advanceTimersByTimeAsync(60000);
70
+ signal();
71
+ expect(run).toHaveBeenCalledOnce();
72
+ expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
73
+ 'accepted then threw',
74
+ );
75
+ });
76
+
77
+ it('keeps separate legitimate equal-payload calls', async () => {
78
+ const q = queue(2);
79
+ const run = vi.fn();
80
+ const first = q.enqueue(work(run));
81
+ const second = q.enqueue(work(run));
82
+ ready = true;
83
+ signal();
84
+ await Promise.all([first, second]);
85
+ expect(run).toHaveBeenCalledTimes(2);
86
+ });
87
+
88
+ it('expires without submission and cleans up its subscription and timer', async () => {
89
+ const run = vi.fn();
90
+ const pending = queue().enqueue(work(run));
91
+ await vi.advanceTimersByTimeAsync(60000);
92
+ await pending;
93
+ ready = true;
94
+ signal();
95
+ expect(run).not.toHaveBeenCalled();
96
+ expect(detach).toHaveBeenCalledOnce();
97
+ expect(vi.getTimerCount()).toBe(0);
98
+ });
99
+
100
+ it('evicts the oldest waiting work at capacity and rejects oversized work', async () => {
101
+ const q = queue();
102
+ const runs = [vi.fn(), vi.fn(), vi.fn(), vi.fn()];
103
+ const promises = runs.map((run, i) =>
104
+ q.enqueue(work(run, i === 3 ? 11 : 4)),
105
+ );
106
+ ready = true;
107
+ signal();
108
+ await Promise.all(promises);
109
+ expect(runs[0]).not.toHaveBeenCalled();
110
+ expect(runs[1]).toHaveBeenCalledOnce();
111
+ expect(runs[2]).toHaveBeenCalledOnce();
112
+ expect(runs[3]).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it('invalidates waiting work before identity changes can submit it', async () => {
116
+ let current = true;
117
+ const run = vi.fn();
118
+ const pending = queue().enqueue({ ...work(run), isCurrent: () => current });
119
+ current = false;
120
+ signal();
121
+ await pending;
122
+ ready = true;
123
+ signal();
124
+ expect(run).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('enforces the byte budget before the entry-count budget is reached', async () => {
128
+ const q = queue();
129
+ const first = vi.fn();
130
+ const second = vi.fn();
131
+ const old = q.enqueue(work(first, 6));
132
+ const retained = q.enqueue(work(second, 6));
133
+ ready = true;
134
+ signal();
135
+ await Promise.all([old, retained]);
136
+ expect(first).not.toHaveBeenCalled();
137
+ expect(second).toHaveBeenCalledOnce();
138
+ });
139
+
140
+ it('suppresses expired active effects without exceeding actual provider concurrency', async () => {
141
+ const q = queue();
142
+ ready = true;
143
+ let finish!: () => void;
144
+ let active!: () => boolean;
145
+ const first = q.enqueue({
146
+ ...work(() => undefined),
147
+ run: (isActive) => {
148
+ active = isActive;
149
+ return new Promise<void>((resolve) => {
150
+ finish = resolve;
151
+ });
152
+ },
153
+ });
154
+ await vi.advanceTimersByTimeAsync(60000);
155
+ await first;
156
+ expect(active()).toBe(false);
157
+ const run = vi.fn();
158
+ const second = q.enqueue(work(run));
159
+ expect(run).not.toHaveBeenCalled();
160
+ finish();
161
+ await second;
162
+ expect(run).toHaveBeenCalledOnce();
163
+ });
164
+ });
@@ -0,0 +1,148 @@
1
+ interface PendingWork {
2
+ eventName: string;
3
+ bytes: number;
4
+ isCurrent: () => boolean;
5
+ run: (isActive: () => boolean) => void | Promise<void>;
6
+ }
7
+
8
+ interface QueueOptions {
9
+ isReady: () => boolean;
10
+ subscribe: (notify: () => void) => () => void;
11
+ maxEntries: number;
12
+ maxBytes: number;
13
+ maxActive: number;
14
+ lifetime: number;
15
+ wait: number;
16
+ stage: 'readiness' | 'fingerprint';
17
+ }
18
+
19
+ interface Entry extends PendingWork {
20
+ state: 'waiting' | 'active' | 'closed';
21
+ deadline: number;
22
+ foregroundDeadline: number;
23
+ resolve?: () => void;
24
+ }
25
+
26
+ /** Retains bounded local work and claims each entry once, before invoking it. */
27
+ export function createPendingWorkQueue(options: QueueOptions) {
28
+ const entries = new Set<Entry>();
29
+ let bytes = 0;
30
+ let active = 0;
31
+ let draining = false;
32
+ let unsubscribe: (() => void) | undefined;
33
+ let timer: ReturnType<typeof setTimeout> | undefined;
34
+
35
+ function diagnostic(entry: PendingWork, reason: string): void {
36
+ console.error('Tracking recovery diagnostic', {
37
+ eventName: entry.eventName,
38
+ stage: options.stage,
39
+ reason,
40
+ });
41
+ }
42
+
43
+ function resolveForeground(entry: Entry): void {
44
+ entry.resolve?.();
45
+ entry.resolve = undefined;
46
+ }
47
+
48
+ function close(entry: Entry, reason?: string): void {
49
+ if (entry.state === 'closed') return;
50
+ entry.state = 'closed';
51
+ entries.delete(entry);
52
+ bytes -= entry.bytes;
53
+ resolveForeground(entry);
54
+ if (reason) diagnostic(entry, reason);
55
+ }
56
+
57
+ function schedule(): void {
58
+ clearTimeout(timer);
59
+ timer = undefined;
60
+ if (entries.size === 0) {
61
+ unsubscribe?.();
62
+ unsubscribe = undefined;
63
+ return;
64
+ }
65
+ let next = Infinity;
66
+ for (const entry of entries) {
67
+ next = Math.min(next, entry.deadline);
68
+ if (entry.resolve) next = Math.min(next, entry.foregroundDeadline);
69
+ }
70
+ timer = setTimeout(drain, Math.max(0, next - Date.now()));
71
+ }
72
+
73
+ function drain(): void {
74
+ if (draining) return;
75
+ draining = true;
76
+ try {
77
+ for (const entry of entries) {
78
+ if (!entry.isCurrent()) close(entry, 'identity_changed');
79
+ else if (Date.now() >= entry.deadline) close(entry, 'timeout');
80
+ else if (entry.resolve && Date.now() >= entry.foregroundDeadline) {
81
+ resolveForeground(entry);
82
+ }
83
+ }
84
+ for (const entry of entries) {
85
+ if (active >= options.maxActive || !options.isReady()) break;
86
+ if (entry.state !== 'waiting') continue;
87
+ // No await before this claim: reentrant or repeated signals cannot replay it.
88
+ entry.state = 'active';
89
+ active++;
90
+ const isActive = () =>
91
+ entry.state === 'active' &&
92
+ Date.now() < entry.deadline &&
93
+ entry.isCurrent();
94
+ const finish = (failed = false) => {
95
+ if (failed && isActive()) diagnostic(entry, 'failed');
96
+ close(entry);
97
+ // An expired remote call still occupies its physical concurrency slot.
98
+ active--;
99
+ drain();
100
+ };
101
+ try {
102
+ void Promise.resolve(entry.run(isActive)).then(
103
+ () => finish(),
104
+ () => finish(true),
105
+ );
106
+ } catch {
107
+ finish(true);
108
+ }
109
+ }
110
+ } finally {
111
+ draining = false;
112
+ schedule();
113
+ }
114
+ }
115
+
116
+ function enqueue(work: PendingWork): Promise<void> {
117
+ if (work.bytes > options.maxBytes) {
118
+ diagnostic(work, 'capacity');
119
+ return Promise.resolve();
120
+ }
121
+ while (
122
+ entries.size >= options.maxEntries ||
123
+ bytes + work.bytes > options.maxBytes
124
+ ) {
125
+ const oldest = [...entries].find((entry) => entry.state === 'waiting');
126
+ if (!oldest) {
127
+ diagnostic(work, 'capacity');
128
+ return Promise.resolve();
129
+ }
130
+ close(oldest, 'capacity');
131
+ }
132
+ return new Promise((resolve) => {
133
+ const entry: Entry = {
134
+ ...work,
135
+ state: 'waiting',
136
+ deadline: Date.now() + options.lifetime,
137
+ foregroundDeadline: Date.now() + options.wait,
138
+ resolve,
139
+ };
140
+ entries.add(entry);
141
+ bytes += entry.bytes;
142
+ unsubscribe ??= options.subscribe(drain);
143
+ drain();
144
+ });
145
+ }
146
+
147
+ return { enqueue };
148
+ }
@@ -0,0 +1,15 @@
1
+ /** Copies serializable event structures without replacing array undefined values with null. */
2
+ export function snapshotEventData<T>(value: T): T {
3
+ if (Array.isArray(value))
4
+ return value.map((item: unknown) => snapshotEventData(item)) as T;
5
+ if (value instanceof Date) return new Date(value.getTime()) as T;
6
+ if (value !== null && typeof value === 'object') {
7
+ return Object.fromEntries(
8
+ Object.entries(value).map(([key, item]) => [
9
+ key,
10
+ snapshotEventData(item),
11
+ ]),
12
+ ) as T;
13
+ }
14
+ return value;
15
+ }