diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..34fee9f --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,4 @@ + +.claude/ + +.codegraph/ diff --git a/apps/api/package.json b/apps/api/package.json index 65c4bc6..0995564 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2d80cc4..91d11be 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) diff --git a/apps/api/src/lib/telemetry.ts b/apps/api/src/lib/telemetry.ts new file mode 100644 index 0000000..cd55a90 --- /dev/null +++ b/apps/api/src/lib/telemetry.ts @@ -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 { + return await registry.metrics(); +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 2d4fc81..770d1e5 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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, diff --git a/apps/api/src/routes/classifications.ts b/apps/api/src/routes/classifications.ts index 6f67dc6..b39d943 100644 --- a/apps/api/src/routes/classifications.ts +++ b/apps/api/src/routes/classifications.ts @@ -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 { diff --git a/apps/api/src/routes/diagnoses.ts b/apps/api/src/routes/diagnoses.ts index 4d8f1fd..b839f6b 100644 --- a/apps/api/src/routes/diagnoses.ts +++ b/apps/api/src/routes/diagnoses.ts @@ -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; } }) diff --git a/apps/api/src/routes/metrics.ts b/apps/api/src/routes/metrics.ts new file mode 100644 index 0000000..86c6ced --- /dev/null +++ b/apps/api/src/routes/metrics.ts @@ -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() }, + }); + }); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts new file mode 100644 index 0000000..be30f73 --- /dev/null +++ b/apps/api/src/types.ts @@ -0,0 +1,8 @@ +declare global { + interface Request { + metricsStart?: number; + metricsPath?: string; + } +} + +export {};