[Phase 4] feat: Prometheus metrics instrumentation for VIN Decoder #2
@@ -4,3 +4,4 @@ resources:
|
||||
- namespace.yaml
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
- servicemonitor.yaml
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: vin-decoder
|
||||
namespace: vin-decoder
|
||||
labels:
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: vin-decoder
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Lightweight Prometheus metrics exposition for vin-decoder.
|
||||
* Uses no external dependencies — builds text manually per
|
||||
* https://prometheus.io/docs/instrumenting/exposition_formats/
|
||||
*/
|
||||
|
||||
// ─── Internal state ────────────────────────────────────────────────────────────
|
||||
|
||||
/** api_requests_total{api, method, route, status_code} */
|
||||
const _requestsTotal = new Map(); // key → count
|
||||
|
||||
/** api_response_duration_seconds histogram */
|
||||
const BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
||||
const _durationBuckets = new Map(); // key → Map<le, count>
|
||||
const _durationSum = new Map(); // key → sum (seconds)
|
||||
const _durationCount = new Map(); // key → count
|
||||
|
||||
/** api_data_freshness_seconds — seconds since last successful NHTSA upstream call */
|
||||
let _lastNhtsaCallMs = Date.now();
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record one request completing.
|
||||
* @param {string} method HTTP method e.g. "GET"
|
||||
* @param {string} route route pattern e.g. "/v1/decode"
|
||||
* @param {number} status HTTP status code e.g. 200
|
||||
* @param {number} durationMs elapsed time in milliseconds
|
||||
*/
|
||||
export function recordRequest(method, route, status, durationMs) {
|
||||
const rKey = `${method}\x00${route}\x00${status}`;
|
||||
_requestsTotal.set(rKey, (_requestsTotal.get(rKey) ?? 0) + 1);
|
||||
|
||||
const dKey = `${method}\x00${route}`;
|
||||
const dSec = durationMs / 1000;
|
||||
|
||||
_durationSum.set(dKey, (_durationSum.get(dKey) ?? 0) + dSec);
|
||||
_durationCount.set(dKey, (_durationCount.get(dKey) ?? 0) + 1);
|
||||
|
||||
if (!_durationBuckets.has(dKey)) {
|
||||
_durationBuckets.set(dKey, new Map(BUCKETS.map(b => [b, 0])));
|
||||
}
|
||||
const bmap = _durationBuckets.get(dKey);
|
||||
for (const b of BUCKETS) {
|
||||
if (dSec <= b) {
|
||||
bmap.set(b, bmap.get(b) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call after every successful upstream NHTSA fetch to reset the freshness clock.
|
||||
*/
|
||||
export function recordNhtsaCall() {
|
||||
_lastNhtsaCallMs = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return seconds since last successful NHTSA call (for the gauge metric).
|
||||
*/
|
||||
export function getDataFreshnessSeconds() {
|
||||
return (Date.now() - _lastNhtsaCallMs) / 1000;
|
||||
}
|
||||
|
||||
// ─── Text exposition ───────────────────────────────────────────────────────────
|
||||
|
||||
function labelStr(obj) {
|
||||
const parts = Object.entries(obj).map(
|
||||
([k, v]) => `${k}="${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`
|
||||
);
|
||||
return '{' + parts.join(',') + '}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Prometheus text format (Content-Type: text/plain; version=0.0.4).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function metricsText() {
|
||||
const lines = [];
|
||||
|
||||
// ── api_requests_total ──────────────────────────────────────────────────────
|
||||
lines.push('# HELP api_requests_total Total HTTP requests handled by the VIN Decoder API');
|
||||
lines.push('# TYPE api_requests_total counter');
|
||||
for (const [key, count] of _requestsTotal) {
|
||||
const [method, route, status_code] = key.split('\x00');
|
||||
lines.push(`api_requests_total${labelStr({ api: 'vin-decoder', method, route, status_code })} ${count}`);
|
||||
}
|
||||
|
||||
// ── api_response_duration_seconds ───────────────────────────────────────────
|
||||
lines.push('# HELP api_response_duration_seconds HTTP request latency histogram');
|
||||
lines.push('# TYPE api_response_duration_seconds histogram');
|
||||
for (const [key, bmap] of _durationBuckets) {
|
||||
const [method, route] = key.split('\x00');
|
||||
let cumulative = 0;
|
||||
for (const b of BUCKETS) {
|
||||
cumulative += bmap.get(b) ?? 0;
|
||||
lines.push(`api_response_duration_seconds_bucket${labelStr({ api: 'vin-decoder', method, route, le: String(b) })} ${cumulative}`);
|
||||
}
|
||||
const total = _durationCount.get(key) ?? 0;
|
||||
lines.push(`api_response_duration_seconds_bucket${labelStr({ api: 'vin-decoder', method, route, le: '+Inf' })} ${total}`);
|
||||
lines.push(`api_response_duration_seconds_sum${labelStr({ api: 'vin-decoder', method, route })} ${(_durationSum.get(key) ?? 0).toFixed(6)}`);
|
||||
lines.push(`api_response_duration_seconds_count${labelStr({ api: 'vin-decoder', method, route })} ${total}`);
|
||||
}
|
||||
|
||||
// ── api_data_freshness_seconds ──────────────────────────────────────────────
|
||||
lines.push('# HELP api_data_freshness_seconds Seconds since last successful upstream NHTSA vPIC call');
|
||||
lines.push('# TYPE api_data_freshness_seconds gauge');
|
||||
lines.push(`api_data_freshness_seconds${labelStr({ api: 'vin-decoder' })} ${getDataFreshnessSeconds().toFixed(3)}`);
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { recordNhtsaCall } from './metrics.js';
|
||||
|
||||
const NHTSA_BASE = 'https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues';
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days
|
||||
@@ -11,6 +12,7 @@ export async function fetchFromNhtsa(vin) {
|
||||
const json = await res.json();
|
||||
const results = json.Results?.[0];
|
||||
if (!results) throw new Error('NHTSA returned empty Results array');
|
||||
recordNhtsaCall(); // reset freshness clock on successful upstream fetch
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Fastify from 'fastify';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { decodeVin, validateVin, getCacheStats, evictExpired } from './cache.js';
|
||||
import { recordRequest, metricsText } from './metrics.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const PROXY_SECRET = process.env.RAPIDAPI_PROXY_SECRET || '';
|
||||
@@ -14,6 +15,16 @@ fastify.addHook('onRequest', async (req, reply) => {
|
||||
const requestId = randomUUID();
|
||||
req.requestId = requestId;
|
||||
reply.header('X-Request-Id', requestId);
|
||||
req._startMs = Date.now();
|
||||
});
|
||||
|
||||
// Prometheus instrumentation — record every response
|
||||
fastify.addHook('onResponse', async (req, reply) => {
|
||||
const durationMs = Date.now() - (req._startMs ?? Date.now());
|
||||
const route = req.routerPath ?? req.url.split('?')[0];
|
||||
if (route !== '/metrics') {
|
||||
recordRequest(req.method, route, reply.statusCode, durationMs);
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy secret validation helper
|
||||
@@ -107,6 +118,12 @@ fastify.get('/v1/health', async (request, reply) => {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /metrics — Prometheus text exposition (no proxy-secret required)
|
||||
fastify.get('/metrics', async (request, reply) => {
|
||||
reply.header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
||||
return metricsText();
|
||||
});
|
||||
|
||||
fastify.listen({ port: PORT, host: '0.0.0.0' }, (err) => {
|
||||
if (err) { fastify.log.error(err); process.exit(1); }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { recordRequest, recordNhtsaCall, getDataFreshnessSeconds, metricsText } from '../metrics.js';
|
||||
|
||||
describe('metricsText', () => {
|
||||
it('returns HTTP 200 content-type text/plain (via metricsText string)', () => {
|
||||
const text = metricsText();
|
||||
assert.equal(typeof text, 'string');
|
||||
assert.ok(text.length > 0, 'metricsText should not be empty');
|
||||
});
|
||||
|
||||
it('contains all three required metric names', () => {
|
||||
const text = metricsText();
|
||||
assert.ok(text.includes('api_requests_total'), 'must include api_requests_total');
|
||||
assert.ok(text.includes('api_response_duration_seconds'), 'must include api_response_duration_seconds');
|
||||
assert.ok(text.includes('api_data_freshness_seconds'), 'must include api_data_freshness_seconds');
|
||||
});
|
||||
|
||||
it('has correct HELP and TYPE lines', () => {
|
||||
const text = metricsText();
|
||||
assert.ok(text.includes('# HELP api_requests_total'), 'must have HELP for api_requests_total');
|
||||
assert.ok(text.includes('# TYPE api_requests_total counter'), 'must have TYPE counter');
|
||||
assert.ok(text.includes('# TYPE api_response_duration_seconds histogram'), 'must have TYPE histogram');
|
||||
assert.ok(text.includes('# TYPE api_data_freshness_seconds gauge'), 'must have TYPE gauge');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordRequest', () => {
|
||||
it('increments api_requests_total counter', () => {
|
||||
const before = metricsText();
|
||||
const beforeCount = (before.match(/api_requests_total\{[^}]*route="\/v1\/decode-test"[^}]*\} (\d+)/)?.[1] ?? 0);
|
||||
|
||||
recordRequest('GET', '/v1/decode-test', 200, 42);
|
||||
|
||||
const after = metricsText();
|
||||
assert.ok(after.includes('/v1/decode-test'), 'route should appear in metrics output');
|
||||
const afterCount = parseInt(after.match(/api_requests_total\{[^}]*route="\/v1\/decode-test"[^}]*\} (\d+)/)?.[1] ?? '0');
|
||||
assert.equal(afterCount, Number(beforeCount) + 1, 'counter should increment by 1');
|
||||
});
|
||||
|
||||
it('creates histogram bucket lines', () => {
|
||||
recordRequest('POST', '/v1/batch-test', 200, 15);
|
||||
const text = metricsText();
|
||||
assert.ok(text.includes('/v1/batch-test'), 'histogram route should appear');
|
||||
assert.ok(text.includes('api_response_duration_seconds_bucket'), 'bucket lines should exist');
|
||||
assert.ok(text.includes('api_response_duration_seconds_sum'), 'sum line should exist');
|
||||
assert.ok(text.includes('api_response_duration_seconds_count'), 'count line should exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordNhtsaCall + getDataFreshnessSeconds', () => {
|
||||
it('resets freshness clock and returns near-zero seconds', () => {
|
||||
recordNhtsaCall();
|
||||
const freshness = getDataFreshnessSeconds();
|
||||
assert.ok(freshness >= 0, 'freshness must be non-negative');
|
||||
assert.ok(freshness < 5, `freshness should be <5s right after recordNhtsaCall(), got ${freshness}`);
|
||||
});
|
||||
|
||||
it('metricsText freshness gauge is close to 0', () => {
|
||||
recordNhtsaCall();
|
||||
const text = metricsText();
|
||||
const match = text.match(/api_data_freshness_seconds\{[^}]*\} ([\d.]+)/);
|
||||
assert.ok(match, 'gauge line should be present');
|
||||
const val = parseFloat(match[1]);
|
||||
assert.ok(val >= 0 && val < 5, `freshness gauge should be <5s, got ${val}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user