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
14export interface CounterValue {
15 labels: Record<string, string>;
16 value: number;
17}
18
19export interface GaugeProvider {
20 (): Array<{ labels: Record<string, string>; value: number }>;
21}
22
23interface InternalCounter {
24 name: string;
25 help: string;
26 values: Map<string, CounterValue>;
27}
28
29interface 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
44interface InternalGauge {
45 name: string;
46 help: string;
47 provider: GaugeProvider;
48}
49
50export 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
65export 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
74const DEFAULT_BUCKETS = [10, 50, 100, 200, 500, 1000, 5000] as const;
75
76function 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
82function escapeLabelValue(value: string): string {
83 return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
84}
85
86function 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
93function formatLabelsWith(
94 labels: Record<string, string>,
95 extra: Record<string, string>,
96): string {
97 return formatLabels({ ...labels, ...extra });
98}
99
100export 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}