autotel
Version:
Write Once, Observe Anywhere
865 lines (711 loc) • 25.2 kB
text/typescript
/**
* Tests for events queue guardrails
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { EventQueue, type EventDropReason } from './event-queue';
import { configure, resetConfig } from './config';
// Mock adapter for testing
type MockEvent = {
name: string;
attributes?: Record<string, unknown>;
};
class MockAdapter {
public name = 'MockAdapter';
public events: MockEvent[] = [];
public callCount = 0;
public shouldFail = false;
async trackEvent(
name: string,
attributes?: Record<string, unknown>,
): Promise<void> {
this.callCount++;
if (this.shouldFail) {
throw new Error('Adapter failed');
}
this.events.push({ name, attributes });
}
async trackFunnelStep(): Promise<void> {}
async trackOutcome(): Promise<void> {}
async trackValue(): Promise<void> {}
}
describe('EventQueue', () => {
let mockAdapter: MockAdapter;
let queue: EventQueue;
beforeEach(() => {
mockAdapter = new MockAdapter();
queue = new EventQueue([mockAdapter], {
maxSize: 10,
batchSize: 3,
flushInterval: 100,
maxRetries: 2,
});
});
describe('Batching', () => {
it('should enqueue events without immediate sending', () => {
queue.enqueue({ name: 'test1', attributes: {}, timestamp: Date.now() });
queue.enqueue({ name: 'test2', attributes: {}, timestamp: Date.now() });
expect(queue.size()).toBe(2);
expect(mockAdapter.callCount).toBe(0); // Not sent yet
});
it('should flush after interval', async () => {
queue.enqueue({ name: 'test1', attributes: {}, timestamp: Date.now() });
queue.enqueue({ name: 'test2', attributes: {}, timestamp: Date.now() });
// Wait for flush interval
await new Promise((resolve) => setTimeout(resolve, 150));
expect(queue.size()).toBe(0);
expect(mockAdapter.events.length).toBeGreaterThan(0);
});
it('should batch events efficiently', async () => {
// Enqueue 5 events (batch size is 3)
for (let i = 0; i < 5; i++) {
queue.enqueue({
name: `test${i}`,
attributes: {},
timestamp: Date.now(),
});
}
// Manual flush
await queue.flush();
expect(queue.size()).toBe(0);
expect(mockAdapter.events.length).toBe(5);
});
});
describe('Backpressure', () => {
it('should drop oldest when queue is full in production', () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
// Fill queue to max (10 events)
for (let i = 0; i < 12; i++) {
queue.enqueue({
name: `test${i}`,
attributes: { index: i },
timestamp: Date.now(),
});
}
// Queue should be at max size
expect(queue.size()).toBe(10);
process.env.NODE_ENV = originalEnv;
});
it('should drop oldest and log when queue is full (consistent behavior)', () => {
// Fill queue to max
for (let i = 0; i < 10; i++) {
queue.enqueue({
name: `test${i}`,
attributes: {},
timestamp: Date.now(),
});
}
// Next enqueue should drop oldest (not throw)
expect(() => {
queue.enqueue({
name: 'test11',
attributes: {},
timestamp: Date.now(),
});
}).not.toThrow();
// Verify queue is still at max size
expect(queue['queue']).toHaveLength(10);
});
});
describe('Retry logic', () => {
it('should retry on failure', async () => {
mockAdapter.shouldFail = true;
queue.enqueue({ name: 'test1', attributes: {}, timestamp: Date.now() });
await queue.flush();
// Should have tried maxRetries + 1 times (initial + 2 retries = 3)
expect(mockAdapter.callCount).toBeGreaterThanOrEqual(3);
});
it('should succeed after transient failure', async () => {
let failCount = 0;
mockAdapter.trackEvent = async () => {
if (failCount < 2) {
failCount++;
throw new Error('Transient failure');
}
mockAdapter.events.push({ name: 'test', attributes: {} });
};
queue.enqueue({ name: 'test1', attributes: {}, timestamp: Date.now() });
await queue.flush();
// Should eventually succeed
expect(mockAdapter.events.length).toBe(1);
});
});
describe('Metrics semantics', () => {
function createMockMeter() {
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
const histograms = new Map<
string,
{ record: ReturnType<typeof vi.fn> }
>();
const mockMeter = {
createObservableGauge: vi.fn(() => ({
addCallback: vi.fn(),
removeCallback: vi.fn(),
})),
createCounter: vi.fn((name: string) => {
const counter = { add: vi.fn() };
counters.set(name, counter);
return counter;
}),
createHistogram: vi.fn((name: string) => {
const histogram = { record: vi.fn() };
histograms.set(name, histogram);
return histogram;
}),
};
return { mockMeter, counters, histograms };
}
it('should not increment failed counter when a retry succeeds', async () => {
const { mockMeter, counters } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
let failCount = 0;
adapter.trackEvent = async () => {
if (failCount < 1) {
failCount++;
throw new Error('Transient failure');
}
adapter.events.push({ name: 'test', attributes: {} });
};
const localQueue = new EventQueue([adapter], { maxRetries: 1 });
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const failedCounter = counters.get(
'autotel.event_delivery.queue.failed',
);
expect(failedCounter?.add).toHaveBeenCalledTimes(0);
} finally {
resetConfig();
}
});
it('should increment failed counter after all retries exhausted', async () => {
const { mockMeter, counters } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
adapter.shouldFail = true; // Always fail
const localQueue = new EventQueue([adapter], { maxRetries: 2 });
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const failedCounter = counters.get(
'autotel.event_delivery.queue.failed',
);
// Should be called once per event per subscriber after retries exhausted
expect(failedCounter?.add).toHaveBeenCalledTimes(1);
expect(failedCounter?.add).toHaveBeenCalledWith(1, {
subscriber: 'mockadapter',
});
} finally {
resetConfig();
}
});
it('should count failed events per event, not per batch', async () => {
const { mockMeter, counters } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
adapter.trackEvent = async (name) => {
if (name === 'bad') {
throw new Error('Per-event failure');
}
adapter.events.push({ name, attributes: {} });
};
const localQueue = new EventQueue([adapter], {
batchSize: 2,
maxRetries: 0,
});
localQueue.enqueue({
name: 'good',
attributes: {},
timestamp: Date.now(),
});
localQueue.enqueue({
name: 'bad',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const failedCounter = counters.get(
'autotel.event_delivery.queue.failed',
);
expect(failedCounter?.add).toHaveBeenCalledTimes(1);
} finally {
resetConfig();
}
});
it('should increment delivered counter on successful delivery', async () => {
const { mockMeter, counters } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
const localQueue = new EventQueue([adapter], { maxRetries: 1 });
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const deliveredCounter = counters.get(
'autotel.event_delivery.queue.delivered',
);
expect(deliveredCounter?.add).toHaveBeenCalledTimes(1);
expect(deliveredCounter?.add).toHaveBeenCalledWith(1, {
subscriber: 'mockadapter',
});
} finally {
resetConfig();
}
});
it('should increment delivered counter when retry eventually succeeds', async () => {
const { mockMeter, counters } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
let failCount = 0;
adapter.trackEvent = async () => {
if (failCount < 2) {
failCount++;
throw new Error('Transient failure');
}
adapter.events.push({ name: 'test', attributes: {} });
};
const localQueue = new EventQueue([adapter], { maxRetries: 3 });
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const deliveredCounter = counters.get(
'autotel.event_delivery.queue.delivered',
);
const failedCounter = counters.get(
'autotel.event_delivery.queue.failed',
);
// Delivered should be incremented once (eventual success)
expect(deliveredCounter?.add).toHaveBeenCalledTimes(1);
// Failed should NOT be incremented (retry succeeded)
expect(failedCounter?.add).toHaveBeenCalledTimes(0);
} finally {
resetConfig();
}
});
it('should not double-count delivered events when retrying a mixed-success batch', async () => {
const { mockMeter, counters } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
let badFailCount = 0;
adapter.trackEvent = async (name) => {
if (name === 'bad' && badFailCount < 1) {
badFailCount++;
throw new Error('Transient failure');
}
adapter.events.push({ name, attributes: {} });
};
const localQueue = new EventQueue([adapter], {
batchSize: 2,
maxRetries: 1,
});
localQueue.enqueue({
name: 'good',
attributes: {},
timestamp: Date.now(),
});
localQueue.enqueue({
name: 'bad',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const deliveredCounter = counters.get(
'autotel.event_delivery.queue.delivered',
);
// Expect one delivery per event (good + bad), not double-counting good on retry
expect(deliveredCounter?.add).toHaveBeenCalledTimes(2);
} finally {
resetConfig();
}
});
it('should not re-send to healthy subscribers when retrying failed ones', async () => {
const { mockMeter } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const failingAdapter = new MockAdapter();
failingAdapter.name = 'FailingAdapter';
let failCount = 0;
failingAdapter.trackEvent = async () => {
if (failCount < 1) {
failCount++;
throw new Error('Transient failure');
}
failingAdapter.events.push({ name: 'test', attributes: {} });
};
const healthyAdapter = new MockAdapter();
healthyAdapter.name = 'HealthyAdapter';
const localQueue = new EventQueue([failingAdapter, healthyAdapter], {
maxRetries: 1,
});
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
// Healthy subscriber should only receive the event once
expect(healthyAdapter.callCount).toBe(1);
} finally {
resetConfig();
}
});
it('should retry only failed events and deliver each event once (three-event batch)', async () => {
const { mockMeter, counters } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
let middleFailCount = 0;
adapter.trackEvent = async (name) => {
if (name === 'middle' && middleFailCount < 1) {
middleFailCount++;
throw new Error('Transient failure');
}
adapter.events.push({ name, attributes: {} });
};
const localQueue = new EventQueue([adapter], {
batchSize: 3,
maxRetries: 1,
});
localQueue.enqueue({
name: 'first',
attributes: {},
timestamp: Date.now(),
});
localQueue.enqueue({
name: 'middle',
attributes: {},
timestamp: Date.now(),
});
localQueue.enqueue({
name: 'last',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
// Adapter receives exactly three events, each once (first and last on first attempt, middle on retry)
expect(adapter.events).toHaveLength(3);
expect(adapter.events.map((e) => e.name)).toEqual([
'first',
'last',
'middle',
]);
const deliveredCounter = counters.get(
'autotel.event_delivery.queue.delivered',
);
expect(deliveredCounter?.add).toHaveBeenCalledTimes(3);
} finally {
resetConfig();
}
});
it('should record latency histogram on successful delivery', async () => {
const { mockMeter, histograms } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
const localQueue = new EventQueue([adapter], { maxRetries: 1 });
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const latencyHistogram = histograms.get(
'autotel.event_delivery.queue.latency_ms',
);
expect(latencyHistogram?.record).toHaveBeenCalledTimes(1);
// First argument is the latency value (number), second is attributes
expect(latencyHistogram?.record).toHaveBeenCalledWith(
expect.any(Number),
{ subscriber: 'mockadapter' },
);
} finally {
resetConfig();
}
});
it('should not record latency when delivery fails', async () => {
const { mockMeter, histograms } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
adapter.shouldFail = true;
const localQueue = new EventQueue([adapter], { maxRetries: 1 });
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const latencyHistogram = histograms.get(
'autotel.event_delivery.queue.latency_ms',
);
// No latency recorded for failed deliveries
expect(latencyHistogram?.record).toHaveBeenCalledTimes(0);
} finally {
resetConfig();
}
});
it('should mark subscriber unhealthy on transient failure', async () => {
const { mockMeter } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter = new MockAdapter();
let failCount = 0;
adapter.trackEvent = async () => {
if (failCount < 1) {
failCount++;
throw new Error('Transient failure');
}
adapter.events.push({ name: 'test', attributes: {} });
};
const localQueue = new EventQueue([adapter], { maxRetries: 2 });
// Initially healthy
expect(localQueue.isSubscriberHealthy('mockadapter')).toBe(true);
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
// After successful retry, should be healthy again
expect(localQueue.isSubscriberHealthy('mockadapter')).toBe(true);
} finally {
resetConfig();
}
});
it('should handle multiple subscribers with mixed success/failure', async () => {
const { mockMeter, counters } = createMockMeter();
configure({ meter: mockMeter as any });
try {
const adapter1 = new MockAdapter();
adapter1.name = 'SuccessAdapter';
const adapter2 = new MockAdapter();
adapter2.name = 'FailAdapter';
adapter2.shouldFail = true;
const localQueue = new EventQueue([adapter1, adapter2], {
maxRetries: 1,
});
localQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await localQueue.flush();
const deliveredCounter = counters.get(
'autotel.event_delivery.queue.delivered',
);
const failedCounter = counters.get(
'autotel.event_delivery.queue.failed',
);
// One subscriber succeeded, one failed
expect(deliveredCounter?.add).toHaveBeenCalledWith(1, {
subscriber: 'successadapter',
});
expect(failedCounter?.add).toHaveBeenCalledWith(1, {
subscriber: 'failadapter',
});
// Verify counters were NOT called for the wrong subscribers
expect(deliveredCounter?.add).not.toHaveBeenCalledWith(1, {
subscriber: 'failadapter',
});
expect(failedCounter?.add).not.toHaveBeenCalledWith(1, {
subscriber: 'successadapter',
});
// Delivered once (successful subscriber only; retry only sends to failed subscriber)
expect(deliveredCounter?.add).toHaveBeenCalledTimes(1);
// Failed counter only called once after all retries exhausted
expect(failedCounter?.add).toHaveBeenCalledTimes(1);
} finally {
resetConfig();
}
});
});
describe('Graceful flush', () => {
it('should flush all remaining events', async () => {
for (let i = 0; i < 5; i++) {
queue.enqueue({
name: `test${i}`,
attributes: {},
timestamp: Date.now(),
});
}
expect(queue.size()).toBe(5);
await queue.flush();
expect(queue.size()).toBe(0);
expect(mockAdapter.events.length).toBe(5);
});
it('should handle empty queue flush', async () => {
await expect(queue.flush()).resolves.not.toThrow();
});
it('should allow enqueuing after flush', async () => {
queue.enqueue({ name: 'test1', attributes: {}, timestamp: Date.now() });
await queue.flush();
queue.enqueue({ name: 'test2', attributes: {}, timestamp: Date.now() });
await queue.flush();
expect(mockAdapter.events.map((event) => event.name)).toEqual([
'test1',
'test2',
]);
});
});
describe('Multiple adapters', () => {
it('should send to all adapters', async () => {
const adapter1 = new MockAdapter();
const adapter2 = new MockAdapter();
const multiQueue = new EventQueue([adapter1, adapter2]);
multiQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await multiQueue.flush();
expect(adapter1.events.length).toBe(1);
expect(adapter2.events.length).toBe(1);
});
it('should handle partial adapter failures', async () => {
const adapter1 = new MockAdapter();
const adapter2 = new MockAdapter();
adapter1.shouldFail = true; // One adapter fails
const multiQueue = new EventQueue([adapter1, adapter2], {
maxRetries: 1,
});
multiQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
// Should not throw, just log error
await expect(multiQueue.flush()).resolves.not.toThrow();
});
});
describe('Subscriber health tracking', () => {
it('should track subscriber health status', () => {
const adapter = new MockAdapter();
adapter.name = 'TestAdapter';
const healthQueue = new EventQueue([adapter]);
// All subscribers start healthy
expect(healthQueue.isSubscriberHealthy('testadapter')).toBe(true);
});
it('should mark subscriber as unhealthy on persistent failure', async () => {
const adapter = new MockAdapter();
adapter.name = 'FailingAdapter';
adapter.shouldFail = true;
const healthQueue = new EventQueue([adapter], {
maxRetries: 1,
});
healthQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await healthQueue.flush();
// After failure, subscriber should be marked unhealthy
expect(healthQueue.isSubscriberHealthy('failingadapter')).toBe(false);
});
it('should mark subscriber as healthy on success', async () => {
const adapter = new MockAdapter();
adapter.name = 'HealthyAdapter';
const healthQueue = new EventQueue([adapter]);
// Manually mark as unhealthy first
healthQueue.setSubscriberHealth('healthyadapter', false);
expect(healthQueue.isSubscriberHealthy('healthyadapter')).toBe(false);
// Successful delivery should mark as healthy
healthQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await healthQueue.flush();
expect(healthQueue.isSubscriberHealthy('healthyadapter')).toBe(true);
});
it('should return health status for all subscribers', () => {
const adapter1 = new MockAdapter();
adapter1.name = 'Adapter1';
const adapter2 = new MockAdapter();
adapter2.name = 'Adapter2';
const healthQueue = new EventQueue([adapter1, adapter2]);
healthQueue.setSubscriberHealth('adapter1', false);
const healthMap = healthQueue.getSubscriberHealth();
expect(healthMap.get('adapter1')).toBe(false);
expect(healthMap.get('adapter2')).toBe(true);
});
it('should not mark healthy subscribers as unhealthy when another fails', async () => {
const failingAdapter = new MockAdapter();
failingAdapter.name = 'FailingAdapter';
failingAdapter.shouldFail = true;
const healthyAdapter = new MockAdapter();
healthyAdapter.name = 'HealthyAdapter';
const healthQueue = new EventQueue([failingAdapter, healthyAdapter], {
maxRetries: 0,
});
healthQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await healthQueue.flush();
expect(healthQueue.isSubscriberHealthy('failingadapter')).toBe(false);
expect(healthQueue.isSubscriberHealthy('healthyadapter')).toBe(true);
});
});
describe('Shutdown behavior', () => {
it('should reject events during shutdown', async () => {
const adapter = new MockAdapter();
const shutdownQueue = new EventQueue([adapter], {
flushInterval: 100,
});
// Enqueue some events
shutdownQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
// Start shutdown (sets isShuttingDown, then flushes)
const shutdownPromise = shutdownQueue.shutdown();
// Try to enqueue during shutdown - should be rejected
shutdownQueue.enqueue({
name: 'test2',
attributes: {},
timestamp: Date.now(),
});
await shutdownPromise;
// Only first event should be delivered
expect(adapter.events.length).toBe(1);
expect(adapter.events[0].name).toBe('test1');
});
});
describe('Correlation ID enrichment', () => {
it('should enrich events with correlation ID', async () => {
const adapter = new MockAdapter();
const correlationQueue = new EventQueue([adapter], {
flushInterval: 50,
});
correlationQueue.enqueue({
name: 'test1',
attributes: {},
timestamp: Date.now(),
});
await correlationQueue.flush();
// The queue should have enriched the event with _correlationId
// We can verify this by checking that the event was delivered
expect(adapter.events.length).toBe(1);
});
});
});