light-sentry-sdk/tests/EventQueue.test.ts

384 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventQueue } from '../src/core/EventQueue';
import { EventBus } from '../src/core/EventBus';
import type { SentryEvent, ErrorEvent } from '../src/types';
describe('EventQueue', () => {
let eventQueue: EventQueue;
let eventBus: EventBus;
let flushCallback: ReturnType<typeof vi.fn>;
let syncFlushCallback: ReturnType<typeof vi.fn>;
beforeEach(() => {
eventBus = new EventBus();
flushCallback = vi.fn().mockResolvedValue(undefined);
syncFlushCallback = vi.fn();
eventQueue = new EventQueue(100, 5000, eventBus, flushCallback, syncFlushCallback);
});
afterEach(() => {
eventQueue.destroy();
vi.clearAllMocks();
});
describe('enqueue', () => {
it('should add event to queue', () => {
const event = createErrorEvent('Test error');
eventQueue.enqueue(event);
expect(eventQueue.size()).toBe(1);
});
it('should emit event when enqueueing', () => {
const event = createErrorEvent('Test error');
const emitSpy = vi.spyOn(eventBus, 'emit');
eventQueue.enqueue(event);
expect(emitSpy).toHaveBeenCalledWith('event', event);
});
it('should trigger flush when queue is full', () => {
const eventQueueSmall = new EventQueue(2, 5000, eventBus, flushCallback, syncFlushCallback);
eventQueueSmall.enqueue(createErrorEvent('error 1'));
eventQueueSmall.enqueue(createErrorEvent('error 2'));
// flushCallback should be called due to maxSize reached
expect(flushCallback).toHaveBeenCalled();
eventQueueSmall.destroy();
});
it('should skip deduplicated events', () => {
const event1 = createErrorEvent('Duplicate error', 'fingerprint1');
const event2 = createErrorEvent('Duplicate error', 'fingerprint1');
const event3 = createErrorEvent('Duplicate error', 'fingerprint1');
const event4 = createErrorEvent('Duplicate error', 'fingerprint1'); // count >= 3, should be deduped
eventQueue.enqueue(event1);
eventQueue.enqueue(event2);
eventQueue.enqueue(event3);
eventQueue.enqueue(event4);
// First 3 events are allowed, 4th is deduped
expect(eventQueue.size()).toBe(3);
});
it('should not skip non-duplicate events', () => {
const event1 = createErrorEvent('Error 1', 'fp1');
const event2 = createErrorEvent('Error 2', 'fp2');
eventQueue.enqueue(event1);
eventQueue.enqueue(event2);
expect(eventQueue.size()).toBe(2);
});
});
describe('flush', () => {
it('should call flush callback with events', async () => {
const event1 = createErrorEvent('Error 1');
const event2 = createErrorEvent('Error 2');
eventQueue.enqueue(event1);
eventQueue.enqueue(event2);
await eventQueue.flush();
expect(flushCallback).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ message: 'Error 1' }),
expect.objectContaining({ message: 'Error 2' }),
]));
});
it('should not flush when queue is empty', async () => {
await eventQueue.flush();
expect(flushCallback).not.toHaveBeenCalled();
});
it('should clear queue after successful flush', async () => {
const event = createErrorEvent('Test error');
eventQueue.enqueue(event);
expect(eventQueue.size()).toBe(1);
await eventQueue.flush();
expect(eventQueue.size()).toBe(0);
});
it('should return events to queue on flush failure', async () => {
flushCallback.mockRejectedValueOnce(new Error('Network error'));
const event = createErrorEvent('Test error');
eventQueue.enqueue(event);
await expect(eventQueue.flush()).rejects.toThrow('Network error');
// Events should be returned to queue
expect(eventQueue.size()).toBe(1);
});
it('should prevent concurrent flush reentry', async () => {
flushCallback.mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
const event = createErrorEvent('Test error');
eventQueue.enqueue(event);
// Start first flush
const flushPromise = eventQueue.flush();
// Try second flush immediately
await eventQueue.flush();
await flushPromise;
// flushCallback should only be called once
expect(flushCallback).toHaveBeenCalledTimes(1);
});
});
describe('flushSync', () => {
it('should call sync flush callback', () => {
const event = createErrorEvent('Sync error');
eventQueue.enqueue(event);
eventQueue.flushSync();
expect(syncFlushCallback).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ message: 'Sync error' }),
]));
});
it('should return events to queue on sync flush failure', () => {
syncFlushCallback.mockImplementationOnce(() => {
throw new Error('Sync error');
});
const event = createErrorEvent('Test error');
eventQueue.enqueue(event);
eventQueue.flushSync();
// Events should be returned to queue
expect(eventQueue.size()).toBe(1);
});
});
describe('destroy', () => {
it('should clear all data', () => {
const event = createErrorEvent('Test error');
eventQueue.enqueue(event);
expect(eventQueue.size()).toBe(1);
eventQueue.destroy();
expect(eventQueue.size()).toBe(0);
});
});
describe('deduplication', () => {
it('should generate dedup key from fingerprint first', () => {
const event = createErrorEvent('Test error', 'fp1');
eventQueue.enqueue(event);
expect(eventQueue.size()).toBe(1);
});
it('should generate dedup key from message if no fingerprint', () => {
const event1 = createErrorEvent('Same message');
const event2 = createErrorEvent('Same message');
const event3 = createErrorEvent('Same message');
const event4 = createErrorEvent('Same message');
eventQueue.enqueue(event1);
eventQueue.enqueue(event2);
eventQueue.enqueue(event3);
eventQueue.enqueue(event4);
expect(eventQueue.size()).toBe(3);
});
it('should reset dedup entry after expiry', async () => {
const event1 = createErrorEvent('Test', 'fp-expire');
const event2 = createErrorEvent('Test', 'fp-expire');
const event3 = createErrorEvent('Test', 'fp-expire');
eventQueue.enqueue(event1);
eventQueue.enqueue(event2);
eventQueue.enqueue(event3);
expect(eventQueue.size()).toBe(3);
// 4th event should be deduped
const event4 = createErrorEvent('Test', 'fp-expire');
eventQueue.enqueue(event4);
expect(eventQueue.size()).toBe(3);
});
});
describe('count events', () => {
it('should build count events for deduped errors', async () => {
// Enqueue 3 events to reach threshold
for (let i = 0; i < 3; i++) {
eventQueue.enqueue(createErrorEvent('Dedup error', 'count-fp'));
}
// 4th event is deduped and goes to pendingCounts
eventQueue.enqueue(createErrorEvent('Dedup error', 'count-fp'));
eventQueue.enqueue(createErrorEvent('Dedup error', 'count-fp'));
expect(eventQueue.size()).toBe(3);
// Flush should include count events
await eventQueue.flush();
expect(flushCallback).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ type: 'count', fingerprint: 'count-fp', count: 2 }),
])
);
});
it('should flush pending counts even when regular queue is empty', async () => {
for (let i = 0; i < 3; i++) {
eventQueue.enqueue(createErrorEvent('Dedup error', 'count-only-fp'));
}
// Add some deduped events (keep below infinite loop threshold of 10)
for (let i = 0; i < 5; i++) {
eventQueue.enqueue(createErrorEvent('Dedup error', 'count-only-fp'));
}
// Flush first to clear regular queue
await eventQueue.flush();
flushCallback.mockClear();
// Enqueue more deduped events
for (let i = 0; i < 3; i++) {
eventQueue.enqueue(createErrorEvent('Dedup error 2', 'count-only-fp-2'));
}
for (let i = 0; i < 2; i++) {
eventQueue.enqueue(createErrorEvent('Dedup error 2', 'count-only-fp-2'));
}
// Flush again - should have count events even with empty regular queue
await eventQueue.flush();
const lastCall = flushCallback.mock.calls[flushCallback.mock.calls.length - 1];
const countEvents = lastCall[0].filter((e: any) => e.type === 'count');
expect(countEvents.length).toBeGreaterThan(0);
});
});
describe('infinite loop detection', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should pause queue when too many errors in 1 second', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
for (let i = 0; i < 11; i++) {
eventQueue.enqueue(createErrorEvent('Infinite loop test', 'infinite-fp'));
}
// Should have paused
expect(consoleWarnSpy).toHaveBeenCalled();
// After pause, no more events should be added
const sizeAfterLoop = eventQueue.size();
eventQueue.enqueue(createErrorEvent('After pause', 'infinite-fp'));
expect(eventQueue.size()).toBe(sizeAfterLoop);
consoleWarnSpy.mockRestore();
});
it('should emit error event on infinite loop detection', () => {
const emitSpy = vi.spyOn(eventBus, 'emit');
for (let i = 0; i < 11; i++) {
eventQueue.enqueue(createErrorEvent('Infinite loop', 'loop-fp'));
}
expect(emitSpy).toHaveBeenCalledWith('error', expect.any(Error));
});
it('should resume after pause duration', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
for (let i = 0; i < 11; i++) {
eventQueue.enqueue(createErrorEvent('Resume test', 'resume-fp'));
}
const sizeBeforeResume = eventQueue.size();
// Advance time past the 60s pause
vi.advanceTimersByTime(61000);
// Should resume accepting events
eventQueue.enqueue(createErrorEvent('After resume', 'different-fp'));
expect(eventQueue.size()).toBe(sizeBeforeResume + 1);
consoleWarnSpy.mockRestore();
consoleInfoSpy.mockRestore();
});
});
describe('non-error events', () => {
it('should enqueue non-error events without dedup', () => {
const transactionEvent: SentryEvent = {
type: 'transaction',
level: 'info',
transaction: 'test-tx',
timestamp: Date.now(),
} as any;
eventQueue.enqueue(transactionEvent);
eventQueue.enqueue(transactionEvent);
expect(eventQueue.size()).toBe(2);
});
});
describe('start/stop timer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should start timer and flush on interval when queue has events', async () => {
const eq = new EventQueue(100, 1000, eventBus, flushCallback, syncFlushCallback);
eq.start();
eq.enqueue(createErrorEvent('Interval test'));
// flushCallback should not be called yet
expect(flushCallback).not.toHaveBeenCalled();
// Advance timer past interval
vi.advanceTimersByTime(1500);
// Wait for the flush promise to resolve
await vi.runOnlyPendingTimersAsync();
expect(flushCallback).toHaveBeenCalled();
eq.destroy();
});
it('should not flush on interval when queue is empty', async () => {
const eq = new EventQueue(100, 1000, eventBus, flushCallback, syncFlushCallback);
eq.start();
vi.advanceTimersByTime(1500);
await vi.runOnlyPendingTimersAsync();
expect(flushCallback).not.toHaveBeenCalled();
eq.destroy();
});
});
});
function createErrorEvent(message: string, fingerprint?: string): ErrorEvent {
return {
type: 'error',
level: 'error',
message,
timestamp: Date.now(),
fingerprint,
};
}