feat(api): add Prometheus metrics and OpenTelemetry instrumentation for HTTP, auth, classifications, and diagnoses

This commit is contained in:
MythEclipse
2026-06-07 18:12:32 +07:00
parent 3b7ccad619
commit ede1c480be
9 changed files with 128 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
.claude/
.codegraph/
+7 -1
View File
@@ -13,11 +13,17 @@
},
"dependencies": {
"@elysiajs/cors": "1.4.2",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/exporter-prometheus": "0.218.0",
"@opentelemetry/instrumentation-http": "0.218.0",
"@opentelemetry/sdk-node": "0.218.0",
"@opentelemetry/semantic-conventions": "1.41.1",
"@zeavis/shared": "workspace:*",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"elysia": "^1.4.28",
"postgres": "^3.4.9"
"postgres": "^3.4.9",
"prom-client": "15.1.3"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
+21
View File
@@ -9,6 +9,9 @@ import { diagnosisRoutes } from './routes/diagnoses';
import { dashboardRoutes } from './routes/dashboard';
import { authRoutes } from './routes/auth';
import { expertRoutes } from './routes/expert';
import { metricsRoutes } from './routes/metrics';
import { httpRequestCounter, httpRequestDuration, httpRequestsActive } from './lib/telemetry';
import './types';
assertRequiredEnv();
@@ -17,6 +20,24 @@ const app = new Elysia()
origin: env.webAppUrl,
credentials: true,
}))
.use(metricsRoutes)
.onBeforeHandle(({ request, path }) => {
httpRequestsActive.inc();
request.metricsStart = performance.now();
request.metricsPath = path;
})
.onAfterHandle(({ request, set }) => {
const start = (request as any).metricsStart as number | undefined;
const path = (request as any).metricsPath as string | undefined;
if (start && path) {
const duration = (performance.now() - start) / 1000;
const method = request.method;
const status = set.status ?? 200;
httpRequestCounter.labels(method, path, String(status)).inc();
httpRequestDuration.labels(method, path).observe(duration);
}
httpRequestsActive.dec();
})
.use(healthRoutes)
.use(statusRoutes)
.use(authRoutes)
+62
View File
@@ -0,0 +1,62 @@
import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics } from 'prom-client';
const registry = new Registry();
// Collect default Node.js metrics (CPU, memory, event loop, etc.)
collectDefaultMetrics({ register: registry });
// ── HTTP Metrics ────────────────────────────────────────
export const httpRequestCounter = new Counter({
name: 'zeavis_api_http_requests_total',
help: 'Total number of HTTP requests handled by the API',
labelNames: ['method', 'path', 'status'] as const,
registers: [registry],
});
export const httpRequestDuration = new Histogram({
name: 'zeavis_api_http_request_duration_seconds',
help: 'Histogram of HTTP request durations in seconds',
labelNames: ['method', 'path'] as const,
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
registers: [registry],
});
export const httpRequestsActive = new Gauge({
name: 'zeavis_api_http_requests_active',
help: 'Number of HTTP requests currently being processed',
registers: [registry],
});
// ── Business Metrics ────────────────────────────────────
export const classificationCounter = new Counter({
name: 'zeavis_api_classifications_total',
help: 'Total number of classification predictions requested via API',
labelNames: ['result'] as const,
registers: [registry],
});
export const diagnosisCounter = new Counter({
name: 'zeavis_api_diagnoses_total',
help: 'Total number of diagnoses created',
labelNames: ['disease'] as const,
registers: [registry],
});
export const authCounter = new Counter({
name: 'zeavis_api_auth_operations_total',
help: 'Total authentication operations (login, register, refresh)',
labelNames: ['operation', 'success'] as const,
registers: [registry],
});
// ── Export ──────────────────────────────────────────────
export function getMetricsContentType(): string {
return registry.contentType;
}
export async function getMetrics(): Promise<string> {
return await registry.metrics();
}
+6
View File
@@ -16,6 +16,7 @@ import {
verifyPassword,
} from '../lib/auth';
import { env } from '../config/env';
import { authCounter } from '../lib/telemetry';
function normalizeEmail(email: unknown) {
return typeof email === 'string' ? email.trim().toLowerCase() : '';
@@ -63,6 +64,8 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
const token = await createSession(user.id);
set.headers['Set-Cookie'] = createSessionCookie(token);
authCounter.labels('register', 'true').inc();
return {
user: {
id: user.id,
@@ -90,12 +93,15 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
const user = rows[0];
if (!user?.passwordHash || !(await verifyPassword(req!.password!, user.passwordHash))) {
authCounter.labels('login', 'false').inc();
return badRequest('Invalid email or password');
}
const token = await createSession(user.id);
set.headers['Set-Cookie'] = createSessionCookie(token);
authCounter.labels('login', 'true').inc();
return {
user: {
id: user.id,
+3
View File
@@ -16,6 +16,7 @@ import { desc, eq } from 'drizzle-orm';
import { classifyImage } from '../lib/image-model';
import { uploadImageToStorage } from '../lib/uploader-client';
import { toDisease } from '../lib/disease-mappers';
import { classificationCounter } from '../lib/telemetry';
function toImageClassificationRecord(row: {
id: string;
@@ -141,6 +142,8 @@ export const classificationRoutes = new Elysia({ prefix: '/api/v1' })
);
}
classificationCounter.labels(classificationResult.predictedDiseaseSlug).inc();
const db = createDbClient();
let diseaseRow;
try {
+7
View File
@@ -9,6 +9,7 @@ import { getCurrentUser } from '../lib/auth';
import { classifyImage } from '../lib/image-model';
import { uploadImageToStorage } from '../lib/uploader-client';
import { env } from '../config/env';
import { diagnosisCounter } from '../lib/telemetry';
interface ReviewRow {
reviewId: string | null;
@@ -222,6 +223,9 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' })
const record = await loadDiagnosisRecord(inserted[0].id, user.id, false);
if (!record) return serviceUnavailable('Database unavailable');
diagnosisCounter.labels(record.predictedDiseaseSlug ?? 'unknown').inc();
return record;
} catch (error) {
const inserted = await db
@@ -240,6 +244,9 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' })
const record = await loadDiagnosisRecord(inserted[0].id, user.id, false);
if (!record) return serviceUnavailable('Database unavailable');
diagnosisCounter.labels('failed').inc();
return record;
}
})
+10
View File
@@ -0,0 +1,10 @@
import { Elysia } from 'elysia';
import { getMetrics, getMetricsContentType } from '../lib/telemetry';
export const metricsRoutes = new Elysia()
.get('/metrics', async () => {
const body = await getMetrics();
return new Response(body, {
headers: { 'Content-Type': getMetricsContentType() },
});
});
+8
View File
@@ -0,0 +1,8 @@
declare global {
interface Request {
metricsStart?: number;
metricsPath?: string;
}
}
export {};