/** * Unit tests for src/observability/metrics.ts * * Covers: * - serializeMetrics() returns valid Prometheus exposition format with the * full metric inventory present (some at zero) * - counter increments are reflected in the serialized output * - teltonika_unknown_codec_total{codec_id="0xff"} appears after an unknown-codec event * - startMetricsServer responds correctly to /metrics, /healthz, and /readyz * - /readyz returns 503 when Redis is not ready or TCP is not listening */ import { describe, it, expect, afterEach } from 'vitest'; import * as http from 'node:http'; import { createMetrics, startMetricsServer } from '../src/observability/metrics.js'; import type { ReadyzDeps } from '../src/observability/metrics.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Makes an HTTP GET request and resolves with { statusCode, body }. * Uses the raw node:http module to match the server's implementation. */ function get(port: number, path: string): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { const req = http.get(`http://localhost:${port}${path}`, (res) => { let body = ''; res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); res.on('end', () => { resolve({ statusCode: res.statusCode ?? 0, body }); }); }); req.on('error', reject); }); } /** * Waits for a server to be listening, then runs `fn`, then closes the server. * Returns after the server is fully closed. */ function withServer( server: http.Server, fn: (port: number) => Promise, ): Promise { return new Promise((resolve, reject) => { server.once('listening', () => { const addr = server.address(); const port = typeof addr === 'object' && addr !== null ? addr.port : 0; fn(port) .then(() => { server.close((err) => { if (err) reject(err); else resolve(); }); }) .catch((err: unknown) => { server.close(() => reject(err)); }); }); server.once('error', reject); }); } // --------------------------------------------------------------------------- // 1. serializeMetrics — Prometheus exposition format // --------------------------------------------------------------------------- describe('createMetrics — serializeMetrics', () => { it('returns a non-empty string', async () => { const metrics = createMetrics(); const text = await metrics.serializeMetrics(); expect(typeof text).toBe('string'); expect(text.length).toBeGreaterThan(0); }); it('contains every teltonika_* metric in the Phase 1 inventory', async () => { const metrics = createMetrics(); const text = await metrics.serializeMetrics(); const expectedMetrics = [ 'teltonika_connections_active', 'teltonika_handshake_total', 'teltonika_device_authority_failures_total', 'teltonika_frames_total', 'teltonika_records_published_total', 'teltonika_parse_duration_seconds', 'teltonika_unknown_codec_total', 'teltonika_publish_queue_depth', 'teltonika_publish_overflow_total', 'teltonika_publish_duration_seconds', ]; for (const name of expectedMetrics) { expect(text, `expected "${name}" in metrics output`).toContain(name); } }); it('contains nodejs_* default process metrics', async () => { const metrics = createMetrics(); const text = await metrics.serializeMetrics(); // prom-client collectDefaultMetrics registers nodejs_version_info at minimum expect(text).toContain('nodejs_'); }); it('exposition format contains HELP and TYPE lines', async () => { const metrics = createMetrics(); const text = await metrics.serializeMetrics(); expect(text).toContain('# HELP teltonika_handshake_total'); expect(text).toContain('# TYPE teltonika_handshake_total counter'); expect(text).toContain('# HELP teltonika_frames_total'); expect(text).toContain('# TYPE teltonika_frames_total counter'); }); }); // --------------------------------------------------------------------------- // 2. Counter increments // --------------------------------------------------------------------------- describe('createMetrics — counter increments', () => { it('increments teltonika_frames_total and teltonika_records_published_total', async () => { const metrics = createMetrics(); // Simulate a successful Codec 8E frame metrics.inc('teltonika_frames_total', { codec: '8E', result: 'ok' }); metrics.inc('teltonika_records_published_total', { codec: '8E' }); const text = await metrics.serializeMetrics(); // The exposition format for a counter with labels looks like: // teltonika_frames_total{codec="8E",result="ok"} 1 expect(text).toMatch(/teltonika_frames_total\{[^}]*codec="8E"[^}]*result="ok"[^}]*\}\s+1/); expect(text).toMatch(/teltonika_records_published_total\{[^}]*codec="8E"[^}]*\}\s+1/); }); it('accumulates multiple increments on the same label set', async () => { const metrics = createMetrics(); metrics.inc('teltonika_frames_total', { codec: '8', result: 'ok' }); metrics.inc('teltonika_frames_total', { codec: '8', result: 'ok' }); metrics.inc('teltonika_frames_total', { codec: '8', result: 'ok' }); const text = await metrics.serializeMetrics(); expect(text).toMatch(/teltonika_frames_total\{[^}]*codec="8"[^}]*result="ok"[^}]*\}\s+3/); }); it('tracks crc_fail result separately from ok', async () => { const metrics = createMetrics(); metrics.inc('teltonika_frames_total', { codec: '8', result: 'ok' }); metrics.inc('teltonika_frames_total', { codec: '8', result: 'crc_fail' }); const text = await metrics.serializeMetrics(); expect(text).toMatch(/teltonika_frames_total\{[^}]*codec="8"[^}]*result="ok"[^}]*\}\s+1/); expect(text).toMatch(/teltonika_frames_total\{[^}]*codec="8"[^}]*result="crc_fail"[^}]*\}\s+1/); }); }); // --------------------------------------------------------------------------- // 3. Unknown codec canary // --------------------------------------------------------------------------- describe('createMetrics — unknown codec canary', () => { it('records teltonika_unknown_codec_total{codec_id="0xff"} after an unknown-codec event', async () => { const metrics = createMetrics(); metrics.inc('teltonika_unknown_codec_total', { codec_id: '0xff' }); const text = await metrics.serializeMetrics(); expect(text).toMatch(/teltonika_unknown_codec_total\{[^}]*codec_id="0xff"[^}]*\}\s+1/); }); it('distinguishes different codec_id values', async () => { const metrics = createMetrics(); metrics.inc('teltonika_unknown_codec_total', { codec_id: '0x0f' }); metrics.inc('teltonika_unknown_codec_total', { codec_id: '0x0f' }); metrics.inc('teltonika_unknown_codec_total', { codec_id: '0x20' }); const text = await metrics.serializeMetrics(); expect(text).toMatch(/teltonika_unknown_codec_total\{[^}]*codec_id="0x0f"[^}]*\}\s+2/); expect(text).toMatch(/teltonika_unknown_codec_total\{[^}]*codec_id="0x20"[^}]*\}\s+1/); }); }); // --------------------------------------------------------------------------- // 4. observe() — gauge and histogram // --------------------------------------------------------------------------- describe('createMetrics — observe', () => { it('sets teltonika_publish_queue_depth gauge', async () => { const metrics = createMetrics(); metrics.observe('teltonika_publish_queue_depth', 42); const text = await metrics.serializeMetrics(); expect(text).toMatch(/teltonika_publish_queue_depth\s+42/); }); it('records teltonika_parse_duration_seconds histogram observation', async () => { const metrics = createMetrics(); metrics.observe('teltonika_parse_duration_seconds', 0.0003, { codec: '8E' }); const text = await metrics.serializeMetrics(); // The histogram sum should contain the observed value expect(text).toMatch(/teltonika_parse_duration_seconds_sum\{[^}]*codec="8E"[^}]*\}\s+0\.0003/); }); it('ignores unknown metric names without throwing', () => { const metrics = createMetrics(); // Must not throw — the Metrics interface contract is never-throw expect(() => metrics.inc('teltonika_nonexistent_metric')).not.toThrow(); expect(() => metrics.observe('teltonika_nonexistent_metric', 1.0)).not.toThrow(); }); }); // --------------------------------------------------------------------------- // 5. startMetricsServer — HTTP endpoint behaviour // --------------------------------------------------------------------------- describe('startMetricsServer', () => { // Track servers for cleanup in case of test failure const openServers: http.Server[] = []; afterEach(() => { for (const s of openServers) { if (s.listening) s.close(); } openServers.length = 0; }); it('GET /metrics returns 200 with Prometheus text', async () => { const metrics = createMetrics(); const readyzDeps: ReadyzDeps = { isRedisReady: () => true, isTcpListening: () => true, }; // port=0 → OS picks a free port const server = startMetricsServer(0, metrics.serializeMetrics, readyzDeps); openServers.push(server); await withServer(server, async (port) => { const { statusCode, body } = await get(port, '/metrics'); expect(statusCode).toBe(200); expect(body).toContain('teltonika_'); expect(body).toContain('nodejs_'); }); }); it('GET /healthz returns 200 regardless of Redis/TCP state', async () => { const metrics = createMetrics(); const readyzDeps: ReadyzDeps = { isRedisReady: () => false, // deliberately unhealthy isTcpListening: () => false, }; const server = startMetricsServer(0, metrics.serializeMetrics, readyzDeps); openServers.push(server); await withServer(server, async (port) => { const { statusCode, body } = await get(port, '/healthz'); expect(statusCode).toBe(200); expect(JSON.parse(body)).toEqual({ status: 'ok' }); }); }); it('GET /readyz returns 200 when Redis is ready and TCP is listening', async () => { const metrics = createMetrics(); const readyzDeps: ReadyzDeps = { isRedisReady: () => true, isTcpListening: () => true, }; const server = startMetricsServer(0, metrics.serializeMetrics, readyzDeps); openServers.push(server); await withServer(server, async (port) => { const { statusCode, body } = await get(port, '/readyz'); expect(statusCode).toBe(200); expect(JSON.parse(body)).toEqual({ status: 'ok' }); }); }); it('GET /readyz returns 503 when Redis is not ready', async () => { const metrics = createMetrics(); const readyzDeps: ReadyzDeps = { isRedisReady: () => false, isTcpListening: () => true, }; const server = startMetricsServer(0, metrics.serializeMetrics, readyzDeps); openServers.push(server); await withServer(server, async (port) => { const { statusCode, body } = await get(port, '/readyz'); expect(statusCode).toBe(503); const parsed = JSON.parse(body) as { status: string; redis: boolean; tcp: boolean }; expect(parsed.status).toBe('not ready'); expect(parsed.redis).toBe(false); expect(parsed.tcp).toBe(true); }); }); it('GET /readyz returns 503 when TCP server is not listening', async () => { const metrics = createMetrics(); const readyzDeps: ReadyzDeps = { isRedisReady: () => true, isTcpListening: () => false, }; const server = startMetricsServer(0, metrics.serializeMetrics, readyzDeps); openServers.push(server); await withServer(server, async (port) => { const { statusCode } = await get(port, '/readyz'); expect(statusCode).toBe(503); }); }); it('GET /readyz returns 503 when both Redis and TCP are down', async () => { const metrics = createMetrics(); const readyzDeps: ReadyzDeps = { isRedisReady: () => false, isTcpListening: () => false, }; const server = startMetricsServer(0, metrics.serializeMetrics, readyzDeps); openServers.push(server); await withServer(server, async (port) => { const { statusCode, body } = await get(port, '/readyz'); expect(statusCode).toBe(503); const parsed = JSON.parse(body) as { status: string; redis: boolean; tcp: boolean }; expect(parsed.redis).toBe(false); expect(parsed.tcp).toBe(false); }); }); it('GET /unknown-path returns 404', async () => { const metrics = createMetrics(); const readyzDeps: ReadyzDeps = { isRedisReady: () => true, isTcpListening: () => true, }; const server = startMetricsServer(0, metrics.serializeMetrics, readyzDeps); openServers.push(server); await withServer(server, async (port) => { const { statusCode } = await get(port, '/not-a-real-endpoint'); expect(statusCode).toBe(404); }); }); });