diff --git a/client/package-lock.json b/client/package-lock.json index 19a08b7444..ac9c5caea9 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -28,6 +28,7 @@ "dompurify": "^3.2.4", "dropzone": "^6.0.0-beta.2", "express": "^5.1.0", + "express-prom-bundle": "^8.0.0", "file-saver": "^2.0.5", "filesize": "^6.4.0", "graphql": "^16.8.1", @@ -41,6 +42,7 @@ "mermaid": "^11.10.0", "morgan": "^1.10.1", "pica": "^9.0.1", + "prom-client": "^15.1.3", "query-string": "^6.14.1", "react": "^18.2.0", "react-autosuggest": "^10.1.0", @@ -4838,6 +4840,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -8609,7 +8620,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": "*", @@ -8631,7 +8641,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": "*" @@ -8936,7 +8945,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", - "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8947,7 +8955,6 @@ "version": "5.0.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -8981,7 +8988,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/istanbul-lib-coverage": { @@ -9283,7 +9289,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": { @@ -9304,7 +9309,6 @@ "version": "20.11.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.7.tgz", "integrity": "sha512-GPmeN1C3XAyV5uybAf4cMLWT9fDWcmQhZVtMFu7OR32WjrqGG+Wnk2V1d0bmtUyE/Zy1QJ9BxyiTih9z8Oks8A==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -9339,14 +9343,12 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "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/react": { @@ -9435,7 +9437,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -9446,7 +9447,6 @@ "version": "1.15.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -11133,6 +11133,12 @@ "node": ">=8" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -15523,6 +15529,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-prom-bundle": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-8.0.0.tgz", + "integrity": "sha512-UHdpaMks6Z/tvxQsNzhsE7nkdXb4/zEh/jwN0tfZSZOEF+aD0dlfl085EU4jveOq09v01c5sIUfjV4kJODZ2eQ==", + "license": "MIT", + "dependencies": { + "@types/express": "^5.0.0", + "on-finished": "^2.3.0", + "url-value-parser": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "prom-client": ">=15.0.0" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -31685,6 +31708,19 @@ "node": ">=8" } }, + "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==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -37317,6 +37353,15 @@ "node": ">=10" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/terminal-link": { "version": "2.1.1", "dev": true, @@ -37919,8 +37964,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unified": { "version": "10.1.2", @@ -38144,6 +38188,15 @@ "requires-port": "^1.0.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/use-isomorphic-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", diff --git a/client/package.json b/client/package.json index fd345bf34e..e00ed6f171 100644 --- a/client/package.json +++ b/client/package.json @@ -69,6 +69,7 @@ "dompurify": "^3.2.4", "dropzone": "^6.0.0-beta.2", "express": "^5.1.0", + "express-prom-bundle": "^8.0.0", "file-saver": "^2.0.5", "filesize": "^6.4.0", "graphql": "^16.8.1", @@ -82,6 +83,7 @@ "mermaid": "^11.10.0", "morgan": "^1.10.1", "pica": "^9.0.1", + "prom-client": "^15.1.3", "query-string": "^6.14.1", "react": "^18.2.0", "react-autosuggest": "^10.1.0", diff --git a/client/server.js b/client/server.js index f0f47d074f..10082d5ac4 100644 --- a/client/server.js +++ b/client/server.js @@ -22,17 +22,22 @@ import morgan from "morgan"; const BUILD_PATH = "./build/server/index.js"; const DEVELOPMENT = process.env.NODE_ENV === "development"; -const PORT = Number.parseInt(process.env.PORT || "3000"); +const PORT = Number.parseInt(process.env.PORT || "3000", 10); +const METRICS_ENABLED = + !!+process.env.METRICS_ENABLED || + process.env.METRICS_ENABLED?.toLowerCase() === "true" || + false; +const METRICS_PORT = Number.parseInt(process.env.METRICS_PORT || "9090", 10); + +if (DEVELOPMENT) { + throw new Error("Can only run in production"); +} const app = express(); app.use(compression()); app.disable("x-powered-by"); -if (DEVELOPMENT) { - throw new Error("Can only run in production"); -} - // eslint-disable-next-line no-console console.log("Starting production server"); @@ -107,6 +112,34 @@ if (process.env.CI !== "1") { // Client files app.use(express.static("build/client")); +// Register metrics for application routes, we do not want to collect metrics for the routes above +if (METRICS_ENABLED) { + // eslint-disable-next-line no-console + console.log("Setting up metrics"); + + const metricsApp = express(); + metricsApp.use(compression()); + metricsApp.disable("x-powered-by"); + + await import(BUILD_PATH).then( + async ( + /** + * @import * as ModuleType from './server/app' + * @type {ModuleType} */ + mod + ) => { + await mod.metrics({ app, metricsApp }); + } + ); + + metricsApp.listen(METRICS_PORT, () => { + // eslint-disable-next-line no-console + console.log( + `Prometheus exporter is running at http://localhost:${METRICS_PORT}/metrics` + ); + }); +} + // Server-side rendering app.use( await import(BUILD_PATH).then( @@ -121,5 +154,5 @@ app.use( app.listen(PORT, () => { // eslint-disable-next-line no-console - console.log(`Server is running on http://localhost:${PORT}`); + console.log(`Server is running at http://localhost:${PORT}`); }); diff --git a/client/server/app.ts b/client/server/app.ts index c36efa97db..a97a6c872f 100644 --- a/client/server/app.ts +++ b/client/server/app.ts @@ -11,3 +11,5 @@ app.use( ); export * as constants from "./constants"; + +export { metrics } from "./metrics"; diff --git a/client/server/metrics.ts b/client/server/metrics.ts new file mode 100644 index 0000000000..4e58cbd5bd --- /dev/null +++ b/client/server/metrics.ts @@ -0,0 +1,70 @@ +/*! + * Copyright 2025 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Express } from "express"; +import type { PrometheusContentType, Registry } from "prom-client"; + +interface MetricsArgs { + app: Express; + metricsApp: Express; +} + +export async function metrics({ + app, + metricsApp, +}: MetricsArgs): Promise> { + // Initialize metrics + const promClient = await import("prom-client"); + const promBundle = (await import("express-prom-bundle")).default; + const register = new promClient.Registry(); + + // Collect default metrics + promClient.collectDefaultMetrics({ register }); + + // Register the "prom-bundle" middleware + app.use( + promBundle({ + autoregister: false, + includeMethod: true, + includeStatusCode: true, + metricsApp, + promRegistry: register, + }) + ); + + // Collect HTTP requests total (App only) + const requestCounter = new promClient.Counter({ + name: "http_requests_total", + help: "Total number of HTTP requests.", + labelNames: ["method", "status_code"], + registers: [register], + }); + app.use(async (req, res, next) => { + res.on("close", () => { + requestCounter + .labels({ + method: req.method, + status_code: res.statusCode, + }) + .inc(); + }); + next(); + }); + + return register; +}