UNPKG

http-metrics-middleware

Version:

Express middleware for adding common prometheus metrics

230 lines (206 loc) 7.74 kB
const express = require('express'); const promClient = require('prom-client'); const UrlValueParser = require('url-value-parser'); const url = require('url'); const os = require('os'); const onFinished = require('on-finished'); const now = require('performance-now'); const _ = require('lodash'); const defaultOpts = { metricsPath: '/metrics', enableDurationHistogram: true, enableDurationSummary: true, timeBuckets: [0.01, 0.1, 0.5, 1, 5], quantileBuckets: [0.1, 0.5, 0.95, 0.99], includeError: false, includePath: true, paramIgnores: [], durationHistogramName: 'http_request_duration_seconds', durationSummaryName: 'http_request_duration_quantile_seconds', }; /** Express middleware to add prometheus integration */ class MetricsMiddleware { /** * @typedef MetricsOptions * @type {object} * @property {string} metricsPath - defines custom metrics path * @property {number[]} timeBuckets - the buckets to assign to duration histogram (in seconds) * @property {number[]} quantileBuckets - the quantiles to assign to duration summary (0.0 - 1.0) * @property {number} quantileMaxAge configures sliding time window for summary (in seconds) * @property {number} quantileAgeBuckets configures number of sliding time window buckets for summary * @property {string[]} paramIgnores - array of params _not_ to replace * @property {boolean} includeError - whether or not to include presence of an unhandled error as a label - defaults to false * @property {boolean} includePath - whether or not to include the URL path as a metric label - defaults to true * @property {Function} normalizePath - a `function(req)` - generates path values from the express `req` object * @property {Function} formatStatusCode - a `function(req)` - generates path values from the express `req` object * @property {boolean} enableDurationHistogram - whether to enable the request duration histogram (default: true) * @property {boolean} enableDurationSummary - whether to enable the request duration summary (default: true) * @property {string} durationHistogramName - the name of the duration histogram metric (if enabled) - must be unique * @property {string} durationSummaryName - the name of duration summary metric (if enabled) - must be unique */ /** * Create a MetricsMiddleware * * @param {MetricsOptions} options - the options */ constructor(options = {}) { _.defaults(options, defaultOpts, { normalizePath: this.normalizePath.bind(this), formatStatusCode: this.normalizeStatusCode.bind(this), quantileMaxAge: 600, quantileAgeBuckets: 5, }); this.options = options; this.router = express.Router(); this.urlValueParser = this.options.urlValueParser || new UrlValueParser(); this.durationMetrics = []; } /** * Initialize the build_info metric * * @param {string} ns - the namespace for the metric - usually the name of the service * @param {string} version - the service's version * @param {string} revision - the git SHA hash for the running code (usually short-SHA) */ initBuildInfo(ns, version, revision) { if (!ns) { throw new Error('namespace (ns) must be provided for build_info metric!'); } const buildInfo = new promClient.Gauge({ name: `${ns}_build_info`, help: `A metric with a constant 1 value labeled by version, revision, platform, nodeVersion, os from which ${ns} was built`, labelNames: ['version', 'revision', 'platform', 'nodeVersion', 'os', 'osRelease'], }); buildInfo.set( { version, revision, platform: process.release.name, nodeVersion: process.version, os: process.platform, osRelease: os.release(), }, 1, ); return buildInfo; } initRoutes() { const labelNames = ['status_code', 'method']; if (this.options.includePath) { labelNames.push('path'); } if (this.options.enableDurationSummary) { this.durationMetrics.push(new promClient.Summary({ name: this.options.durationSummaryName, help: `duration summary of http responses labeled with: ${labelNames.join(', ')}`, labelNames, percentiles: this.options.quantileBuckets, maxAgeSeconds: this.options.quantileMaxAge, ageBuckets: this.options.quantileAgeBuckets, })); } if (this.options.enableDurationHistogram) { this.durationMetrics.push(new promClient.Histogram({ name: this.options.durationHistogramName, help: `duration histogram of http responses labeled with: ${labelNames.join(', ')}`, labelNames, buckets: this.options.timeBuckets, })); } promClient.collectDefaultMetrics(); this.router.get(this.options.metricsPath, this.metricsRoute.bind(this)); this.router.use(this.trackDuration.bind(this)); return this.router; } async metricsRoute(req, res) { if (req.headers['x-forwarded-for']) { res.writeHead(404); return res.end('Not Found'); } res.statusCode = 200; return res.end(await promClient.register.metrics()); } trackDuration(req, res, next) { if ( this.options.excludeRoutes && this.matchVsRegExps(req.originalUrl, this.options.excludeRoutes) ) { return next(); } const start = now(); onFinished(res, (err, resp) => { const end = now(); const labels = { status_code: this.options.formatStatusCode(resp, this.options), method: req.method, }; // if we're on a route that has been mounted, resp.req.route.path will be set if ( this.options.includePath && resp.req && resp.req.route && resp.req.route.path ) { labels.path = this.options.normalizePath(req, this.options); } if (this.options.includeError && !!err) { labels.error = 'true'; } const duration = (parseFloat(end.toFixed(9)) - parseFloat(start.toFixed(9))) / 1000; this.observeDurations(labels, duration); }); return next(); } observeDurations(labelValues, duration) { this.durationMetrics.forEach((metric) => { metric.observe(labelValues, duration); }); } normalizeStatusCode(res) { return res.status_code || res.statusCode; } normalizePath(req) { let path = url.parse(req.originalUrl).pathname; path = this.replaceParams(path, req.params); return this.urlValueParser.replacePathValues(path); } replaceParams(path, params) { let pathValue = path; if (params) { Object.keys(params).forEach((param) => { if ( Object.prototype.hasOwnProperty.call(params, param) && !this.options.paramIgnores.includes(param) ) { pathValue = this.replaceParam(params, param, pathValue); } }); } return pathValue; } replaceParam(params, param, path) { let encoded = encodeURI(params[param]); if (path.includes(encoded)) { return path.replace(encoded, `#${param}`); } encoded = encodeURIComponent(params[param]); if (path.includes(encoded)) { return path.replace(encoded, `#${param}`); } if (path.includes(params[param])) { return encodeURI(path.replace(params[param], `#${param}`)); } return path; } matchVsRegExps(element, regexps) { if (!element || !regexps) { return false; } return regexps.some((regexp) => (regexp instanceof RegExp && element.match(regexp)) || element === regexp); } } // export prom-client for use in custom metrics MetricsMiddleware.promClient = promClient; MetricsMiddleware.defaultOpts = defaultOpts; module.exports = MetricsMiddleware;