UNPKG

8.19 kBPlain TextView Raw
1import * as express from 'express';
2import * as prometheus from 'prom-client';
3import { TypedError } from 'typed-error';
4
5import * as Debug from 'debug';
6const debug = Debug('node-metrics-gatherer');
7
8import { collectAPIMetrics } from './collectors/api/collect';
9
10import {
11 AuthTestFunc,
12 ConstructorMap,
13 CustomParams,
14 Kind,
15 LabelSet,
16 MetricConstructor,
17 MetricsMap,
18 MetricsMetaMap,
19} from './types';
20
21export class MetricsGathererError extends TypedError {}
22
23const constructors: ConstructorMap = {
24 gauge: new MetricConstructor(prometheus.Gauge),
25 counter: new MetricConstructor(prometheus.Counter),
26 summary: new MetricConstructor(prometheus.Summary),
27 histogram: new MetricConstructor(prometheus.Histogram),
28};
29
30interface Describer {
31 [kind: string]: (
32 name: string,
33 help: string,
34 customParams?: CustomParams,
35 ) => void;
36}
37
38export class MetricsGatherer {
39 public internalErrorCount: number;
40 public meta: MetricsMetaMap;
41 private metrics: MetricsMap;
42 public describe: Describer;
43 public client: any;
44
45 constructor() {
46 this.initState();
47 this.setupDescribe();
48 this.client = prometheus;
49 }
50
51 private initState() {
52 try {
53 this.metrics = {
54 gauge: {},
55 counter: {},
56 histogram: {},
57 summary: {},
58 };
59 this.meta = {};
60 this.internalErrorCount = 0;
61 } catch (e) {
62 this.err(e);
63 }
64 }
65
66 private setupDescribe() {
67 this.describe = {};
68 for (const kind of ['gauge', 'counter', 'histogram', 'summary'] as const) {
69 this.describe[kind] = (
70 name: string,
71 help: string,
72 customParams: CustomParams = {},
73 ) => {
74 if (this.meta[name]) {
75 throw new MetricsGathererError(
76 `tried to describe metric "${name}" twice`,
77 );
78 } else {
79 this.meta[name] = {
80 kind,
81 help,
82 customParams,
83 };
84 }
85 };
86 }
87 }
88
89 // observe a gauge metric
90 public gauge(name: string, val: number, labels: LabelSet = {}) {
91 try {
92 this.ensureExists('gauge', name, labels);
93 this.metrics.gauge[name].set(labels, val);
94 } catch (e) {
95 this.err(e);
96 }
97 }
98
99 // increment a counter or gauge
100 public inc(name: string, val: number = 1, labels: LabelSet = {}) {
101 try {
102 // ensure either that this metric already exists, or if not
103 // create either a counter if `_total` suffix is found, or
104 // a gauge otherwise
105 const kind = /.+_total$/.test(name) ? 'counter' : 'gauge';
106 this.ensureExists(kind, name, labels);
107 if (!this.checkMetricType(name, ['gauge', 'counter'])) {
108 throw new MetricsGathererError(
109 `Tried to increment non-gauge, non-counter metric ${name}`,
110 );
111 }
112 if (this.meta[name].kind === 'gauge') {
113 this.metrics.gauge[name].inc(labels, val);
114 } else {
115 this.metrics.counter[name].inc(labels, val);
116 }
117 } catch (e) {
118 this.err(e);
119 }
120 }
121
122 // decrement a gauge
123 public dec(name: string, val: number = 1, labels: LabelSet = {}) {
124 try {
125 // ensure either that this metric already exists, or if not, create a gauge
126 this.ensureExists('gauge', name, labels);
127 if (!this.checkMetricType(name, ['gauge'])) {
128 throw new MetricsGathererError(
129 `Tried to decrement non-gauge metric ${name}`,
130 );
131 }
132 this.metrics.gauge[name].dec(labels, val);
133 } catch (e) {
134 this.err(e);
135 }
136 }
137
138 // observe a counter metric
139 public counter(name: string, val: number = 1, labels: LabelSet = {}) {
140 try {
141 this.ensureExists('counter', name, labels);
142 this.metrics.counter[name].inc(labels, val);
143 } catch (e) {
144 this.err(e);
145 }
146 }
147
148 // observe a summary metric
149 public summary(
150 name: string,
151 val: number,
152 labels: LabelSet = {},
153 customParams: CustomParams = {},
154 ) {
155 try {
156 this.ensureExists('summary', name, labels, customParams);
157 this.metrics.summary[name].observe(labels, val);
158 } catch (e) {
159 this.err(e);
160 }
161 }
162
163 // observe a histogram metric
164 public histogram(
165 name: string,
166 val: number,
167 labels: LabelSet = {},
168 customParams: CustomParams = {},
169 ) {
170 try {
171 this.ensureExists('histogram', name, labels, customParams);
172 this.metrics.histogram[name].observe(labels, val);
173 } catch (e) {
174 this.err(e);
175 }
176 }
177
178 // observe both a histogram and a summary, adding suffixes to differentiate
179 public histogramSummary(name: string, val: number, labels: LabelSet = {}) {
180 try {
181 this.histogram(`${name}_hist`, val, labels);
182 this.summary(`${name}_summary`, val, labels);
183 } catch (e) {
184 this.err(e);
185 }
186 }
187
188 // check that a metric is of the given type(s)
189 public checkMetricType(name: string, kinds: string[]) {
190 try {
191 return kinds.includes(this.meta[name].kind);
192 } catch (e) {
193 this.err(e);
194 }
195 }
196
197 public getMetric<T extends string = string>(
198 name: string,
199 ): prometheus.Metric<T> | undefined {
200 if (this.meta[name]) {
201 return this.metrics[this.meta[name].kind][name];
202 }
203 }
204
205 public exists(name: string) {
206 return this.getMetric(name) != null;
207 }
208
209 // used declaratively to ensure a given metric of a certain kind exists
210 private ensureExists(
211 kind: Kind,
212 name: string,
213 labels: LabelSet = {},
214 customParams: CustomParams = {},
215 ) {
216 try {
217 // if exists, bail early
218 if (this.exists(name)) {
219 return;
220 }
221 // if no meta, describe by default to satisfy prometheus
222 if (!this.meta[name]) {
223 this.describe[kind](name, `undescribed ${kind} metric`, {
224 labelNames: Object.keys(labels),
225 ...customParams,
226 });
227 } else if (this.meta[name].kind !== kind) {
228 // if name already associated with another kind, throw error
229 throw new MetricsGathererError(
230 `tried to use ${name} twice - first as ` +
231 `${this.meta[name].kind}, then as ${kind}`,
232 );
233 }
234 // create prometheus.Metric instance
235 this.metrics[kind][name] = constructors[kind].create({
236 name,
237 help: this.meta[name].help,
238 labelNames: Object.keys(labels),
239 ...customParams,
240 ...this.meta[name].customParams,
241 });
242 } catch (e) {
243 this.err(e);
244 }
245 }
246
247 // reset the metrics or only a given metric if name supplied
248 public reset(name?: string) {
249 try {
250 if (!name) {
251 prometheus.register.resetMetrics();
252 } else {
253 const metric = this.getMetric(name);
254 if (metric) {
255 metric.reset();
256 }
257 }
258 } catch (e) {
259 this.err(e);
260 }
261 }
262
263 // create an express app listening on a given port, responding with the given
264 // requesthandler
265 public exportOn(
266 port: number,
267 path: string = '/metrics',
268 requestHandler?: express.Handler,
269 ) {
270 const app = express();
271 app.use(path, requestHandler ?? this.requestHandler());
272 app.listen(port);
273 }
274
275 // create an express request handler given an auth test function
276 public requestHandler(authTest?: AuthTestFunc): express.Handler {
277 return (req: express.Request, res: express.Response) => {
278 if (authTest && !authTest(req)) {
279 return res.status(403).send();
280 }
281 res.writeHead(200, { 'Content-Type': 'text/plain' });
282 res.end(prometheus.register.metrics());
283 };
284 }
285
286 // create an express request handler given an auth test function which is
287 // suitable for use in a context where we're using node's `cluster` module
288 public aggregateRequestHandler(authTest?: AuthTestFunc): express.Handler {
289 const aggregatorRegistry = new prometheus.AggregatorRegistry();
290 return (req, res) => {
291 if (authTest && !authTest(req)) {
292 return res.status(403).send();
293 }
294 aggregatorRegistry
295 .clusterMetrics()
296 .then((metrics: string) => {
297 res.set('Content-Type', aggregatorRegistry.contentType);
298 res.send(metrics);
299 })
300 .catch((err: Error) => {
301 this.err(err);
302 res.status(500).send();
303 });
304 };
305 }
306
307 // collect default metrics (underlying prom-client)
308 public collectDefaultMetrics() {
309 prometheus.collectDefaultMetrics();
310 }
311
312 // collect generic API metrics given an express app
313 public collectAPIMetrics(app: express.Application): express.Application {
314 app.use(collectAPIMetrics(this));
315 return app;
316 }
317
318 // get the prometheus output
319 public output(): string {
320 try {
321 return prometheus.register.metrics();
322 } catch (e) {
323 this.err(e);
324 return '';
325 }
326 }
327
328 // clear all metrics
329 public clear() {
330 try {
331 prometheus.register.clear();
332 this.initState();
333 } catch (e) {
334 this.err(e);
335 }
336 }
337
338 private err(e: Error) {
339 debug(e);
340 this.internalErrorCount++;
341 }
342}