import { describe, it, expect, beforeEach } from 'vitest'; import { MetricsCollector } from './metrics.js'; import type { ModelCallEntry, EventEntry } from './metrics.js'; describe('MetricsCollector', () => { let collector: MetricsCollector; beforeEach(() => { collector = new MetricsCollector(); }); describe('counters', () => { it('starts with zero counters', () => { expect(collector.messagesProcessed).toBe(0); expect(collector.errors).toBe(0); expect(collector.activeRequestCount).toBe(0); }); it('increments messages processed', () => { collector.incrementMessages(); collector.incrementMessages(); expect(collector.messagesProcessed).toBe(2); }); it('increments errors', () => { collector.incrementErrors(); expect(collector.errors).toBe(1); }); }); describe('model call ring buffer', () => { function makeCall(overrides?: Partial): ModelCallEntry { return { timestamp: Date.now(), provider: 'anthropic', latency: 500, inputTokens: 100, outputTokens: 50, tokensPerSec: 100, ...overrides, }; } it('records model calls', () => { collector.recordModelCall(makeCall()); expect(collector.getModelMetrics()).toHaveLength(1); }); it('enforces max buffer size (default 200)', () => { for (let i = 0; i < 210; i++) { collector.recordModelCall(makeCall({ latency: i })); } const calls = collector.getModelMetrics(); expect(calls).toHaveLength(200); // First entry should be index 10 (the first 10 were evicted) expect(calls[0].latency).toBe(10); }); it('respects custom buffer size', () => { const small = new MetricsCollector({ modelCallBufferSize: 5 }); for (let i = 0; i < 8; i++) { small.recordModelCall(makeCall({ latency: i })); } const calls = small.getModelMetrics(); expect(calls).toHaveLength(5); expect(calls[0].latency).toBe(3); }); it('returns a copy, not the internal array', () => { collector.recordModelCall(makeCall()); const a = collector.getModelMetrics(); const b = collector.getModelMetrics(); expect(a).not.toBe(b); }); }); describe('event ring buffer', () => { function makeEvent(overrides?: Partial): EventEntry { return { timestamp: Date.now(), level: 'info', source: 'test', message: 'test event', ...overrides, }; } it('records events', () => { collector.recordEvent(makeEvent()); expect(collector.getEvents()).toHaveLength(1); }); it('enforces max buffer size (default 500)', () => { for (let i = 0; i < 510; i++) { collector.recordEvent(makeEvent({ message: `event-${i}` })); } const events = collector.getEvents(); expect(events).toHaveLength(500); }); it('returns events newest first', () => { collector.recordEvent(makeEvent({ message: 'first', timestamp: 1000 })); collector.recordEvent(makeEvent({ message: 'second', timestamp: 2000 })); collector.recordEvent(makeEvent({ message: 'third', timestamp: 3000 })); const events = collector.getEvents(); expect(events[0].message).toBe('third'); expect(events[2].message).toBe('first'); }); it('filters by level', () => { collector.recordEvent(makeEvent({ level: 'info', message: 'info-1' })); collector.recordEvent(makeEvent({ level: 'error', message: 'error-1' })); collector.recordEvent(makeEvent({ level: 'info', message: 'info-2' })); collector.recordEvent(makeEvent({ level: 'warn', message: 'warn-1' })); const errors = collector.getEvents({ level: 'error' }); expect(errors).toHaveLength(1); expect(errors[0].message).toBe('error-1'); const infos = collector.getEvents({ level: 'info' }); expect(infos).toHaveLength(2); }); it('limits results', () => { for (let i = 0; i < 10; i++) { collector.recordEvent(makeEvent({ message: `event-${i}` })); } const limited = collector.getEvents({ limit: 3 }); expect(limited).toHaveLength(3); // Should be the 3 newest expect(limited[0].message).toBe('event-9'); expect(limited[2].message).toBe('event-7'); }); it('combines level filter and limit', () => { for (let i = 0; i < 10; i++) { collector.recordEvent(makeEvent({ level: i % 2 === 0 ? 'error' : 'info', message: `event-${i}` })); } const result = collector.getEvents({ level: 'error', limit: 2 }); expect(result).toHaveLength(2); expect(result[0].message).toBe('event-8'); expect(result[1].message).toBe('event-6'); }); }); describe('active request tracking', () => { it('tracks start and end of requests', () => { collector.startRequest('req-1', { sessionId: 'ws:abc', channel: 'ws' }); expect(collector.activeRequestCount).toBe(1); const active = collector.getActiveRequests(); expect(active).toHaveLength(1); expect(active[0].id).toBe('req-1'); expect(active[0].sessionId).toBe('ws:abc'); expect(active[0].channel).toBe('ws'); expect(active[0].durationMs).toBeGreaterThanOrEqual(0); collector.endRequest('req-1'); expect(collector.activeRequestCount).toBe(0); expect(collector.getActiveRequests()).toHaveLength(0); }); it('handles ending non-existent request', () => { collector.endRequest('nonexistent'); expect(collector.activeRequestCount).toBe(0); }); it('tracks multiple concurrent requests', () => { collector.startRequest('req-1', { sessionId: 'ws:a', channel: 'ws' }); collector.startRequest('req-2', { sessionId: 'tg:b', channel: 'telegram' }); expect(collector.activeRequestCount).toBe(2); expect(collector.getActiveRequests()).toHaveLength(2); collector.endRequest('req-1'); expect(collector.activeRequestCount).toBe(1); expect(collector.getActiveRequests()[0].id).toBe('req-2'); }); }); describe('getSnapshot', () => { it('returns correct shape with zero data', () => { const snapshot = collector.getSnapshot(); expect(snapshot.messagesProcessed).toBe(0); expect(snapshot.errors).toBe(0); expect(snapshot.activeRequests).toBe(0); expect(typeof snapshot.uptime).toBe('number'); expect(snapshot.uptime).toBeGreaterThanOrEqual(0); expect(snapshot.modelCalls.total).toBe(0); expect(snapshot.modelCalls.avgLatency).toBe(0); expect(snapshot.modelCalls.errorRate).toBe(0); expect(snapshot.modelCalls.recentCalls).toEqual([]); expect(snapshot.queueDepth).toBe(0); }); it('reflects accumulated data', () => { collector.incrementMessages(); collector.incrementMessages(); collector.incrementErrors(); collector.recordModelCall({ timestamp: Date.now(), provider: 'anthropic', latency: 200, inputTokens: 100, outputTokens: 50, tokensPerSec: 250, }); collector.recordModelCall({ timestamp: Date.now(), provider: 'openai', latency: 400, inputTokens: 200, outputTokens: 100, tokensPerSec: 250, error: 'rate limit', }); const snapshot = collector.getSnapshot(); expect(snapshot.messagesProcessed).toBe(2); expect(snapshot.errors).toBe(1); expect(snapshot.modelCalls.total).toBe(2); expect(snapshot.modelCalls.avgLatency).toBe(300); expect(snapshot.modelCalls.errorRate).toBe(0.5); expect(snapshot.modelCalls.recentCalls).toHaveLength(2); }); it('uses getQueueDepth callback', () => { const withQueue = new MetricsCollector({ getQueueDepth: () => 5 }); const snapshot = withQueue.getSnapshot(); expect(snapshot.queueDepth).toBe(5); }); it('limits recentCalls in snapshot to 20', () => { for (let i = 0; i < 30; i++) { collector.recordModelCall({ timestamp: Date.now(), provider: 'anthropic', latency: i * 10, inputTokens: 100, outputTokens: 50, tokensPerSec: 100, }); } const snapshot = collector.getSnapshot(); expect(snapshot.modelCalls.recentCalls).toHaveLength(20); // Should be the last 20 expect(snapshot.modelCalls.recentCalls[0].latency).toBe(100); }); }); });