@nextsparkjs/plugin-amplitude 0.1.0-beta.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/CODE_REVIEW_REPORT.md +462 -0
- package/README.md +619 -0
- package/__tests__/amplitude-core.test.ts +279 -0
- package/__tests__/hooks.test.ts +478 -0
- package/__tests__/validation.test.ts +393 -0
- package/components/AnalyticsDashboard.tsx +339 -0
- package/components/ConsentManager.tsx +265 -0
- package/components/ExperimentWrapper.tsx +440 -0
- package/components/PerformanceMonitor.tsx +578 -0
- package/hooks/useAmplitude.ts +132 -0
- package/hooks/useAmplitudeEvents.ts +100 -0
- package/hooks/useExperiment.ts +195 -0
- package/hooks/useSessionReplay.ts +238 -0
- package/jest.setup.ts +276 -0
- package/lib/amplitude-core.ts +178 -0
- package/lib/cache.ts +181 -0
- package/lib/performance.ts +319 -0
- package/lib/queue.ts +389 -0
- package/lib/security.ts +188 -0
- package/package.json +15 -0
- package/plugin.config.ts +58 -0
- package/providers/AmplitudeProvider.tsx +113 -0
- package/styles/amplitude.css +593 -0
- package/translations/en.json +45 -0
- package/translations/es.json +45 -0
- package/tsconfig.json +47 -0
- package/types/amplitude.types.ts +105 -0
- package/utils/debounce.ts +133 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { EventType } from '../types/amplitude.types';
|
|
2
|
+
|
|
3
|
+
export interface PerformanceMetric {
|
|
4
|
+
name: string;
|
|
5
|
+
value: number;
|
|
6
|
+
unit: string;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
properties?: Record<string, any>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PerformanceStats {
|
|
12
|
+
amplitudeCore: {
|
|
13
|
+
initTime: number;
|
|
14
|
+
trackingLatency: number[];
|
|
15
|
+
errorRate: number;
|
|
16
|
+
eventQueueSize: number;
|
|
17
|
+
memoryUsage: number;
|
|
18
|
+
};
|
|
19
|
+
webVitals: {
|
|
20
|
+
cls: number;
|
|
21
|
+
fid: number;
|
|
22
|
+
fcp: number;
|
|
23
|
+
lcp: number;
|
|
24
|
+
ttfb: number;
|
|
25
|
+
inp: number;
|
|
26
|
+
};
|
|
27
|
+
browserMetrics: {
|
|
28
|
+
memoryUsage: number;
|
|
29
|
+
connectionType: string;
|
|
30
|
+
devicePixelRatio: number;
|
|
31
|
+
screenResolution: string;
|
|
32
|
+
viewportSize: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const performanceMetrics: PerformanceMetric[] = [];
|
|
37
|
+
const performanceObservers: PerformanceObserver[] = [];
|
|
38
|
+
const MAX_METRICS = 10000;
|
|
39
|
+
const CLEANUP_THRESHOLD = 12000;
|
|
40
|
+
|
|
41
|
+
// Internal performance stats
|
|
42
|
+
let amplitudeCoreStats = {
|
|
43
|
+
initTime: 0,
|
|
44
|
+
trackingLatency: [] as number[],
|
|
45
|
+
errorCount: 0,
|
|
46
|
+
successCount: 0,
|
|
47
|
+
eventQueueSize: 0,
|
|
48
|
+
memoryUsage: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function trackPerformanceMetric(name: EventType | string, value: number, unit: string = 'ms', properties?: Record<string, any>): void {
|
|
52
|
+
if (typeof window === 'undefined') return;
|
|
53
|
+
|
|
54
|
+
const metric: PerformanceMetric = {
|
|
55
|
+
name: name as string,
|
|
56
|
+
value,
|
|
57
|
+
unit,
|
|
58
|
+
timestamp: Date.now(),
|
|
59
|
+
properties,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
performanceMetrics.push(metric);
|
|
63
|
+
|
|
64
|
+
// Update internal stats based on metric type
|
|
65
|
+
updateInternalStats(name as string, value);
|
|
66
|
+
|
|
67
|
+
// Cleanup old metrics if we exceed threshold
|
|
68
|
+
if (performanceMetrics.length > CLEANUP_THRESHOLD) {
|
|
69
|
+
performanceMetrics.splice(0, performanceMetrics.length - MAX_METRICS);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Log to console in debug mode
|
|
73
|
+
if (process.env.NODE_ENV === 'development') {
|
|
74
|
+
console.debug(`[Performance] ${name}: ${value}${unit}`, properties);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateInternalStats(name: string, value: number): void {
|
|
79
|
+
switch (name) {
|
|
80
|
+
case 'amplitude_init_success':
|
|
81
|
+
amplitudeCoreStats.initTime = value;
|
|
82
|
+
break;
|
|
83
|
+
case 'amplitude_track_latency':
|
|
84
|
+
amplitudeCoreStats.trackingLatency.push(value);
|
|
85
|
+
if (amplitudeCoreStats.trackingLatency.length > 100) {
|
|
86
|
+
amplitudeCoreStats.trackingLatency = amplitudeCoreStats.trackingLatency.slice(-50);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
case 'amplitude_track_success':
|
|
90
|
+
case 'amplitude_identify_success':
|
|
91
|
+
case 'amplitude_user_properties_success':
|
|
92
|
+
amplitudeCoreStats.successCount += value;
|
|
93
|
+
break;
|
|
94
|
+
case 'amplitude_track_error':
|
|
95
|
+
case 'amplitude_identify_error':
|
|
96
|
+
case 'amplitude_user_properties_error':
|
|
97
|
+
case 'amplitude_init_error':
|
|
98
|
+
amplitudeCoreStats.errorCount += value;
|
|
99
|
+
break;
|
|
100
|
+
case 'amplitude_queue_size':
|
|
101
|
+
amplitudeCoreStats.eventQueueSize = value;
|
|
102
|
+
break;
|
|
103
|
+
case 'amplitude_memory_usage':
|
|
104
|
+
amplitudeCoreStats.memoryUsage = value;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getPerformanceMetrics(): PerformanceMetric[] {
|
|
110
|
+
return [...performanceMetrics].sort((a, b) => b.timestamp - a.timestamp);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getPerformanceStats(): PerformanceStats {
|
|
114
|
+
const stats: PerformanceStats = {
|
|
115
|
+
amplitudeCore: {
|
|
116
|
+
...amplitudeCoreStats,
|
|
117
|
+
errorRate: amplitudeCoreStats.errorCount / Math.max(amplitudeCoreStats.successCount + amplitudeCoreStats.errorCount, 1),
|
|
118
|
+
},
|
|
119
|
+
webVitals: {
|
|
120
|
+
cls: 0,
|
|
121
|
+
fid: 0,
|
|
122
|
+
fcp: 0,
|
|
123
|
+
lcp: 0,
|
|
124
|
+
ttfb: 0,
|
|
125
|
+
inp: 0,
|
|
126
|
+
},
|
|
127
|
+
browserMetrics: {
|
|
128
|
+
memoryUsage: 0,
|
|
129
|
+
connectionType: 'unknown',
|
|
130
|
+
devicePixelRatio: 1,
|
|
131
|
+
screenResolution: '0x0',
|
|
132
|
+
viewportSize: '0x0',
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (typeof window !== 'undefined') {
|
|
137
|
+
// Browser metrics
|
|
138
|
+
stats.browserMetrics = {
|
|
139
|
+
memoryUsage: (performance as any).memory?.usedJSHeapSize || 0,
|
|
140
|
+
connectionType: (navigator as any).connection?.effectiveType || 'unknown',
|
|
141
|
+
devicePixelRatio: window.devicePixelRatio || 1,
|
|
142
|
+
screenResolution: `${window.screen.width}x${window.screen.height}`,
|
|
143
|
+
viewportSize: `${window.innerWidth}x${window.innerHeight}`,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Web Vitals from recent metrics
|
|
147
|
+
const recentMetrics = performanceMetrics.filter(m => Date.now() - m.timestamp < 60000); // Last minute
|
|
148
|
+
stats.webVitals = {
|
|
149
|
+
cls: getLatestMetricValue(recentMetrics, 'CLS') || 0,
|
|
150
|
+
fid: getLatestMetricValue(recentMetrics, 'FID') || 0,
|
|
151
|
+
fcp: getLatestMetricValue(recentMetrics, 'FCP') || 0,
|
|
152
|
+
lcp: getLatestMetricValue(recentMetrics, 'LCP') || 0,
|
|
153
|
+
ttfb: getLatestMetricValue(recentMetrics, 'TTFB') || 0,
|
|
154
|
+
inp: getLatestMetricValue(recentMetrics, 'INP') || 0,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return stats;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getLatestMetricValue(metrics: PerformanceMetric[], name: string): number | null {
|
|
162
|
+
const metric = metrics.filter(m => m.name === name).sort((a, b) => b.timestamp - a.timestamp)[0];
|
|
163
|
+
return metric?.value || null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function clearPerformanceMetrics(): void {
|
|
167
|
+
performanceMetrics.length = 0;
|
|
168
|
+
amplitudeCoreStats = {
|
|
169
|
+
initTime: 0,
|
|
170
|
+
trackingLatency: [],
|
|
171
|
+
errorCount: 0,
|
|
172
|
+
successCount: 0,
|
|
173
|
+
eventQueueSize: 0,
|
|
174
|
+
memoryUsage: 0,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function setupPerformanceObservers(): void {
|
|
179
|
+
if (typeof window === 'undefined' || !window.PerformanceObserver) return;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
// Largest Contentful Paint (LCP)
|
|
183
|
+
const lcpObserver = new PerformanceObserver((entryList) => {
|
|
184
|
+
for (const entry of entryList.getEntries()) {
|
|
185
|
+
trackPerformanceMetric('LCP', entry.startTime + entry.duration, 'ms', {
|
|
186
|
+
element: (entry as any).element?.tagName,
|
|
187
|
+
url: window.location.href,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
192
|
+
performanceObservers.push(lcpObserver);
|
|
193
|
+
|
|
194
|
+
// Cumulative Layout Shift (CLS)
|
|
195
|
+
const clsObserver = new PerformanceObserver((entryList) => {
|
|
196
|
+
let cls = 0;
|
|
197
|
+
for (const entry of entryList.getEntries()) {
|
|
198
|
+
if (!(entry as any).hadRecentInput) {
|
|
199
|
+
cls += (entry as any).value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (cls > 0) {
|
|
203
|
+
trackPerformanceMetric('CLS', cls, 'score', { url: window.location.href });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
clsObserver.observe({ type: 'layout-shift', buffered: true });
|
|
207
|
+
performanceObservers.push(clsObserver);
|
|
208
|
+
|
|
209
|
+
// First Contentful Paint (FCP)
|
|
210
|
+
const fcpObserver = new PerformanceObserver((entryList) => {
|
|
211
|
+
for (const entry of entryList.getEntries()) {
|
|
212
|
+
if (entry.name === 'first-contentful-paint') {
|
|
213
|
+
trackPerformanceMetric('FCP', entry.startTime, 'ms', { url: window.location.href });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
fcpObserver.observe({ type: 'paint', buffered: true });
|
|
218
|
+
performanceObservers.push(fcpObserver);
|
|
219
|
+
|
|
220
|
+
// Navigation Timing
|
|
221
|
+
const navigationObserver = new PerformanceObserver((entryList) => {
|
|
222
|
+
for (const entry of entryList.getEntries()) {
|
|
223
|
+
const navEntry = entry as PerformanceNavigationTiming;
|
|
224
|
+
trackPerformanceMetric('TTFB', navEntry.responseStart - navEntry.requestStart, 'ms');
|
|
225
|
+
trackPerformanceMetric('DOMContentLoaded', navEntry.domContentLoadedEventEnd - navEntry.domContentLoadedEventStart, 'ms');
|
|
226
|
+
trackPerformanceMetric('Load', navEntry.loadEventEnd - navEntry.loadEventStart, 'ms');
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
navigationObserver.observe({ type: 'navigation', buffered: true });
|
|
230
|
+
performanceObservers.push(navigationObserver);
|
|
231
|
+
|
|
232
|
+
// Long Tasks
|
|
233
|
+
const longTaskObserver = new PerformanceObserver((entryList) => {
|
|
234
|
+
for (const entry of entryList.getEntries()) {
|
|
235
|
+
trackPerformanceMetric('Long Task', entry.duration, 'ms', {
|
|
236
|
+
name: entry.name,
|
|
237
|
+
startTime: entry.startTime,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
longTaskObserver.observe({ type: 'longtask', buffered: true });
|
|
242
|
+
performanceObservers.push(longTaskObserver);
|
|
243
|
+
|
|
244
|
+
// Resource Timing
|
|
245
|
+
const resourceObserver = new PerformanceObserver((entryList) => {
|
|
246
|
+
for (const entry of entryList.getEntries()) {
|
|
247
|
+
const resourceEntry = entry as PerformanceResourceTiming;
|
|
248
|
+
trackPerformanceMetric('Resource Load', entry.duration, 'ms', {
|
|
249
|
+
name: entry.name,
|
|
250
|
+
initiatorType: resourceEntry.initiatorType,
|
|
251
|
+
transferSize: resourceEntry.transferSize,
|
|
252
|
+
responseStatus: (resourceEntry as any).responseStatus,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
resourceObserver.observe({ type: 'resource', buffered: true });
|
|
257
|
+
performanceObservers.push(resourceObserver);
|
|
258
|
+
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.warn('[Performance] Failed to setup performance observers:', error);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function disconnectPerformanceObservers(): void {
|
|
265
|
+
performanceObservers.forEach(observer => {
|
|
266
|
+
try {
|
|
267
|
+
observer.disconnect();
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.warn('[Performance] Failed to disconnect observer:', error);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
performanceObservers.length = 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Memory monitoring
|
|
276
|
+
export function startMemoryMonitoring(intervalMs: number = 30000): () => void {
|
|
277
|
+
if (typeof window === 'undefined') return () => {};
|
|
278
|
+
|
|
279
|
+
const interval = setInterval(() => {
|
|
280
|
+
if ((performance as any).memory) {
|
|
281
|
+
const memory = (performance as any).memory;
|
|
282
|
+
trackPerformanceMetric('JS Heap Used', memory.usedJSHeapSize, 'bytes');
|
|
283
|
+
trackPerformanceMetric('JS Heap Total', memory.totalJSHeapSize, 'bytes');
|
|
284
|
+
trackPerformanceMetric('JS Heap Limit', memory.jsHeapSizeLimit, 'bytes');
|
|
285
|
+
}
|
|
286
|
+
}, intervalMs);
|
|
287
|
+
|
|
288
|
+
return () => clearInterval(interval);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Connection monitoring
|
|
292
|
+
export function monitorConnection(): () => void {
|
|
293
|
+
if (typeof window === 'undefined' || !(navigator as any).connection) return () => {};
|
|
294
|
+
|
|
295
|
+
const connection = (navigator as any).connection;
|
|
296
|
+
|
|
297
|
+
const handleConnectionChange = () => {
|
|
298
|
+
trackPerformanceMetric('Connection Type', 1, 'event', {
|
|
299
|
+
effectiveType: connection.effectiveType,
|
|
300
|
+
downlink: connection.downlink,
|
|
301
|
+
rtt: connection.rtt,
|
|
302
|
+
saveData: connection.saveData,
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
connection.addEventListener('change', handleConnectionChange);
|
|
307
|
+
|
|
308
|
+
// Initial reading
|
|
309
|
+
handleConnectionChange();
|
|
310
|
+
|
|
311
|
+
return () => connection.removeEventListener('change', handleConnectionChange);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Export for cleanup
|
|
315
|
+
export function shutdownPerformanceMonitoring(): void {
|
|
316
|
+
disconnectPerformanceObservers();
|
|
317
|
+
clearPerformanceMetrics();
|
|
318
|
+
}
|
|
319
|
+
|
package/lib/queue.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { EventType, EventProperties } from '../types/amplitude.types';
|
|
2
|
+
import { trackPerformanceMetric } from './performance';
|
|
3
|
+
|
|
4
|
+
interface QueuedEvent {
|
|
5
|
+
id: string;
|
|
6
|
+
eventType: EventType;
|
|
7
|
+
properties?: EventProperties;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
retries: number;
|
|
10
|
+
priority: 'low' | 'normal' | 'high' | 'critical';
|
|
11
|
+
userId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface QueueOptions {
|
|
15
|
+
flushInterval?: number;
|
|
16
|
+
batchSize?: number;
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
retryDelayMs?: number;
|
|
19
|
+
maxQueueSize?: number;
|
|
20
|
+
persistenceEnabled?: boolean;
|
|
21
|
+
priorityEnabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class EventQueue {
|
|
25
|
+
private queue: QueuedEvent[] = [];
|
|
26
|
+
private isProcessing = false;
|
|
27
|
+
private flushInterval: number;
|
|
28
|
+
private batchSize: number;
|
|
29
|
+
private maxRetries: number;
|
|
30
|
+
private retryDelayMs: number;
|
|
31
|
+
private maxQueueSize: number;
|
|
32
|
+
private flushTimer: NodeJS.Timeout | null = null;
|
|
33
|
+
private sendBatchCallback: (events: QueuedEvent[]) => Promise<void>;
|
|
34
|
+
private persistenceEnabled: boolean;
|
|
35
|
+
private priorityEnabled: boolean;
|
|
36
|
+
private storageKey = 'amplitude_event_queue';
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
sendBatchCallback: (events: QueuedEvent[]) => Promise<void>,
|
|
40
|
+
options: QueueOptions = {}
|
|
41
|
+
) {
|
|
42
|
+
this.sendBatchCallback = sendBatchCallback;
|
|
43
|
+
this.flushInterval = options.flushInterval || 10000; // 10 seconds
|
|
44
|
+
this.batchSize = options.batchSize || 30;
|
|
45
|
+
this.maxRetries = options.maxRetries || 3;
|
|
46
|
+
this.retryDelayMs = options.retryDelayMs || 1000;
|
|
47
|
+
this.maxQueueSize = options.maxQueueSize || 10000;
|
|
48
|
+
this.persistenceEnabled = options.persistenceEnabled || true;
|
|
49
|
+
this.priorityEnabled = options.priorityEnabled || true;
|
|
50
|
+
|
|
51
|
+
this.loadPersistedEvents();
|
|
52
|
+
this.startFlushTimer();
|
|
53
|
+
this.setupOnlineListener();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public enqueue(
|
|
57
|
+
eventType: EventType,
|
|
58
|
+
properties?: EventProperties,
|
|
59
|
+
priority: 'low' | 'normal' | 'high' | 'critical' = 'normal',
|
|
60
|
+
userId?: string
|
|
61
|
+
): string {
|
|
62
|
+
const event: QueuedEvent = {
|
|
63
|
+
id: this.generateEventId(),
|
|
64
|
+
eventType,
|
|
65
|
+
properties,
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
retries: 0,
|
|
68
|
+
priority,
|
|
69
|
+
userId,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Check queue size limit
|
|
73
|
+
if (this.queue.length >= this.maxQueueSize) {
|
|
74
|
+
this.removeOldestLowPriorityEvent();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Insert based on priority if enabled
|
|
78
|
+
if (this.priorityEnabled) {
|
|
79
|
+
this.insertByPriority(event);
|
|
80
|
+
} else {
|
|
81
|
+
this.queue.push(event);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Persist to storage
|
|
85
|
+
if (this.persistenceEnabled) {
|
|
86
|
+
this.persistEvents();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Track queue metrics
|
|
90
|
+
trackPerformanceMetric('amplitude_queue_size', this.queue.length, 'count');
|
|
91
|
+
|
|
92
|
+
// Trigger immediate processing for critical events
|
|
93
|
+
if (priority === 'critical') {
|
|
94
|
+
this.processQueue();
|
|
95
|
+
} else {
|
|
96
|
+
// Normal processing
|
|
97
|
+
this.scheduleProcessing();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return event.id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private generateEventId(): string {
|
|
104
|
+
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private insertByPriority(event: QueuedEvent): void {
|
|
108
|
+
const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 };
|
|
109
|
+
const eventPriority = priorityOrder[event.priority];
|
|
110
|
+
|
|
111
|
+
let insertIndex = this.queue.length;
|
|
112
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
113
|
+
const queuePriority = priorityOrder[this.queue[i].priority];
|
|
114
|
+
if (eventPriority < queuePriority) {
|
|
115
|
+
insertIndex = i;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.queue.splice(insertIndex, 0, event);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private removeOldestLowPriorityEvent(): void {
|
|
124
|
+
// Find and remove the oldest low priority event
|
|
125
|
+
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
126
|
+
if (this.queue[i].priority === 'low') {
|
|
127
|
+
this.queue.splice(i, 1);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// If no low priority events, remove oldest normal priority
|
|
133
|
+
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
134
|
+
if (this.queue[i].priority === 'normal') {
|
|
135
|
+
this.queue.splice(i, 1);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fallback: remove oldest event
|
|
141
|
+
if (this.queue.length > 0) {
|
|
142
|
+
this.queue.shift();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private scheduleProcessing(): void {
|
|
147
|
+
// Don't schedule if already processing or if queue is small
|
|
148
|
+
if (this.isProcessing || this.queue.length < this.batchSize) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Use setTimeout for immediate processing to avoid blocking
|
|
153
|
+
setTimeout(() => this.processQueue(), 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private startFlushTimer(): void {
|
|
157
|
+
if (this.flushTimer) {
|
|
158
|
+
clearInterval(this.flushTimer);
|
|
159
|
+
}
|
|
160
|
+
this.flushTimer = setInterval(() => this.processQueue(true), this.flushInterval);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private stopFlushTimer(): void {
|
|
164
|
+
if (this.flushTimer) {
|
|
165
|
+
clearInterval(this.flushTimer);
|
|
166
|
+
this.flushTimer = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async processQueue(forceFlush = false): Promise<void> {
|
|
171
|
+
if (this.isProcessing) return;
|
|
172
|
+
|
|
173
|
+
// Don't process if offline (will be handled by online listener)
|
|
174
|
+
if (!navigator.onLine && !forceFlush) return;
|
|
175
|
+
|
|
176
|
+
// Don't process small queues unless forced
|
|
177
|
+
if (this.queue.length === 0 || (this.queue.length < this.batchSize && !forceFlush)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.isProcessing = true;
|
|
182
|
+
const startTime = Date.now();
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
// Take events to process (prioritize critical/high priority)
|
|
186
|
+
const eventsToSend = this.selectEventsForBatch();
|
|
187
|
+
|
|
188
|
+
if (eventsToSend.length === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Remove from queue before sending
|
|
193
|
+
this.removeEventsFromQueue(eventsToSend.map(e => e.id));
|
|
194
|
+
|
|
195
|
+
// Send batch
|
|
196
|
+
await this.sendBatchCallback(eventsToSend);
|
|
197
|
+
|
|
198
|
+
// Track successful batch
|
|
199
|
+
trackPerformanceMetric('amplitude_batch_sent', eventsToSend.length, 'count');
|
|
200
|
+
trackPerformanceMetric('amplitude_batch_latency', Date.now() - startTime, 'ms');
|
|
201
|
+
|
|
202
|
+
console.debug(`[Event Queue] Successfully sent ${eventsToSend.length} events`);
|
|
203
|
+
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error('[Event Queue] Failed to send event batch:', error);
|
|
206
|
+
|
|
207
|
+
// Re-queue events that failed, with retry logic
|
|
208
|
+
const failedEvents = this.queue.splice(-this.batchSize); // Get last batch that was attempted
|
|
209
|
+
await this.handleFailedEvents(failedEvents);
|
|
210
|
+
|
|
211
|
+
// Track failed batch
|
|
212
|
+
trackPerformanceMetric('amplitude_batch_error', 1, 'count');
|
|
213
|
+
|
|
214
|
+
} finally {
|
|
215
|
+
this.isProcessing = false;
|
|
216
|
+
|
|
217
|
+
// Update persisted events
|
|
218
|
+
if (this.persistenceEnabled) {
|
|
219
|
+
this.persistEvents();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Continue processing if there are more events
|
|
223
|
+
if (this.queue.length > 0) {
|
|
224
|
+
setTimeout(() => this.processQueue(), 100);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private selectEventsForBatch(): QueuedEvent[] {
|
|
230
|
+
const batch: QueuedEvent[] = [];
|
|
231
|
+
const criticalAndHigh = this.queue.filter(e => e.priority === 'critical' || e.priority === 'high');
|
|
232
|
+
|
|
233
|
+
// Prioritize critical and high priority events
|
|
234
|
+
if (criticalAndHigh.length > 0) {
|
|
235
|
+
batch.push(...criticalAndHigh.slice(0, this.batchSize));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Fill remaining space with normal/low priority events
|
|
239
|
+
const remaining = this.batchSize - batch.length;
|
|
240
|
+
if (remaining > 0) {
|
|
241
|
+
const normalAndLow = this.queue.filter(e => e.priority === 'normal' || e.priority === 'low');
|
|
242
|
+
batch.push(...normalAndLow.slice(0, remaining));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return batch;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private removeEventsFromQueue(eventIds: string[]): void {
|
|
249
|
+
this.queue = this.queue.filter(event => !eventIds.includes(event.id));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async handleFailedEvents(failedEvents: QueuedEvent[]): Promise<void> {
|
|
253
|
+
for (const event of failedEvents) {
|
|
254
|
+
event.retries += 1;
|
|
255
|
+
|
|
256
|
+
if (event.retries < this.maxRetries) {
|
|
257
|
+
// Calculate exponential backoff delay
|
|
258
|
+
const delay = this.retryDelayMs * Math.pow(2, event.retries - 1);
|
|
259
|
+
|
|
260
|
+
setTimeout(() => {
|
|
261
|
+
// Re-add to queue with priority adjustment
|
|
262
|
+
const adjustedPriority = event.retries >= 2 ? 'low' : event.priority;
|
|
263
|
+
this.queue.unshift({ ...event, priority: adjustedPriority });
|
|
264
|
+
|
|
265
|
+
if (this.persistenceEnabled) {
|
|
266
|
+
this.persistEvents();
|
|
267
|
+
}
|
|
268
|
+
}, delay);
|
|
269
|
+
|
|
270
|
+
console.debug(`[Event Queue] Retrying event ${event.id} in ${delay}ms (attempt ${event.retries})`);
|
|
271
|
+
} else {
|
|
272
|
+
console.warn(`[Event Queue] Discarding event ${event.id} after ${this.maxRetries} retries`);
|
|
273
|
+
trackPerformanceMetric('amplitude_event_discarded', 1, 'count');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private loadPersistedEvents(): void {
|
|
279
|
+
if (!this.persistenceEnabled || typeof localStorage === 'undefined') return;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const stored = localStorage.getItem(this.storageKey);
|
|
283
|
+
if (stored) {
|
|
284
|
+
const events = JSON.parse(stored) as QueuedEvent[];
|
|
285
|
+
this.queue = events.filter(event => {
|
|
286
|
+
// Remove very old events (older than 24 hours)
|
|
287
|
+
return Date.now() - event.timestamp < 24 * 60 * 60 * 1000;
|
|
288
|
+
});
|
|
289
|
+
console.debug(`[Event Queue] Loaded ${this.queue.length} persisted events`);
|
|
290
|
+
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.warn('[Event Queue] Failed to load persisted events:', error);
|
|
293
|
+
// Clear corrupted storage
|
|
294
|
+
localStorage.removeItem(this.storageKey);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private persistEvents(): void {
|
|
299
|
+
if (!this.persistenceEnabled || typeof localStorage === 'undefined') return;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
// Only persist recent events to avoid storage bloat
|
|
303
|
+
const recentEvents = this.queue.filter(event =>
|
|
304
|
+
Date.now() - event.timestamp < 12 * 60 * 60 * 1000 // 12 hours
|
|
305
|
+
);
|
|
306
|
+
localStorage.setItem(this.storageKey, JSON.stringify(recentEvents));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.warn('[Event Queue] Failed to persist events:', error);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private setupOnlineListener(): void {
|
|
313
|
+
if (typeof window === 'undefined') return;
|
|
314
|
+
|
|
315
|
+
const handleOnline = () => {
|
|
316
|
+
console.debug('[Event Queue] Connection restored, processing queued events');
|
|
317
|
+
if (this.queue.length > 0) {
|
|
318
|
+
setTimeout(() => this.processQueue(true), 1000); // Give connection a moment to stabilize
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const handleOffline = () => {
|
|
323
|
+
console.debug('[Event Queue] Connection lost, events will be queued');
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
window.addEventListener('online', handleOnline);
|
|
327
|
+
window.addEventListener('offline', handleOffline);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
public getQueueSize(): number {
|
|
331
|
+
return this.queue.length;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
public getQueueStats(): {
|
|
335
|
+
total: number;
|
|
336
|
+
byPriority: Record<string, number>;
|
|
337
|
+
oldestTimestamp: number | null;
|
|
338
|
+
averageAge: number;
|
|
339
|
+
} {
|
|
340
|
+
const byPriority = { critical: 0, high: 0, normal: 0, low: 0 };
|
|
341
|
+
let oldestTimestamp: number | null = null;
|
|
342
|
+
let totalAge = 0;
|
|
343
|
+
|
|
344
|
+
for (const event of this.queue) {
|
|
345
|
+
byPriority[event.priority]++;
|
|
346
|
+
if (!oldestTimestamp || event.timestamp < oldestTimestamp) {
|
|
347
|
+
oldestTimestamp = event.timestamp;
|
|
348
|
+
}
|
|
349
|
+
totalAge += Date.now() - event.timestamp;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
total: this.queue.length,
|
|
354
|
+
byPriority,
|
|
355
|
+
oldestTimestamp,
|
|
356
|
+
averageAge: this.queue.length > 0 ? totalAge / this.queue.length : 0,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
public clear(): void {
|
|
361
|
+
this.queue = [];
|
|
362
|
+
if (this.persistenceEnabled && typeof localStorage !== 'undefined') {
|
|
363
|
+
localStorage.removeItem(this.storageKey);
|
|
364
|
+
}
|
|
365
|
+
trackPerformanceMetric('amplitude_queue_cleared', 1, 'count');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
public shutdown(): void {
|
|
369
|
+
this.stopFlushTimer();
|
|
370
|
+
|
|
371
|
+
// Attempt to send any remaining critical events
|
|
372
|
+
const criticalEvents = this.queue.filter(e => e.priority === 'critical');
|
|
373
|
+
if (criticalEvents.length > 0) {
|
|
374
|
+
console.debug(`[Event Queue] Flushing ${criticalEvents.length} critical events before shutdown`);
|
|
375
|
+
// Fire and forget - don't wait for completion
|
|
376
|
+
this.sendBatchCallback(criticalEvents).catch(error =>
|
|
377
|
+
console.warn('[Event Queue] Failed to send critical events during shutdown:', error)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Persist remaining events
|
|
382
|
+
if (this.persistenceEnabled) {
|
|
383
|
+
this.persistEvents();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
console.debug('[Event Queue] Shutdown complete');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|