/** Minimal Prometheus registry (no dependency): request counter, latency histogram, cache counters, gauges. */ const BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; class Metrics { private requests = new Map(); // "route|status" → count private histCounts = new Map(); // route → bucket counts (+Inf last) private histSum = new Map(); private histCount = new Map(); private cacheHits: Record = { memory: 0, redis: 0 }; private cacheMisses = 0; private gauges = new Map(); readonly startedAt = Date.now(); observe(route: string, status: number, seconds: number): void { const k = `${route}|${status}`; this.requests.set(k, (this.requests.get(k) ?? 0) + 1); let b = this.histCounts.get(route); if (!b) { b = new Array(BUCKETS.length + 1).fill(0) as number[]; this.histCounts.set(route, b); } for (let i = 0; i < BUCKETS.length; i++) if (seconds <= BUCKETS[i]!) b[i] = (b[i] ?? 0) + 1; b[BUCKETS.length] = (b[BUCKETS.length] ?? 0) + 1; this.histSum.set(route, (this.histSum.get(route) ?? 0) + seconds); this.histCount.set(route, (this.histCount.get(route) ?? 0) + 1); } cacheHit(layer: "memory" | "redis"): void { this.cacheHits[layer] = (this.cacheHits[layer] ?? 0) + 1; } cacheMiss(): void { this.cacheMisses++; } gauge(name: string, value: number): void { this.gauges.set(name, value); } render(extra: Record = {}): string { const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const lines: string[] = []; lines.push("# HELP dci_api_requests_total HTTP requests by route and status.", "# TYPE dci_api_requests_total counter"); for (const [k, v] of this.requests) { const [route, status] = k.split("|"); lines.push(`dci_api_requests_total{route="${esc(route ?? "")}",status="${status}"} ${v}`); } lines.push("# HELP dci_api_request_duration_seconds Request latency.", "# TYPE dci_api_request_duration_seconds histogram"); for (const [route, b] of this.histCounts) { for (let i = 0; i < BUCKETS.length; i++) lines.push(`dci_api_request_duration_seconds_bucket{route="${esc(route)}",le="${BUCKETS[i]}"} ${b[i]}`); lines.push(`dci_api_request_duration_seconds_bucket{route="${esc(route)}",le="+Inf"} ${b[BUCKETS.length]}`); lines.push(`dci_api_request_duration_seconds_sum{route="${esc(route)}"} ${(this.histSum.get(route) ?? 0).toFixed(6)}`); lines.push(`dci_api_request_duration_seconds_count{route="${esc(route)}"} ${this.histCount.get(route) ?? 0}`); } lines.push("# HELP dci_api_cache_hits_total Response cache hits by layer.", "# TYPE dci_api_cache_hits_total counter"); for (const [layer, v] of Object.entries(this.cacheHits)) lines.push(`dci_api_cache_hits_total{layer="${layer}"} ${v}`); lines.push("# HELP dci_api_cache_misses_total Response cache misses.", "# TYPE dci_api_cache_misses_total counter", `dci_api_cache_misses_total ${this.cacheMisses}`); lines.push("# HELP dci_api_uptime_seconds Process uptime.", "# TYPE dci_api_uptime_seconds gauge", `dci_api_uptime_seconds ${Math.round((Date.now() - this.startedAt) / 1000)}`); for (const [name, v] of [...this.gauges, ...Object.entries(extra)]) lines.push(`# TYPE ${name} gauge`, `${name} ${v}`); return lines.join("\n") + "\n"; } } export const metrics = new Metrics();