From 824aa8f2e2b6532e89e5487f0bc3d1f217534248 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 16:45:58 +0200 Subject: [PATCH 1/6] feat: install prometheus node client --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 40 insertions(+) diff --git a/package-lock.json b/package-lock.json index 68fc680d..d9a5ee14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "nodemailer": "^8.0.5", "pg": "^8.20.0", "postgres": "^3.4.9", + "prom-client": "^15.1.3", "radix-ui": "^1.4.3", "react": "^19.2.6", "react-chartjs-2": "^5.3.1", @@ -4369,6 +4370,16 @@ "dev": true, "license": "MIT" }, + "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/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -10803,6 +10814,12 @@ "node": "*" } }, + "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.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -17560,6 +17577,19 @@ "node": ">=6" } }, + "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/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -20587,6 +20617,15 @@ "url": "https://opencollective.com/webpack" } }, + "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/three": { "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", diff --git a/package.json b/package.json index 91905fa8..912bd859 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "nodemailer": "^8.0.5", "pg": "^8.20.0", "postgres": "^3.4.9", + "prom-client": "^15.1.3", "radix-ui": "^1.4.3", "react": "^19.2.6", "react-chartjs-2": "^5.3.1", From 891b53e5ef73ea6613f3e5176d0f23b9513b6e62 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 16:47:24 +0200 Subject: [PATCH 2/6] feat: metrics registry, create metrics --- app/lib/metrics.server.ts | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 app/lib/metrics.server.ts diff --git a/app/lib/metrics.server.ts b/app/lib/metrics.server.ts new file mode 100644 index 00000000..1b597c5c --- /dev/null +++ b/app/lib/metrics.server.ts @@ -0,0 +1,49 @@ +import client from 'prom-client' + +type Metrics = { + register: client.Registry + httpRequestsTotal: client.Counter<'method' | 'status_code'> + httpRequestDurationSeconds: client.Histogram<'method' | 'status_code'> +} + +function createMetrics(): Metrics { + const register = new client.Registry() + + register.setDefaultLabels({ + application: 'osem_frontend', + }) + + client.collectDefaultMetrics({ + register, + prefix: 'osem_', + }) + + const httpRequestsTotal = new client.Counter({ + name: 'osem_http_requests_total', + help: 'Total number of HTTP requests handled by the React Router server', + labelNames: ['method', 'status_code'], + registers: [register], + }) + + const httpRequestDurationSeconds = new client.Histogram({ + name: 'osem_http_request_duration_seconds', + help: 'HTTP request duration in seconds for requests handled by the React Router server', + labelNames: ['method', 'status_code'], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [register], + }) + + return { + register, + httpRequestsTotal, + httpRequestDurationSeconds, + } +} + +declare global { + var __osemMetrics: Metrics | undefined +} + +export const metrics = globalThis.__osemMetrics ?? createMetrics() + +globalThis.__osemMetrics = metrics From 3df2e56d0bea1c539d4d995334e36335dded6a81 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 16:47:46 +0200 Subject: [PATCH 3/6] feat: metrics middleware --- app/middleware/metrics.server.ts | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 app/middleware/metrics.server.ts diff --git a/app/middleware/metrics.server.ts b/app/middleware/metrics.server.ts new file mode 100644 index 00000000..a6f9aa2c --- /dev/null +++ b/app/middleware/metrics.server.ts @@ -0,0 +1,40 @@ +import { metrics } from '~/lib/metrics.server' + +export async function prometheusMetricsMiddleware( + { request }: { request: Request }, + next: () => Promise, +) { + const url = new URL(request.url) + + // Do not count Prometheus scrapes as application traffic. + if (url.pathname === '/metrics') { + return next() + } + + const end = metrics.httpRequestDurationSeconds.startTimer({ + method: request.method, + }) + + try { + const response = await next() + const statusCode = String(response.status) + + end({ status_code: statusCode }) + + metrics.httpRequestsTotal.inc({ + method: request.method, + status_code: statusCode, + }) + + return response + } catch (error) { + end({ status_code: '500' }) + + metrics.httpRequestsTotal.inc({ + method: request.method, + status_code: '500', + }) + + throw error + } +} From 0bd709374b8b8a1924d57e3598df8a8bffbf5153 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 16:48:03 +0200 Subject: [PATCH 4/6] feat: expose metrics endpoint --- app/routes/metrics.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 app/routes/metrics.ts diff --git a/app/routes/metrics.ts b/app/routes/metrics.ts new file mode 100644 index 00000000..b16e972d --- /dev/null +++ b/app/routes/metrics.ts @@ -0,0 +1,11 @@ +import { metrics } from '~/lib/metrics.server' + +export async function loader() { + return new Response(await metrics.register.metrics(), { + status: 200, + headers: { + 'Content-Type': metrics.register.contentType, + 'Cache-Control': 'no-store', + }, + }) +} From 936c43c162ab3fcda902023ad0051e11772e1b48 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 16:48:21 +0200 Subject: [PATCH 5/6] feat: exclude metrics endpoint from tos middleware --- app/middleware/tos-ui.server.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/middleware/tos-ui.server.ts b/app/middleware/tos-ui.server.ts index 759c230f..c104656d 100644 --- a/app/middleware/tos-ui.server.ts +++ b/app/middleware/tos-ui.server.ts @@ -19,6 +19,10 @@ export async function tosUiMiddleware( ) { const url = new URL(request.url) + if (url.pathname === '/metrics') { + return next() + } + if (url.pathname.startsWith('/api')) { // handled by tos-api middleware return next() From 136c0aa130cb73a415676b6b8ba7750d374b6ca2 Mon Sep 17 00:00:00 2001 From: jona159 Date: Thu, 21 May 2026 16:48:40 +0200 Subject: [PATCH 6/6] feat: add prometheus middleware to root middlewares --- app/root.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/root.tsx b/app/root.tsx index baf47f24..7e128f2a 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -21,9 +21,11 @@ import { updateUserlocale } from './db/models/user.server' import { getEnv } from './lib/env.server' import { getLocale, i18nCookie, i18nextMiddleware } from './middleware/i18next' import { tosUiMiddleware } from './middleware/tos-ui.server' +import { prometheusMetricsMiddleware } from './middleware/metrics.server' import { getUser } from './services/session-service.server' export const middleware: Route.MiddlewareFunction[] = [ + prometheusMetricsMiddleware, i18nextMiddleware, tosUiMiddleware, ]