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 | null = null; private eventBus: EventBus; private flushCallback: (events: SentryEvent[]) => Promise; private syncFlushCallback: (events: SentryEvent[]) => void; private lastFlushTime: number = 0; private isFlushing: boolean = false; private onVisibilityChange?: () => void; private onBeforeUnload?: () => void; private onPagehide?: () => void; private dedupeMap: Map = new Map(); private pendingCounts: Map = new Map(); private errorRateWindow: { [key: string]: number[] } = {}; private paused: boolean = false; private pauseTimer: ReturnType | null = null; constructor( maxSize: number, flushInterval: number, eventBus: EventBus, flushCallback: (events: SentryEvent[]) => Promise, 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 { this.onVisibilityChange = () => { if (document.hidden && (this.queue.length > 0 || this.pendingCounts.size > 0)) { this.flushSync(); } }; if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', this.onVisibilityChange); } this.onBeforeUnload = () => { if (this.queue.length > 0 || this.pendingCounts.size > 0) { this.flushSync(); } }; this.onPagehide = () => { if (this.queue.length > 0 || this.pendingCounts.size > 0) { this.flushSync(); } }; if (typeof window !== 'undefined') { window.addEventListener('beforeunload', this.onBeforeUnload); window.addEventListener('pagehide', this.onPagehide); } } 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 === 0) { delete this.errorRateWindow[fingerprint]; return false; } 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; this.cleanupExpiredDedupeEntries(); 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 }); } } this.queue.push(event); this.eventBus.emit('event', event); if (this.queue.length >= this.maxSize) { this.flush(); } } private cleanupExpiredDedupeEntries(): void { // 每 100 条或每 10 次清理一次,避免过于频繁 if (this.dedupeMap.size < 100 && Math.random() > 0.1) return; const currentTime = now(); const expiryTime = 60000; for (const [key, entry] of this.dedupeMap) { if (currentTime - entry.lastTime > expiryTime) { this.dedupeMap.delete(key); } } } 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); } 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 { if (this.queue.length === 0 && this.pendingCounts.size === 0) return; if (this.isFlushing) return; this.isFlushing = true; 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.pendingCounts.clear(); this.eventBus.emit('reported', allEvents); } catch (e) { events.forEach(evt => this.queue.unshift(evt)); throw e; } finally { this.isFlushing = false; } } flushSync(): void { if (this.queue.length === 0 && this.pendingCounts.size === 0) return; const events = this.queue.splice(0, this.queue.length); const pendingCountsSnapshot = new Map(this.pendingCounts); const countEvents = this.buildCountEvents(); const allEvents = [...events, ...countEvents]; this.lastFlushTime = now(); this.eventBus.emit('report', allEvents); try { this.syncFlushCallback(allEvents); this.pendingCounts.clear(); this.eventBus.emit('reported', allEvents); } catch (e) { console.error('[LightSDK] Sync flush failed', e); events.forEach(evt => this.queue.unshift(evt)); for (const [key, value] of pendingCountsSnapshot) { if (!this.pendingCounts.has(key)) { this.pendingCounts.set(key, value); } } } } 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; } if (typeof document !== 'undefined' && this.onVisibilityChange) { document.removeEventListener('visibilitychange', this.onVisibilityChange); } if (typeof window !== 'undefined') { if (this.onBeforeUnload) { window.removeEventListener('beforeunload', this.onBeforeUnload); } if (this.onPagehide) { window.removeEventListener('pagehide', this.onPagehide); } } this.dedupeMap.clear(); this.pendingCounts.clear(); this.errorRateWindow = {}; } }