import { describe, it, expect, beforeEach } from 'vitest'; import { Registry } from 'prom-client'; import { createGatewayMetrics } from './gateway-metrics.js'; describe('metrics/gateway-metrics', () => { let reg: Registry; beforeEach(() => { reg = new Registry(); }); it('registers all 11 metrics under the supplied prefix', async () => { createGatewayMetrics(reg, 'aao_gateway_t'); const out = await reg.metrics(); for (const name of [ 'aao_gateway_t_requests_total', 'aao_gateway_t_request_duration_seconds', 'aao_gateway_t_tokens_total', 'aao_gateway_t_backend_busy_slots', 'aao_gateway_t_backend_total_slots', 'aao_gateway_t_backend_online', 'aao_gateway_t_cache_hit_total', 'aao_gateway_t_cache_miss_total', 'aao_gateway_t_virtual_key_budget_used_ratio', 'aao_gateway_t_rate_limit_rejections_total', 'aao_gateway_t_active_streams', ]) { expect(out).toContain(name); } }); it('counters emit a series per unique label set', async () => { const m = createGatewayMetrics(reg, 'aao_gateway_c'); m.requestsTotal.inc({ team: 'alpha', backend: 'gpu-a', model: 'qwen3:8b', status: 'success' }, 3); m.requestsTotal.inc({ team: 'alpha', backend: 'gpu-a', model: 'qwen3:8b', status: 'success' }, 2); m.requestsTotal.inc({ team: 'bravo', backend: 'gpu-b', model: 'qwen3:8b', status: 'success' }, 1); const out = await reg.metrics(); // 3 + 2 = 5 on the alpha series expect(out).toMatch(/aao_gateway_c_requests_total\{[^}]*team="alpha"[^}]*\} 5/); expect(out).toMatch(/aao_gateway_c_requests_total\{[^}]*team="bravo"[^}]*\} 1/); }); it('histogram observe records a single sample', async () => { const m = createGatewayMetrics(reg, 'aao_gateway_h'); m.requestDurationSeconds.observe( { team: 'alpha', backend: 'gpu-a', model: 'qwen3:8b', status: 'success' }, 0.42, ); const out = await reg.metrics(); expect(out).toContain('aao_gateway_h_request_duration_seconds_count{'); expect(out).toMatch(/aao_gateway_h_request_duration_seconds_sum\{[^}]+\} 0\.42/); }); it('gauge set + reset returns latest value', async () => { const m = createGatewayMetrics(reg, 'aao_gateway_g'); m.backendBusySlots.set({ backend: 'gpu-a' }, 4); m.backendBusySlots.set({ backend: 'gpu-a' }, 7); const out = await reg.metrics(); expect(out).toMatch(/aao_gateway_g_backend_busy_slots\{backend="gpu-a"\} 7/); }); it('activeStreams gauge has no labels', async () => { const m = createGatewayMetrics(reg, 'aao_gateway_s'); m.activeStreams.set(5); const out = await reg.metrics(); expect(out).toContain('aao_gateway_s_active_streams 5'); }); it('resetMetrics zeroes counters without unregistering', async () => { const m = createGatewayMetrics(reg, 'aao_gateway_r'); m.cacheHitTotal.inc({ cache: 'key' }, 10); reg.resetMetrics(); const out = await reg.metrics(); expect(out).toContain('aao_gateway_r_cache_hit_total'); // No data line after reset (counter not yet observed post-reset) expect(out).not.toMatch(/aao_gateway_r_cache_hit_total\{cache="key"\} 10/); }); it('is idempotent on (registry, prefix) — returns the cached handle, does NOT throw', () => { // CRITICAL-1 fix: prom-client throws on duplicate metric name // registration, but createGatewayMetrics is memoized so callers // (e.g. same-process gateway bounces with a shared bridge // Registry) can safely call it again. The cache is per (registry, // prefix) so a different prefix on the same registry still // registers fresh counters. const first = createGatewayMetrics(reg, 'aao_gateway_dup'); expect(() => createGatewayMetrics(reg, 'aao_gateway_dup')).not.toThrow(); const second = createGatewayMetrics(reg, 'aao_gateway_dup'); expect(second).toBe(first); // same handle, no fresh Counter instances // Different prefix → fresh registration, no cache hit const other = createGatewayMetrics(reg, 'aao_gateway_other'); expect(other).not.toBe(first); }); });