248 lines
6.8 KiB
TypeScript
248 lines
6.8 KiB
TypeScript
import type { SentryEvent, ErrorEvent, CountEvent } from '../types';
|
|
import { EventBus } from './EventBus';
|
|
import { now } from '../utils/helper';
|
|
|
|
interface DedupeEntry {
|
|
count: number;
|
|
lastTime: number;
|
|
firstReported: boolean;
|
|
}
|
|
|
|
export class EventQueue {
|
|
private queue: SentryEvent[] = [];
|
|
private maxSize: number;
|
|
private flushInterval: number;
|
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
private eventBus: EventBus;
|
|
private flushCallback: (events: SentryEvent[]) => Promise<void>;
|
|
private syncFlushCallback: (events: SentryEvent[]) => void;
|
|
private lastFlushTime: number = 0;
|
|
private dedupeMap: Map<string, DedupeEntry> = new Map();
|
|
private pendingCounts: Map<string, { count: number; ts_start: number; ts_end: number }> = new Map();
|
|
|
|
private errorRateWindow: { [key: string]: number[] } = {};
|
|
private paused: boolean = false;
|
|
private pauseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
constructor(
|
|
maxSize: number,
|
|
flushInterval: number,
|
|
eventBus: EventBus,
|
|
flushCallback: (events: SentryEvent[]) => Promise<void>,
|
|
syncFlushCallback: (events: SentryEvent[]) => void
|
|
) {
|
|
this.maxSize = maxSize;
|
|
this.flushInterval = flushInterval;
|
|
this.eventBus = eventBus;
|
|
this.flushCallback = flushCallback;
|
|
this.syncFlushCallback = syncFlushCallback;
|
|
}
|
|
|
|
start(): void {
|
|
this.startTimer();
|
|
this.setupVisibilityListener();
|
|
}
|
|
|
|
private startTimer(): void {
|
|
if (this.timer) return;
|
|
this.timer = setInterval(() => {
|
|
if (this.queue.length > 0 || this.pendingCounts.size > 0) {
|
|
this.flush();
|
|
}
|
|
}, this.flushInterval);
|
|
}
|
|
|
|
private setupVisibilityListener(): void {
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden && (this.queue.length > 0 || this.pendingCounts.size > 0)) {
|
|
this.flushSync();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('beforeunload', () => {
|
|
if (this.queue.length > 0 || this.pendingCounts.size > 0) {
|
|
this.flushSync();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('pagehide', () => {
|
|
if (this.queue.length > 0 || this.pendingCounts.size > 0) {
|
|
this.flushSync();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private checkInfiniteLoop(fingerprint: string): boolean {
|
|
const currentTime = now();
|
|
const windowStart = currentTime - 1000;
|
|
|
|
if (!this.errorRateWindow[fingerprint]) {
|
|
this.errorRateWindow[fingerprint] = [];
|
|
}
|
|
|
|
const window = this.errorRateWindow[fingerprint];
|
|
window.push(currentTime);
|
|
|
|
while (window.length > 0 && window[0] < windowStart) {
|
|
window.shift();
|
|
}
|
|
|
|
if (window.length > 10) {
|
|
if (!this.paused) {
|
|
console.warn('[LightSDK] Infinite loop detected, pausing SDK for 60s', { fingerprint, count: window.length });
|
|
this.eventBus.emit('error', new Error('Infinite loop detected'));
|
|
this.paused = true;
|
|
|
|
if (this.pauseTimer) {
|
|
clearTimeout(this.pauseTimer);
|
|
}
|
|
this.pauseTimer = setTimeout(() => {
|
|
this.paused = false;
|
|
this.errorRateWindow = {};
|
|
console.info('[LightSDK] Resumed after infinite loop detection');
|
|
}, 60000);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
enqueue(event: SentryEvent): void {
|
|
if (this.paused) return;
|
|
|
|
const fingerprint = this.getDedupeKey(event);
|
|
|
|
if (fingerprint) {
|
|
if (this.checkInfiniteLoop(fingerprint)) {
|
|
return;
|
|
}
|
|
|
|
const currentTime = now();
|
|
const existing = this.dedupeMap.get(fingerprint);
|
|
|
|
if (existing) {
|
|
if (currentTime - existing.lastTime < 60000) {
|
|
if (existing.count >= 3) {
|
|
if (!this.pendingCounts.has(fingerprint)) {
|
|
this.pendingCounts.set(fingerprint, { count: 1, ts_start: currentTime, ts_end: currentTime });
|
|
} else {
|
|
const pending = this.pendingCounts.get(fingerprint)!;
|
|
pending.count++;
|
|
pending.ts_end = currentTime;
|
|
}
|
|
return;
|
|
}
|
|
existing.count++;
|
|
existing.lastTime = currentTime;
|
|
} else {
|
|
this.dedupeMap.set(fingerprint, { count: 1, lastTime: currentTime, firstReported: true });
|
|
this.pendingCounts.delete(fingerprint);
|
|
}
|
|
} else {
|
|
this.dedupeMap.set(fingerprint, { count: 1, lastTime: currentTime, firstReported: false });
|
|
}
|
|
}
|
|
|
|
if (this.queue.length >= this.maxSize) {
|
|
this.flush();
|
|
}
|
|
|
|
this.queue.push(event);
|
|
this.eventBus.emit('event', event);
|
|
|
|
if (this.queue.length >= this.maxSize) {
|
|
this.flush();
|
|
}
|
|
}
|
|
|
|
private buildCountEvents(): SentryEvent[] {
|
|
const countEvents: SentryEvent[] = [];
|
|
|
|
for (const [fingerprint, data] of this.pendingCounts.entries()) {
|
|
// 构建聚合计数事件
|
|
const event: CountEvent = {
|
|
type: 'count',
|
|
fingerprint,
|
|
count: data.count,
|
|
ts_start: data.ts_start,
|
|
ts_end: data.ts_end,
|
|
timestamp: now(),
|
|
level: 'info',
|
|
};
|
|
countEvents.push(event);
|
|
}
|
|
|
|
// 清空已上报的聚合计数
|
|
this.pendingCounts.clear();
|
|
return countEvents;
|
|
}
|
|
|
|
private getDedupeKey(event: SentryEvent): string | null {
|
|
if (event.type === 'error') {
|
|
const errEvent = event as ErrorEvent;
|
|
return errEvent.fingerprint || errEvent.message;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async flush(): Promise<void> {
|
|
if (this.queue.length === 0 && this.pendingCounts.size === 0) return;
|
|
|
|
const events = this.queue.splice(0, this.queue.length);
|
|
const countEvents = this.buildCountEvents();
|
|
const allEvents = [...events, ...countEvents];
|
|
|
|
this.lastFlushTime = now();
|
|
this.eventBus.emit('report', allEvents);
|
|
|
|
try {
|
|
await this.flushCallback(allEvents);
|
|
this.eventBus.emit('reported', allEvents);
|
|
} catch (e) {
|
|
events.forEach(evt => this.queue.unshift(evt));
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
flushSync(): void {
|
|
if (this.queue.length === 0 && this.pendingCounts.size === 0) return;
|
|
|
|
const events = this.queue.splice(0, this.queue.length);
|
|
const countEvents = this.buildCountEvents();
|
|
const allEvents = [...events, ...countEvents];
|
|
|
|
this.lastFlushTime = now();
|
|
this.eventBus.emit('report', allEvents);
|
|
|
|
try {
|
|
this.syncFlushCallback(allEvents);
|
|
this.eventBus.emit('reported', allEvents);
|
|
} catch (e) {
|
|
console.error('[LightSDK] Sync flush failed', e);
|
|
}
|
|
}
|
|
|
|
size(): number {
|
|
return this.queue.length;
|
|
}
|
|
|
|
destroy(): void {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
if (this.pauseTimer) {
|
|
clearTimeout(this.pauseTimer);
|
|
this.pauseTimer = null;
|
|
}
|
|
this.dedupeMap.clear();
|
|
this.pendingCounts.clear();
|
|
this.errorRateWindow = {};
|
|
}
|
|
}
|