diff --git a/apps/calendar/apps/backend/package.json b/apps/calendar/apps/backend/package.json index 305e1f4cf..909efa017 100644 --- a/apps/calendar/apps/backend/package.json +++ b/apps/calendar/apps/backend/package.json @@ -32,6 +32,7 @@ "drizzle-orm": "^0.38.3", "ical.js": "^2.1.0", "postgres": "^3.4.5", + "prom-client": "^15.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "tsdav": "^2.1.1" diff --git a/apps/calendar/apps/backend/src/app.module.ts b/apps/calendar/apps/backend/src/app.module.ts index 3ccbf19e8..bc7d48a9f 100644 --- a/apps/calendar/apps/backend/src/app.module.ts +++ b/apps/calendar/apps/backend/src/app.module.ts @@ -10,6 +10,7 @@ import { EventTagGroupModule } from './event-tag-group/event-tag-group.module'; import { ReminderModule } from './reminder/reminder.module'; import { ShareModule } from './share/share.module'; import { NetworkModule } from './network/network.module'; +import { MetricsModule } from './metrics'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { NetworkModule } from './network/network.module'; envFilePath: '.env', }), ScheduleModule.forRoot(), + MetricsModule, DatabaseModule, HealthModule, CalendarModule, diff --git a/apps/calendar/apps/backend/src/main.ts b/apps/calendar/apps/backend/src/main.ts index 9efe3d430..7cf6f2ece 100644 --- a/apps/calendar/apps/backend/src/main.ts +++ b/apps/calendar/apps/backend/src/main.ts @@ -1,10 +1,48 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; import { AppModule } from './app.module'; +import { MetricsService } from './metrics/metrics.service'; + +// Normalize route paths to prevent high cardinality +function normalizeRoute(path: string): string { + return path + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') + .replace(/\/\d+/g, '/:id'); +} async function bootstrap() { const app = await NestFactory.create(AppModule); + // Get MetricsService for request tracking + const metricsService = app.get(MetricsService); + + // Global Express middleware to track ALL HTTP requests + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path === '/metrics') { + return next(); + } + + const startTime = Date.now(); + const method = req.method; + const route = normalizeRoute(req.path); + + res.once('finish', () => { + const duration = (Date.now() - startTime) / 1000; + metricsService.httpRequestsTotal.inc({ + method, + route, + status: res.statusCode.toString(), + }); + metricsService.httpRequestDuration.observe( + { method, route, status: res.statusCode.toString() }, + duration + ); + }); + + next(); + }); + // Enable CORS for mobile and web apps const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [ 'http://localhost:3000', @@ -30,8 +68,10 @@ async function bootstrap() { }) ); - // Set global prefix for API routes - app.setGlobalPrefix('api/v1'); + // Set global prefix for API routes (exclude metrics endpoint) + app.setGlobalPrefix('api/v1', { + exclude: ['metrics', 'health'], + }); const port = process.env.PORT || 3014; await app.listen(port); diff --git a/apps/calendar/apps/backend/src/metrics/index.ts b/apps/calendar/apps/backend/src/metrics/index.ts new file mode 100644 index 000000000..860cd0cdf --- /dev/null +++ b/apps/calendar/apps/backend/src/metrics/index.ts @@ -0,0 +1,3 @@ +export * from './metrics.module'; +export * from './metrics.service'; +export * from './metrics.controller'; diff --git a/apps/calendar/apps/backend/src/metrics/metrics.controller.ts b/apps/calendar/apps/backend/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..eb3168a67 --- /dev/null +++ b/apps/calendar/apps/backend/src/metrics/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + async getMetrics(): Promise { + return this.metricsService.getMetrics(); + } +} diff --git a/apps/calendar/apps/backend/src/metrics/metrics.module.ts b/apps/calendar/apps/backend/src/metrics/metrics.module.ts new file mode 100644 index 000000000..32b20829b --- /dev/null +++ b/apps/calendar/apps/backend/src/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; +import { MetricsController } from './metrics.controller'; + +@Global() +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/apps/calendar/apps/backend/src/metrics/metrics.service.ts b/apps/calendar/apps/backend/src/metrics/metrics.service.ts new file mode 100644 index 000000000..c34cf4269 --- /dev/null +++ b/apps/calendar/apps/backend/src/metrics/metrics.service.ts @@ -0,0 +1,50 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as client from 'prom-client'; + +@Injectable() +export class MetricsService implements OnModuleInit { + private readonly register: client.Registry; + + // HTTP metrics + readonly httpRequestsTotal: client.Counter; + readonly httpRequestDuration: client.Histogram; + + constructor() { + this.register = new client.Registry(); + + // Add default metrics (CPU, memory, event loop, etc.) + client.collectDefaultMetrics({ + register: this.register, + prefix: 'calendar_', + }); + + // HTTP request counter + this.httpRequestsTotal = new client.Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status'], + registers: [this.register], + }); + + // HTTP request duration histogram + this.httpRequestDuration = new client.Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5], + registers: [this.register], + }); + } + + onModuleInit() { + // Metrics are ready + } + + async getMetrics(): Promise { + return this.register.metrics(); + } + + getContentType(): string { + return this.register.contentType; + } +} diff --git a/apps/chat/apps/backend/package.json b/apps/chat/apps/backend/package.json index c4b909062..d3a50cd6b 100644 --- a/apps/chat/apps/backend/package.json +++ b/apps/chat/apps/backend/package.json @@ -37,6 +37,7 @@ "drizzle-orm": "^0.38.3", "openai": "^4.77.0", "postgres": "^3.4.5", + "prom-client": "^15.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/apps/chat/apps/backend/src/app.module.ts b/apps/chat/apps/backend/src/app.module.ts index ec4fd9696..384d57344 100644 --- a/apps/chat/apps/backend/src/app.module.ts +++ b/apps/chat/apps/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { SpaceModule } from './space/space.module'; import { DocumentModule } from './document/document.module'; import { ModelModule } from './model/model.module'; import { HealthModule } from './health/health.module'; +import { MetricsModule } from './metrics'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { HealthModule } from './health/health.module'; isGlobal: true, envFilePath: '.env', }), + MetricsModule, DatabaseModule, ChatModule, ConversationModule, diff --git a/apps/chat/apps/backend/src/main.ts b/apps/chat/apps/backend/src/main.ts index 2d5c7ed8d..ed035407b 100644 --- a/apps/chat/apps/backend/src/main.ts +++ b/apps/chat/apps/backend/src/main.ts @@ -1,10 +1,48 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; import { AppModule } from './app.module'; +import { MetricsService } from './metrics/metrics.service'; + +// Normalize route paths to prevent high cardinality +function normalizeRoute(path: string): string { + return path + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') + .replace(/\/\d+/g, '/:id'); +} async function bootstrap() { const app = await NestFactory.create(AppModule); + // Get MetricsService for request tracking + const metricsService = app.get(MetricsService); + + // Global Express middleware to track ALL HTTP requests + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path === '/metrics') { + return next(); + } + + const startTime = Date.now(); + const method = req.method; + const route = normalizeRoute(req.path); + + res.once('finish', () => { + const duration = (Date.now() - startTime) / 1000; + metricsService.httpRequestsTotal.inc({ + method, + route, + status: res.statusCode.toString(), + }); + metricsService.httpRequestDuration.observe( + { method, route, status: res.statusCode.toString() }, + duration + ); + }); + + next(); + }); + // Enable CORS for mobile and web apps const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [ 'http://localhost:3000', @@ -22,9 +60,6 @@ async function bootstrap() { credentials: true, }); - // Global exception filter will be added later via module - // app.useGlobalFilters(new AppExceptionFilter()); - // Enable validation app.useGlobalPipes( new ValidationPipe({ @@ -34,8 +69,10 @@ async function bootstrap() { }) ); - // Set global prefix for API routes - app.setGlobalPrefix('api/v1'); + // Set global prefix for API routes (exclude metrics endpoint) + app.setGlobalPrefix('api/v1', { + exclude: ['metrics', 'health'], + }); const port = process.env.PORT || 3002; await app.listen(port); diff --git a/apps/chat/apps/backend/src/metrics/index.ts b/apps/chat/apps/backend/src/metrics/index.ts new file mode 100644 index 000000000..860cd0cdf --- /dev/null +++ b/apps/chat/apps/backend/src/metrics/index.ts @@ -0,0 +1,3 @@ +export * from './metrics.module'; +export * from './metrics.service'; +export * from './metrics.controller'; diff --git a/apps/chat/apps/backend/src/metrics/metrics.controller.ts b/apps/chat/apps/backend/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..eb3168a67 --- /dev/null +++ b/apps/chat/apps/backend/src/metrics/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + async getMetrics(): Promise { + return this.metricsService.getMetrics(); + } +} diff --git a/apps/chat/apps/backend/src/metrics/metrics.module.ts b/apps/chat/apps/backend/src/metrics/metrics.module.ts new file mode 100644 index 000000000..32b20829b --- /dev/null +++ b/apps/chat/apps/backend/src/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; +import { MetricsController } from './metrics.controller'; + +@Global() +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/apps/chat/apps/backend/src/metrics/metrics.service.ts b/apps/chat/apps/backend/src/metrics/metrics.service.ts new file mode 100644 index 000000000..0e4a11910 --- /dev/null +++ b/apps/chat/apps/backend/src/metrics/metrics.service.ts @@ -0,0 +1,50 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as client from 'prom-client'; + +@Injectable() +export class MetricsService implements OnModuleInit { + private readonly register: client.Registry; + + // HTTP metrics + readonly httpRequestsTotal: client.Counter; + readonly httpRequestDuration: client.Histogram; + + constructor() { + this.register = new client.Registry(); + + // Add default metrics (CPU, memory, event loop, etc.) + client.collectDefaultMetrics({ + register: this.register, + prefix: 'chat_', + }); + + // HTTP request counter + this.httpRequestsTotal = new client.Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status'], + registers: [this.register], + }); + + // HTTP request duration histogram + this.httpRequestDuration = new client.Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5], + registers: [this.register], + }); + } + + onModuleInit() { + // Metrics are ready + } + + async getMetrics(): Promise { + return this.register.metrics(); + } + + getContentType(): string { + return this.register.contentType; + } +} diff --git a/apps/clock/apps/backend/package.json b/apps/clock/apps/backend/package.json index ea6c245ef..5f30e18eb 100644 --- a/apps/clock/apps/backend/package.json +++ b/apps/clock/apps/backend/package.json @@ -31,6 +31,7 @@ "drizzle-kit": "^0.30.2", "drizzle-orm": "^0.38.3", "postgres": "^3.4.5", + "prom-client": "^15.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/apps/clock/apps/backend/src/app.module.ts b/apps/clock/apps/backend/src/app.module.ts index f20991dbd..678ac2eb4 100644 --- a/apps/clock/apps/backend/src/app.module.ts +++ b/apps/clock/apps/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AlarmModule } from './alarm/alarm.module'; import { TimerModule } from './timer/timer.module'; import { WorldClockModule } from './world-clock/world-clock.module'; import { PresetModule } from './preset/preset.module'; +import { MetricsModule } from './metrics'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { PresetModule } from './preset/preset.module'; envFilePath: '.env', }), ScheduleModule.forRoot(), + MetricsModule, DatabaseModule, HealthModule, AlarmModule, diff --git a/apps/clock/apps/backend/src/main.ts b/apps/clock/apps/backend/src/main.ts index aa0b8c76d..bb35eca6e 100644 --- a/apps/clock/apps/backend/src/main.ts +++ b/apps/clock/apps/backend/src/main.ts @@ -1,10 +1,48 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; import { AppModule } from './app.module'; +import { MetricsService } from './metrics/metrics.service'; + +// Normalize route paths to prevent high cardinality +function normalizeRoute(path: string): string { + return path + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') + .replace(/\/\d+/g, '/:id'); +} async function bootstrap() { const app = await NestFactory.create(AppModule); + // Get MetricsService for request tracking + const metricsService = app.get(MetricsService); + + // Global Express middleware to track ALL HTTP requests + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path === '/metrics') { + return next(); + } + + const startTime = Date.now(); + const method = req.method; + const route = normalizeRoute(req.path); + + res.once('finish', () => { + const duration = (Date.now() - startTime) / 1000; + metricsService.httpRequestsTotal.inc({ + method, + route, + status: res.statusCode.toString(), + }); + metricsService.httpRequestDuration.observe( + { method, route, status: res.statusCode.toString() }, + duration + ); + }); + + next(); + }); + // Enable CORS for mobile and web apps const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [ 'http://localhost:3000', @@ -30,8 +68,10 @@ async function bootstrap() { }) ); - // Set global prefix for API routes - app.setGlobalPrefix('api/v1'); + // Set global prefix for API routes (exclude metrics endpoint) + app.setGlobalPrefix('api/v1', { + exclude: ['metrics', 'health'], + }); const port = process.env.PORT || 3017; await app.listen(port); diff --git a/apps/clock/apps/backend/src/metrics/index.ts b/apps/clock/apps/backend/src/metrics/index.ts new file mode 100644 index 000000000..860cd0cdf --- /dev/null +++ b/apps/clock/apps/backend/src/metrics/index.ts @@ -0,0 +1,3 @@ +export * from './metrics.module'; +export * from './metrics.service'; +export * from './metrics.controller'; diff --git a/apps/clock/apps/backend/src/metrics/metrics.controller.ts b/apps/clock/apps/backend/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..eb3168a67 --- /dev/null +++ b/apps/clock/apps/backend/src/metrics/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + async getMetrics(): Promise { + return this.metricsService.getMetrics(); + } +} diff --git a/apps/clock/apps/backend/src/metrics/metrics.module.ts b/apps/clock/apps/backend/src/metrics/metrics.module.ts new file mode 100644 index 000000000..32b20829b --- /dev/null +++ b/apps/clock/apps/backend/src/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; +import { MetricsController } from './metrics.controller'; + +@Global() +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/apps/clock/apps/backend/src/metrics/metrics.service.ts b/apps/clock/apps/backend/src/metrics/metrics.service.ts new file mode 100644 index 000000000..6f3913cf2 --- /dev/null +++ b/apps/clock/apps/backend/src/metrics/metrics.service.ts @@ -0,0 +1,50 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as client from 'prom-client'; + +@Injectable() +export class MetricsService implements OnModuleInit { + private readonly register: client.Registry; + + // HTTP metrics + readonly httpRequestsTotal: client.Counter; + readonly httpRequestDuration: client.Histogram; + + constructor() { + this.register = new client.Registry(); + + // Add default metrics (CPU, memory, event loop, etc.) + client.collectDefaultMetrics({ + register: this.register, + prefix: 'clock_', + }); + + // HTTP request counter + this.httpRequestsTotal = new client.Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status'], + registers: [this.register], + }); + + // HTTP request duration histogram + this.httpRequestDuration = new client.Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5], + registers: [this.register], + }); + } + + onModuleInit() { + // Metrics are ready + } + + async getMetrics(): Promise { + return this.register.metrics(); + } + + getContentType(): string { + return this.register.contentType; + } +} diff --git a/apps/contacts/apps/backend/package.json b/apps/contacts/apps/backend/package.json index d53cbe40b..8b793dcad 100644 --- a/apps/contacts/apps/backend/package.json +++ b/apps/contacts/apps/backend/package.json @@ -20,7 +20,6 @@ "dependencies": { "@manacore/shared-nestjs-auth": "workspace:*", "@manacore/shared-storage": "workspace:*", - "multer": "^1.4.5-lts.1", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", @@ -32,7 +31,9 @@ "drizzle-kit": "^0.30.2", "drizzle-orm": "^0.38.3", "googleapis": "^144.0.0", + "multer": "^1.4.5-lts.1", "postgres": "^3.4.5", + "prom-client": "^15.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "vcard4": "^4.0.2" diff --git a/apps/contacts/apps/backend/src/app.module.ts b/apps/contacts/apps/backend/src/app.module.ts index 778b8ae18..da037ba22 100644 --- a/apps/contacts/apps/backend/src/app.module.ts +++ b/apps/contacts/apps/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { DuplicatesModule } from './duplicates/duplicates.module'; import { PhotoModule } from './photo/photo.module'; import { BatchModule } from './batch/batch.module'; import { NetworkModule } from './network/network.module'; +import { MetricsModule } from './metrics'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { NetworkModule } from './network/network.module'; isGlobal: true, envFilePath: '.env', }), + MetricsModule, DatabaseModule, ContactModule, TagModule, diff --git a/apps/contacts/apps/backend/src/main.ts b/apps/contacts/apps/backend/src/main.ts index e6de26d09..ef7042c47 100644 --- a/apps/contacts/apps/backend/src/main.ts +++ b/apps/contacts/apps/backend/src/main.ts @@ -1,10 +1,48 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; import { AppModule } from './app.module'; +import { MetricsService } from './metrics/metrics.service'; + +// Normalize route paths to prevent high cardinality +function normalizeRoute(path: string): string { + return path + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') + .replace(/\/\d+/g, '/:id'); +} async function bootstrap() { const app = await NestFactory.create(AppModule); + // Get MetricsService for request tracking + const metricsService = app.get(MetricsService); + + // Global Express middleware to track ALL HTTP requests + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path === '/metrics') { + return next(); + } + + const startTime = Date.now(); + const method = req.method; + const route = normalizeRoute(req.path); + + res.once('finish', () => { + const duration = (Date.now() - startTime) / 1000; + metricsService.httpRequestsTotal.inc({ + method, + route, + status: res.statusCode.toString(), + }); + metricsService.httpRequestDuration.observe( + { method, route, status: res.statusCode.toString() }, + duration + ); + }); + + next(); + }); + // Enable CORS for mobile and web apps const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [ 'http://localhost:3000', @@ -30,8 +68,10 @@ async function bootstrap() { }) ); - // Set global prefix for API routes - app.setGlobalPrefix('api/v1'); + // Set global prefix for API routes (exclude metrics endpoint) + app.setGlobalPrefix('api/v1', { + exclude: ['metrics', 'health'], + }); const port = process.env.PORT || 3015; await app.listen(port); diff --git a/apps/contacts/apps/backend/src/metrics/index.ts b/apps/contacts/apps/backend/src/metrics/index.ts new file mode 100644 index 000000000..860cd0cdf --- /dev/null +++ b/apps/contacts/apps/backend/src/metrics/index.ts @@ -0,0 +1,3 @@ +export * from './metrics.module'; +export * from './metrics.service'; +export * from './metrics.controller'; diff --git a/apps/contacts/apps/backend/src/metrics/metrics.controller.ts b/apps/contacts/apps/backend/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..eb3168a67 --- /dev/null +++ b/apps/contacts/apps/backend/src/metrics/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + async getMetrics(): Promise { + return this.metricsService.getMetrics(); + } +} diff --git a/apps/contacts/apps/backend/src/metrics/metrics.module.ts b/apps/contacts/apps/backend/src/metrics/metrics.module.ts new file mode 100644 index 000000000..32b20829b --- /dev/null +++ b/apps/contacts/apps/backend/src/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; +import { MetricsController } from './metrics.controller'; + +@Global() +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/apps/contacts/apps/backend/src/metrics/metrics.service.ts b/apps/contacts/apps/backend/src/metrics/metrics.service.ts new file mode 100644 index 000000000..dfffa3962 --- /dev/null +++ b/apps/contacts/apps/backend/src/metrics/metrics.service.ts @@ -0,0 +1,50 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as client from 'prom-client'; + +@Injectable() +export class MetricsService implements OnModuleInit { + private readonly register: client.Registry; + + // HTTP metrics + readonly httpRequestsTotal: client.Counter; + readonly httpRequestDuration: client.Histogram; + + constructor() { + this.register = new client.Registry(); + + // Add default metrics (CPU, memory, event loop, etc.) + client.collectDefaultMetrics({ + register: this.register, + prefix: 'contacts_', + }); + + // HTTP request counter + this.httpRequestsTotal = new client.Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status'], + registers: [this.register], + }); + + // HTTP request duration histogram + this.httpRequestDuration = new client.Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5], + registers: [this.register], + }); + } + + onModuleInit() { + // Metrics are ready + } + + async getMetrics(): Promise { + return this.register.metrics(); + } + + getContentType(): string { + return this.register.contentType; + } +} diff --git a/services/mana-core-auth/package.json b/services/mana-core-auth/package.json index 64212a16e..5079862fe 100644 --- a/services/mana-core-auth/package.json +++ b/services/mana-core-auth/package.json @@ -42,6 +42,7 @@ "jsonwebtoken": "^9.0.2", "nanoid": "^5.0.9", "postgres": "^3.4.5", + "prom-client": "^15.1.0", "redis": "^4.7.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 0cb0ce214..15e593264 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -11,6 +11,7 @@ import { SettingsModule } from './settings/settings.module'; import { TagsModule } from './tags/tags.module'; import { AiModule } from './ai/ai.module'; import { HealthModule } from './health/health.module'; +import { MetricsModule } from './metrics'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; @Module({ @@ -25,6 +26,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; limit: 100, // 100 requests per minute }, ]), + MetricsModule, AiModule, AuthModule, CreditsModule, diff --git a/services/mana-core-auth/src/main.ts b/services/mana-core-auth/src/main.ts index 8245fd05e..353e9d9a3 100644 --- a/services/mana-core-auth/src/main.ts +++ b/services/mana-core-auth/src/main.ts @@ -1,15 +1,53 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Request, Response, NextFunction } from 'express'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; +import { MetricsService } from './metrics/metrics.service'; + +// Normalize route paths to prevent high cardinality +function normalizeRoute(path: string): string { + return path + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') + .replace(/\/\d+/g, '/:id'); +} async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); + // Get MetricsService for request tracking + const metricsService = app.get(MetricsService); + + // Global Express middleware to track ALL HTTP requests + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path === '/metrics') { + return next(); + } + + const startTime = Date.now(); + const method = req.method; + const route = normalizeRoute(req.path); + + res.once('finish', () => { + const duration = (Date.now() - startTime) / 1000; + metricsService.httpRequestsTotal.inc({ + method, + route, + status: res.statusCode.toString(), + }); + metricsService.httpRequestDuration.observe( + { method, route, status: res.statusCode.toString() }, + duration + ); + }); + + next(); + }); + // Security middleware - configure helmet to allow CORS app.use( helmet({ @@ -41,8 +79,10 @@ async function bootstrap() { }) ); - // Global prefix - app.setGlobalPrefix('api/v1'); + // Global prefix (exclude metrics endpoint) + app.setGlobalPrefix('api/v1', { + exclude: ['metrics', 'health'], + }); const port = configService.get('port') || 3001; await app.listen(port); diff --git a/services/mana-core-auth/src/metrics/index.ts b/services/mana-core-auth/src/metrics/index.ts new file mode 100644 index 000000000..860cd0cdf --- /dev/null +++ b/services/mana-core-auth/src/metrics/index.ts @@ -0,0 +1,3 @@ +export * from './metrics.module'; +export * from './metrics.service'; +export * from './metrics.controller'; diff --git a/services/mana-core-auth/src/metrics/metrics.controller.ts b/services/mana-core-auth/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..eb3168a67 --- /dev/null +++ b/services/mana-core-auth/src/metrics/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + async getMetrics(): Promise { + return this.metricsService.getMetrics(); + } +} diff --git a/services/mana-core-auth/src/metrics/metrics.module.ts b/services/mana-core-auth/src/metrics/metrics.module.ts new file mode 100644 index 000000000..32b20829b --- /dev/null +++ b/services/mana-core-auth/src/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; +import { MetricsController } from './metrics.controller'; + +@Global() +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/services/mana-core-auth/src/metrics/metrics.service.ts b/services/mana-core-auth/src/metrics/metrics.service.ts new file mode 100644 index 000000000..1ac1f71b7 --- /dev/null +++ b/services/mana-core-auth/src/metrics/metrics.service.ts @@ -0,0 +1,50 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as client from 'prom-client'; + +@Injectable() +export class MetricsService implements OnModuleInit { + private readonly register: client.Registry; + + // HTTP metrics + readonly httpRequestsTotal: client.Counter; + readonly httpRequestDuration: client.Histogram; + + constructor() { + this.register = new client.Registry(); + + // Add default metrics (CPU, memory, event loop, etc.) + client.collectDefaultMetrics({ + register: this.register, + prefix: 'auth_', + }); + + // HTTP request counter + this.httpRequestsTotal = new client.Counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status'], + registers: [this.register], + }); + + // HTTP request duration histogram + this.httpRequestDuration = new client.Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5], + registers: [this.register], + }); + } + + onModuleInit() { + // Metrics are ready + } + + async getMetrics(): Promise { + return this.register.metrics(); + } + + getContentType(): string { + return this.register.contentType; + } +}