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 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 + } +} 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() 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, ] 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', + }, + }) +} diff --git a/package-lock.json b/package-lock.json index 4d46f822..d80ba5bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "nodemailer": "^8.0.9", "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", @@ -4384,6 +4385,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", @@ -10822,6 +10833,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", @@ -17567,6 +17584,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", @@ -20585,6 +20615,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 fe7c5f16..e820003a 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "nodemailer": "^8.0.9", "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",