diff --git a/bin/configurable-http-proxy b/bin/configurable-http-proxy index e2eb6d2e..d439f76a 100755 --- a/bin/configurable-http-proxy +++ b/bin/configurable-http-proxy @@ -87,9 +87,8 @@ cli ) .option("--insecure", "Disable SSL cert verification") .option("--host-routing", "Use host routing (host as first level of path)") - .option("--statsd-host ", "Host to send statsd statistics to") - .option("--statsd-port ", "Port to send statsd statistics to", parseInt) - .option("--statsd-prefix ", "Prefix to use for statsd statistics") + .option("--metrics-ip ", "IP for metrics server", "0.0.0.0") + .option("--metrics-port ", "Port of metrics server. Defaults to no metrics server") .option("--log-level ", "Log level (debug, info, warn, error)", "info") .option( "--timeout ", @@ -266,14 +265,8 @@ options.headers = args.customHeader; options.timeout = args.timeout; options.proxyTimeout = args.proxyTimeout; -// statsd options -if (args.statsdHost) { - var lynx = require("lynx"); - options.statsd = new lynx(args.statsdHost, args.statsdPort || 8125, { - scope: args.statsdPrefix || "chp", - }); - log.info("Sending metrics to statsd at " + args.statsdHost + ":" + args.statsdPort || 8125); -} +// metrics options +options.enableMetrics = !!args.metricsPort; // certs need to be provided for https redirection if (!options.ssl && options.redirectPort) { @@ -326,9 +319,14 @@ if (args.ip === "*") { listen.ip = args.ip; listen.apiIp = args.apiIp || "localhost"; listen.apiPort = args.apiPort || listen.port + 1; +listen.metricsIp = args.metricsIp || "0.0.0.0"; +listen.metricsPort = args.metricsPort; proxy.proxyServer.listen(listen.port, listen.ip); proxy.apiServer.listen(listen.apiPort, listen.apiIp); +if (listen.metricsPort) { + proxy.metricsServer.listen(listen.metricsPort, listen.metricsIp); +} log.info( "Proxying %s://%s:%s to %s", @@ -343,6 +341,9 @@ log.info( listen.apiIp || "*", listen.apiPort ); +if (listen.metricsPort) { + log.info("Serve metrics at %s://%s:%s/metrics", "http", listen.metricsIp, listen.metricsPort); +} if (args.pidFile) { log.info("Writing pid %s to %s", process.pid, args.pidFile); diff --git a/lib/configproxy.js b/lib/configproxy.js index 1e862bcc..3b34f429 100644 --- a/lib/configproxy.js +++ b/lib/configproxy.js @@ -18,7 +18,8 @@ var http = require("http"), util = require("util"), URL = require("url"), defaultLogger = require("./log").defaultLogger, - querystring = require("querystring"); + querystring = require("querystring"), + metrics = require("./metrics"); function bound(that, method) { // bind a method, to ensure `this=that` when it is called @@ -159,23 +160,11 @@ class ConfigurableProxy extends EventEmitter { this.errorTarget = this.errorTarget + "/"; // ensure trailing / } this.errorPath = options.errorPath || path.join(__dirname, "error"); - if (options.statsd) { - this.statsd = options.statsd; + + if (this.options.enableMetrics) { + this.metrics = new metrics.Metrics(); } else { - // Mock the statsd object, rather than pepper the codebase with - // null checks. FIXME: Maybe use a JS Proxy object (if available?) - this.statsd = { - increment: function () {}, - decrement: function () {}, - timing: function () {}, - gauge: function () {}, - set: function () {}, - createTimer: function () { - return { - stop: function () {}, - }; - }, - }; + this.metrics = new metrics.MockMetrics(); } if (this.options.defaultTarget) { @@ -224,6 +213,12 @@ class ConfigurableProxy extends EventEmitter { this.apiServer = http.createServer(apiCallback); } + // handle metrics + if (this.options.enableMetrics) { + var metricsCallback = logErrors(that.handleMetrics); + this.metricsServer = http.createServer(metricsCallback); + } + // proxy requests separately var proxyCallback = logErrors(this.handleProxyWeb); if (this.options.ssl) { @@ -235,7 +230,7 @@ class ConfigurableProxy extends EventEmitter { this.proxyServer.on("upgrade", bound(this, this.handleProxyWs)); this.proxy.on("proxyRes", function (proxyRes, req, res) { - that.statsd.increment("requests." + proxyRes.statusCode, 1); + that.metrics.requestsProxyCount.labels(proxyRes.statusCode).inc(); }); } @@ -265,8 +260,8 @@ class ConfigurableProxy extends EventEmitter { var that = this; return this._routes.add(path, data).then(() => { - this.updateLastActivity(path); - this.log.info("Route added %s -> %s", path, data.target); + that.updateLastActivity(path); + that.log.info("Route added %s -> %s", path, data.target); }); } @@ -341,7 +336,7 @@ class ConfigurableProxy extends EventEmitter { res.write(JSON.stringify(results)); res.end(); - that.statsd.increment("api.route.get", 1); + that.metrics.apiRouteGetCount.inc(); }); } @@ -359,7 +354,7 @@ class ConfigurableProxy extends EventEmitter { return this.addRoute(path, data).then(function () { res.writeHead(201); res.end(); - that.statsd.increment("api.route.add", 1); + that.metrics.apiRouteAddCount.inc(); }); } @@ -378,19 +373,19 @@ class ConfigurableProxy extends EventEmitter { return p.then(() => { res.writeHead(code); res.end(); - this.statsd.increment("api.route.delete", 1); + this.metrics.apiRouteDeleteCount.inc(); }); }); } targetForReq(req) { - var timer = this.statsd.createTimer("find_target_for_req"); + var metricsTimerEnd = this.metrics.findTargetForReqSummary.startTimer(); // return proxy target for a given url path var basePath = this.hostRouting ? "/" + parseHost(req) : ""; var path = basePath + decodeURIComponent(URL.parse(req.url).pathname); return this._routes.getTarget(path).then(function (route) { - timer.stop(); + metricsTimerEnd(); if (route) { return { prefix: route.prefix, @@ -401,7 +396,7 @@ class ConfigurableProxy extends EventEmitter { } updateLastActivity(prefix) { - var timer = this.statsd.createTimer("last_activity_updating"); + var metricsTimerEnd = this.metrics.lastActivityUpdatingSummary.startTimer(); var routes = this._routes; return routes @@ -411,7 +406,7 @@ class ConfigurableProxy extends EventEmitter { return routes.update(prefix, { last_activity: new Date() }); } }) - .then(timer.stop); + .then(metricsTimerEnd); } _handleProxyErrorDefault(code, kind, req, res) { @@ -430,7 +425,7 @@ class ConfigurableProxy extends EventEmitter { // /404?url=%2Fuser%2Ffoo var errMsg = ""; - this.statsd.increment("requests." + code, 1); + this.metrics.requestsProxyCount.labels(code).inc(); if (e) { // avoid stack traces on known not-our-problem errors: // ECONNREFUSED, EHOSTUNREACH (backend isn't there) @@ -512,7 +507,7 @@ class ConfigurableProxy extends EventEmitter { this._handleProxyErrorDefault(code, kind, req, res); return; } - if (res.writableEnded) return; // response already done + if (!res.writable) return; // response already done if (res.writeHead) res.writeHead(code, { "Content-Type": "text/html" }); if (res.write) res.write(data); if (res.end) res.end(); @@ -603,7 +598,7 @@ class ConfigurableProxy extends EventEmitter { handleProxyWs(req, socket, head) { // Proxy a websocket request - this.statsd.increment("requests.ws", 1); + this.metrics.requestsWsCount.inc(); return this.handleProxy("ws", req, socket, head); } @@ -611,7 +606,7 @@ class ConfigurableProxy extends EventEmitter { this.handleHealthCheck(req, res); if (res.finished) return; // Proxy a web request - this.statsd.increment("requests.web", 1); + this.metrics.requestsWebCount.inc(); return this.handleProxy("web", req, res); } @@ -623,11 +618,18 @@ class ConfigurableProxy extends EventEmitter { } } + handleMetrics(req, res) { + if (req.url === "/metrics") { + return this.metrics.render(res); + } + fail(req, res, 404); + } + handleApiRequest(req, res) { // Handle a request to the REST API - this.statsd.increment("requests.api", 1); if (res) { res.on("finish", () => { + this.metrics.requestsApiCount.labels(res.statusCode).inc(); this.logResponse(req, res); }); } diff --git a/lib/metrics.js b/lib/metrics.js new file mode 100644 index 00000000..76da0252 --- /dev/null +++ b/lib/metrics.js @@ -0,0 +1,113 @@ +"use strict"; + +var client = require("prom-client"); + +class Metrics { + constructor() { + this.register = new client.Registry(); + client.collectDefaultMetrics({ register: this.register }); + + this.apiRouteGetCount = new client.Counter({ + name: "api_route_get", + help: "Count of API route get requests", + registers: [this.register], + }); + + this.apiRouteAddCount = new client.Counter({ + name: "api_route_add", + help: "Count of API route add requests", + registers: [this.register], + }); + + this.apiRouteDeleteCount = new client.Counter({ + name: "api_route_delete", + help: "Count of API route delete requests", + registers: [this.register], + }); + + this.findTargetForReqSummary = new client.Summary({ + name: "find_target_for_req", + help: "Summary of find target requests", + registers: [this.register], + }); + + this.lastActivityUpdatingSummary = new client.Summary({ + name: "last_activity_updating", + help: "Summary of last activity updating requests", + registers: [this.register], + }); + + this.requestsWsCount = new client.Counter({ + name: "requests_ws", + help: "Count of websocket requests", + registers: [this.register], + }); + + this.requestsWebCount = new client.Counter({ + name: "requests_web", + help: "Count of web requests", + registers: [this.register], + }); + + this.requestsProxyCount = new client.Counter({ + name: "requests_proxy", + help: "Count of proxy requests", + labelNames: ["status"], + registers: [this.register], + }); + + this.requestsApiCount = new client.Counter({ + name: "requests_api", + help: "Count of API requests", + labelNames: ["status"], + registers: [this.register], + }); + } + + render(res) { + return this.register.metrics().then((s) => { + res.writeHead(200, { "Content-Type": this.register.contentType }); + res.write(s); + res.end(); + }); + } +} + +class MockMetrics { + constructor() { + return new Proxy(this, { + get(target, name) { + const mockCounter = new Proxy( + {}, + { + get(target, name) { + if (name == "inc") { + return () => {}; + } + if (name == "startTimer") { + return () => { + return () => {}; + }; + } + if (name == "labels") { + return () => { + return mockCounter; + }; + } + }, + } + ); + return mockCounter; + }, + }); + } + + render(res) { + return Promise.resolve(); + } +} + +module.exports = { + Metrics, + MockMetrics, +}; diff --git a/lib/testutil.js b/lib/testutil.js index 9717f52b..b0527080 100644 --- a/lib/testutil.js +++ b/lib/testutil.js @@ -128,12 +128,18 @@ exports.setupProxy = function (port, options, paths) { servers.push(proxy.apiServer); servers.push(proxy.proxyServer); + if (options.enableMetrics) { + servers.push(proxy.metricsServer); + } proxy.apiServer.on("listening", onlisten); proxy.proxyServer.on("listening", onlisten); addTargets(proxy, paths || ["/"], port + 2).then(function () { proxy.proxyServer.listen(port, ip); proxy.apiServer.listen(port + 1, ip); + if (options.enableMetrics) { + proxy.metricsServer.listen(port + 3, ip); + } }); return p; }; diff --git a/package-lock.json b/package-lock.json index 1f29c082..c8afb602 100644 --- a/package-lock.json +++ b/package-lock.json @@ -392,6 +392,11 @@ "tweetnacl": "^0.14.3" } }, + "bintrees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", + "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1284,15 +1289,6 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, - "lynx": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/lynx/-/lynx-0.2.0.tgz", - "integrity": "sha1-eeZnRTDaQYPoeVO9aGFx4HDaULk=", - "requires": { - "mersenne": "~0.0.3", - "statsd-parser": "~0.0.4" - } - }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -1302,11 +1298,6 @@ "semver": "^6.0.0" } }, - "mersenne": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/mersenne/-/mersenne-0.0.4.tgz", - "integrity": "sha1-QB/ex+whzbngPNPTAhOY2iGycIU=" - }, "mime-db": { "version": "1.46.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz", @@ -1501,6 +1492,14 @@ "fromentries": "^1.2.0" } }, + "prom-client": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-13.1.0.tgz", + "integrity": "sha512-jT9VccZCWrJWXdyEtQddCDszYsiuWj5T0ekrPszi/WEegj3IZy6Mm09iOOVM86A4IKMWq8hZkT2dD9MaSe+sng==", + "requires": { + "tdigest": "^0.1.1" + } + }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -1724,11 +1723,6 @@ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" }, - "statsd-parser": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/statsd-parser/-/statsd-parser-0.0.4.tgz", - "integrity": "sha1-y9JDlTzELv/VSLXSI4jtaJ7GOb0=" - }, "stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", @@ -1789,6 +1783,14 @@ "has-flag": "^3.0.0" } }, + "tdigest": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", + "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", + "requires": { + "bintrees": "1.0.1" + } + }, "test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index 36f6d77c..1d14c089 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dependencies": { "commander": "~7.2.0", "http-proxy": "^1.18.1", - "lynx": "^0.2.0", + "prom-client": "13.1.0", "strftime": "~0.10.0", "winston": "~3.3.0" },