light-sentry-sdk/src/core/EventQueue.ts

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 = {};
}
}