diff --git a/config.yaml b/config.yaml index aa87fe3..57e6747 100644 --- a/config.yaml +++ b/config.yaml @@ -42,4 +42,3 @@ services: port: 12345 interface: localhost # more per-service config settings - diff --git a/lib/heapwatch.js b/lib/heapwatch.js index 88cdad3..5f93ba3 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 c77df63..454c2b8 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -86,12 +86,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 3dd5496..83e168b 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/test/features/tests.js b/test/features/tests.js index d78ab4d..bfd35e1 100644 --- a/test/features/tests.js +++ b/test/features/tests.js @@ -101,8 +101,30 @@ describe( 'service-runner tests', () => { .finally( () => process.removeListener( 'warning', warningListener ) ); } ); - // preq prevents the AssertionErrors from surfacing and failing the test - // performing the test this way presents them correctly + 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( async () => { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + await fetch( 'http://127.0.0.1:9000' ) + .then( async ( res ) => { + response.status = res.status; + // This is a ReadableStream of a Uint8Array, but we just want the string + response.body = new TextDecoder().decode( await res.arrayBuffer() ); + } ); + } ) + .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() ); + } ); + it( 'Must increment hitcount metrics when hit, no workers', () => { const server = new TestServer( `${ __dirname }/../utils/simple_config_no_workers.yaml` ); const response = { status: null, body: null }; 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..7140362 --- /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