metrics.ts237 lines · main
| 1 | /** |
| 2 | * In-process Prometheus exposition — Counter + Histogram + Gauge. |
| 3 | * |
| 4 | * Each app calls `createMetricsRegistry({ help })` once at boot to get |
| 5 | * its own scoped registry; counters/histograms/gauges live inside the |
| 6 | * registry instance, NOT in module-level state, so per-app namespaces |
| 7 | * don't bleed into each other and tests can spin up fresh registries. |
| 8 | * |
| 9 | * The renderer emits text that scrapes cleanly with a stock Prometheus |
| 10 | * client. Bucket boundaries are configurable per-registry (default |
| 11 | * targets HTTP-style request latencies in milliseconds). |
| 12 | */ |
| 13 | |
| 14 | export interface CounterValue { |
| 15 | labels: Record<string, string>; |
| 16 | value: number; |
| 17 | } |
| 18 | |
| 19 | export interface GaugeProvider { |
| 20 | (): Array<{ labels: Record<string, string>; value: number }>; |
| 21 | } |
| 22 | |
| 23 | interface InternalCounter { |
| 24 | name: string; |
| 25 | help: string; |
| 26 | values: Map<string, CounterValue>; |
| 27 | } |
| 28 | |
| 29 | interface InternalHistogram { |
| 30 | name: string; |
| 31 | help: string; |
| 32 | buckets: readonly number[]; |
| 33 | values: Map< |
| 34 | string, |
| 35 | { |
| 36 | labels: Record<string, string>; |
| 37 | bucketCounts: number[]; |
| 38 | sum: number; |
| 39 | count: number; |
| 40 | } |
| 41 | >; |
| 42 | } |
| 43 | |
| 44 | interface InternalGauge { |
| 45 | name: string; |
| 46 | help: string; |
| 47 | provider: GaugeProvider; |
| 48 | } |
| 49 | |
| 50 | export interface MetricsRegistryOptions { |
| 51 | /** |
| 52 | * Map of metric name → HELP text. Names not present here render with |
| 53 | * their own name as the help string (acceptable but not ideal). Update |
| 54 | * the help map when adding a new metric so prometheus.io scrapers see |
| 55 | * meaningful documentation. |
| 56 | */ |
| 57 | help?: Record<string, string>; |
| 58 | /** |
| 59 | * Histogram bucket upper bounds. Defaults to a request-latency-friendly |
| 60 | * set (10 / 50 / 100 / 200 / 500 / 1000 / 5000 ms). +Inf is implicit. |
| 61 | */ |
| 62 | buckets?: readonly number[]; |
| 63 | } |
| 64 | |
| 65 | export interface MetricsRegistry { |
| 66 | incCounter(name: string, labels?: Record<string, string>): void; |
| 67 | observeHistogram(name: string, value: number, labels?: Record<string, string>): void; |
| 68 | registerGauge(name: string, provider: GaugeProvider): void; |
| 69 | render(): string; |
| 70 | /** Test-only — clears all state. */ |
| 71 | reset(): void; |
| 72 | } |
| 73 | |
| 74 | const DEFAULT_BUCKETS = [10, 50, 100, 200, 500, 1000, 5000] as const; |
| 75 | |
| 76 | function labelKey(labels: Record<string, string> | undefined): string { |
| 77 | if (!labels) return ''; |
| 78 | const keys = Object.keys(labels).sort(); |
| 79 | return keys.map((k) => `${k}=${labels[k]}`).join(','); |
| 80 | } |
| 81 | |
| 82 | function escapeLabelValue(value: string): string { |
| 83 | return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); |
| 84 | } |
| 85 | |
| 86 | function formatLabels(labels: Record<string, string>): string { |
| 87 | const keys = Object.keys(labels); |
| 88 | if (keys.length === 0) return ''; |
| 89 | const parts = keys.map((k) => `${k}="${escapeLabelValue(labels[k] ?? '')}"`); |
| 90 | return `{${parts.join(',')}}`; |
| 91 | } |
| 92 | |
| 93 | function formatLabelsWith( |
| 94 | labels: Record<string, string>, |
| 95 | extra: Record<string, string>, |
| 96 | ): string { |
| 97 | return formatLabels({ ...labels, ...extra }); |
| 98 | } |
| 99 | |
| 100 | export function createMetricsRegistry(options: MetricsRegistryOptions = {}): MetricsRegistry { |
| 101 | const help = options.help ?? {}; |
| 102 | const buckets = options.buckets ?? DEFAULT_BUCKETS; |
| 103 | |
| 104 | const counters = new Map<string, InternalCounter>(); |
| 105 | const histograms = new Map<string, InternalHistogram>(); |
| 106 | const gauges = new Map<string, InternalGauge>(); |
| 107 | |
| 108 | function incCounter(name: string, labels?: Record<string, string>): void { |
| 109 | let counter = counters.get(name); |
| 110 | if (!counter) { |
| 111 | counter = { name, help: help[name] ?? name, values: new Map() }; |
| 112 | counters.set(name, counter); |
| 113 | } |
| 114 | const key = labelKey(labels); |
| 115 | const existing = counter.values.get(key); |
| 116 | if (existing) { |
| 117 | existing.value += 1; |
| 118 | } else { |
| 119 | counter.values.set(key, { labels: { ...(labels ?? {}) }, value: 1 }); |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | function observeHistogram( |
| 124 | name: string, |
| 125 | value: number, |
| 126 | labels?: Record<string, string>, |
| 127 | ): void { |
| 128 | let hist = histograms.get(name); |
| 129 | if (!hist) { |
| 130 | hist = { |
| 131 | name, |
| 132 | help: help[name] ?? name, |
| 133 | buckets, |
| 134 | values: new Map(), |
| 135 | }; |
| 136 | histograms.set(name, hist); |
| 137 | } |
| 138 | const key = labelKey(labels); |
| 139 | let entry = hist.values.get(key); |
| 140 | if (!entry) { |
| 141 | entry = { |
| 142 | labels: { ...(labels ?? {}) }, |
| 143 | bucketCounts: new Array(hist.buckets.length).fill(0), |
| 144 | sum: 0, |
| 145 | count: 0, |
| 146 | }; |
| 147 | hist.values.set(key, entry); |
| 148 | } |
| 149 | entry.sum += value; |
| 150 | entry.count += 1; |
| 151 | for (let i = 0; i < hist.buckets.length; i += 1) { |
| 152 | const upper = hist.buckets[i]; |
| 153 | if (upper !== undefined && value <= upper) { |
| 154 | entry.bucketCounts[i] = (entry.bucketCounts[i] ?? 0) + 1; |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | function registerGauge(name: string, provider: GaugeProvider): void { |
| 160 | gauges.set(name, { name, help: help[name] ?? name, provider }); |
| 161 | } |
| 162 | |
| 163 | function render(): string { |
| 164 | const out: string[] = []; |
| 165 | |
| 166 | for (const counter of counters.values()) { |
| 167 | out.push(`# HELP ${counter.name} ${counter.help}`); |
| 168 | out.push(`# TYPE ${counter.name} counter`); |
| 169 | if (counter.values.size === 0) { |
| 170 | out.push(`${counter.name} 0`); |
| 171 | } else { |
| 172 | for (const v of counter.values.values()) { |
| 173 | out.push(`${counter.name}${formatLabels(v.labels)} ${v.value}`); |
| 174 | } |
| 175 | } |
| 176 | out.push(''); |
| 177 | } |
| 178 | |
| 179 | for (const hist of histograms.values()) { |
| 180 | out.push(`# HELP ${hist.name} ${hist.help}`); |
| 181 | out.push(`# TYPE ${hist.name} histogram`); |
| 182 | if (hist.values.size === 0) { |
| 183 | for (const upper of hist.buckets) { |
| 184 | out.push(`${hist.name}_bucket{le="${upper}"} 0`); |
| 185 | } |
| 186 | out.push(`${hist.name}_bucket{le="+Inf"} 0`); |
| 187 | out.push(`${hist.name}_sum 0`); |
| 188 | out.push(`${hist.name}_count 0`); |
| 189 | } else { |
| 190 | for (const v of hist.values.values()) { |
| 191 | for (let i = 0; i < hist.buckets.length; i += 1) { |
| 192 | const upper = hist.buckets[i] ?? 0; |
| 193 | const count = v.bucketCounts[i] ?? 0; |
| 194 | out.push( |
| 195 | `${hist.name}_bucket${formatLabelsWith(v.labels, { le: String(upper) })} ${count}`, |
| 196 | ); |
| 197 | } |
| 198 | out.push( |
| 199 | `${hist.name}_bucket${formatLabelsWith(v.labels, { le: '+Inf' })} ${v.count}`, |
| 200 | ); |
| 201 | out.push(`${hist.name}_sum${formatLabels(v.labels)} ${v.sum}`); |
| 202 | out.push(`${hist.name}_count${formatLabels(v.labels)} ${v.count}`); |
| 203 | } |
| 204 | } |
| 205 | out.push(''); |
| 206 | } |
| 207 | |
| 208 | for (const gauge of gauges.values()) { |
| 209 | out.push(`# HELP ${gauge.name} ${gauge.help}`); |
| 210 | out.push(`# TYPE ${gauge.name} gauge`); |
| 211 | let entries: Array<{ labels: Record<string, string>; value: number }> = []; |
| 212 | try { |
| 213 | entries = gauge.provider(); |
| 214 | } catch { |
| 215 | entries = []; |
| 216 | } |
| 217 | if (entries.length === 0) { |
| 218 | out.push(`${gauge.name} 0`); |
| 219 | } else { |
| 220 | for (const e of entries) { |
| 221 | out.push(`${gauge.name}${formatLabels(e.labels)} ${e.value}`); |
| 222 | } |
| 223 | } |
| 224 | out.push(''); |
| 225 | } |
| 226 | |
| 227 | return `${out.join('\n')}\n`; |
| 228 | } |
| 229 | |
| 230 | function reset(): void { |
| 231 | counters.clear(); |
| 232 | histograms.clear(); |
| 233 | gauges.clear(); |
| 234 | } |
| 235 | |
| 236 | return { incCounter, observeHistogram, registerGauge, render, reset }; |
| 237 | } |