diff --git a/.gitignore b/.gitignore index 2b46987..5c7c5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .npmrc node_modules coverage +/.vscode + diff --git a/.travis.yml b/.travis.yml index 276425a..045b41b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,3 +2,4 @@ language: node_js node_js: - "6" - "8" + - "10" diff --git a/README.md b/README.md index b638ff4..29edd83 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Express middleware with popular prometheus metrics in one bundle. It's also compatible with koa v1 and v2 (see below). -Internally it uses **prom-client**. See: https://github.com/siimon/prom-client +Since version 5 it uses **prom-client** as a peer dependency. See: https://github.com/siimon/prom-client Included metrics: @@ -14,7 +14,7 @@ Included metrics: ## Install ``` -npm install express-prom-bundle +npm install prom-client express-prom-bundle ``` ## Sample Usage @@ -49,6 +49,8 @@ Which labels to include in `http_request_duration_seconds` metric: * **includePath**: URL path (see importent details below), default: **false** * **customLabels**: an object containing extra labels, e.g. ```{project_name: 'hello_world'}```. Most useful together with **transformLabels** callback, otherwise it's better to use native Prometheus relabeling. +* **includeUp**: include an auxiliary "up"-metric which always returns 1, default: **true** +* **metricsPath**: replace the `/metrics` route with a **regex** or exact **string**. Note: it is highly recommended to just stick to the default Extra transformation callbacks: diff --git a/advanced-example.js b/advanced-example.js index ce49f40..53a53b8 100644 --- a/advanced-example.js +++ b/advanced-example.js @@ -2,6 +2,7 @@ const express = require('express'); const app = express(); +const promClient = require('prom-client'); const promBundle = require('express-prom-bundle'); const bundle = promBundle({ @@ -10,6 +11,7 @@ const bundle = promBundle({ includePath: true, customLabels: {year: null}, transformLabels: labels => Object.assign(labels, {year: new Date().getFullYear()}), + metricsPath: '/prometheus', promClient: { collectDefaultMetrics: { timeout: 1000 @@ -29,7 +31,7 @@ const bundle = promBundle({ app.use(bundle); // native prom-client metric (no prefix) -const c1 = new bundle.promClient.Counter({name: 'c1', help: 'c1 help'}); +const c1 = new promClient.Counter({name: 'c1', help: 'c1 help'}); c1.inc(10); app.get('/foo/:id', (req, res) => { @@ -45,11 +47,13 @@ app.delete('/foo/:id', (req, res) => { app.get('/bar', (req, res) => res.send('bar response\n')); app.listen(3000, () => console.info( // eslint-disable-line - 'listening on 3000\n' - + 'test in shell console\n\n' - + 'curl localhost:3000/foo/1234\n' - + 'curl localhost:3000/foo/09.08.2018\n' - + 'curl -X DELETE localhost:3000/foo/5432\n' - + 'curl localhost:3000/bar\n' - + 'curl localhost:3000/metrics\n' + `listening on 3000 +test in shell console: + +curl localhost:3000/foo/1234 +curl localhost:3000/foo/09.08.2018 +curl -X DELETE localhost:3000/foo/5432 +curl localhost:3000/bar +curl localhost:3000/prometheus +` )); diff --git a/spec/index.spec.js b/spec/index.spec.js index 7feda7b..d27ea6e 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -18,7 +18,7 @@ describe('index', () => { it('metrics returns up=1', done => { const app = express(); const bundled = bundle({ - whitelist: ['up'] + excludeRoutes: ['/irrelevant', /at.all/] }); app.use(bundled); app.use('/test', (req, res) => res.send('it worked')); @@ -35,6 +35,64 @@ describe('index', () => { }); }); + it('"up"-metric can be excluded', done => { + const app = express(); + const bundled = bundle({ + includeUp: false + }); + app.use(bundled); + app.use('/test', (req, res) => res.send('it worked')); + + const agent = supertest(app); + agent.get('/test').end(() => { + agent + .get('/metrics') + .end((err, res) => { + expect(res.status).toBe(200); + expect(res.text).not.toMatch(/up\s1/); + done(); + }); + }); + }); + + it('metrics path can be defined with a regex', done => { + const app = express(); + const bundled = bundle({ + metricsPath: /^\/prometheus$/ + }); + app.use(bundled); + app.use('/test', (req, res) => res.send('it worked')); + + const agent = supertest(app); + agent.get('/test').end(() => { + agent + .get('/prometheus') + .end((err, res) => { + expect(res.status).toBe(200); + expect(res.text).toMatch(/up\s1/); + done(); + }); + }); + }); + + it('metrics path can be defined as regexp', done => { + const app = express(); + const bundled = bundle(); + app.use(bundled); + app.use('/test', (req, res) => res.send('it worked')); + + const agent = supertest(app); + agent.get('/test').end(() => { + agent + .get('/metrics') + .end((err, res) => { + expect(res.status).toBe(200); + expect(res.text).toMatch(/up\s1/); + done(); + }); + }); + }); + it('httpDurationMetricName overrides histogram metric name', done => { const app = express(); const bundled = bundle({ @@ -52,9 +110,7 @@ describe('index', () => { it('metrics should be attached to /metrics by default', done => { const app = express(); - const bundled = bundle({ - whitelist: ['up'] - }); + const bundled = bundle(); app.use(bundled); const agent = supertest(app); @@ -83,27 +139,6 @@ describe('index', () => { }); }); - it('metrics can be filtered using exect match', () => { - const instance = bundle({blacklist: ['up']}); - expect(instance.metrics.up).not.toBeDefined(); - expect(instance.metrics.http_request_duration_seconds).toBeDefined(); - }); - it('metrics can be filtered using regex', () => { - const instance = bundle({blacklist: [/http/]}); - expect(instance.metrics.up).toBeDefined(); - expect(instance.metrics.http_request_duration_seconds).not.toBeDefined(); - }); - it('metrics can be whitelisted', () => { - const instance = bundle({whitelist: [/^up$/]}); - expect(instance.metrics.up).toBeDefined(); - expect(instance.metrics.nodejs_memory_heap_total_bytes).not.toBeDefined(); - expect(instance.metrics.http_request_duration_seconds).not.toBeDefined(); - }); - it('throws on both white and blacklist', () => { - expect(() => { - bundle({whitelist: [/up/], blacklist: [/up/]}); - }).toThrow(); - }); it('returns error 500 on incorrect middleware usage', done => { const app = express(); app.use(bundle); @@ -140,22 +175,27 @@ describe('index', () => { it('filters out the excludeRoutes', done => { const app = express(); const instance = bundle({ - excludeRoutes: ['/test'] + excludeRoutes: ['/test', /bad.word/] }); app.use(instance); app.use('/test', (req, res) => res.send('it worked')); + app.use('/some/bad-word', (req, res) => res.send('it worked too')); const agent = supertest(app); agent .get('/test') .end(() => { - const metricHashMap = instance.metrics.http_request_duration_seconds.hashMap; - expect(metricHashMap['status_code:200']).not.toBeDefined(); - agent - .get('/metrics') - .end((err, res) => { - expect(res.status).toBe(200); - done(); + .get('/some/bad-word') + .end(() => { + const metricHashMap = instance.metrics.http_request_duration_seconds.hashMap; + expect(metricHashMap['status_code:200']).not.toBeDefined(); + + agent + .get('/metrics') + .end((err, res) => { + expect(res.status).toBe(200); + done(); + }); }); }); }); @@ -186,27 +226,6 @@ describe('index', () => { }); }); - it('metric type summary works', done => { - const app = express(); - const bundled = bundle({ - metricType: 'summary', - percentiles: [0.5, 0.85, 0.99], - }); - app.use(bundled); - app.use('/test', (req, res) => res.send('it worked')); - - const agent = supertest(app); - agent.get('/test').end(() => { - agent - .get('/metrics') - .end((err, res) => { - expect(res.status).toBe(200); - expect(res.text).toMatch(/quantile="0.85"/); - done(); - }); - }); - }); - it('metric type histogram works', done => { const app = express(); const bundled = bundle({ @@ -400,9 +419,7 @@ describe('index', () => { it('Koa: metrics returns up=1', done => { const app = new Koa(); - const bundled = bundle({ - whitelist: ['up'] - }); + const bundled = bundle(); app.use(c2k(bundled)); app.use(function(ctx, next) { @@ -468,4 +485,47 @@ describe('index', () => { }); }, 6000); }); + + describe('metricType: summary', () => { + it('metric type summary works', done => { + const app = express(); + const bundled = bundle({ + metricType: 'summary' + }); + app.use(bundled); + app.use('/test', (req, res) => res.send('it worked')); + + const agent = supertest(app); + agent.get('/test').end(() => { + agent + .get('/metrics') + .end((err, res) => { + expect(res.status).toBe(200); + expect(res.text).toMatch(/quantile="0.98"/); + done(); + }); + }); + }); + + it('custom pecentiles work', done => { + const app = express(); + const bundled = bundle({ + metricType: 'summary', + percentiles: [0.5, 0.85, 0.99], + }); + app.use(bundled); + app.use('/test', (req, res) => res.send('it worked')); + + const agent = supertest(app); + agent.get('/test').end(() => { + agent + .get('/metrics') + .end((err, res) => { + expect(res.status).toBe(200); + expect(res.text).toMatch(/quantile="0.85"/); + done(); + }); + }); + }); + }); }); diff --git a/src/index.js b/src/index.js index eac05cf..a5d1227 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,10 @@ -'use strict'; const onFinished = require('on-finished'); const promClient = require('prom-client'); const normalizePath = require('./normalizePath'); const normalizeStatusCode = require('./normalizeStatusCode'); function matchVsRegExps(element, regexps) { - for (let regexp of regexps) { + for (const regexp of regexps) { if (regexp instanceof RegExp) { if (element.match(regexp)) { return true; @@ -17,31 +16,10 @@ function matchVsRegExps(element, regexps) { return false; } -function filterArrayByRegExps(array, regexps) { - return array.filter(element => { - return matchVsRegExps(element, regexps); - }); -} - -function prepareMetricNames(opts, metricTemplates) { - const names = Object.keys(metricTemplates); - if (opts.whitelist) { - if (opts.blacklist) { - throw new Error('you cannot have whitelist and blacklist at the same time'); - } - return filterArrayByRegExps(names, opts.whitelist); - } - if (opts.blacklist) { - const blacklisted = filterArrayByRegExps(names, opts.blacklist); - return names.filter(name => blacklisted.indexOf(name) === -1); - } - return names; -} - function clusterMetrics() { const aggregatorRegistry = new promClient.AggregatorRegistry(); - const metricsMiddleware = function(req, res, next) { + const metricsMiddleware = function(req, res) { aggregatorRegistry.clusterMetrics((err, clusterMetrics) => { if (err) { console.error(err); @@ -56,17 +34,6 @@ function clusterMetrics() { } function main(opts) { - opts = Object.assign( - { - autoregister: true, - includeStatusCode: true, - normalizePath: main.normalizePath, - formatStatusCode: main.normalizeStatusCode, - metricType: 'histogram', - promClient: {} - }, - opts - ); if (arguments[2] && arguments[1] && arguments[1].send) { arguments[1].status(500) .send('

500 Error

\n' @@ -76,12 +43,27 @@ function main(opts) { return; } - if (opts.prefix || opts.keepDefaultMetrics !== undefined) { + opts = Object.assign( + { + autoregister: true, + includeStatusCode: true, + normalizePath: main.normalizePath, + formatStatusCode: main.normalizeStatusCode, + metricType: 'histogram', + promClient: {} + }, opts + ); + + if (opts.prefix + || opts.keepDefaultMetrics !== undefined + || opts.whitelist !== undefined + || opts.blacklist !== undefined + ) { throw new Error( - 'express-prom-bundle detected obsolete options:' - + 'prefix and/or keepDefaultMetrics. ' + 'express-prom-bundle detected one of the obsolete options: ' + + 'prefix, keepDefaultMetrics, whitelist, blacklist. ' + 'Please refer to oficial docs. ' - + 'Most likely you upgraded the module without necessary code changes' + + 'Most likely you upgraded the module without the necessary code changes' ); } @@ -91,51 +73,46 @@ function main(opts) { const httpMetricName = opts.httpDurationMetricName || 'http_request_duration_seconds'; - const metricTemplates = { - 'up': () => new promClient.Gauge({ - name: 'up', - help: '1 = up, 0 = not up' - }), - [httpMetricName]: () => { - const labels = ['status_code']; - if (opts.includeMethod) { - labels.push('method'); - } - if (opts.includePath) { - labels.push('path'); - } - if (opts.customLabels){ - labels.push.apply(labels, Object.keys(opts.customLabels)); - } - - if (opts.metricType === 'summary') { - return new promClient.Summary({ - name: httpMetricName, - help: 'duration summary of http responses labeled with: ' + labels.join(', '), - labelNames: labels, - percentiles: opts.percentiles || [0.5, 0.75, 0.95, 0.98, 0.99, 0.999] - }); - } else if (opts.metricType === 'histogram' || !opts.metricType) { - return new promClient.Histogram({ - name: httpMetricName, - help: 'duration histogram of http responses labeled with: ' + labels.join(', '), - labelNames: labels, - buckets: opts.buckets || [0.003, 0.03, 0.1, 0.3, 1.5, 10] - }); - } else { - throw new Error('metricType option must be histogram or summary'); - } + function makeHttpMetric() { + const labels = ['status_code']; + if (opts.includeMethod) { + labels.push('method'); + } + if (opts.includePath) { + labels.push('path'); + } + if (opts.customLabels) { + labels.push.apply(labels, Object.keys(opts.customLabels)); } - }; - const metrics = {}; - const names = prepareMetricNames(opts, metricTemplates); - - for (let name of names) { - metrics[name] = metricTemplates[name](); + if (opts.metricType === 'summary') { + return new promClient.Summary({ + name: httpMetricName, + help: 'duration summary of http responses labeled with: ' + labels.join(', '), + labelNames: labels, + percentiles: opts.percentiles || [0.5, 0.75, 0.95, 0.98, 0.99, 0.999] + }); + } else if (opts.metricType === 'histogram' || !opts.metricType) { + return new promClient.Histogram({ + name: httpMetricName, + help: 'duration histogram of http responses labeled with: ' + labels.join(', '), + labelNames: labels, + buckets: opts.buckets || [0.003, 0.03, 0.1, 0.3, 1.5, 10] + }); + } else { + throw new Error('metricType option must be histogram or summary'); + } } - if (metrics.up) { + const metrics = { + [httpMetricName]: makeHttpMetric() + }; + + if (opts.includeUp !== false) { + metrics.up = new promClient.Gauge({ + name: 'up', + help: '1 = up, 0 = not up' + }); metrics.up.set(1); } @@ -144,11 +121,13 @@ function main(opts) { res.end(promClient.register.metrics()); }; + const metricsMatch = opts.metricsPath instanceof RegExp ? opts.metricsPath + : new RegExp('^' + (opts.metricsPath || '/metrics') + '/?$'); + const middleware = function (req, res, next) { const path = req.originalUrl || req.url; // originalUrl gets lost in koa-connect? - let labels; - if (opts.autoregister && path.match(/^\/metrics\/?$/)) { + if (opts.autoregister && path.match(metricsMatch)) { return metricsMiddleware(req, res); } @@ -156,42 +135,42 @@ function main(opts) { return next(); } - if (metrics[httpMetricName]) { - labels = {}; - let timer = metrics[httpMetricName].startTimer(labels); - onFinished(res, () => { - if (opts.includeStatusCode) { - labels.status_code = opts.formatStatusCode(res, opts); - } - if (opts.includeMethod) { - labels.method = req.method; - } - if (opts.includePath) { - labels.path = typeof opts.normalizePath == 'function' - ? opts.normalizePath(req, opts) - : main.normalizePath(req, opts); - } - if (opts.customLabels) { - Object.assign(labels, opts.customLabels); - } - if (opts.transformLabels) { - opts.transformLabels(labels, req, res); - } - timer(); - }); - } + const labels = {}; + const timer = metrics[httpMetricName].startTimer(labels); + + onFinished(res, () => { + if (opts.includeStatusCode) { + labels.status_code = opts.formatStatusCode(res, opts); + } + if (opts.includeMethod) { + labels.method = req.method; + } + if (opts.includePath) { + labels.path = opts.normalizePath instanceof Function + ? opts.normalizePath(req, opts) + : main.normalizePath(req, opts); + } + if (opts.customLabels) { + Object.assign(labels, opts.customLabels); + } + if (opts.transformLabels) { + opts.transformLabels(labels, req, res); + } + timer(); + }); next(); }; - middleware.metricTemplates = metricTemplates; middleware.metrics = metrics; middleware.promClient = promClient; middleware.metricsMiddleware = metricsMiddleware; return middleware; } +// this is kept only for compatibility with the code relying on older version main.promClient = promClient; + main.normalizePath = normalizePath; main.normalizeStatusCode = normalizeStatusCode; main.clusterMetrics = clusterMetrics;