diff --git a/extensions/metrics/.npmrc b/extensions/metrics/.npmrc new file mode 100644 index 000000000000..cafe685a112d --- /dev/null +++ b/extensions/metrics/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/extensions/metrics/LICENSE b/extensions/metrics/LICENSE new file mode 100644 index 000000000000..ae59dda5b146 --- /dev/null +++ b/extensions/metrics/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2019. All Rights Reserved. +Node module: @loopback/extension-metrics +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/extensions/metrics/README.md b/extensions/metrics/README.md new file mode 100644 index 000000000000..dedb756a5117 --- /dev/null +++ b/extensions/metrics/README.md @@ -0,0 +1,192 @@ +# @loopback/extension-metrics + +This module contains a component that reports metrics of Node.js, LoopBack +framework, and your application to [Prometheus](https://prometheus.io/). + +## Stability: :warning:Experimental:warning: + +> Experimental packages provide early access to advanced or experimental +> functionality to get community feedback. Such modules are published to npm +> using `0.x.y` versions. Their APIs and functionality may be subject to +> breaking changes in future releases. + +## Installation + +```sh +npm install --save @loopback/extension-metrics +``` + +## Basic use + +The component should be loaded in the constructor of your custom Application +class. + +Start by importing the component class: + +```ts +import {MetricsComponent} from '@loopback/extension-metrics'; +``` + +In the constructor, add the component to your application: + +```ts +this.component(MetricsComponent); +``` + +By default, Metrics route is mounted at `/metrics`. This path can be customized +via Metrics configuration as follows: + +```ts +this.configure(MetricsBindings.COMPONENT).to({ + endpoint: { + basePath: '/metrics', + }, + defaultMetrics: { + timeout: 5000, + }, +}); +``` + +## Metrics Collected + +There are three types of metrics being collected by this component: + +1. Node.js metrics - built-in by https://github.com/siimon/prom-client +2. LoopBack method invocations - built-in by this module using an interceptor +3. Metrics by the application code or other modules - instrumentations are + required + +## Pull vs Push + +Prometheus supports two modes to collect metrics: + +- pull - scraping from metrics http endpoint exposed by the system being + monitored +- push - pushing metrics from the system being monitored to a push gateway + +See +https://prometheus.io/docs/introduction/faq/#why-do-you-pull-rather-than-push + +## Try it out + +```sh +git clone https://github.com/strongloop/loopback-next +npm install +npm run build +cd examples/metrics-prometheus +npm run demo +``` + +Open http://localhost:9090 to load Prometheus web UI. + +### /metrics endpoint + +http://localhost:3000/metrics returns metrics in plain text format. It includes +information for the Node.js process as well as LoopBack method invocations. + +
+Example of plain text data +
+# HELP process_cpu_user_seconds_total Total user CPU time spent in seconds.
+# TYPE process_cpu_user_seconds_total counter
+process_cpu_user_seconds_total 0.132181 1564508354524
+# HELP process_cpu_system_seconds_total Total system CPU time spent in seconds.
+# TYPE process_cpu_system_seconds_total counter
+process_cpu_system_seconds_total 0.023608999999999998 1564508354524
+# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
+# TYPE process_cpu_seconds_total counter
+process_cpu_seconds_total 0.15578999999999998 1564508354524
+# HELP process_start_time_seconds Start time of the process since unix epoch in seconds.
+# TYPE process_start_time_seconds gauge
+process_start_time_seconds 1564508343
+# HELP process_resident_memory_bytes Resident memory size in bytes.
+# TYPE process_resident_memory_bytes gauge
+process_resident_memory_bytes 61800448 1564508354524
+# HELP nodejs_eventloop_lag_seconds Lag of event loop in seconds.
+# TYPE nodejs_eventloop_lag_seconds gauge
+nodejs_eventloop_lag_seconds 0.002172946 1564508354526
+# HELP nodejs_active_handles Number of active libuv handles grouped by handle type. Every handle type is C++ class name.
+# TYPE nodejs_active_handles gauge
+nodejs_active_handles{type="WriteStream"} 2 1564508354524
+nodejs_active_handles{type="Server"} 1 1564508354524
+nodejs_active_handles{type="Socket"} 2 1564508354524
+# HELP nodejs_active_handles_total Total number of active handles.
+# TYPE nodejs_active_handles_total gauge
+nodejs_active_handles_total 5 1564508354526
+# HELP nodejs_active_requests Number of active libuv requests grouped by request type. Every request type is C++ class name.
+# TYPE nodejs_active_requests gauge
+# HELP nodejs_active_requests_total Total number of active requests.
+# TYPE nodejs_active_requests_total gauge
+nodejs_active_requests_total 0 1564508354526
+# HELP nodejs_heap_size_total_bytes Process heap size from node.js in bytes.
+# TYPE nodejs_heap_size_total_bytes gauge
+nodejs_heap_size_total_bytes 27545600 1564508354526
+# HELP nodejs_heap_size_used_bytes Process heap size used from node.js in bytes.
+# TYPE nodejs_heap_size_used_bytes gauge
+nodejs_heap_size_used_bytes 23788272 1564508354526
+# HELP nodejs_external_memory_bytes Nodejs external memory size in bytes.
+# TYPE nodejs_external_memory_bytes gauge
+nodejs_external_memory_bytes 1234918 1564508354526
+# HELP nodejs_heap_space_size_total_bytes Process heap space size total from node.js in bytes.
+# TYPE nodejs_heap_space_size_total_bytes gauge
+nodejs_heap_space_size_total_bytes{space="read_only"} 524288 1564508354526
+nodejs_heap_space_size_total_bytes{space="new"} 1048576 1564508354526
+nodejs_heap_space_size_total_bytes{space="old"} 16900096 1564508354526
+nodejs_heap_space_size_total_bytes{space="code"} 688128 1564508354526
+nodejs_heap_space_size_total_bytes{space="map"} 1576960 1564508354526
+nodejs_heap_space_size_total_bytes{space="large_object"} 6758400 1564508354526
+nodejs_heap_space_size_total_bytes{space="code_large_object"} 49152 1564508354526
+nodejs_heap_space_size_total_bytes{space="new_large_object"} 0 1564508354526
+# HELP nodejs_heap_space_size_used_bytes Process heap space size used from node.js in bytes.
+# TYPE nodejs_heap_space_size_used_bytes gauge
+nodejs_heap_space_size_used_bytes{space="read_only"} 31712 1564508354526
+nodejs_heap_space_size_used_bytes{space="new"} 9584 1564508354526
+nodejs_heap_space_size_used_bytes{space="old"} 15723128 1564508354526
+nodejs_heap_space_size_used_bytes{space="code"} 377600 1564508354526
+nodejs_heap_space_size_used_bytes{space="map"} 918480 1564508354526
+nodejs_heap_space_size_used_bytes{space="large_object"} 6726408 1564508354526
+nodejs_heap_space_size_used_bytes{space="code_large_object"} 3456 1564508354526
+nodejs_heap_space_size_used_bytes{space="new_large_object"} 0 1564508354526
+# HELP nodejs_heap_space_size_available_bytes Process heap space size available from node.js in bytes.
+# TYPE nodejs_heap_space_size_available_bytes gauge
+nodejs_heap_space_size_available_bytes{space="read_only"} 492264 1564508354526
+nodejs_heap_space_size_available_bytes{space="new"} 1038368 1564508354526
+nodejs_heap_space_size_available_bytes{space="old"} 1105240 1564508354526
+nodejs_heap_space_size_available_bytes{space="code"} 285952 1564508354526
+nodejs_heap_space_size_available_bytes{space="map"} 657072 1564508354526
+nodejs_heap_space_size_available_bytes{space="large_object"} 0 1564508354526
+nodejs_heap_space_size_available_bytes{space="code_large_object"} 0 1564508354526
+nodejs_heap_space_size_available_bytes{space="new_large_object"} 1047952 1564508354526
+# HELP nodejs_version_info Node.js version info.
+# TYPE nodejs_version_info gauge
+nodejs_version_info{version="v12.4.0",major="12",minor="4",patch="0"} 1
+# HELP loopback_invocation_duration_seconds method invocation
+# TYPE loopback_invocation_duration_seconds gauge
+# HELP loopback_invocation_duration_histogram method invocation histogram
+# TYPE loopback_invocation_duration_histogram histogram
+# HELP loopback_invocation_total method invocation counts
+# TYPE loopback_invocation_total counter
+loopback_invocation_total 1
+# HELP loopback_invocation_duration_summary method invocation summary
+# TYPE loopback_invocation_duration_summary summary
+
+ +
+ +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/extensions/metrics/bin/start-pushgateway.sh b/extensions/metrics/bin/start-pushgateway.sh new file mode 100755 index 000000000000..cc530eaf1c51 --- /dev/null +++ b/extensions/metrics/bin/start-pushgateway.sh @@ -0,0 +1,7 @@ +#!/bin/bash +BASE_DIR=`dirname "$0"` +$BASE_DIR/stop-pushgateway.sh + +PROM_PGW_CONTAINER_NAME="prom_pushgateway_lb4" +docker pull prom/pushgateway:latest +docker run --name $PROM_PGW_CONTAINER_NAME -p 9091:9091 -d prom/pushgateway:latest diff --git a/extensions/metrics/bin/stop-pushgateway.sh b/extensions/metrics/bin/stop-pushgateway.sh new file mode 100755 index 000000000000..0fb78bf21577 --- /dev/null +++ b/extensions/metrics/bin/stop-pushgateway.sh @@ -0,0 +1,3 @@ +#!/bin/bash +PROM_PGW_CONTAINER_NAME="prom_pushgateway_lb4" +docker rm -f $PROM_PGW_CONTAINER_NAME diff --git a/extensions/metrics/index.d.ts b/extensions/metrics/index.d.ts new file mode 100644 index 000000000000..4771c0d1bed3 --- /dev/null +++ b/extensions/metrics/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/extensions/metrics/index.js b/extensions/metrics/index.js new file mode 100644 index 000000000000..368652de64a5 --- /dev/null +++ b/extensions/metrics/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/extensions/metrics/index.ts b/extensions/metrics/index.ts new file mode 100644 index 000000000000..c2d70ccb419b --- /dev/null +++ b/extensions/metrics/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/extensions/metrics/package-lock.json b/extensions/metrics/package-lock.json new file mode 100644 index 000000000000..3beec1491741 --- /dev/null +++ b/extensions/metrics/package-lock.json @@ -0,0 +1,536 @@ +{ + "name": "@loopback/extension-metrics", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/body-parser": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.1.tgz", + "integrity": "sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.32", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", + "integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.1.tgz", + "integrity": "sha512-VfH/XCP0QbQk5B5puLqTLEeFgR8lfCJHZJKkInZ9mkYd+u8byX0kztXEQxEk4wZXJs8HI+7km2ALXjn4YKcX9w==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.16.10", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.10.tgz", + "integrity": "sha512-gM6evDj0OvTILTRKilh9T5dTaGpv1oYiFcJAfgSejuMJgGJUsD9hKEU2lB4aiTNy4WwChxRnjfYFuBQsULzsJw==", + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + } + }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==", + "dev": true + }, + "@types/node": { + "version": "10.14.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.19.tgz", + "integrity": "sha512-j6Sqt38ssdMKutXBUuAcmWF8QtHW1Fwz/mz4Y+Wd9mzpBiVFirjpNQf363hG5itkG+yGaD+oiLyb50HxJ36l9Q==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", + "dev": true + }, + "@types/serve-static": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", + "integrity": "sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "dev": true, + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=", + "dev": true + }, + "bintrees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", + "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "dev": true, + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + } + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", + "dev": true + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "dev": true, + "requires": { + "safe-buffer": "5.1.2" + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "dev": true + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "dev": true + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", + "dev": true + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "dev": true + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "dev": true, + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=", + "dev": true + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "dev": true + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "ipaddr.js": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.0.tgz", + "integrity": "sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==", + "dev": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "dev": true + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "dev": true + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, + "requires": { + "mime-db": "1.40.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "p-event": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.1.0.tgz", + "integrity": "sha512-4vAd06GCsgflX4wHN1JqrMzBh/8QZ4j+rzp0cd2scXRwuBEv+QR3wrVA5aLhWDLw4y2WgDKvzWF3CCLmVM1UgA==", + "dev": true, + "requires": { + "p-timeout": "^2.0.1" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-timeout": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", + "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", + "dev": true, + "requires": { + "p-finally": "^1.0.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", + "dev": true + }, + "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" + } + }, + "proxy-addr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", + "integrity": "sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==", + "dev": true, + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.0" + } + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", + "dev": true + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "dev": true, + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "dev": true, + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "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" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "dev": true + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "dev": true + } + } +} diff --git a/extensions/metrics/package.json b/extensions/metrics/package.json new file mode 100644 index 000000000000..61e0488065f2 --- /dev/null +++ b/extensions/metrics/package.json @@ -0,0 +1,56 @@ +{ + "name": "@loopback/extension-metrics", + "version": "0.0.1", + "description": "LoopBack Metrics for Prometheus", + "engines": { + "node": ">=8.9" + }, + "scripts": { + "build": "lb-tsc", + "clean": "lb-clean loopback-extension-metrics*.tgz dist tsconfig.build.tsbuildinfo package", + "pretest": "npm run build", + "test": "lb-mocha \"dist/__tests__/**/*.js\"", + "verify": "npm pack && tar xf loopback-extension-metrics*.tgz && tree package && npm run clean" + }, + "author": "IBM Corp.", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "dependencies": { + "@loopback/context": "^1.23.3", + "@loopback/core": "^1.10.5", + "@loopback/rest": "^1.21.0", + "prom-client": "^11.5.3" + }, + "devDependencies": { + "@loopback/build": "^2.0.14", + "@loopback/eslint-config": "^4.1.2", + "@loopback/testlab": "^1.9.2", + "@types/express": "^4.17.1", + "@types/node": "^10.14.18", + "express": "^4.17.1", + "p-event": "^4.1.0" + }, + "keywords": [ + "LoopBack", + "Cloud Native", + "Prometheus", + "Metrics" + ], + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist", + "src", + "!*/__tests__", + "templates" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git", + "directory": "extensions/metrics" + } +} diff --git a/extensions/metrics/prometheus-demo.png b/extensions/metrics/prometheus-demo.png new file mode 100644 index 000000000000..fe8c643786d7 Binary files /dev/null and b/extensions/metrics/prometheus-demo.png differ diff --git a/extensions/metrics/src/__tests__/acceptance/metrics-push.acceptance.ts b/extensions/metrics/src/__tests__/acceptance/metrics-push.acceptance.ts new file mode 100644 index 000000000000..d72ded8ea9bf --- /dev/null +++ b/extensions/metrics/src/__tests__/acceptance/metrics-push.acceptance.ts @@ -0,0 +1,60 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {RestApplication, RestServerConfig} from '@loopback/rest'; +import {givenHttpServerConfig, supertest} from '@loopback/testlab'; +import {AddressInfo} from 'net'; +import {promisify} from 'util'; +import {MetricsBindings, MetricsComponent, MetricsOptions} from '../..'; +import {PushGateway} from './mock-pushgateway'; + +const gateway = new PushGateway(); + +describe('Metrics (with push gateway)', function() { + let gwUrl: string; + before(async () => { + const server = await gateway.start(0); + const port = (server.address() as AddressInfo).port; + gwUrl = `http://127.0.0.1:${port}`; + }); + + after(async () => { + return gateway.stop(); + }); + + let app: RestApplication; + + afterEach(async () => { + if (app) await app.stop(); + (app as unknown) = undefined; + }); + + beforeEach(async () => { + await givenAppWithCustomConfig({ + // Push metrics each 10 ms + pushGateway: {url: gwUrl, interval: 10}, + }); + }); + + it('pushes metrics to gateway', async () => { + // Wait for 100 ms + await promisify(setTimeout)(50); + const request = supertest(gwUrl); + // Now we expect to get LoopBack metrics from the push gateway + await request.get('/metrics').expect(200, /job="loopback"/); + }); + + async function givenAppWithCustomConfig(config: MetricsOptions) { + app = givenRestApplication(); + app.configure(MetricsBindings.COMPONENT).to(config); + app.component(MetricsComponent); + await app.start(); + } + + function givenRestApplication(config?: RestServerConfig) { + const rest = Object.assign({}, givenHttpServerConfig(), config); + return new RestApplication({rest}); + } +}); diff --git a/extensions/metrics/src/__tests__/acceptance/metrics.acceptance.ts b/extensions/metrics/src/__tests__/acceptance/metrics.acceptance.ts new file mode 100644 index 000000000000..469bf6e835f3 --- /dev/null +++ b/extensions/metrics/src/__tests__/acceptance/metrics.acceptance.ts @@ -0,0 +1,101 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {RestApplication, RestServerConfig} from '@loopback/rest'; +import { + Client, + createRestAppClient, + expect, + givenHttpServerConfig, +} from '@loopback/testlab'; +import {MetricsBindings, MetricsComponent, MetricsOptions} from '../..'; + +describe('Metrics (acceptance)', () => { + let app: RestApplication; + let request: Client; + + afterEach(async () => { + if (app) await app.stop(); + (app as unknown) = undefined; + }); + + context('with default config', () => { + beforeEach(async () => { + app = givenRestApplication(); + app.component(MetricsComponent); + await app.start(); + request = createRestAppClient(app); + }); + + it('exposes metrics at "/metrics/"', async () => { + const res = await request + .get('/metrics') + .expect(200) + .expect('content-type', /text/); + expect(res.text).to.match(/# TYPE/); + expect(res.text).to.match(/# HELP/); + }); + }); + + context('with custom defaultMetrics', () => { + it('honors prefix', async () => { + await givenAppWithCustomConfig({ + defaultMetrics: { + // `-` is not allowed + prefix: 'myapp_', + }, + }); + await request + .get('/metrics') + .expect(200) + .expect('content-type', /text/); + }); + }); + + context('with custom endpoint basePath', () => { + it('honors prefix', async () => { + await givenAppWithCustomConfig({ + endpoint: { + basePath: '/internal/metrics', + }, + }); + await request + .get('/internal/metrics') + .expect(200) + .expect('content-type', /text/); + await request.get('/metrics').expect(404); + }); + }); + + context('with defaultMetrics disabled', () => { + it('does not emit default metrics', async () => { + await givenAppWithCustomConfig({ + defaultMetrics: { + disabled: true, + }, + }); + const res = await request + .get('/metrics') + .expect(200) + .expect('content-type', /text/); + expect(res.text).to.not.match( + /# TYPE process_cpu_user_seconds_total counter/, + ); + }); + }); + + async function givenAppWithCustomConfig(config: MetricsOptions) { + app = givenRestApplication(); + app.configure(MetricsBindings.COMPONENT).to(config); + app.component(MetricsComponent); + await app.start(); + request = createRestAppClient(app); + } + + function givenRestApplication(config?: RestServerConfig) { + const rest = Object.assign({}, givenHttpServerConfig(), config); + return new RestApplication({rest}); + } +}); diff --git a/extensions/metrics/src/__tests__/acceptance/mock-pushgateway.ts b/extensions/metrics/src/__tests__/acceptance/mock-pushgateway.ts new file mode 100644 index 000000000000..9aa2f8c18cb9 --- /dev/null +++ b/extensions/metrics/src/__tests__/acceptance/mock-pushgateway.ts @@ -0,0 +1,45 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as express from 'express'; +import {Server} from 'http'; +import pEvent from 'p-event'; + +/** + * A mockup server for https://github.com/prometheus/pushgateway + */ +export class PushGateway { + private server?: Server; + private metrics: string[] = []; + + async start(port = 9091) { + const app = express(); + // A hack to force content-type to be `text/plain` as prom-client does not + // set `Content-Type` header + app.use((req, res, next) => { + req.headers['content-type'] = 'text/plain'; + next(); + }); + app.use(express.text({type: '*/*'})); + app.get('/metrics', (req, res) => { + res.send(this.metrics.join('\n')); + }); + + app.post('/metrics/job/:jobName', (req, res) => { + this.metrics.push(`job="${req.params.jobName}"\n${req.body}`); + res.send('\n'); + }); + this.server = app.listen(port); + await pEvent(this.server, 'listening'); + return this.server; + } + + async stop() { + if (!this.server) return; + this.server.close(); + await pEvent(this.server, 'close'); + this.server = undefined; + } +} diff --git a/extensions/metrics/src/controllers/index.ts b/extensions/metrics/src/controllers/index.ts new file mode 100644 index 000000000000..ae52c9f35f3d --- /dev/null +++ b/extensions/metrics/src/controllers/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './metrics.controller'; diff --git a/extensions/metrics/src/controllers/metrics.controller.ts b/extensions/metrics/src/controllers/metrics.controller.ts new file mode 100644 index 000000000000..99dc1f5e3cd5 --- /dev/null +++ b/extensions/metrics/src/controllers/metrics.controller.ts @@ -0,0 +1,28 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {bind, BindingScope, Constructor, inject} from '@loopback/core'; +import {get, Response, RestBindings} from '@loopback/rest'; +import {register} from 'prom-client'; + +export function metricsControllerFactory( + basePath = '/metrics', +): Constructor { + @bind({scope: BindingScope.SINGLETON}) + class MetricsController { + @get(basePath, { + responses: {}, + 'x-visibility': 'undocumented', + }) + report(@inject(RestBindings.Http.RESPONSE) res: Response) { + // Set the content type from the register + res.contentType(register.contentType); + res.send(register.metrics()); + return res; + } + } + + return MetricsController; +} diff --git a/extensions/metrics/src/index.ts b/extensions/metrics/src/index.ts new file mode 100644 index 000000000000..6eb8e3e1c197 --- /dev/null +++ b/extensions/metrics/src/index.ts @@ -0,0 +1,9 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from 'prom-client'; +export * from './keys'; +export * from './metrics.component'; +export * from './types'; diff --git a/extensions/metrics/src/interceptors/index.ts b/extensions/metrics/src/interceptors/index.ts new file mode 100644 index 000000000000..40b9b7a34f31 --- /dev/null +++ b/extensions/metrics/src/interceptors/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './metrics.interceptor'; diff --git a/extensions/metrics/src/interceptors/metrics.interceptor.ts b/extensions/metrics/src/interceptors/metrics.interceptor.ts new file mode 100644 index 000000000000..ced0bd759a01 --- /dev/null +++ b/extensions/metrics/src/interceptors/metrics.interceptor.ts @@ -0,0 +1,95 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + asGlobalInterceptor, + bind, + BindingScope, + Interceptor, + InvocationContext, + Provider, + ValueOrPromise, +} from '@loopback/context'; +import {Counter, Gauge, Histogram, register, Summary} from 'prom-client'; + +/** + * This interceptor captures metrics for method invocations, + * excluding sequence actions and middleware executed before + * a method is invoked. Please collect metrics at other places + * if you want to cover more than just method invocations. + */ +@bind(asGlobalInterceptor('metrics'), {scope: BindingScope.SINGLETON}) +export class MetricsInterceptor implements Provider { + private static gauge: Gauge; + + private static histogram: Histogram; + + private static counter: Counter; + + private static summary: Summary; + + private static setup() { + // Check if the gauge is registered + if ( + this.gauge && + register.getSingleMetric('loopback_invocation_duration_seconds') + ) + return; + // The constructor will register the metric with the global registry + this.gauge = new Gauge({ + name: 'loopback_invocation_duration_seconds', + help: 'method invocation', + labelNames: ['targetName'], + }); + + this.histogram = new Histogram({ + name: 'loopback_invocation_duration_histogram', + help: 'method invocation histogram', + labelNames: ['targetName'], + }); + + this.counter = new Counter({ + name: 'loopback_invocation_total', + help: 'method invocation counts', + labelNames: ['targetName'], + }); + + this.summary = new Summary({ + name: 'loopback_invocation_duration_summary', + help: 'method invocation summary', + labelNames: ['targetName'], + }); + } + + constructor() {} + + value() { + return this.intercept.bind(this); + } + + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + MetricsInterceptor.setup(); + const endGauge = MetricsInterceptor.gauge.startTimer({ + targetName: invocationCtx.targetName, + }); + const endHistogram = MetricsInterceptor.histogram.startTimer({ + targetName: invocationCtx.targetName, + }); + const endSummary = MetricsInterceptor.summary.startTimer({ + targetName: invocationCtx.targetName, + }); + try { + MetricsInterceptor.counter.inc(); + return await next(); + } finally { + endGauge(); + endHistogram(); + endSummary(); + } + } +} diff --git a/extensions/metrics/src/keys.ts b/extensions/metrics/src/keys.ts new file mode 100644 index 000000000000..303ce893094e --- /dev/null +++ b/extensions/metrics/src/keys.ts @@ -0,0 +1,17 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BindingKey} from '@loopback/context'; +import {CoreBindings} from '@loopback/core'; +import {MetricsComponent} from './metrics.component'; + +/** + * Binding keys used by this component. + */ +export namespace MetricsBindings { + export const COMPONENT = BindingKey.create( + `${CoreBindings.COMPONENTS}.MetricsComponent`, + ); +} diff --git a/extensions/metrics/src/metrics.component.ts b/extensions/metrics/src/metrics.component.ts new file mode 100644 index 000000000000..6e521122976e --- /dev/null +++ b/extensions/metrics/src/metrics.component.ts @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + bind, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + config, + ContextTags, + createBindingFromClass, + inject, +} from '@loopback/context'; +import {Application, Component, CoreBindings} from '@loopback/core'; +import {metricsControllerFactory} from './controllers'; +import {MetricsInterceptor} from './interceptors'; +import {MetricsBindings} from './keys'; +import {MetricsObserver, MetricsPushObserver} from './observers'; +import {DEFAULT_METRICS_OPTIONS, MetricsOptions} from './types'; + +/** + * A component providing metrics for Prometheus + */ +@bind({tags: {[ContextTags.KEY]: MetricsBindings.COMPONENT}}) +export class MetricsComponent implements Component { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) + private application: Application, + @config() + options: MetricsOptions = DEFAULT_METRICS_OPTIONS, + ) { + if ( + !options.defaultMetrics || + (options.defaultMetrics && !options.defaultMetrics.disabled) + ) { + this.application.lifeCycleObserver(MetricsObserver); + } + if ( + !options.pushGateway || + (options.pushGateway && !options.pushGateway.disabled) + ) { + this.application.lifeCycleObserver(MetricsPushObserver); + } + this.application.add(createBindingFromClass(MetricsInterceptor)); + if (!options.endpoint || (options.endpoint && !options.endpoint.disabled)) { + this.application.controller( + metricsControllerFactory(options.endpoint && options.endpoint.basePath), + ); + } + } +} diff --git a/extensions/metrics/src/observers/index.ts b/extensions/metrics/src/observers/index.ts new file mode 100644 index 000000000000..ed69679e9a98 --- /dev/null +++ b/extensions/metrics/src/observers/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './metrics.observer'; +export * from './metrics.push.observer'; diff --git a/extensions/metrics/src/observers/metrics.observer.ts b/extensions/metrics/src/observers/metrics.observer.ts new file mode 100644 index 000000000000..aec0669dff0a --- /dev/null +++ b/extensions/metrics/src/observers/metrics.observer.ts @@ -0,0 +1,34 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {config, LifeCycleObserver} from '@loopback/core'; +import {collectDefaultMetrics, register} from 'prom-client'; +import {MetricsBindings} from '../keys'; +import {DEFAULT_METRICS_OPTIONS, MetricsOptions} from '../types'; + +/** + * An observer to set default Node.js metrics collection + */ +export class MetricsObserver implements LifeCycleObserver { + private interval: NodeJS.Timeout | undefined; + + constructor( + @config({fromBinding: MetricsBindings.COMPONENT}) + private options: MetricsOptions = DEFAULT_METRICS_OPTIONS, + ) {} + + start() { + const defaultMetricsConfig = this.options.defaultMetrics; + this.interval = collectDefaultMetrics(defaultMetricsConfig); + } + + stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = undefined; + register.clear(); + } + } +} diff --git a/extensions/metrics/src/observers/metrics.push.observer.ts b/extensions/metrics/src/observers/metrics.push.observer.ts new file mode 100644 index 000000000000..c14f2bd4e4c3 --- /dev/null +++ b/extensions/metrics/src/observers/metrics.push.observer.ts @@ -0,0 +1,38 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {config, LifeCycleObserver} from '@loopback/core'; +import {Pushgateway} from 'prom-client'; +import {MetricsBindings} from '../keys'; +import {DEFAULT_METRICS_OPTIONS, MetricsOptions} from '../types'; + +/** + * An observer to set up periodical push of metrics to a push gateway + */ +export class MetricsPushObserver implements LifeCycleObserver { + private interval: NodeJS.Timeout | undefined; + private gateway: Pushgateway; + + constructor( + @config({fromBinding: MetricsBindings.COMPONENT}) + private options: MetricsOptions = DEFAULT_METRICS_OPTIONS, + ) {} + + start() { + const gwConfig = this.options.pushGateway; + if (!gwConfig) return; + this.gateway = new Pushgateway(gwConfig.url); + this.interval = setInterval(() => { + this.gateway.pushAdd({jobName: 'loopback'}, () => {}); + }, gwConfig.interval || 5000); + } + + stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = undefined; + } + } +} diff --git a/extensions/metrics/src/types.ts b/extensions/metrics/src/types.ts new file mode 100644 index 000000000000..5df3d8241c1c --- /dev/null +++ b/extensions/metrics/src/types.ts @@ -0,0 +1,32 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/extension-metrics +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {DefaultMetricsCollectorConfiguration} from 'prom-client'; + +export interface MetricsOptions { + endpoint?: { + disabled?: boolean; + basePath?: string; + }; + + defaultMetrics?: { + disabled?: boolean; + } & DefaultMetricsCollectorConfiguration; + + pushGateway?: { + disabled?: boolean; + url: string; + interval?: number; + }; +} + +export const DEFAULT_METRICS_OPTIONS: MetricsOptions = { + endpoint: { + basePath: '/metrics', + }, + defaultMetrics: { + timeout: 5000, + }, +}; diff --git a/extensions/metrics/tsconfig.build.json b/extensions/metrics/tsconfig.build.json new file mode 100644 index 000000000000..c7b8e49eaca5 --- /dev/null +++ b/extensions/metrics/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +}