feat(api): add Prometheus metrics and OpenTelemetry instrumentation for HTTP, auth, classifications, and diagnoses
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
|
||||
.claude/
|
||||
|
||||
.codegraph/
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
declare global {
|
||||
interface Request {
|
||||
metricsStart?: number;
|
||||
metricsPath?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user