diff --git a/package-lock.json b/package-lock.json index cc39094..9c6a503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "ejs": "^3.1.10", "email-validator": "^2.0.4", "express": "^4.17.1", + "express-prom-bundle": "^7.0.2", "express-rate-limit": "^6.7.0", "fs-extra": "^8.1.0", "got": "^11.8.2", @@ -1636,6 +1637,16 @@ "node": ">=6" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pretendonetwork/eslint-config": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@pretendonetwork/eslint-config/-/eslint-config-0.2.0.tgz", @@ -1965,7 +1976,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -1998,7 +2008,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2035,7 +2044,6 @@ "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -2048,7 +2056,6 @@ "version": "4.19.9", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -2085,7 +2092,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2131,7 +2137,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/morgan": { @@ -2184,14 +2189,12 @@ "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/responselike": { @@ -2207,7 +2210,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2217,7 +2219,6 @@ "version": "1.15.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -2229,7 +2230,6 @@ "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -3302,6 +3302,13 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT", + "peer": true + }, "node_modules/bit-buffer": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/bit-buffer/-/bit-buffer-0.2.5.tgz", @@ -5308,6 +5315,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-prom-bundle": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-7.0.2.tgz", + "integrity": "sha512-ffFV4HGHvCKnkNJFqm42sYztRJE5mLgOj8MpGey1HOatuFhtcwXoBD2m5gca7ZbcyjkIf7lOH5ZdrhlrBf0sGw==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.21", + "express": "^4.18.2", + "on-finished": "^2.3.0", + "url-value-parser": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "prom-client": ">=15.0.0" + } + }, "node_modules/express-rate-limit": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz", @@ -8154,6 +8179,21 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "deprecated": "prom-client has been replaced by @prometheus-io/client", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9328,6 +9368,16 @@ "node": ">=10" } }, + "node_modules/tdigest": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.3.tgz", + "integrity": "sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==", + "license": "MIT", + "peer": true, + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/tga": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/tga/-/tga-1.0.7.tgz", @@ -9813,6 +9863,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-value-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.2.0.tgz", + "integrity": "sha512-yIQdxJpgkPamPPAPuGdS7Q548rLhny42tg8d4vyTNzFqvOnwqrgHXvgehT09U7fwrzxi3RxCiXjoNUNnNOlQ8A==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 3d00970..a389e69 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "ejs": "^3.1.10", "email-validator": "^2.0.4", "express": "^4.17.1", + "express-prom-bundle": "^7.0.2", "express-rate-limit": "^6.7.0", "fs-extra": "^8.1.0", "got": "^11.8.2", diff --git a/src/config-manager.ts b/src/config-manager.ts index 3499f05..7c0ef58 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -38,6 +38,10 @@ export const config: Config = { http: { port: Number(process.env.PN_ACT_CONFIG_HTTP_PORT || '') }, + metrics: { + enabled: process.env.PN_ACT_CONFIG_METRICS_ENABLED === 'true', + port: Number(process.env.PN_ACT_CONFIG_METRICS_PORT || '') + }, mongoose: { connection_string: process.env.PN_ACT_CONFIG_MONGO_CONNECTION_STRING || '', options: mongooseConnectOptions diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..5991220 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,47 @@ +import { format } from 'node:util'; +import expressMetrics from 'express-prom-bundle'; +import express from 'express'; +import { LOG_ERROR, LOG_INFO, LOG_SUCCESS } from '@/logger'; +import { config } from '@/config-manager'; +import type { Express, NextFunction, Request, Response } from 'express'; + +export function registerMetrics(app: Express): Express { + const metrics = express(); + + if (config.metrics.enabled) { + LOG_INFO('Setting up metrics'); + app.use(expressMetrics({ + // * Include full express and nodejs metrics + includeMethod: true, + includePath: true, + urlValueParser: { + minBase64Length: 15 + }, + promClient: { + collectDefaultMetrics: {} + }, + + // * Keep metrics on a different app (so they aren't exposed) + autoregister: false, + metricsApp: metrics + })); + } + + metrics.use((error: Error, req: Request, res: Response, _next: NextFunction) => { + LOG_ERROR(`Request failed (metrics): ${format(error)}`); + res.sendStatus(500); + }); + + return metrics; +} + +export function listenMetrics(metricsApp: Express): void { + if (!config.metrics.enabled) { + return; + } + + const port = config.metrics.port; + metricsApp.listen(port, () => { + LOG_SUCCESS(`Metrics HTTP server started on port ${port}`); + }); +} diff --git a/src/server.ts b/src/server.ts index 88f8f6a..289004f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ import assets from '@/services/assets'; import healthz from '@/services/healthz'; import { config, disabledFeatures } from '@/config-manager'; import { startProvisioner } from '@/provisioning'; +import { listenMetrics, registerMetrics } from '@/metrics'; process.title = 'Pretendo - Account'; process.on('uncaughtException', (err, origin) => { @@ -31,6 +32,9 @@ process.on('SIGTERM', () => { const app = express(); +// * Metrics has to happen first so we can measure the other middleware +const metricsApp = registerMetrics(app); + // * START APPLICATION app.set('view engine', 'ejs'); app.set('views', __dirname + '/views'); @@ -124,6 +128,7 @@ async function main(): Promise { app.listen(config.http.port, () => { LOG_SUCCESS(`HTTP server started on port ${config.http.port}`); }); + listenMetrics(metricsApp); } main().catch(console.error); diff --git a/src/types/common/config.ts b/src/types/common/config.ts index 0eab22f..ec100e1 100644 --- a/src/types/common/config.ts +++ b/src/types/common/config.ts @@ -9,6 +9,10 @@ export interface Config { http: { port: number; }; + metrics: { + enabled: boolean; + port: number; + }; mongoose: { connection_string: string; options: mongoose.ConnectOptions;