diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index dee9f3e..04614d5 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - node-version: [14.x, 16.x] + node-version: [14.x, 16.x, 18.x] steps: - uses: actions/checkout@v2 diff --git a/lib/heapwatch.js b/lib/heapwatch.js index 88cdad3..e01aed5 100644 --- a/lib/heapwatch.js +++ b/lib/heapwatch.js @@ -4,6 +4,16 @@ const cluster = require( 'cluster' ); class HeapWatch { constructor( conf, logger, metrics ) { + if ( metrics !== null ) { + const prometheusOptions = metrics.options.find( ( option ) => + option.type === 'prometheus' && option.collect_default === true + ); + // When true, a noop dummy metric will be instantiated + // and passed down to a PrometheusClient instance. + // This metric won't be registered directly by service-runner, + // but will be used to trigger a call to prom-client.collectDefaultMetrics(). + this.collect_default = !!prometheusOptions; + } this.conf = conf = conf || {}; this.limit = ( conf.limitMB || 1500 ) * 1024 * 1024; this.logger = logger; @@ -15,6 +25,17 @@ class HeapWatch { watch() { const usage = process.memoryUsage(); + if ( this.collect_default ) { + // collect some default metrics recommended by Prometheus itself. + // https://prometheus.io/docs/instrumenting/writing_clientlibs/#standard-and-runtime-collectors + this.metrics.makeMetric( { + type: 'noop', + name: 'prometheus.default', + prometheus: { + collect_default: true + } + } ); + } this.metrics.makeMetric( { type: 'Gauge', name: 'heap.rss', diff --git a/lib/metrics/index.js b/lib/metrics/index.js index 18bf541..624087f 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -88,12 +88,17 @@ class Metrics { // } // } makeMetric( options ) { - let metric = this.cache.get( options.name ); - if ( metric === undefined ) { - metric = new Metric( this.clients, this.logger, options ); - this.cache.set( options.name, metric ); + // noop is a kind of metric not registered by service-runner Metric interface. + // This is used to delegate collection of default prometheus + // metrics to prom-client.collectDefaultMetrics. + if ( options.collect_default === undefined || options.type !== 'noop' ) { + let metric = this.cache.get( options.name ); + if ( metric === undefined ) { + metric = new Metric( this.clients, this.logger, options ); + this.cache.set( options.name, metric ); + } + return metric; } - return metric; } close() { diff --git a/lib/metrics/metric.js b/lib/metrics/metric.js index 0cd08ee..6e38ff2 100644 --- a/lib/metrics/metric.js +++ b/lib/metrics/metric.js @@ -81,6 +81,7 @@ class Metric { this.logger.log( 'error/metrics', `endTiming() unsupported for metric type ${this.type}` ); } } + } module.exports = Metric; diff --git a/lib/metrics/prometheus.js b/lib/metrics/prometheus.js index e6ede33..fb85abb 100644 --- a/lib/metrics/prometheus.js +++ b/lib/metrics/prometheus.js @@ -21,13 +21,24 @@ class PrometheusMetric { this.options.labels.names = this.options.labels.names.map( this._normalize ); this.options.prometheus.name = this._normalize( this.options.prometheus.name ); - this.metric = new this.client[ this.options.type ]( { - name: this.options.prometheus.name, - help: this.options.prometheus.help, - labelNames: this.options.labels.names, - buckets: this.options.prometheus.buckets, - percentiles: this.options.prometheus.percentiles - } ); + if ( this.options.type !== 'noop' ) { + this.metric = new this.client[ this.options.type ]( { + name: this.options.prometheus.name, + help: this.options.prometheus.help, + labelNames: this.options.labels.names, + buckets: this.options.prometheus.buckets, + percentiles: this.options.prometheus.percentiles + } ); + } else if ( this.options.prometheus && this.options.prometheus.collect_default === true ) { + // TODO: update eslint rules to allow optional chaining operator `?` + // A no op metric. + // Invoke collectDefaultMetrics() but don't register any new metric + // via the service-runner Metric interface. + // https://prometheus.io/docs/instrumenting/writing_clientlibs/#standard-and-runtime-collectors + // TODO: collectDefaultMetrics() does not support providing labels. + // Default labels should be set for the whole registry. + this.client.collectDefaultMetrics(); + } } /** diff --git a/lib/metrics/servers/prometheus.js b/lib/metrics/servers/prometheus.js index 67a2f51..cfe39e5 100644 --- a/lib/metrics/servers/prometheus.js +++ b/lib/metrics/servers/prometheus.js @@ -11,6 +11,11 @@ class PrometheusServer { if ( num_workers === 0 ) { res.end( Prometheus.register.metrics() ); } else { + // config should contain the first `metrics` block in service-runner config, + // that matched `type === 'prometheus'`. + if ( config !== null && config.collect_default === true ) { + throw new Error( 'metrics.prometheus.collect_default cannot be enabled in cluster mode' ); + } AggregatorRegistry.clusterMetrics( ( err, metrics ) => { if ( err ) { this._logger.log( 'error/service-runner', err ); diff --git a/package-lock.json b/package-lock.json index bc3d36e..b89627b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,9 @@ "js-yaml": "^3.13.1", "limitation": "^0.2.3", "lodash.clonedeep": "^4.5.0", - "prom-client": "^11.5.3", + "prom-client": "^12.0.0", "semver": "^7.1.2", + "service-runner": "^3.1.0", "tar": "^4.4.16", "yargs": "^17.3.1" }, @@ -4333,14 +4334,14 @@ } }, "node_modules/prom-client": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-11.5.3.tgz", - "integrity": "sha512-iz22FmTbtkyL2vt0MdDFY+kWof+S9UB/NACxSn2aJcewtw+EERsen0urSkZ2WrHseNdydsvcxCTAnPcSMZZv4Q==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-12.0.0.tgz", + "integrity": "sha512-JbzzHnw0VDwCvoqf8y1WDtq4wSBAbthMB1pcVI/0lzdqHGJI3KBJDXle70XK+c7Iv93Gihqo0a5LlOn+g8+DrQ==", "dependencies": { "tdigest": "^0.1.1" }, "engines": { - "node": ">=6.1" + "node": ">=10" } }, "node_modules/psl": { @@ -4781,6 +4782,73 @@ "randombytes": "^2.1.0" } }, + "node_modules/service-runner": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/service-runner/-/service-runner-3.1.0.tgz", + "integrity": "sha512-eDUg9iP17zodtkPNhy+LOgjL/hh245s+D0Z5fgeB1ds39qHQzBds8tM4GByGbrU4u7zv0sO0RXlyq/j6Q1kZsA==", + "dependencies": { + "bluebird": "^3.7.2", + "bunyan": "^1.8.12", + "bunyan-syslog-udp": "^0.2.0", + "dnscache": "^1.0.2", + "gelf-stream": "^1.1.1", + "hot-shots": "^6.8.7", + "js-yaml": "^3.13.1", + "limitation": "^0.2.3", + "lodash.clonedeep": "^4.5.0", + "prom-client": "^11.5.3", + "semver": "^7.1.2", + "tar": "^4.4.16", + "yargs": "^16.2.0" + }, + "bin": { + "service-runner": "service-runner.js" + }, + "engines": { + "node": ">=10" + }, + "optionalDependencies": { + "heapdump": "^0.3.15" + } + }, + "node_modules/service-runner/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/service-runner/node_modules/prom-client": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-11.5.3.tgz", + "integrity": "sha512-iz22FmTbtkyL2vt0MdDFY+kWof+S9UB/NACxSn2aJcewtw+EERsen0urSkZ2WrHseNdydsvcxCTAnPcSMZZv4Q==", + "dependencies": { + "tdigest": "^0.1.1" + }, + "engines": { + "node": ">=6.1" + } + }, + "node_modules/service-runner/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -5435,7 +5503,6 @@ "version": "20.2.4", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", - "dev": true, "engines": { "node": ">=10" } @@ -8752,9 +8819,9 @@ } }, "prom-client": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-11.5.3.tgz", - "integrity": "sha512-iz22FmTbtkyL2vt0MdDFY+kWof+S9UB/NACxSn2aJcewtw+EERsen0urSkZ2WrHseNdydsvcxCTAnPcSMZZv4Q==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-12.0.0.tgz", + "integrity": "sha512-JbzzHnw0VDwCvoqf8y1WDtq4wSBAbthMB1pcVI/0lzdqHGJI3KBJDXle70XK+c7Iv93Gihqo0a5LlOn+g8+DrQ==", "requires": { "tdigest": "^0.1.1" } @@ -9081,6 +9148,61 @@ "randombytes": "^2.1.0" } }, + "service-runner": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/service-runner/-/service-runner-3.1.0.tgz", + "integrity": "sha512-eDUg9iP17zodtkPNhy+LOgjL/hh245s+D0Z5fgeB1ds39qHQzBds8tM4GByGbrU4u7zv0sO0RXlyq/j6Q1kZsA==", + "requires": { + "bluebird": "^3.7.2", + "bunyan": "^1.8.12", + "bunyan-syslog-udp": "^0.2.0", + "dnscache": "^1.0.2", + "gelf-stream": "^1.1.1", + "heapdump": "^0.3.15", + "hot-shots": "^6.8.7", + "js-yaml": "^3.13.1", + "limitation": "^0.2.3", + "lodash.clonedeep": "^4.5.0", + "prom-client": "^11.5.3", + "semver": "^7.1.2", + "tar": "^4.4.16", + "yargs": "^16.2.0" + }, + "dependencies": { + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "prom-client": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-11.5.3.tgz", + "integrity": "sha512-iz22FmTbtkyL2vt0MdDFY+kWof+S9UB/NACxSn2aJcewtw+EERsen0urSkZ2WrHseNdydsvcxCTAnPcSMZZv4Q==", + "requires": { + "tdigest": "^0.1.1" + } + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + } + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -9586,8 +9708,7 @@ "yargs-parser": { "version": "20.2.4", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", - "dev": true + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==" }, "yargs-unparser": { "version": "2.0.0", diff --git a/package.json b/package.json index e0752e2..11f5fc4 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,9 @@ "js-yaml": "^3.13.1", "limitation": "^0.2.3", "lodash.clonedeep": "^4.5.0", - "prom-client": "^11.5.3", + "prom-client": "^12.0.0", "semver": "^7.1.2", + "service-runner": "^3.1.0", "tar": "^4.4.16", "yargs": "^17.3.1" }, diff --git a/test/features/tests.js b/test/features/tests.js index f7b22c8..a48c360 100644 --- a/test/features/tests.js +++ b/test/features/tests.js @@ -103,6 +103,28 @@ describe( 'service-runner tests', () => { .finally( () => process.removeListener( 'warning', warningListener ) ); } ); + it( 'Must produce prometheus default metrics when hit ', () => { + const server = new TestServer( `${__dirname}/../utils/simple_config_no_workers_collect_default.yaml` ); + const response = { status: null, body: null }; + return server.start() + .then( () => { + preq.get( { uri: 'http://127.0.0.1:9000' } ) + .then( ( res ) => { + response.status = res.status; + response.body = res.body; + } ); + } ) + .delay( 1000 ) + .then( () => { + // nodejs_version_info is reported by calls to prom-client.collectDefaultMetrics() + assert.ok( + response.body.includes( 'nodejs_version_info ' ), + 'Must collect default metrics prometheus output.' + ); + } ) + .finally( () => server.stop() ); + } ); + // preq prevents the AssertionErrors from surfacing and failing the test // performing the test this way presents them correctly it( 'Must increment hitcount metrics when hit, no workers', () => { diff --git a/test/utils/simple_config_no_workers_collect_default.yaml b/test/utils/simple_config_no_workers_collect_default.yaml new file mode 100644 index 0000000..ff97b0f --- /dev/null +++ b/test/utils/simple_config_no_workers_collect_default.yaml @@ -0,0 +1,15 @@ +num_workers: 0 +logging: + level: fatal +metrics: + - type: prometheus + port: 9000 + collect_default: true + - type: statsd + host: localhost + port: 8125 +services: + - name: test + module: test/utils/simple_server.js + conf: + port: 12345