@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/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.
|
|
@@ -89,12 +93,44 @@ export function trackEventError(
|
|
|
89
93
|
});
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
/** Waits for one bounded attempt, then consumes callback failures. */
|
|
97
|
+
export async function tryTrackEvent(
|
|
98
|
+
trackEventCallback: () => Promise<void>,
|
|
99
|
+
eventName = 'Track Event Error',
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
if (!mixpanelService.isReady()) {
|
|
102
|
+
const ready = await new Promise<boolean>((resolve) => {
|
|
103
|
+
const finish = (isReady: boolean) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
window.removeEventListener('Tracker Ready', onReady);
|
|
106
|
+
unsubscribe();
|
|
107
|
+
resolve(isReady);
|
|
108
|
+
};
|
|
109
|
+
const onReady = () => {
|
|
110
|
+
if (mixpanelService.isReady()) finish(true);
|
|
111
|
+
};
|
|
112
|
+
const timer = setTimeout(() => finish(mixpanelService.isReady()), 5000);
|
|
113
|
+
const unsubscribe = mixpanelService.onReady(onReady);
|
|
114
|
+
window.addEventListener('Tracker Ready', onReady);
|
|
115
|
+
onReady();
|
|
116
|
+
});
|
|
117
|
+
if (!ready) {
|
|
118
|
+
console.error('Tracking diagnostic', {
|
|
119
|
+
eventName,
|
|
120
|
+
stage: 'readiness',
|
|
121
|
+
reason: 'timeout',
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
94
128
|
await trackEventCallback();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
129
|
+
} catch {
|
|
130
|
+
console.error('Tracking diagnostic', {
|
|
131
|
+
eventName,
|
|
132
|
+
stage: 'submission',
|
|
133
|
+
reason: 'failed',
|
|
98
134
|
});
|
|
99
135
|
}
|
|
100
136
|
}
|
|
@@ -105,33 +141,120 @@ export async function tryTrackEvent(trackEventCallback: () => Promise<void>) {
|
|
|
105
141
|
* @param {string} eventName - The name of the event to track.
|
|
106
142
|
* @param {object} eventProperties - The properties of the event to track.
|
|
107
143
|
* @param {EventMetadata} meta - Additional metadata to include in the event.
|
|
108
|
-
*
|
|
144
|
+
* Retains unsent events for up to sixty seconds without retrying a started submission.
|
|
109
145
|
*/
|
|
110
|
-
|
|
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. */
|
|
167
|
+
export function trackEvent(
|
|
111
168
|
eventName: string,
|
|
112
169
|
eventProperties: object,
|
|
113
170
|
meta: EventMetadata = {},
|
|
114
171
|
): Promise<void> {
|
|
115
|
-
|
|
116
|
-
|
|
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();
|
|
117
210
|
}
|
|
211
|
+
return pendingEvents.enqueue({
|
|
212
|
+
eventName,
|
|
213
|
+
bytes,
|
|
214
|
+
isCurrent,
|
|
215
|
+
run: (isActive) =>
|
|
216
|
+
submitEvent(
|
|
217
|
+
eventName,
|
|
218
|
+
snapshot.eventProperties,
|
|
219
|
+
snapshot.meta,
|
|
220
|
+
isActive,
|
|
221
|
+
trackingParams,
|
|
222
|
+
),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
118
225
|
|
|
226
|
+
async function submitEvent(
|
|
227
|
+
eventName: string,
|
|
228
|
+
eventProperties: object,
|
|
229
|
+
meta: EventMetadata,
|
|
230
|
+
isCurrent: () => boolean,
|
|
231
|
+
trackingParams: Record<string, string>,
|
|
232
|
+
): Promise<void> {
|
|
233
|
+
const observationContext = trackingLifecycle.capture();
|
|
119
234
|
try {
|
|
120
235
|
validator.parseEventProps(eventName, eventProperties);
|
|
121
236
|
// Call the Mixpanel service to store the distinct ID in the cache
|
|
122
237
|
const distinctId = mixpanelService.getMixpanelDistinctId();
|
|
123
238
|
const botDetectionEnabled =
|
|
124
239
|
botDetectionRelayService.isEnabledFor(eventName);
|
|
125
|
-
let fingerprint
|
|
126
|
-
let fingerprintRequestId: string | undefined;
|
|
240
|
+
let fingerprint = '';
|
|
127
241
|
|
|
128
|
-
if (botDetectionEnabled
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
242
|
+
if (botDetectionEnabled || FP_TRIGGER_EVENTS.includes(eventName)) {
|
|
243
|
+
try {
|
|
244
|
+
fingerprint = fingerprintService.getCachedFingerprint() ?? '';
|
|
245
|
+
} catch (error) {
|
|
246
|
+
console.error('Error reading cached fingerprint:', error);
|
|
247
|
+
}
|
|
132
248
|
} else {
|
|
133
|
-
|
|
134
|
-
|
|
249
|
+
try {
|
|
250
|
+
fingerprint = await fingerprintService.getFingerprint(true);
|
|
251
|
+
} catch {
|
|
252
|
+
console.error('Tracking diagnostic', {
|
|
253
|
+
eventName,
|
|
254
|
+
stage: 'fingerprint',
|
|
255
|
+
reason: 'failed',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
135
258
|
}
|
|
136
259
|
const defaultCoordinates: Record<string, string | number> = {};
|
|
137
260
|
const coordinates = geolocationService.getCoordinates();
|
|
@@ -146,7 +269,6 @@ export async function trackEvent(
|
|
|
146
269
|
const defaultProperties = {
|
|
147
270
|
'User Fingerprint': fingerprint,
|
|
148
271
|
};
|
|
149
|
-
const trackingParams = extractTrackingParams();
|
|
150
272
|
|
|
151
273
|
const simplifiedData = flattenEventData(eventProperties);
|
|
152
274
|
const properties = {
|
|
@@ -157,15 +279,22 @@ export async function trackEvent(
|
|
|
157
279
|
...meta,
|
|
158
280
|
};
|
|
159
281
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
visitorId: fingerprint,
|
|
166
|
-
...(distinctId && { distinctId }),
|
|
282
|
+
if (!isCurrent()) return;
|
|
283
|
+
try {
|
|
284
|
+
mixpanelService.track(eventName, properties);
|
|
285
|
+
} catch {
|
|
286
|
+
console.error('Tracking diagnostic', {
|
|
167
287
|
eventName,
|
|
288
|
+
stage: 'submission',
|
|
289
|
+
reason: 'uncertain',
|
|
168
290
|
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (botDetectionEnabled) {
|
|
295
|
+
deferBotDetection(eventName, distinctId, observationContext);
|
|
296
|
+
} else if (FP_TRIGGER_EVENTS.includes(eventName)) {
|
|
297
|
+
void refreshFingerprint(eventName, observationContext);
|
|
169
298
|
}
|
|
170
299
|
} catch (error) {
|
|
171
300
|
console.error(`Error tracking event '${eventName}':`, error);
|
|
@@ -173,6 +302,26 @@ export async function trackEvent(
|
|
|
173
302
|
}
|
|
174
303
|
}
|
|
175
304
|
|
|
305
|
+
/** Refreshes optional identity after submission without delaying the event. */
|
|
306
|
+
async function refreshFingerprint(
|
|
307
|
+
eventName: string,
|
|
308
|
+
isCurrent: () => boolean,
|
|
309
|
+
): Promise<void> {
|
|
310
|
+
const deadline = Date.now() + 10000;
|
|
311
|
+
try {
|
|
312
|
+
await fingerprintService.getFingerprint(false, {
|
|
313
|
+
isActive: () => isCurrent() && Date.now() < deadline,
|
|
314
|
+
report: () => undefined,
|
|
315
|
+
});
|
|
316
|
+
} catch {
|
|
317
|
+
console.error('Tracking diagnostic', {
|
|
318
|
+
eventName,
|
|
319
|
+
stage: 'fingerprint',
|
|
320
|
+
reason: 'failed',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
176
325
|
/**
|
|
177
326
|
* Extracts UTM and other tracking parameters from the current URL that match predefined fields.
|
|
178
327
|
* @returns {Record<string, string>} An object containing matched tracking parameters.
|
|
@@ -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
|
+
}
|