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": {
|
"dependencies": {
|
||||||
"@elysiajs/cors": "1.4.2",
|
"@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:*",
|
"@zeavis/shared": "workspace:*",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"elysia": "^1.4.28",
|
"elysia": "^1.4.28",
|
||||||
"postgres": "^3.4.9"
|
"postgres": "^3.4.9",
|
||||||
|
"prom-client": "15.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { diagnosisRoutes } from './routes/diagnoses';
|
|||||||
import { dashboardRoutes } from './routes/dashboard';
|
import { dashboardRoutes } from './routes/dashboard';
|
||||||
import { authRoutes } from './routes/auth';
|
import { authRoutes } from './routes/auth';
|
||||||
import { expertRoutes } from './routes/expert';
|
import { expertRoutes } from './routes/expert';
|
||||||
|
import { metricsRoutes } from './routes/metrics';
|
||||||
|
import { httpRequestCounter, httpRequestDuration, httpRequestsActive } from './lib/telemetry';
|
||||||
|
import './types';
|
||||||
|
|
||||||
assertRequiredEnv();
|
assertRequiredEnv();
|
||||||
|
|
||||||
@@ -17,6 +20,24 @@ const app = new Elysia()
|
|||||||
origin: env.webAppUrl,
|
origin: env.webAppUrl,
|
||||||
credentials: true,
|
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(healthRoutes)
|
||||||
.use(statusRoutes)
|
.use(statusRoutes)
|
||||||
.use(authRoutes)
|
.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,
|
verifyPassword,
|
||||||
} from '../lib/auth';
|
} from '../lib/auth';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
import { authCounter } from '../lib/telemetry';
|
||||||
|
|
||||||
function normalizeEmail(email: unknown) {
|
function normalizeEmail(email: unknown) {
|
||||||
return typeof email === 'string' ? email.trim().toLowerCase() : '';
|
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);
|
const token = await createSession(user.id);
|
||||||
set.headers['Set-Cookie'] = createSessionCookie(token);
|
set.headers['Set-Cookie'] = createSessionCookie(token);
|
||||||
|
|
||||||
|
authCounter.labels('register', 'true').inc();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -90,12 +93,15 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
|
|
||||||
if (!user?.passwordHash || !(await verifyPassword(req!.password!, user.passwordHash))) {
|
if (!user?.passwordHash || !(await verifyPassword(req!.password!, user.passwordHash))) {
|
||||||
|
authCounter.labels('login', 'false').inc();
|
||||||
return badRequest('Invalid email or password');
|
return badRequest('Invalid email or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
set.headers['Set-Cookie'] = createSessionCookie(token);
|
set.headers['Set-Cookie'] = createSessionCookie(token);
|
||||||
|
|
||||||
|
authCounter.labels('login', 'true').inc();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { desc, eq } from 'drizzle-orm';
|
|||||||
import { classifyImage } from '../lib/image-model';
|
import { classifyImage } from '../lib/image-model';
|
||||||
import { uploadImageToStorage } from '../lib/uploader-client';
|
import { uploadImageToStorage } from '../lib/uploader-client';
|
||||||
import { toDisease } from '../lib/disease-mappers';
|
import { toDisease } from '../lib/disease-mappers';
|
||||||
|
import { classificationCounter } from '../lib/telemetry';
|
||||||
|
|
||||||
function toImageClassificationRecord(row: {
|
function toImageClassificationRecord(row: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -141,6 +142,8 @@ export const classificationRoutes = new Elysia({ prefix: '/api/v1' })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
classificationCounter.labels(classificationResult.predictedDiseaseSlug).inc();
|
||||||
|
|
||||||
const db = createDbClient();
|
const db = createDbClient();
|
||||||
let diseaseRow;
|
let diseaseRow;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getCurrentUser } from '../lib/auth';
|
|||||||
import { classifyImage } from '../lib/image-model';
|
import { classifyImage } from '../lib/image-model';
|
||||||
import { uploadImageToStorage } from '../lib/uploader-client';
|
import { uploadImageToStorage } from '../lib/uploader-client';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
import { diagnosisCounter } from '../lib/telemetry';
|
||||||
|
|
||||||
interface ReviewRow {
|
interface ReviewRow {
|
||||||
reviewId: string | null;
|
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);
|
const record = await loadDiagnosisRecord(inserted[0].id, user.id, false);
|
||||||
if (!record) return serviceUnavailable('Database unavailable');
|
if (!record) return serviceUnavailable('Database unavailable');
|
||||||
|
|
||||||
|
diagnosisCounter.labels(record.predictedDiseaseSlug ?? 'unknown').inc();
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const inserted = await db
|
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);
|
const record = await loadDiagnosisRecord(inserted[0].id, user.id, false);
|
||||||
if (!record) return serviceUnavailable('Database unavailable');
|
if (!record) return serviceUnavailable('Database unavailable');
|
||||||
|
|
||||||
|
diagnosisCounter.labels('failed').inc();
|
||||||
|
|
||||||
return record;
|
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