Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest setup file — runs before each test suite.
// Load environment variables from .env.test if present.
import 'dotenv/config';
15 changes: 2 additions & 13 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@
"start": "node dist/src/index.js",
"start:prod": "node dist/src/index.js",
"dev": "tsx watch src/index.ts",
"test": "jest --runInBand",
"test:coverage": "jest --coverage --runInBand",
"collaboration": "tsx src/collaborationServer.ts",
"test": "DATABASE_URL='postgres://dummy:dummy@localhost:5432/dummy' node --experimental-vm-modules node_modules/jest/bin/jest.js"
"test": "DATABASE_URL='postgres://dummy:dummy@localhost:5432/dummy' node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:coverage": "DATABASE_URL='postgres://dummy:dummy@localhost:5432/dummy' node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
"collaboration": "tsx src/collaborationServer.ts"
},
"keywords": [],
Expand Down Expand Up @@ -52,22 +50,13 @@
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jest": "^29.0.0",
"@types/node": "^25.5.0",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^2.0.12",
"@types/ws": "^8.18.1",
"jest": "^29.0.0",
"supertest": "^6.3.3",
"ts-jest": "^29.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^25.5.0",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^7.2.0",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "^8.18.1",
"jest": "^30.4.2",
"ioredis-mock": "^8.13.1",
"jest": "^30.4.2",
"supertest": "^7.2.2",
Expand Down
277 changes: 277 additions & 0 deletions backend/src/metrics/MetricsCollector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* MetricsCollector — central metrics collection system for Web3 Student Lab.
*
* Collects three categories of metrics:
* - Performance: HTTP request durations, memory/CPU usage
* - Errors: counts by type and HTTP status code
* - Business: user registrations, course enrollments, certificate issuances
*
* Designed as a singleton so all parts of the app share one store.
* Integrates with the existing winston logger for structured output.
*
* Educational note: In production you would typically export these metrics
* to a time-series database (Prometheus, Datadog, CloudWatch) via a push or
* pull mechanism. This implementation keeps everything in-process for
* simplicity while exposing a clean interface that is easy to swap out.
*/

import logger from '../utils/logger.js';

// ─── Types ────────────────────────────────────────────────────────────────────

export interface PerformanceMetric {
/** HTTP method (GET, POST, …) */
method: string;
/** Route path, e.g. /api/v1/courses */
route: string;
/** Response time in milliseconds */
durationMs: number;
/** HTTP status code */
statusCode: number;
/** ISO timestamp */
timestamp: string;
}

export interface ErrorMetric {
/** Error class name or custom label */
type: string;
/** Human-readable message */
message: string;
/** HTTP status code if applicable */
statusCode?: number;
/** ISO timestamp */
timestamp: string;
}

export interface BusinessMetric {
/** Event name, e.g. "user.registered" */
event: string;
/** Arbitrary metadata */
metadata?: Record<string, unknown>;
/** ISO timestamp */
timestamp: string;
}

export interface MetricsSummary {
performance: {
totalRequests: number;
averageDurationMs: number;
/** Requests per route: { "GET /api/v1/courses": count } */
requestsByRoute: Record<string, number>;
/** Requests per status code: { "200": count } */
requestsByStatus: Record<string, number>;
};
errors: {
totalErrors: number;
/** Errors per type: { "ValidationError": count } */
errorsByType: Record<string, number>;
};
business: {
totalEvents: number;
/** Events per name: { "user.registered": count } */
eventsByName: Record<string, number>;
};
system: {
uptimeSeconds: number;
memoryUsageMB: number;
cpuUserMs: number;
};
collectedAt: string;
}

// ─── MetricsCollector ─────────────────────────────────────────────────────────

export class MetricsCollector {
private performanceMetrics: PerformanceMetric[] = [];
private errorMetrics: ErrorMetric[] = [];
private businessMetrics: BusinessMetric[] = [];

/**
* Maximum number of raw metric entries kept in memory per category.
* Older entries are dropped (ring-buffer style) to bound memory usage.
*/
private readonly maxEntries: number;

constructor(maxEntries = 10_000) {
this.maxEntries = maxEntries;
}

// ── Performance ─────────────────────────────────────────────────────────────

/**
* Record a completed HTTP request.
*
* @param method HTTP verb
* @param route Normalised route path (avoid recording raw user input to
* prevent high-cardinality label explosion)
* @param durationMs Time from request start to response end
* @param statusCode HTTP response status
*/
recordRequest(method: string, route: string, durationMs: number, statusCode: number): void {
const metric: PerformanceMetric = {
method,
route,
durationMs,
statusCode,
timestamp: new Date().toISOString(),
};

this.performanceMetrics.push(metric);
this.trim(this.performanceMetrics);

// Log slow requests (>1 s) so they surface in existing log pipelines.
if (durationMs > 1000) {
logger.warn(`Slow request: ${method} ${route} took ${durationMs}ms (status ${statusCode})`);
}
}

// ── Errors ───────────────────────────────────────────────────────────────────

/**
* Record an application error.
*
* @param type Error class name or a short label like "UnhandledRejection"
* @param message Error message (avoid including PII)
* @param statusCode HTTP status code if the error maps to one
*/
recordError(type: string, message: string, statusCode?: number): void {
const metric: ErrorMetric = {
type,
message,
statusCode,
timestamp: new Date().toISOString(),
};

this.errorMetrics.push(metric);
this.trim(this.errorMetrics);

logger.error(`[Metrics] Error recorded: ${type} — ${message}`);
}

// ── Business ─────────────────────────────────────────────────────────────────

/**
* Record a business-level event.
*
* Examples:
* collector.recordEvent('user.registered', { plan: 'free' });
* collector.recordEvent('certificate.issued', { courseId: 'abc' });
*
* @param event Dot-namespaced event name
* @param metadata Optional key/value context (no PII)
*/
recordEvent(event: string, metadata?: Record<string, unknown>): void {
const metric: BusinessMetric = {
event,
metadata,
timestamp: new Date().toISOString(),
};

this.businessMetrics.push(metric);
this.trim(this.businessMetrics);

logger.info(`[Metrics] Business event: ${event}`, metadata ?? {});
}

// ── Summary ──────────────────────────────────────────────────────────────────

/**
* Return an aggregated snapshot of all collected metrics.
* This is what the /api/v1/metrics endpoint exposes.
*/
getSummary(): MetricsSummary {
const requestsByRoute: Record<string, number> = {};
const requestsByStatus: Record<string, number> = {};
let totalDuration = 0;

for (const m of this.performanceMetrics) {
const key = `${m.method} ${m.route}`;
requestsByRoute[key] = (requestsByRoute[key] ?? 0) + 1;
requestsByStatus[String(m.statusCode)] = (requestsByStatus[String(m.statusCode)] ?? 0) + 1;
totalDuration += m.durationMs;
}

const totalRequests = this.performanceMetrics.length;
const averageDurationMs = totalRequests > 0 ? totalDuration / totalRequests : 0;

const errorsByType: Record<string, number> = {};
for (const e of this.errorMetrics) {
errorsByType[e.type] = (errorsByType[e.type] ?? 0) + 1;
}

const eventsByName: Record<string, number> = {};
for (const b of this.businessMetrics) {
eventsByName[b.event] = (eventsByName[b.event] ?? 0) + 1;
}

const memUsage = process.memoryUsage();
const cpuUsage = process.cpuUsage();

return {
performance: {
totalRequests,
averageDurationMs: Math.round(averageDurationMs * 100) / 100,
requestsByRoute,
requestsByStatus,
},
errors: {
totalErrors: this.errorMetrics.length,
errorsByType,
},
business: {
totalEvents: this.businessMetrics.length,
eventsByName,
},
system: {
uptimeSeconds: Math.floor(process.uptime()),
memoryUsageMB: Math.round(memUsage.heapUsed / 1024 / 1024),
cpuUserMs: Math.round(cpuUsage.user / 1000),
},
collectedAt: new Date().toISOString(),
};
}

/**
* Return the raw performance metric entries (useful for debugging or export).
*/
getPerformanceMetrics(): PerformanceMetric[] {
return [...this.performanceMetrics];
}

/**
* Return the raw error metric entries.
*/
getErrorMetrics(): ErrorMetric[] {
return [...this.errorMetrics];
}

/**
* Return the raw business metric entries.
*/
getBusinessMetrics(): BusinessMetric[] {
return [...this.businessMetrics];
}

/**
* Reset all collected metrics (useful in tests or after a scheduled flush).
*/
reset(): void {
this.performanceMetrics = [];
this.errorMetrics = [];
this.businessMetrics = [];
logger.info('[Metrics] All metrics reset');
}

// ── Private helpers ──────────────────────────────────────────────────────────

/** Drop the oldest entries when the array exceeds maxEntries. */
private trim<T>(arr: T[]): void {
if (arr.length > this.maxEntries) {
arr.splice(0, arr.length - this.maxEntries);
}
}
}

// Export a singleton instance so the whole app shares one collector.
const metricsCollector = new MetricsCollector();
export default metricsCollector;
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import exportRouter from './export.routes.js';
import generatorRouter from './generator/generator.routes.js';
import healthRouter from './health.routes.js';
import learningRoutes from './learning/learning.routes.js';
import metricsRouter from './metrics.routes.js';
import securityRouter from './security.routes.js';
import studentsRouter from './students.js';

Expand All @@ -32,5 +33,6 @@ router.use('/security', securityRouter);
router.use('/generator', generatorRouter);
router.use('/export', exportRouter);
router.use('/user', userRouter);
router.use('/metrics', metricsRouter);

export default router;
Loading