diff --git a/flux/vin-decoder/kustomization.yaml b/flux/vin-decoder/kustomization.yaml index 34a36c7..687bd88 100644 --- a/flux/vin-decoder/kustomization.yaml +++ b/flux/vin-decoder/kustomization.yaml @@ -4,3 +4,4 @@ resources: - namespace.yaml - deployment.yaml - ingress.yaml + - servicemonitor.yaml diff --git a/flux/vin-decoder/servicemonitor.yaml b/flux/vin-decoder/servicemonitor.yaml new file mode 100644 index 0000000..3583996 --- /dev/null +++ b/flux/vin-decoder/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 diff --git a/src/metrics.js b/src/metrics.js new file mode 100644 index 0000000..044962a --- /dev/null +++ b/src/metrics.js @@ -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 +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'; +} diff --git a/src/nhtsa.js b/src/nhtsa.js index 687f3e3..22ec830 100644 --- a/src/nhtsa.js +++ b/src/nhtsa.js @@ -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; } diff --git a/src/server.js b/src/server.js index 91b5052..41bbbf6 100644 --- a/src/server.js +++ b/src/server.js @@ -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); } }); diff --git a/src/tests/metrics.test.js b/src/tests/metrics.test.js new file mode 100644 index 0000000..673b841 --- /dev/null +++ b/src/tests/metrics.test.js @@ -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}`); + }); +});