Implement Phase 1 task 1.10 (Prometheus metrics + /healthz + /readyz)
Replaces the placeholder Metrics shim with a prom-client implementation in src/observability/metrics.ts: all 10 Phase 1 metrics from the wiki spec, plus nodejs_* defaults. Exposes /metrics, /healthz, /readyz over node:http on METRICS_PORT (9090); /readyz returns 503 when Redis status is not 'ready' or the TCP listener isn't bound. The Metrics interface in src/core/types.ts is unchanged — adapter call sites continue to use the same inc/observe shape. Only main.ts sees the extended type that adds serializeMetrics(). Side effects: - Dockerfile re-enables HEALTHCHECK pointing at /readyz, and EXPOSE 9090. - frame-ingested log downgraded back to debug now that teltonika_records_published_total is scrapeable. - 19 new unit tests covering exposition format, all metric types, and every HTTP endpoint path. Total now 98 passing. Note: deploy/compose.yaml still does not expose 9090 — separate decision about how Prometheus reaches the service (host port vs. internal scraper on the same Docker network).
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 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<void>,
|
||||
): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user