bd1880a44c
- Add MetricsCollector class with counters, model call ring buffer, event ring buffer, and active request tracking - Add system.metrics, system.events, system.activeRequests RPC handlers - Add GET /health unauthenticated HTTP endpoint for Docker HEALTHCHECK - Add totalPending() to LaneQueue for queue depth metrics - Add 20 tests for MetricsCollector
255 lines
8.3 KiB
TypeScript
255 lines
8.3 KiB
TypeScript
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>): 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>): 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);
|
|
});
|
|
});
|
|
});
|