diff --git a/README.md b/README.md index 02bccb17ad92d5612fcb9693befcc12da82db86a..75d384c066d3c693fa6c66817b8c7dec8a59f967 100644 --- a/README.md +++ b/README.md @@ -132,12 +132,14 @@ SVG or JSON output. When deliberately changing the output, run `SNAPSHOT_DRY=1 npm run test:js:server` to preview changes to the saved snapshots, and `SNAPSHOT_UPDATE=1 npm run test:js:server` to update them. -The server can be [configured][sentry configuration] to use [Sentry][sentry]. +The server can be configured to use [Sentry][] ([configuration][sentry configuration]) and [Prometheus][] ([configuration][prometheus configuration]). [package manager]: https://nodejs.org/en/download/package-manager/ [snapshot tests]: https://glebbahmutov.com/blog/snapshot-testing/ -[sentry configuration]: doc/self-hosting.md#sentry +[Prometheus]: https://prometheus.io/ +[prometheus configuration]: doc/self-hosting.md#prometheus [Sentry]: https://sentry.io/ +[sentry configuration]: doc/self-hosting.md#sentry Hosting your own server ----------------------- diff --git a/doc/self-hosting.md b/doc/self-hosting.md index b35293b90ef705c561c744166bcb53c6770960b9..742681c1f15373a5ea6997c0b42ba1d1df96d3b9 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -205,3 +205,11 @@ sudo SENTRY_DSN=https://xxx:yyy@sentry.io/zzz node server ``` sudo node server ``` + +### Prometheus +Shields uses [prom-client](https://github.com/siimon/prom-client) to provide [default metrics](https://prometheus.io/docs/instrumenting/writing_clientlibs/#standard-and-runtime-collectors). These metrics are disabled by default. +You can enable them by `METRICS_PROMETHEUS_ENABLED` environment variable. Moreover access to metrics resource is blocked for requests from any IP address by default. You can provide a regular expression with allowed IP addresses by `METRICS_PROMETHEUS_ALLOWED_IPS` environment variable. +```bash +METRICS_PROMETHEUS_ENABLED=true METRICS_PROMETHEUS_ALLOWED_IPS="^127\.0\.0\.1$" npm start +``` +Metrics are available at `/metrics` resource. diff --git a/lib/server-config.js b/lib/server-config.js index 1a5ce8dabcc93bc927ec0b1bf3ffa66725706dbb..90ebec8fb26588a9d6a2e177298ce74da9fcfa51 100644 --- a/lib/server-config.js +++ b/lib/server-config.js @@ -39,6 +39,12 @@ const config = { port, address, }, + metrics: { + prometheus: { + enabled: envFlag(process.env.METRICS_PROMETHEUS_ENABLED, false), + allowedIps: process.env.METRICS_PROMETHEUS_ALLOWED_IPS, + }, + }, ssl: { isSecure, key: process.env.HTTPS_KEY, diff --git a/lib/sys/prometheus-metrics.js b/lib/sys/prometheus-metrics.js new file mode 100644 index 0000000000000000000000000000000000000000..1892f81e87b3ff09aeefeddc274fd81444689cdd --- /dev/null +++ b/lib/sys/prometheus-metrics.js @@ -0,0 +1,43 @@ +'use strict' + +const prometheus = require('prom-client') + +class PrometheusMetrics { + constructor(config = {}) { + this.enabled = config.enabled || false + const matchNothing = /(?!)/ + this.allowedIps = config.allowedIps + ? new RegExp(config.allowedIps) + : matchNothing + if (this.enabled) { + console.log( + `Metrics are enabled. Access to /metrics resoure is limited to IP addresses matching: ${ + this.allowedIps + }` + ) + } + } + + async initialize(server) { + if (this.enabled) { + const register = prometheus.register + prometheus.collectDefaultMetrics() + this.setRoutes(server, register) + } + } + + setRoutes(server, register) { + server.route(/^\/metrics$/, (data, match, end, ask) => { + const ip = ask.req.socket.remoteAddress + if (this.allowedIps.test(ip)) { + ask.res.setHeader('Content-Type', register.contentType) + ask.res.end(register.metrics()) + } else { + ask.res.statusCode = 403 + ask.res.end() + } + }) + } +} + +module.exports = PrometheusMetrics diff --git a/lib/sys/prometheus-metrics.spec.js b/lib/sys/prometheus-metrics.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..32d1861e77567fa16108c9defe789cc066679164 --- /dev/null +++ b/lib/sys/prometheus-metrics.spec.js @@ -0,0 +1,93 @@ +'use strict' + +const { expect } = require('chai') +const Camp = require('camp') +const fetch = require('node-fetch') +const config = require('../test-config') +const Metrics = require('./prometheus-metrics') + +describe('Prometheus metrics route', function() { + const baseUrl = `http://127.0.0.1:${config.port}` + + let camp + afterEach(function(done) { + if (camp) { + camp.close(() => done()) + camp = undefined + } + }) + + function startServer(metricsConfig) { + return new Promise((resolve, reject) => { + camp = Camp.start({ port: config.port, hostname: '::' }) + const metrics = new Metrics(metricsConfig) + metrics.initialize(camp) + camp.on('listening', () => resolve()) + }) + } + + it('returns 404 when metrics are disabled', async function() { + startServer({ enabled: false }) + + const res = await fetch(`${baseUrl}/metrics`) + + expect(res.status).to.be.equal(404) + expect(await res.text()).to.not.contains('nodejs_version_info') + }) + + it('returns 404 when there is no configuration', async function() { + startServer() + + const res = await fetch(`${baseUrl}/metrics`) + + expect(res.status).to.be.equal(404) + expect(await res.text()).to.not.contains('nodejs_version_info') + }) + + it('returns metrics for allowed IP', async function() { + startServer({ + enabled: true, + allowedIps: '^(127\\.0\\.0\\.1|::1|::ffff:127\\.0\\.0\\.1)$', + }) + + const res = await fetch(`${baseUrl}/metrics`) + + expect(res.status).to.be.equal(200) + expect(await res.text()).to.contains('nodejs_version_info') + }) + + it('returns metrics for request from allowed remote address', async function() { + startServer({ + enabled: true, + allowedIps: '^(127\\.0\\.0\\.1|::1|::ffff:127\\.0\\.0\\.1)$', + }) + + const res = await fetch(`${baseUrl}/metrics`) + + expect(res.status).to.be.equal(200) + expect(await res.text()).to.contains('nodejs_version_info') + }) + + it('returns 403 for not allowed IP', async function() { + startServer({ + enabled: true, + allowedIps: '^127\\.0\\.0\\.200$', + }) + + const res = await fetch(`${baseUrl}/metrics`) + + expect(res.status).to.be.equal(403) + expect(await res.text()).to.not.contains('nodejs_version_info') + }) + + it('returns 403 for every request when list with allowed IPs not defined', async function() { + startServer({ + enabled: true, + }) + + const res = await fetch(`${baseUrl}/metrics`) + + expect(res.status).to.be.equal(403) + expect(await res.text()).to.not.contains('nodejs_version_info') + }) +}) diff --git a/package-lock.json b/package-lock.json index 6bb41e9f654e29d1909db5e423e75f500a851b73..6c819a6348b1de58d3195721e211d130b8a66c0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1887,6 +1887,11 @@ "integrity": "sha512-DYWGk01lDcxeS/K9IHPGWfT8PsJmbXRtRd2Sx72Tnb8pcYZQFF1oSDb8hJtS1vhp212q1Rzi5dUf9+nq0o9UIg==", "dev": true }, + "bintrees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", + "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + }, "blob": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", @@ -12213,6 +12218,14 @@ "integrity": "sha512-OE+a6vzqazc+K6LxJrX5UPyKFvGnL5CYmq2jFGNIBWHpc4QyE49/YOumcrpQFJpfejmvRtbJzgO1zPmMCqlbBg==", "dev": true }, + "prom-client": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-11.1.2.tgz", + "integrity": "sha512-w06+FdkNw91QhzpJvw8eT6T2Od/7gfW5OCv3bmGutbb8/6Hu28f3qpoNSwCT/Al+KZABnzFaSNILcS/aazi2ew==", + "requires": { + "tdigest": "^0.1.1" + } + }, "promise": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/promise/-/promise-7.0.4.tgz", @@ -14448,6 +14461,14 @@ "integrity": "sha1-mTcqXJmb8t8WCvwNdL7U9HlIzSI=", "dev": true }, + "tdigest": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", + "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", + "requires": { + "bintrees": "1.0.1" + } + }, "temp-write": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/temp-write/-/temp-write-2.1.0.tgz", @@ -15110,7 +15131,7 @@ "dependencies": { "chalk": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { diff --git a/package.json b/package.json index b865835d2bea4baba80813b09b7c4235aeab0332..98ec9bffaae1a1355a14c1e8b587badeb6f93098 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "pdfkit": "~0.8.0", "pretty-bytes": "^5.0.0", "priorityqueuejs": "^1.0.0", + "prom-client": "^11.1.2", "query-string": "^6.0.0", "raven": "^2.4.2", "redis": "~2.8.0", diff --git a/server.js b/server.js index e72cbebd8f1ad31e441ad61d3879e2ddd0dcbf96..7aac2a71b5dec95e7490f08fe98d21d2edde4173 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,7 @@ const { checkErrorResponse } = require('./lib/error-helper') const analytics = require('./lib/analytics') const config = require('./lib/server-config') const GithubConstellation = require('./services/github/github-constellation') +const PrometheusMetrics = require('./lib/sys/prometheus-metrics') const sysMonitor = require('./lib/sys/monitor') const log = require('./lib/log') const { makeMakeBadgeFn } = require('./lib/make-badge') @@ -50,6 +51,7 @@ const githubConstellation = new GithubConstellation({ persistence: config.persistence, service: config.services.github, }) +const metrics = new PrometheusMetrics(config.metrics.prometheus) const { apiProvider: githubApiProvider } = githubConstellation function reset() { @@ -92,6 +94,7 @@ if (serverSecrets && serverSecrets.shieldsSecret) { } githubConstellation.initialize(camp) +metrics.initialize(camp) suggest.setRoutes(config.cors.allowedOrigin, githubApiProvider, camp)