mirror of
https://github.com/BreizhHardware/express-prom-bundle.git
synced 2026-01-19 00:37:36 +01:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0606bf5f4f | ||
|
|
ec9835270f | ||
|
|
1cf8c86acb | ||
|
|
43b51d4ab1 | ||
|
|
f6e87b7697 | ||
|
|
3be8d53a27 | ||
|
|
34e6a21be1 | ||
|
|
02fcda4721 | ||
|
|
5e0cd75673 | ||
|
|
d292dcab33 | ||
|
|
84f99cc49c | ||
|
|
fffe35ce5e | ||
|
|
06341c227a | ||
|
|
45b8f373be | ||
|
|
4ee269faee | ||
|
|
e4d6113ff2 | ||
|
|
06f55c9ab8 | ||
|
|
c8996a7730 | ||
|
|
aed7edc684 | ||
|
|
4a840cfce6 | ||
|
|
d12248dcaf | ||
|
|
68eb617f88 | ||
|
|
bb0e453078 | ||
|
|
b0928b1b94 | ||
|
|
26f9f5ca10 | ||
|
|
a261ab76a3 | ||
|
|
8318e0ec1d | ||
|
|
d58b434f0a | ||
|
|
47fd051aba | ||
|
|
313673f16f | ||
|
|
bbd62e34d6 | ||
|
|
b8ba87009e | ||
|
|
1cc588c2da | ||
|
|
5b1517ca91 | ||
|
|
5b1aa494cb | ||
|
|
65549a769b | ||
|
|
de83ac09a0 | ||
|
|
52865dfb02 | ||
|
|
5c6ed64a31 | ||
|
|
d8c6492163 | ||
|
|
c92b85ae96 | ||
|
|
48f8b992fd | ||
|
|
61e4343a8c | ||
|
|
7b89690d3b | ||
|
|
40db5cacbd | ||
|
|
43334b923f | ||
|
|
20eb668e36 | ||
|
|
53c4505378 |
@@ -1,5 +1,4 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- "6"
|
||||
- "5"
|
||||
- "4"
|
||||
- "8"
|
||||
|
||||
197
README.md
197
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# express prometheus bundle
|
||||
|
||||
Express middleware with popular prometheus metrics in one bundle. It's also compatible with koa v1 (see below).
|
||||
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
|
||||
|
||||
@@ -11,20 +11,18 @@ Included metrics:
|
||||
* `up`: normally is just 1
|
||||
* `http_request_duration_seconds`: http latency histogram labeled with `status_code`, `method` and `path`
|
||||
|
||||
**Please not version 2.x is NOT compatible with 1.x**
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
npm install express-prom-bundle
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Sample Usage
|
||||
|
||||
```javascript
|
||||
const promBundle = require("express-prom-bundle");
|
||||
const metricsMiddleware = promBundle({/* options */ });
|
||||
const app = require("express")();
|
||||
const metricsMiddleware = promBundle({includeMethod: true});
|
||||
|
||||
app.use(metricsMiddleware);
|
||||
app.use(/* your middleware */);
|
||||
@@ -36,7 +34,7 @@ app.listen(3000);
|
||||
|
||||
**ALERT!**
|
||||
|
||||
The order in wich the routes are registered is important, since
|
||||
The order in which the routes are registered is important, since
|
||||
**only the routes registered after the express-prom-bundle will be measured**
|
||||
|
||||
You can use this to your advantage to bypass some of the routes.
|
||||
@@ -44,26 +42,88 @@ See the example below.
|
||||
|
||||
## Options
|
||||
|
||||
* **buckets**: buckets used for `http_request_seconds` histogram
|
||||
* **includeMethod**: include HTTP method (GET, PUT, ...) as a label to `http_request_duration_seconds`
|
||||
* **includePath**: include URL path as a label (see below)
|
||||
* **normalizePath**: boolean or `function(req)` - path normalization for `includePath` option
|
||||
* **excludeRoutes**: array of strings or regexp specifying which routes should be skipped for `http_request_duration_seconds` metric. It uses `req.path` as subject when checking
|
||||
Which labels to include in `http_request_duration_seconds` metric:
|
||||
|
||||
* **includeStatusCode**: HTTP status code (200, 400, 404 etc.), default: **true**
|
||||
* **includeMethod**: HTTP method (GET, PUT, ...), default: **false**
|
||||
* **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.
|
||||
|
||||
Extra transformation callbacks:
|
||||
|
||||
* **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**
|
||||
|
||||
Other options:
|
||||
|
||||
* **buckets**: buckets used for `http_request_duration_seconds` histogram
|
||||
* **autoregister**: if `/metrics` endpoint should be registered. (Default: **true**)
|
||||
* **whitelist**, **blacklist**: array of strings or regexp specifying which metrics to include/exclude
|
||||
* **promClient**: options for promClient startup, e.g. **collectDefaultMetrics**. This option was added
|
||||
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)
|
||||
|
||||
### More details on includePath option
|
||||
|
||||
The goal is to have separate latency statistics by URL path, e.g. `/my-app/user/`, `/products/by-category` etc.
|
||||
Let's say you want to have latency statistics by URL path,
|
||||
e.g. separate metrics for `/my-app/user/`, `/products/by-category` etc.
|
||||
|
||||
Just taking `req.path` as a label value won't work as IDs are often part of the URL, 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.
|
||||
Just taking `req.path` as a label value won't work as IDs are often part of the URL,
|
||||
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 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? */));
|
||||
|
||||
// let's reuse the existing one and just add some
|
||||
// functionality on top
|
||||
const originalNormalize = promBundle.normalizePath;
|
||||
promBundle.normalizePath = (req, opts) => {
|
||||
const path = originalNormalize(req, opts);
|
||||
// count all docs as one path, but /docs/login as a separate one
|
||||
return (path.match(/^\/docs/) && !path.match(/^\/login/)) ? '/docs/*' : path;
|
||||
};
|
||||
```
|
||||
|
||||
For more details:
|
||||
* [url-value-parser](https://www.npmjs.com/package/url-value-parser) - magic behind automatic path normalization
|
||||
* [normalizePath.js](https://github.com/jochen-schweizer/express-prom-bundle/blob/master/src/normalizePath.js) - source code for path processing, for you
|
||||
|
||||
* [normalizePath.js](https://github.com/jochen-schweizer/express-prom-bundle/blob/master/src/normalizePath.js) - source code for path processing
|
||||
|
||||
|
||||
## express example
|
||||
@@ -71,23 +131,20 @@ For more details:
|
||||
setup std. metrics but exclude `up`-metric:
|
||||
|
||||
```javascript
|
||||
"use strict";
|
||||
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
const promBundle = require("express-prom-bundle");
|
||||
|
||||
|
||||
// calls to this route will not appear in metrics
|
||||
// because it's applied before promBundle
|
||||
app.get("/status", (req, res) => res.send("i am healthy"));
|
||||
|
||||
app.use(promBundle({
|
||||
includePath: true,
|
||||
excludeRoutes: ["/foo"]
|
||||
}));
|
||||
// register metrics collection for all routes
|
||||
// ... except those starting with /foo
|
||||
app.use("/((?!foo))*", promBundle({includePath: true}));
|
||||
|
||||
// this call will NOT appear in metrics, because it matches excludeRoutes
|
||||
// this call will NOT appear in metrics,
|
||||
// because express will skip the metrics middleware
|
||||
app.get("/foo", (req, res) => res.send("bar"));
|
||||
|
||||
// calls to this route will appear in metrics
|
||||
@@ -98,38 +155,94 @@ app.listen(3000);
|
||||
|
||||
See an [advanced example on github](https://github.com/jochen-schweizer/express-prom-bundle/blob/master/advanced-example.js)
|
||||
|
||||
## koa v1 example
|
||||
## koa v2 example
|
||||
|
||||
```javascript
|
||||
const promBundle = require("express-prom-bundle");
|
||||
const koa = require("koa");
|
||||
const Koa = require("koa");
|
||||
const c2k = require("koa-connect");
|
||||
const metricsMiddleware = promBundle({/* options */ });
|
||||
|
||||
const app = koa();
|
||||
const app = new Koa();
|
||||
|
||||
app.use(c2k(metricsMiddleware));
|
||||
app.use(/* your middleware */);
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
## Changelog
|
||||
## using with cluster
|
||||
|
||||
* **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
|
||||
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.
|
||||
|
||||
* **1.2.1**
|
||||
* upgrade prom-client to 6.1.2
|
||||
* add options: includeMethod, includePath, keepDefaultMetrics
|
||||
``` 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.
|
||||
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
|
||||
{
|
||||
"middleware": {
|
||||
"expressPromBundle": {
|
||||
"route": "/((?!status|favicon.ico|robots.txt))*",
|
||||
"priority": 0,
|
||||
"module": {
|
||||
"name": "express-prom-bundle",
|
||||
"arguments": [
|
||||
{
|
||||
"includeMethod": true,
|
||||
"includePath": true,
|
||||
"buckets": [0.1, 1, 5],
|
||||
"promClient": {
|
||||
"collectDefaultMetrics": {
|
||||
"timeout": 2000
|
||||
}
|
||||
},
|
||||
"urlValueParser": {
|
||||
"minHexLength": 5,
|
||||
"extraMasks": [
|
||||
"^[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -4,22 +4,32 @@ const express = require('express');
|
||||
const app = express();
|
||||
const promBundle = require('express-prom-bundle');
|
||||
|
||||
// here we want to remove default metrics provided in prom-client
|
||||
// this must be done before initializing promBundle
|
||||
clearInterval(promBundle.promClient.defaultMetrics());
|
||||
promBundle.promClient.register.clear();
|
||||
|
||||
const bundle = promBundle({
|
||||
blacklist: [/up/],
|
||||
buckets: [0.1, 0.4, 0.7],
|
||||
includeMethod: true,
|
||||
includePath: true
|
||||
includePath: true,
|
||||
customLabels: {year: null},
|
||||
transformLabels: labels => Object.assign(labels, {year: new Date().getFullYear()}),
|
||||
promClient: {
|
||||
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);
|
||||
|
||||
// native prom-client metric (no prefix)
|
||||
const c1 = new bundle.promClient.Counter('c1', 'c1 help');
|
||||
const c1 = new bundle.promClient.Counter({name: 'c1', help: 'c1 help'});
|
||||
c1.inc(10);
|
||||
|
||||
app.get('/foo/:id', (req, res) => {
|
||||
@@ -38,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'
|
||||
|
||||
2668
package-lock.json
generated
Normal file
2668
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -1,37 +1,42 @@
|
||||
{
|
||||
"name": "express-prom-bundle",
|
||||
"version": "2.0.0",
|
||||
"version": "4.2.1",
|
||||
"description": "express middleware with popular prometheus metrics in one bundle",
|
||||
"main": "src/index.js",
|
||||
"keywords": [
|
||||
"prometheus",
|
||||
"metrics",
|
||||
"express",
|
||||
"bundle"
|
||||
"path",
|
||||
"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": "^6.3.0",
|
||||
"url-value-parser": "^1.0.0"
|
||||
"prom-client": "~11.1.1",
|
||||
"url-value-parser": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"coveralls": "^2.11.15",
|
||||
"eslint": "^3.11.1",
|
||||
"express": "^4.14.0",
|
||||
"coveralls": "^3.0.2",
|
||||
"eslint": "^5.3.0",
|
||||
"express": "^4.16.3",
|
||||
"istanbul": "^0.4.5",
|
||||
"jasme": "^5.2.0",
|
||||
"koa": "^1.2.4",
|
||||
"koa-connect": "^1.0.0",
|
||||
"supertest": "^2.0.1",
|
||||
"koa": "^2.5.2",
|
||||
"koa-connect": "^2.0.0",
|
||||
"supertest": "^3.0.0",
|
||||
"supertest-koa-agent": "^0.3.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jochen-schweizer/express-prom-bundle.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
const express = require('express');
|
||||
const supertest = require('supertest');
|
||||
const bundle = require('../');
|
||||
const koa = require('koa');
|
||||
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(() => {
|
||||
@@ -34,6 +35,21 @@ describe('index', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('httpDurationMetricName overrides histogram metric name', done => {
|
||||
const app = express();
|
||||
const bundled = bundle({
|
||||
httpDurationMetricName: 'my_http_duration'
|
||||
});
|
||||
app.use(bundled);
|
||||
|
||||
const agent = supertest(app);
|
||||
agent.get('/metrics')
|
||||
.end((err, res) => {
|
||||
expect(res.text).toMatch(/my_http_duration/m);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('metrics should be attached to /metrics by default', done => {
|
||||
const app = express();
|
||||
const bundled = bundle({
|
||||
@@ -148,11 +164,12 @@ describe('index', () => {
|
||||
expect(() => bundle({prefix: 'hello'})).toThrow();
|
||||
});
|
||||
|
||||
it('tolerates includePath, includeMethod', done => {
|
||||
it('tolerates includePath, includeMethod, includeCustomLabels', done => {
|
||||
const app = express();
|
||||
const instance = bundle({
|
||||
includePath: true,
|
||||
includeMethod: true
|
||||
includeMethod: true,
|
||||
includeCustomLabels: {foo: 'bar'}
|
||||
});
|
||||
app.use(instance);
|
||||
app.use('/test', (req, res) => res.send('it worked'));
|
||||
@@ -169,18 +186,179 @@ describe('index', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('usage of normalizePath()', () => {
|
||||
|
||||
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 => {
|
||||
const app = express();
|
||||
const instance = bundle({
|
||||
formatStatusCode: () => 555
|
||||
});
|
||||
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(/555/);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('includeStatusCode=false removes status_code label from metrics', done => {
|
||||
const app = express();
|
||||
const instance = bundle({
|
||||
includeStatusCode: false
|
||||
});
|
||||
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).not.toMatch(/="200"/);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('customLabels={foo: "bar"} adds foo="bar" label to metrics', done => {
|
||||
const app = express();
|
||||
const instance = bundle({
|
||||
customLabels: {foo: 'bar'}
|
||||
});
|
||||
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(/foo="bar"/);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('tarnsformLabels can set label values', done => {
|
||||
const app = express();
|
||||
const instance = bundle({
|
||||
includePath: true,
|
||||
customLabels: {foo: 'bar'},
|
||||
transformLabels: labels => {
|
||||
labels.foo = 'baz';
|
||||
labels.path += '/ok';
|
||||
}
|
||||
});
|
||||
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(/foo="baz"/);
|
||||
expect(res.text).toMatch(/path="\/test\/ok"/);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Koa: metrics returns up=1', done => {
|
||||
const app = koa();
|
||||
const app = new Koa();
|
||||
const bundled = bundle({
|
||||
whitelist: ['up']
|
||||
});
|
||||
app.use(c2k(bundled));
|
||||
|
||||
app.use(function*(next) {
|
||||
if (this.path !== 'test') {
|
||||
return yield next;
|
||||
}
|
||||
this.body = 'it worked';
|
||||
app.use(function(ctx, next) {
|
||||
return next().then(() => ctx.body = 'it worked');
|
||||
});
|
||||
|
||||
const agent = supertestKoa(app);
|
||||
@@ -194,4 +372,52 @@ describe('index', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls promClient.collectDefaultMetrics', () => {
|
||||
const spy = spyOn(promClient, 'collectDefaultMetrics');
|
||||
bundle({
|
||||
promClient: {
|
||||
collectDefaultMetrics: {
|
||||
timeout: 3000
|
||||
}
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,25 +4,28 @@
|
||||
const normalizePath = require('../src/normalizePath');
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('returns original if disabled in opts', () => {
|
||||
expect(
|
||||
normalizePath({originalUrl: '/a/12345'}, {normalizePath: false})
|
||||
).toBe('/a/12345');
|
||||
});
|
||||
|
||||
it('returns run callback if configured', () => {
|
||||
expect(
|
||||
normalizePath(
|
||||
{originalUrl: '/a/12345'},
|
||||
{
|
||||
normalizePath: req => req.originalUrl + '-ok'
|
||||
}
|
||||
)
|
||||
).toBe('/a/12345-ok');
|
||||
});
|
||||
|
||||
it('uses UrlValueParser by default', () => {
|
||||
expect(normalizePath({originalUrl: '/a/12345'}))
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
12
spec/normalizeStatusCode.spec.js
Normal file
12
spec/normalizeStatusCode.spec.js
Normal file
@@ -0,0 +1,12 @@
|
||||
'use strict';
|
||||
/* eslint-env jasmine */
|
||||
|
||||
const normalizeStatusCode = require('../src/normalizeStatusCode');
|
||||
|
||||
describe('normalizeStatusCode', () => {
|
||||
it('returns run callback if configured', () => {
|
||||
expect(
|
||||
normalizeStatusCode({status_code: 500})
|
||||
).toBe(500);
|
||||
});
|
||||
});
|
||||
95
src/index.js
95
src/index.js
@@ -1,8 +1,8 @@
|
||||
'use strict';
|
||||
const onFinished = require('on-finished');
|
||||
const url = require('url');
|
||||
const promClient = require('prom-client');
|
||||
const normalizePath = require('./normalizePath');
|
||||
const normalizeStatusCode = require('./normalizeStatusCode');
|
||||
|
||||
function matchVsRegExps(element, regexps) {
|
||||
for (let regexp of regexps) {
|
||||
@@ -38,8 +38,34 @@ 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({autoregister: true}, opts || {});
|
||||
opts = Object.assign(
|
||||
{
|
||||
autoregister: true,
|
||||
includeStatusCode: true,
|
||||
normalizePath: main.normalizePath,
|
||||
formatStatusCode: main.normalizeStatusCode,
|
||||
promClient: {}
|
||||
},
|
||||
opts
|
||||
);
|
||||
if (arguments[2] && arguments[1] && arguments[1].send) {
|
||||
arguments[1].status(500)
|
||||
.send('<h1>500 Error</h1>\n'
|
||||
@@ -58,14 +84,18 @@ function main(opts) {
|
||||
);
|
||||
}
|
||||
|
||||
const httpMtricName = opts.httpDurationMetricName || 'http_request_duration_seconds';
|
||||
if (opts.promClient.collectDefaultMetrics) {
|
||||
promClient.collectDefaultMetrics(opts.promClient.collectDefaultMetrics);
|
||||
}
|
||||
|
||||
const httpMetricName = opts.httpDurationMetricName || 'http_request_duration_seconds';
|
||||
|
||||
const metricTemplates = {
|
||||
'up': () => new promClient.Gauge(
|
||||
'up',
|
||||
'1 = up, 0 = not up'
|
||||
),
|
||||
'http_request_duration_seconds': () => {
|
||||
'up': () => new promClient.Gauge({
|
||||
name: 'up',
|
||||
help: '1 = up, 0 = not up'
|
||||
}),
|
||||
[httpMetricName]: () => {
|
||||
const labels = ['status_code'];
|
||||
if (opts.includeMethod) {
|
||||
labels.push('method');
|
||||
@@ -73,14 +103,15 @@ function main(opts) {
|
||||
if (opts.includePath) {
|
||||
labels.push('path');
|
||||
}
|
||||
const metric = new promClient.Histogram(
|
||||
httpMtricName,
|
||||
'duration histogram of http responses labeled with: ' + labels.join(', '),
|
||||
labels,
|
||||
{
|
||||
buckets: opts.buckets || [0.003, 0.03, 0.1, 0.3, 1.5, 10]
|
||||
}
|
||||
);
|
||||
if (opts.customLabels){
|
||||
labels.push.apply(labels, Object.keys(opts.customLabels));
|
||||
}
|
||||
const metric = 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]
|
||||
});
|
||||
return metric;
|
||||
}
|
||||
};
|
||||
@@ -96,34 +127,43 @@ function main(opts) {
|
||||
metrics.up.set(1);
|
||||
}
|
||||
|
||||
const metricsMiddleware = function(req,res) {
|
||||
const metricsMiddleware = function(req, res) {
|
||||
res.writeHead(200, {'Content-Type': 'text/plain'});
|
||||
res.end(promClient.register.metrics());
|
||||
};
|
||||
|
||||
const middleware = function (req, res, next) {
|
||||
|
||||
const path = req.path || url.parse(req.url).pathname;
|
||||
const path = req.originalUrl || req.url; // originalUrl gets lost in koa-connect?
|
||||
let labels;
|
||||
|
||||
if (opts.autoregister && path === '/metrics') {
|
||||
return metricsMiddleware(req,res);
|
||||
if (opts.autoregister && path.match(/^\/metrics\/?$/)) {
|
||||
return metricsMiddleware(req, res);
|
||||
}
|
||||
|
||||
if (opts.excludeRoutes && matchVsRegExps(path, opts.excludeRoutes)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (metrics[httpMtricName]) {
|
||||
labels = {'status_code': 0};
|
||||
let timer = metrics[httpMtricName].startTimer(labels);
|
||||
if (metrics[httpMetricName]) {
|
||||
labels = {};
|
||||
let timer = metrics[httpMetricName].startTimer(labels);
|
||||
onFinished(res, () => {
|
||||
labels.status_code = res.statusCode;
|
||||
if (opts.includeStatusCode) {
|
||||
labels.status_code = opts.formatStatusCode(res, opts);
|
||||
}
|
||||
if (opts.includeMethod) {
|
||||
labels.method = req.method;
|
||||
}
|
||||
if (opts.includePath) {
|
||||
labels.path = 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);
|
||||
}
|
||||
if (opts.transformLabels) {
|
||||
opts.transformLabels(labels, req, res);
|
||||
}
|
||||
timer();
|
||||
});
|
||||
@@ -140,4 +180,7 @@ function main(opts) {
|
||||
}
|
||||
|
||||
main.promClient = promClient;
|
||||
main.normalizePath = normalizePath;
|
||||
main.normalizeStatusCode = normalizeStatusCode;
|
||||
main.clusterMetrics = clusterMetrics;
|
||||
module.exports = main;
|
||||
|
||||
@@ -2,26 +2,29 @@
|
||||
|
||||
const UrlValueParser = require('url-value-parser');
|
||||
const url = require('url');
|
||||
|
||||
// ATTENTION! urlValueParser is a singleton!
|
||||
let urlValueParser;
|
||||
|
||||
module.exports = function(req, opts) {
|
||||
opts = 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).pathname;
|
||||
let path = url.parse(req.originalUrl || req.url).pathname;
|
||||
|
||||
if (opts.normalizePath !== undefined && !opts.normalizePath) {
|
||||
return path;
|
||||
}
|
||||
if (typeof opts.normalizePath === 'function') {
|
||||
return opts.normalizePath(req, opts);
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
5
src/normalizeStatusCode.js
Normal file
5
src/normalizeStatusCode.js
Normal file
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = function(res) {
|
||||
return res.status_code || res.statusCode;
|
||||
};
|
||||
Reference in New Issue
Block a user