From a437e5eb400367183367806d42da8991759c8fd8 Mon Sep 17 00:00:00 2001 From: AtilaU19 Date: Mon, 20 Oct 2025 16:46:08 +0000 Subject: [PATCH 1/2] feat(backend): add Prometheus monitoring and metrics This change adds the `prom-client` dependency and a new PrometheusAgent class to expose a `/metrics` endpoint (on port $METRICS_PORT, default 3099). New metrics are registered for: - API calls (success/errors) - Webhook processing (events/errors) - Feedback submissions (total/failures) - A histogram for feedback ratings (1-10). The server.js file is instrumented to track these metrics, and the docker-compose.yml is updated to expose the new metrics port. --- backend/http-server.js | 53 ++++++++++++++ backend/package.json | 1 + backend/prometheus-agent.js | 134 ++++++++++++++++++++++++++++++++++++ backend/server.js | 81 +++++++++++++++++++++- docker-compose.yml | 2 + 5 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 backend/http-server.js create mode 100644 backend/prometheus-agent.js diff --git a/backend/http-server.js b/backend/http-server.js new file mode 100644 index 0000000..666933d --- /dev/null +++ b/backend/http-server.js @@ -0,0 +1,53 @@ +import http from "http"; + +let Logger; + +export default class HttpServer { + constructor(host, port, callback, logger) { + this.host = host; + this.port = port; + this.requestCallback = callback; + Logger = logger; + this.server = null; + } + + start () { + this.server = http.createServer(this.requestCallback) + .on('error', this.handleError.bind(this)) + .on('clientError', this.handleError.bind(this)); + } + + close (callback) { + if (this.server) { + return this.server.close(callback); + } + } + + handleError (error) { + if (error.code === 'EADDRINUSE') { + Logger.warn({ + host: this.host, port: this.port, + }, "EADDRINUSE, won't spawn HTTP server"); + if (this.server) { + this.server.close(); + } + } else if (error.code === 'ECONNRESET') { + Logger.warn({ errorMessage: error.message }, "HTTPServer: ECONNRESET "); + } else { + Logger.error(error, "Returned error"); + } + } + + getServerObject() { + return this.server; + } + + listen(callback) { + Logger.info(`HTTPServer is listening: ${this.host}:${this.port}`); + if (this.server) { + this.server.listen(this.port, this.host, callback); + } else { + Logger.error("Server not started, call start() before listen()"); + } + } +} diff --git a/backend/package.json b/backend/package.json index fee654e..97d0289 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "body-parser": "^1.19.0", "express": "^4.19.2", "pino": "^9.3.2", + "prom-client": "^15.1.3", "redis": "^4.7.0" }, "scripts": { diff --git a/backend/prometheus-agent.js b/backend/prometheus-agent.js new file mode 100644 index 0000000..9a7638b --- /dev/null +++ b/backend/prometheus-agent.js @@ -0,0 +1,134 @@ +import { + register, + collectDefaultMetrics, +} from 'prom-client'; +import HTTPServer from './http-server.js'; + +let Logger; + +export default class PrometheusScrapeAgent { + constructor (host, port, options, logger) { + this.host = host; + this.port = port; + this.metrics = {}; + this.started = false; + Logger = logger; + + this.path = options.path || '/metrics'; + this.collectDefaultMetrics = options.collectDefaultMetrics || false; + this.metricsPrefix = options.prefix || ''; + this.collectionTimeout = options.collectionTimeout || 10000; + } + + getMetric (name) { + return this.metrics[name]; + } + + async collect (response) { + try { + response.writeHead(200, { 'Content-Type': register.contentType }); + const content = await register.metrics(); + response.end(content); + } catch (error) { + response.writeHead(500) + response.end(error.message); + Logger.error(error, 'Prometheus: error collecting metrics'); + } + } + + getMetricsHandler (request, response) { + switch (request.method) { + case 'GET': + if (request.url === this.path) return this.collect(response); + response.writeHead(404).end(); + break; + default: + response.writeHead(501) + response.end(); + break; + } + } + + start (requestHandler = this.getMetricsHandler.bind(this)) { + if (this.collectDefaultMetrics) collectDefaultMetrics({ + prefix: this.metricsPrefix, + timeout: this.collectionTimeout, + }); + + this.metricsServer = new HTTPServer(this.host, this.port, requestHandler, Logger); + this.metricsServer.start(); + this.metricsServer.listen(); + this.started = true; + } + + injectMetrics (metricsDictionary) { + this.metrics = { ...this.metrics, ...metricsDictionary } + } + + increment (metricName, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + metric.inc(labelsObject) + } + } + + incrementBy (metricName, value, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + + if (metric) { + metric.inc(labelsObject, value) + } + } + + decrement (metricName, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + metric.dec(labelsObject) + } + } + + set (metricName, value, labelsObject = {}) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + metric.set(labelsObject, value) + } + } + + setCollectorWithGenerator (metricName, generator) { + const metric = this.getMetric(metricName); + if (metric) { + metric.collect = () => { + metric.set(generator()); + }; + } + } + + setCollector (metricName, collector) { + const metric = this.getMetric(metricName); + + if (metric) { + metric.collect = collector.bind(metric); + } + } + + observe(metricName, value, labelsObject = {}) { + if (!this.started) return null; + + const metric = this.metrics[metricName]; + + if (metric) { + metric.observe(labelsObject, value); + return metric; + } + + return null; + } +} diff --git a/backend/server.js b/backend/server.js index 1b9316d..f18e283 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,9 +5,13 @@ import path from 'path'; import Utils from './utils.js'; import pino from 'pino'; import { URLSearchParams } from 'url'; +import { Counter, Histogram } from 'prom-client'; +import PrometheusScrapeAgent from './prometheus-agent.js'; const app = express(); const port = process.env.PORT || 3009; +const metricsPort = process.env.METRICS_PORT || 3099; +const metricsHost = '0.0.0.0'; const FEEDBACK_URL = process.env.FEEDBACK_URL; const SHARED_SECRET = process.env.SHARED_SECRET; @@ -28,8 +32,57 @@ const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime, }); -const usersLocales = {} +const metrics = { + apiCallsTotal: new Counter({ + name: 'feedback_app_api_calls_total', + help: 'Total number of API calls made', + labelNames: ['api'], + }), + apiCallErrorsTotal: new Counter({ + name: 'feedback_app_api_call_errors_total', + help: 'Total number of API call errors', + labelNames: ['api'], + }), + webhookEventsTotal: new Counter({ + name: 'feedback_app_webhook_events_total', + help: 'Total number of webhook events received', + labelNames: ['event_type'], + }), + webhookErrorsTotal: new Counter({ + name: 'feedback_app_webhook_errors_total', + help: 'Total number of errors processing webhooks', + }), + feedbackRegistrationsTotal: new Counter({ + name: 'feedback_app_registrations_total', + help: 'Total number of feedbacks registered', + labelNames: ['source'], // 'web', 'mobile', 'skipped', 'unknown' + }), + feedbackFailuresTotal: new Counter({ + name: 'feedback_app_failures_total', + help: 'Total number of feedback registration failures', + labelNames: ['reason'], // 'missing_session_or_user', 'already_submitted', 'internal_error' + }), + feedbackRatingsHistogram: new Histogram({ + name: 'feedback_app_ratings_histogram', + help: 'Histogram of feedback ratings', + buckets: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }), +}; + +const prometheusAgent = new PrometheusScrapeAgent( + metricsHost, + metricsPort, + { + prefix: 'feedback_app_', + collectDefaultMetrics: true, + }, + logger +); +prometheusAgent.injectMetrics(metrics); +prometheusAgent.start(); +logger.info(`Prometheus metrics server running at http://${metricsHost}:${metricsPort}/metrics`); +const usersLocales = {} if (!SHARED_SECRET || !BASIC_URL) { logger.error('SHARED_SECRET, and BASIC_URL must be defined in the environment variables.'); @@ -59,6 +112,7 @@ async function createHook() { logger.info(`Final URL with checksum: ${urlWithChecksum}`); try { + prometheusAgent.increment('apiCallsTotal', { api: 'createHook' }); const response = await fetch(urlWithChecksum); if (response.ok) { const body = await response.text(); @@ -69,14 +123,17 @@ async function createHook() { success = true; } else { logger.error('Failed to parse hook ID'); + prometheusAgent.increment('apiCallErrorsTotal', { api: 'createHook' }); success = false; } } else { logger.error('Failed to create hook', response.statusText); + prometheusAgent.increment('apiCallErrorsTotal', { api: 'createHook' }); success = false; } } catch (error) { logger.error('Failed to create hook', error); + prometheusAgent.increment('apiCallErrorsTotal', { api: 'createHook' }); success = false; } @@ -93,14 +150,17 @@ async function destroyHook() { const fullUrl = `${destroyUrl}&checksum=${checksum}`; try { + prometheusAgent.increment('apiCallsTotal', { api: 'destroyHook' }); const response = await fetch(fullUrl); if (response.ok) { logger.info(`Hook with ID: ${storedHookId} destroyed`); } else { logger.error('Failed to destroy hook', response.statusText); + prometheusAgent.increment('apiCallErrorsTotal', { api: 'destroyHook' }); } } catch (error) { logger.error('Failed to destroy hook', error); + prometheusAgent.increment('apiCallErrorsTotal', { api: 'destroyHook' }); } } } @@ -158,6 +218,7 @@ app.post('/feedback/webhook', async (req, res) => { for (const evt of events) { if (evt.data.type === 'event') { const eventType = evt.data.id; + prometheusAgent.increment('webhookEventsTotal', { event_type: eventType }); if (eventType === 'meeting-created') { const meeting = evt.data.attributes.meeting; @@ -239,6 +300,7 @@ app.post('/feedback/webhook', async (req, res) => { res.status(200).send('Webhook received'); } catch (error) { + prometheusAgent.increment('webhookErrorsTotal'); logger.error(`Error processing webhook: ${error?.message || 'Unknown error'}`, { errorStack: error?.stack, errorMessage: error?.message, @@ -255,6 +317,7 @@ app.post('/feedback/submit', async (req, res) => { body = JSON.parse(req.body); } catch (e) { logger.error('Error parsing feedback body:', e); + prometheusAgent.increment('feedbackFailuresTotal', { reason: 'invalid_body' }); return res.status(400).send(); } } @@ -263,6 +326,7 @@ app.post('/feedback/submit', async (req, res) => { if (!session || !user) { logger.warn('Received feedback submission with missing session or user.', body); + prometheusAgent.increment('feedbackFailuresTotal', { reason: 'missing_session_or_user' }); return res.status(400).json({ status: 'error', message: 'Missing session or user information' }); } @@ -283,11 +347,13 @@ app.post('/feedback/submit', async (req, res) => { if (isFeedbackEmpty) { // Feedback was skipped, but we have to provide to the client the redirect url logger.info('No rating and feedback is empty, probably skipped.'); + prometheusAgent.increment('feedbackRegistrationsTotal', { source: 'skipped' }); return res.json({ status: 'success', data: essentialData }); } if (existingFeedback) { logger.warn(`Feedback already submitted for userID: ${user.userId} sessionID: ${session.sessionId}`); + prometheusAgent.increment('feedbackFailuresTotal', { reason: 'already_submitted' }); return res.status(400).json({ status: 'error', message: 'Feedback already submitted' }); } @@ -321,14 +387,22 @@ app.post('/feedback/submit', async (req, res) => { if (cleanFeedback.rating !== undefined && cleanFeedback.rating !== null) { console.log(`${new Date().toISOString()} custom-feedback [${logLevel}] : CUSTOM FEEDBACK LOG: ${JSON.stringify(cleanFeedback)}`); + + const source = device?.type || 'unknown'; + prometheusAgent.increment('feedbackRegistrationsTotal', { source: source }); + prometheusAgent.observe('feedbackRatingsHistogram', cleanFeedback.rating); + } else { - return logger.info(`Not logging feedback without rating`); + logger.info(`Not logging feedback without rating`); + // without rating -> define unwknow + prometheusAgent.increment('feedbackRegistrationsTotal', { source: 'skipped_no_rating' }); } await redisClient.set(feedbackKey, JSON.stringify(completeFeedback), { EX: REDIS_HASH_KEYS_EXPIRATION_IN_SECONDS }); if (FEEDBACK_URL) { try { + prometheusAgent.increment('apiCallsTotal', { api: 'submitFeedback' }); const response = await fetch(FEEDBACK_URL, { method: 'POST', headers: { @@ -339,9 +413,11 @@ app.post('/feedback/submit', async (req, res) => { if (!response.ok) { logger.error('Failed to send feedback to FEEDBACK_URL', response.statusText); + prometheusAgent.increment('apiCallErrorsTotal', { api: 'submitFeedback' }); } } catch (error) { logger.error('Failed to send feedback to FEEDBACK_URL', error); + prometheusAgent.increment('apiCallErrorsTotal', { api: 'submitFeedback' }); } } else { logger.debug('No FEEDBACK_URL set, logging feedback to syslog only.'); @@ -351,6 +427,7 @@ app.post('/feedback/submit', async (req, res) => { res.json({ status: 'success', data: completeFeedback }); } catch (error) { logger.error('Error submitting feedback:', error); + prometheusAgent.increment('feedbackFailuresTotal', { reason: 'internal_error' }); await Utils.redisStaleKeysCleanup(redisClient, user.userId); res.status(500).send(); } diff --git a/docker-compose.yml b/docker-compose.yml index 73753e2..382d86d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: network_mode: host ports: - "3009:3009" + - "3099:3099" environment: - FEEDBACK_URL= - REDIRECT_TIMEOUT=10000 @@ -17,5 +18,6 @@ services: - LOG_LEVEL=info - LOG_STDOUT=true - PORT=3009 + - METRICS_PORT=3099 - REDIS_HOST=localhost - REGISTER_HOOKS=false From c3e554d47fb6f8fc11abea0d0fcdcdf9e087d999 Mon Sep 17 00:00:00 2001 From: AtilaU19 Date: Mon, 20 Oct 2025 17:50:38 +0000 Subject: [PATCH 2/2] fix: moved prometheus metrics for a specific paste --- backend/{ => metrics}/http-server.js | 0 backend/metrics/index.js | 40 ++++++++++++++++++++++ backend/{ => metrics}/prometheus-agent.js | 0 backend/package-lock.json | 38 +++++++++++++++++++++ backend/server.js | 41 ++--------------------- 5 files changed, 80 insertions(+), 39 deletions(-) rename backend/{ => metrics}/http-server.js (100%) create mode 100644 backend/metrics/index.js rename backend/{ => metrics}/prometheus-agent.js (100%) diff --git a/backend/http-server.js b/backend/metrics/http-server.js similarity index 100% rename from backend/http-server.js rename to backend/metrics/http-server.js diff --git a/backend/metrics/index.js b/backend/metrics/index.js new file mode 100644 index 0000000..f4b4fcd --- /dev/null +++ b/backend/metrics/index.js @@ -0,0 +1,40 @@ +import { Counter, Histogram } from 'prom-client'; + +const metrics = { + apiCallsTotal: new Counter({ + name: 'feedback_app_api_calls_total', + help: 'Total number of API calls made', + labelNames: ['api'], + }), + apiCallErrorsTotal: new Counter({ + name: 'feedback_app_api_call_errors_total', + help: 'Total number of API call errors', + labelNames: ['api'], + }), + webhookEventsTotal: new Counter({ + name: 'feedback_app_webhook_events_total', + help: 'Total number of webhook events received', + labelNames: ['event_type'], + }), + webhookErrorsTotal: new Counter({ + name: 'feedback_app_webhook_errors_total', + help: 'Total number of errors processing webhooks', + }), + feedbackRegistrationsTotal: new Counter({ + name: 'feedback_app_registrations_total', + help: 'Total number of feedbacks registered', + labelNames: ['source'], // 'web', 'mobile', 'skipped', 'unknown' + }), + feedbackFailuresTotal: new Counter({ + name: 'feedback_app_failures_total', + help: 'Total number of feedback registration failures', + labelNames: ['reason'], // 'missing_session_or_user', 'already_submitted', 'internal_error' + }), + feedbackRatingsHistogram: new Histogram({ + name: 'feedback_app_ratings_histogram', + help: 'Histogram of feedback ratings', + buckets: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }), +}; + +export default metrics; diff --git a/backend/prometheus-agent.js b/backend/metrics/prometheus-agent.js similarity index 100% rename from backend/prometheus-agent.js rename to backend/metrics/prometheus-agent.js diff --git a/backend/package-lock.json b/backend/package-lock.json index abc93ce..4fd2289 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,9 +11,19 @@ "body-parser": "^1.19.0", "express": "^4.19.2", "pino": "^9.3.2", + "prom-client": "^15.1.3", "redis": "^4.7.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/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -125,6 +135,12 @@ ], "license": "MIT" }, + "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/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -784,6 +800,19 @@ "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==", "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==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1078,6 +1107,15 @@ "safe-buffer": "~5.2.0" } }, + "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/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", diff --git a/backend/server.js b/backend/server.js index f18e283..a41f770 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,8 +5,8 @@ import path from 'path'; import Utils from './utils.js'; import pino from 'pino'; import { URLSearchParams } from 'url'; -import { Counter, Histogram } from 'prom-client'; -import PrometheusScrapeAgent from './prometheus-agent.js'; +import PrometheusScrapeAgent from './metrics/prometheus-agent.js'; +import metrics from './metrics/index.js'; const app = express(); const port = process.env.PORT || 3009; @@ -32,43 +32,6 @@ const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime, }); -const metrics = { - apiCallsTotal: new Counter({ - name: 'feedback_app_api_calls_total', - help: 'Total number of API calls made', - labelNames: ['api'], - }), - apiCallErrorsTotal: new Counter({ - name: 'feedback_app_api_call_errors_total', - help: 'Total number of API call errors', - labelNames: ['api'], - }), - webhookEventsTotal: new Counter({ - name: 'feedback_app_webhook_events_total', - help: 'Total number of webhook events received', - labelNames: ['event_type'], - }), - webhookErrorsTotal: new Counter({ - name: 'feedback_app_webhook_errors_total', - help: 'Total number of errors processing webhooks', - }), - feedbackRegistrationsTotal: new Counter({ - name: 'feedback_app_registrations_total', - help: 'Total number of feedbacks registered', - labelNames: ['source'], // 'web', 'mobile', 'skipped', 'unknown' - }), - feedbackFailuresTotal: new Counter({ - name: 'feedback_app_failures_total', - help: 'Total number of feedback registration failures', - labelNames: ['reason'], // 'missing_session_or_user', 'already_submitted', 'internal_error' - }), - feedbackRatingsHistogram: new Histogram({ - name: 'feedback_app_ratings_histogram', - help: 'Histogram of feedback ratings', - buckets: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - }), -}; - const prometheusAgent = new PrometheusScrapeAgent( metricsHost, metricsPort,