Compare commits

...

16 Commits
3.3.0 ... 4.2.1

Author SHA1 Message Date
Konstantin Pogorelov
0606bf5f4f remove a duplicate cluster docs piece, versio 4.2.1 2018-08-26 14:00:25 +02:00
Konstantin Pogorelov
ec9835270f version 4.2.0 2018-08-25 13:11:31 +02:00
Konstantin Pogorelov
1cf8c86acb Squashed commit of the following:
commit bc65dc45cb
Author: Konstantin Pogorelov <or@pluseq.com>
Date:   Sat Aug 25 12:57:15 2018 +0200

    #16 fix typo in docs

commit 2003e7743f
Author: Konstantin Pogorelov <or@pluseq.com>
Date:   Sat Aug 25 12:44:24 2018 +0200

    #16 fix unit test (cluster_metrics/metrics_cluster), cover the error branch

commit 568c87216a
Author: Konstantin Pogorelov <or@pluseq.com>
Date:   Sat Aug 25 12:42:53 2018 +0200

    #16 remove unnecessary check for existance after new, break on error with 500

commit 98be36244e
Author: Konstantin Pogorelov <or@pluseq.com>
Date:   Sat Aug 25 12:40:15 2018 +0200

    #16 make the cluster example runnable
2018-08-25 13:07:36 +02:00
Konstantin Pogorelov
43b51d4ab1 fix jasme path (again) 2018-08-25 12:58:44 +02:00
Konstantin Pogorelov
f6e87b7697 revert way jasme was called, add npm run coverage 2018-08-25 12:58:34 +02:00
Konstantin Pogorelov
3be8d53a27 fix newlines in index.spec.js 2018-08-25 12:58:24 +02:00
Konstantin Pogorelov
34e6a21be1 update package-lock.json 2018-08-25 12:58:20 +02:00
Adam Yost
02fcda4721 Add support for clusterMaster option re: #16 (#17)
* Add support for clusterMaster option re: #16
* Add Cluster instructions to README
* Use the approach recommended in PR
* use console.error for errors
* Update with new method signature
* add code coverage for new clusterMetrics middleware
2018-08-25 12:58:09 +02:00
Konstantin Pogorelov
5e0cd75673 add normalizePath option as tuple array, improve docs and advanced example, version to 4.1.0 2018-08-09 11:27:04 +02:00
Konstantin Pogorelov
d292dcab33 remove changelog from readme as it duplicates github releases function 2018-08-08 12:00:29 +02:00
Konstantin Pogorelov
84f99cc49c add urlValueParser option, update docs accordingly 2018-08-08 11:23:27 +02:00
Konstantin Pogorelov
fffe35ce5e update dependencies, use url-value-parser v2 2018-08-08 11:22:03 +02:00
Konstantin Pogorelov
06341c227a wip: 4.0.0, prom-client to v11, url-value-parser to v2 2018-08-07 17:55:25 +02:00
Konstantin Pogorelov
45b8f373be include collectDefaultMetrics in kraken example 2018-01-26 17:40:05 +01:00
Konstantin Pogorelov
4ee269faee make test more specific 2018-01-23 16:58:49 +01:00
Konstantin Pogorelov
e4d6113ff2 replace node 7 with node 8 in .travis.yml 2018-01-23 16:52:12 +01:00
9 changed files with 1481 additions and 1238 deletions

View File

@@ -1,4 +1,4 @@
language: node_js
node_js:
- "6"
- "7"
- "8"

146
README.md
View File

@@ -52,7 +52,13 @@ Which labels to include in `http_request_duration_seconds` metric:
Extra transformation callbacks:
* **normalizePath**: `function(req)` generates path values from express `req` (see details below)
* **normalizePath**: `function(req)` or `Array`
* if function is provided, then it should generate path value from express `req`
* if array is provided, then it should be an array of tuples `[regex, replacement]`. The `regex` can be a string and is automatically converted into JS regex.
* ... see more details in the section below
* **urlValueParser**: options passed when instantiating [url-value-parser](https://github.com/disjunction/url-value-parser).
This is the easiest way to customize which parts of the URL should be replaced with "#val".
See the [docs](https://github.com/disjunction/url-value-parser) of url-value-parser module for details.
* **formatStatusCode**: `function(res)` producing final status code from express `res` object, e.g. you can combine `200`, `201` and `204` to just `2xx`.
* **transformLabels**: `function(labels, req, res)` transforms the **labels** object, e.g. setting dynamic values to **customLabels**
@@ -64,12 +70,6 @@ Other options:
to keep `express-prom-bundle` runnable using confit (e.g. with kraken.js) without writing any JS code,
see [advanced example](https://github.com/jochen-schweizer/express-prom-bundle/blob/master/advanced-example.js)
Deprecated:
* **whitelist**, **blacklist**: array of strings or regexp specifying which metrics to include/exclude (there are only 2 metrics)
* **excludeRoutes**: array of strings or regexp specifying which routes should be skipped for `http_request_duration_seconds` metric. It uses `req.originalUrl` as subject when checking. You want to use express or meddleware features instead of this option.
* **httpDurationMetricName**: name of the request duration histogram metric. (Default: `http_request_duration_seconds`)
### More details on includePath option
Let's say you want to have latency statistics by URL path,
@@ -80,10 +80,33 @@ like `/user/12352/profile`. So what we actually need is a path template.
The module tries to figure out what parts of the path are values or IDs,
and what is an actual path. The example mentioned before would be
normalized to `/user/#val/profile` and that will become the value for the label.
These conversions are handled by `normalizePath` function.
You can override this magical behavior and define your own function by
providing an optional callback using **normalizePath** option.
You can also replace the default **normalizePath** function globally.
You can extend this magical behavior by providing
additional RegExp rules to be performed,
or override `normalizePath` with your own function.
#### Example 1 (add custom RegExp):
```javascript
app.use(promBundle({
normalizePath: [
// collect paths like "/customer/johnbobson" as just one "/custom/#name"
['^/customer/.*', '/customer/#name'],
// collect paths like "/bobjohnson/order-list" as just one "/#name/order-list"
['^.*/order-list', '/#name/order-list']
],
urlValueParser: {
minHexLength: 5,
extraMasks: [
'ORD[0-9]{5,}' // replace strings like ORD1243423, ORD673562 as #val
]
}
}));
```
#### Example 2 (override normalizePath function):
```javascript
app.use(promBundle(/* options? */));
@@ -93,8 +116,8 @@ app.use(promBundle(/* options? */));
const originalNormalize = promBundle.normalizePath;
promBundle.normalizePath = (req, opts) => {
const path = originalNormalize(req, opts);
// count all docs (no matter which file) as a single path
return path.match(/^\/docs/) ? '/docs/*' : path;
// count all docs as one path, but /docs/login as a separate one
return (path.match(/^\/docs/) && !path.match(/^\/login/)) ? '/docs/*' : path;
};
```
@@ -103,7 +126,6 @@ For more details:
* [normalizePath.js](https://github.com/jochen-schweizer/express-prom-bundle/blob/master/src/normalizePath.js) - source code for path processing
## express example
setup std. metrics but exclude `up`-metric:
@@ -148,9 +170,47 @@ app.use(/* your middleware */);
app.listen(3000);
```
## using with cluster
You'll need to use an additional **clusterMetrics()** middleware.
In the example below the master process will expose an API with a single endpoint `/metrics`
which returns an aggregate of all metrics from all the workers.
``` javascript
const cluster = require('cluster');
const promBundle = require('./src/index');
const numCPUs = Math.max(2, require('os').cpus().length);
const express = require('express');
if (cluster.isMaster) {
for (let i = 1; i < numCPUs; i++) {
cluster.fork();
}
const metricsApp = express();
metricsApp.use('/metrics', promBundle.clusterMetrics());
metricsApp.listen(9999);
console.log('cluster metrics listening on 9999');
console.log('call localhost:9999/metrics for aggregated metrics');
} else {
const app = express();
app.use(promBundle({
autoregister: false, // disable /metrics for single workers
includeMethod: true
}));
app.use((req, res) => res.send(`hello from pid ${process.pid}\n`));
app.listen(3000);
console.log(`worker ${process.pid} listening on 3000`);
}
```
## using with kraken.js
Here is meddleware config sample, which can be used in a standard **kraken.js** application:
Here is meddleware config sample, which can be used in a standard **kraken.js** application.
In this case the stats for URI paths and HTTP methods are collected separately,
while replacing all HEX values starting from 5 characters and all emails in the path as #val.
```json
{
@@ -163,7 +223,19 @@ Here is meddleware config sample, which can be used in a standard **kraken.js**
"arguments": [
{
"includeMethod": true,
"buckets": [0.1, 1, 5]
"includePath": true,
"buckets": [0.1, 1, 5],
"promClient": {
"collectDefaultMetrics": {
"timeout": 2000
}
},
"urlValueParser": {
"minHexLength": 5,
"extraMasks": [
"^[0-9]+\\.[0-9]+\\.[0-9]+$"
]
}
}
]
}
@@ -172,50 +244,6 @@ Here is meddleware config sample, which can be used in a standard **kraken.js**
}
```
## Changelog
* **3.3.0**
* added option **promClient** to be able to call collectDefaultMetrics
* upgrade **prom-client** to ~10.2.2 (switch to semver "approximately")
* **3.2.0**
* added options **customLabels**, **transformLabels**
* upgrade **prom-client** to 10.1.0
* **3.1.0**
* upgrade **prom-client** to 10.0.0
* **3.0.0**
* upgrade dependencies, most notably **prom-client** to 9.0.0
* switch to koa v2 in koa unittest
* only node v6 or higher is supported (stop supporting node v4 and v5)
* switch to npm5 and use package-lock.json
* options added: includeStatusCode, formatStatusCode
* **2.1.0**
* deprecate **excludeRoutes**, use **req.originalUrl** instead of **req.path**
* **2.0.0**
* the reason for the version lift were:
* compliance to official naming recommendation: https://prometheus.io/docs/practices/naming/
* stopping promotion of an anti-pattern - see https://groups.google.com/d/msg/prometheus-developers/XjlOnDCK9qc/ovKzV3AIBwAJ
* dealing with **prom-client** being a singleton with a built-in registry
* main histogram metric renamed from `http_request_seconds` to `http_request_duration_seconds`
* options removed: **prefix**, **keepDefaultMetrics**
* factory removed (as the only reason of it was adding the prefix)
* upgrade prom-client to 6.3.0
* code style changed to the one closer to express
* **1.2.1**
* upgrade prom-client to 6.1.2
* add options: includeMethod, includePath, keepDefaultMetrics
## License
MIT

View File

@@ -5,7 +5,6 @@ const app = express();
const promBundle = require('express-prom-bundle');
const bundle = promBundle({
blacklist: [/up/],
buckets: [0.1, 0.4, 0.7],
includeMethod: true,
includePath: true,
@@ -15,7 +14,16 @@ const bundle = promBundle({
collectDefaultMetrics: {
timeout: 1000
}
}
},
urlValueParser: {
minHexLength: 5,
extraMasks: [
"^[0-9]+\\.[0-9]+\\.[0-9]+$" // replace dot-separated dates with #val
]
},
normalizePath: [
['^/foo', '/example'] // replace /foo with /example
]
});
app.use(bundle);
@@ -40,6 +48,7 @@ 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'

2327
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "express-prom-bundle",
"version": "3.3.0",
"version": "4.2.1",
"description": "express middleware with popular prometheus metrics in one bundle",
"main": "src/index.js",
"keywords": [
@@ -11,22 +11,23 @@
"method"
],
"scripts": {
"test": "node_modules/jasme/run.js"
"test": "node_modules/jasme/run.js",
"coverage": "make coverage"
},
"author": "Konstantin Pogorelov <or@pluseq.com>",
"license": "MIT",
"dependencies": {
"on-finished": "^2.3.0",
"prom-client": "~10.2.2",
"url-value-parser": "^1.0.0"
"prom-client": "~11.1.1",
"url-value-parser": "^2.0.0"
},
"devDependencies": {
"coveralls": "^2.13.1",
"eslint": "^3.19.0",
"express": "^4.15.3",
"coveralls": "^3.0.2",
"eslint": "^5.3.0",
"express": "^4.16.3",
"istanbul": "^0.4.5",
"jasme": "^5.2.0",
"koa": "^2.2.0",
"koa": "^2.5.2",
"koa-connect": "^2.0.0",
"supertest": "^3.0.0",
"supertest-koa-agent": "^0.3.0"

View File

@@ -8,6 +8,7 @@ const Koa = require('koa');
const c2k = require('koa-connect');
const supertestKoa = require('supertest-koa-agent');
const promClient = require('prom-client');
const cluster = require('cluster');
describe('index', () => {
beforeEach(() => {
@@ -185,50 +186,78 @@ describe('index', () => {
});
});
it('normalizePath can be replaced gloablly', done => {
const app = express();
const original = bundle.normalizePath;
bundle.normalizePath = () => 'dummy';
const instance = bundle({
includePath: true,
});
app.use(instance);
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(/"dummy"/m);
bundle.normalizePath = original;
done();
});
});
});
describe('usage of normalizePath()', () => {
it('normalizePath can be overridden', done => {
const app = express();
const instance = bundle({
includePath: true,
normalizePath: req => req.originalUrl + '-suffixed'
});
app.use(instance);
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(/"\/test-suffixed"/m);
done();
});
it('normalizePath can be replaced gloablly', done => {
const app = express();
const original = bundle.normalizePath;
bundle.normalizePath = () => 'dummy';
const instance = bundle({
includePath: true,
});
app.use(instance);
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(/"dummy"/m);
bundle.normalizePath = original;
done();
});
});
});
it('normalizePath function can be overridden', done => {
const app = express();
const instance = bundle({
includePath: true,
normalizePath: req => req.originalUrl + '-suffixed'
});
app.use(instance);
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(/"\/test-suffixed"/m);
done();
});
});
});
it('normalizePath can be passed as an array of [regex, replacement]', done => {
const app = express();
const instance = bundle({
includePath: true,
normalizePath: [
['^/docs/whatever/.*$', '/docs'],
[/^\/docs$/, '/mocks']
]
});
app.use(instance);
app.use('/docs/whatever/:andmore', (req, res) => res.send('it worked'));
const agent = supertest(app);
agent
.get('/docs/whatever/unimportant')
.end(() => {
agent
.get('/metrics')
.end((err, res) => {
expect(res.status).toBe(200);
expect(res.text).toMatch(/"\/mocks"/m);
done();
});
});
});
});
it('formatStatusCode can be overridden', done => {
@@ -267,7 +296,7 @@ describe('index', () => {
.get('/metrics')
.end((err, res) => {
expect(res.status).toBe(200);
expect(res.text).not.toMatch(/200/);
expect(res.text).not.toMatch(/="200"/);
done();
});
});
@@ -355,4 +384,40 @@ describe('index', () => {
});
expect(spy).toHaveBeenCalledWith({timeout: 3000});
});
describe('usage of clusterMetrics()', () => {
it('clusterMetrics returns 200 even without a cluster', (done) => {
const app = express();
cluster.workers = [];
app.use('/cluster_metrics', bundle.clusterMetrics());
const agent = supertest(app);
agent
.get('/cluster_metrics')
.end((err, res) => {
expect(res.status).toBe(200);
done();
});
});
it('clusterMetrics returns 500 in case of an error', (done) => {
const app = express();
app.use('/cluster_metrics', bundle.clusterMetrics());
const agent = supertest(app);
// create a fake worker, which would not respond in time
cluster.workers = [{send: () => {}}];
const errorSpy = spyOn(console, 'error'); // mute console.error
agent
.get('/cluster_metrics')
.end((err, res) => {
expect(res.status).toBe(500);
expect(errorSpy).toHaveBeenCalled();
done();
});
}, 6000);
});
});

View File

@@ -8,4 +8,24 @@ describe('normalizePath', () => {
expect(normalizePath({url: '/a/12345'}))
.toBe('/a/#val');
});
it('uses normalizePath option', () => {
const url = '/hello/world/i/am/finally/free!!!';
const result = normalizePath({url}, {
normalizePath: [
['/hello','/goodbye'],
['[^/]+$','happy'],
]
});
expect(result).toBe('/goodbye/world/i/am/finally/happy');
});
it('throws error is bad tuples provided as normalizePath', () => {
const subject = () => normalizePath({url: '/test'}, {
normalizePath: [
['/hello','/goodbye', 'test']
]
});
expect(subject).toThrow();
});
});

View File

@@ -38,6 +38,23 @@ function prepareMetricNames(opts, metricTemplates) {
return names;
}
function clusterMetrics() {
const aggregatorRegistry = new promClient.AggregatorRegistry();
const metricsMiddleware = function(req, res, next) {
aggregatorRegistry.clusterMetrics((err, clusterMetrics) => {
if (err) {
console.error(err);
return res.sendStatus(500);
}
res.set('Content-Type', aggregatorRegistry.contentType);
res.send(clusterMetrics);
});
};
return metricsMiddleware;
}
function main(opts) {
opts = Object.assign(
{
@@ -120,7 +137,7 @@ function main(opts) {
let labels;
if (opts.autoregister && path.match(/^\/metrics\/?$/)) {
return metricsMiddleware(req, res);
return metricsMiddleware(req, res);
}
if (opts.excludeRoutes && matchVsRegExps(path, opts.excludeRoutes)) {
@@ -138,7 +155,9 @@ function main(opts) {
labels.method = req.method;
}
if (opts.includePath) {
labels.path = opts.normalizePath(req, opts);
labels.path = typeof opts.normalizePath == 'function'
? opts.normalizePath(req, opts)
: main.normalizePath(req, opts);
}
if (opts.customLabels) {
Object.assign(labels, opts.customLabels);
@@ -163,4 +182,5 @@ function main(opts) {
main.promClient = promClient;
main.normalizePath = normalizePath;
main.normalizeStatusCode = normalizeStatusCode;
main.clusterMetrics = clusterMetrics;
module.exports = main;

View File

@@ -2,16 +2,29 @@
const UrlValueParser = require('url-value-parser');
const url = require('url');
// ATTENTION! urlValueParser is a singleton!
let urlValueParser;
module.exports = function(req) {
module.exports = function(req, opts) {
// originalUrl is taken, because url and path can be changed
// by middlewares such as 'router'. Note: this function is called onFinish
/// i.e. always in the tail of the middleware chain
const path = url.parse(req.originalUrl || req.url).pathname;
let path = url.parse(req.originalUrl || req.url).pathname;
const normalizePath = opts && opts.normalizePath;
if (Array.isArray(normalizePath)) {
for (const tuple of normalizePath) {
if (!Array.isArray(tuple) || tuple.length !== 2) {
throw new Error('Bad tuple provided in normalizePath option, expected: [regex, replacement]');
}
const regex = typeof tuple[0] === 'string' ? RegExp(tuple[0]) : tuple[0];
path = path.replace(regex, tuple[1]);
}
}
if (!urlValueParser) {
urlValueParser = new UrlValueParser();
urlValueParser = new UrlValueParser(opts && opts.urlValueParser);
}
return urlValueParser.replacePathValues(path);
};