diff --git a/package-lock.json b/package-lock.json index 069593cab..6b753fa93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2675,6 +2675,15 @@ "npm": ">=5.10.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3695,6 +3704,16 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@willsoto/nestjs-prometheus": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@willsoto/nestjs-prometheus/-/nestjs-prometheus-6.1.0.tgz", + "integrity": "sha512-lrCEnJBBSzUIYWGR+PsZw1YXs1B9jzxFEuNAa3RzTxuFAFdI+sW7Fp52il/U/dX2MWoHc32x06OS0nm56QwyzQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "prom-client": "^15.0.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -4337,6 +4356,12 @@ "node": "*" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -6243,6 +6268,28 @@ "node": ">=10" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9677,6 +9724,45 @@ "node": ">=0.10.0" } }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/postgres-interval": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", @@ -9772,6 +9858,19 @@ ], "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -11028,6 +11127,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/terser": { "version": "5.46.2", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", diff --git a/package.json b/package.json index 6e912baac..8952ccd37 100755 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@nestjs/typeorm": "^11.0.1", "@types/cookie-parser": "^1.4.8", "@types/multer": "^2.0.0", + "@willsoto/nestjs-prometheus": "^6.1.0", "bcrypt": "^5.1.1", "bcryptjs": "^3.0.3", "cache-manager": "^7.2.8", @@ -70,6 +71,7 @@ "passport-custom": "^1.1.1", "passport-jwt": "^4.0.1", "pg": "^8.18.0", + "prom-client": "^15.1.3", "redis": "^5.12.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", diff --git a/src/modules/metrics/basic-auth.guard.ts b/src/modules/metrics/basic-auth.guard.ts new file mode 100644 index 000000000..0ef1a4587 --- /dev/null +++ b/src/modules/metrics/basic-auth.guard.ts @@ -0,0 +1,29 @@ +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class BasicAuthGuard implements CanActivate { + constructor(private configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Basic ')) { + throw new UnauthorizedException('Missing or invalid authorization header'); + } + + const base64Credentials = authHeader.split(' ')[1]; + const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); + const [username, password] = credentials.split(':'); + + const expectedUsername = this.configService.get('METRICS_USERNAME', 'admin'); + const expectedPassword = this.configService.get('METRICS_PASSWORD', 'password'); + + if (username !== expectedUsername || password !== expectedPassword) { + throw new UnauthorizedException('Invalid credentials'); + } + + return true; + } +} \ No newline at end of file diff --git a/src/modules/metrics/metrics.controller.ts b/src/modules/metrics/metrics.controller.ts new file mode 100644 index 000000000..8a9cfbd6d --- /dev/null +++ b/src/modules/metrics/metrics.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { PrometheusController } from '@willsoto/nestjs-prometheus'; +import { MetricsService } from './metrics.service'; +import { BasicAuthGuard } from './basic-auth.guard'; + +@ApiTags('Metrics') +@Controller() +export class MetricsController extends PrometheusController { + constructor(private readonly metricsService: MetricsService) { + super(); + } + + @Get('/metrics') + @UseGuards(BasicAuthGuard) + @ApiOperation({ + summary: 'Get Prometheus metrics', + description: 'Returns Prometheus-compatible metrics for monitoring and alerting.', + }) + @ApiResponse({ + status: 200, + description: 'Prometheus metrics in text format', + content: { + 'text/plain': { + example: '# HELP http_requests_total Total number of HTTP requests\n# TYPE http_requests_total counter\nhttp_requests_total{method="GET",status="200"} 42\n', + }, + }, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - Invalid credentials', + }) + async getMetrics() { + return super.index(); + } +} \ No newline at end of file diff --git a/src/modules/metrics/metrics.module.ts b/src/modules/metrics/metrics.module.ts new file mode 100644 index 000000000..ea8d531e5 --- /dev/null +++ b/src/modules/metrics/metrics.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { PrometheusModule } from '@willsoto/nestjs-prometheus'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +@Module({ + imports: [ + PrometheusModule.register({ + defaultMetrics: { + enabled: true, + }, + path: '/metrics', + defaultLabels: { + app: 'skillsync_server', + }, + }), + ], + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} \ No newline at end of file diff --git a/src/modules/metrics/metrics.service.ts b/src/modules/metrics/metrics.service.ts new file mode 100644 index 000000000..bfc3dc91a --- /dev/null +++ b/src/modules/metrics/metrics.service.ts @@ -0,0 +1,78 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { Counter, Histogram, Gauge } from 'prom-client'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +// Assuming you have User entity, adjust as needed +import { User } from '../user/user.entity'; + +@Injectable() +export class MetricsService { + constructor( + @InjectMetric('http_requests_total') + private readonly httpRequestsTotal: Counter, + @InjectMetric('http_request_duration_seconds') + private readonly httpRequestDuration: Histogram, + @InjectMetric('db_query_duration_seconds') + private readonly dbQueryDuration: Histogram, + @InjectMetric('db_connection_pool_size') + private readonly dbConnectionPoolSize: Gauge, + @InjectMetric('redis_operation_duration_seconds') + private readonly redisOperationDuration: Histogram, + @InjectMetric('active_users') + private readonly activeUsers: Gauge, + @InjectMetric('jwt_verification_failures_total') + private readonly jwtVerificationFailures: Counter, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + // HTTP metrics + incrementHttpRequests(method: string, status: string, endpoint: string) { + this.httpRequestsTotal + .labels({ method, status, endpoint }) + .inc(); + } + + recordHttpRequestDuration(method: string, status: string, endpoint: string, duration: number) { + this.httpRequestDuration + .labels({ method, status, endpoint }) + .observe(duration); + } + + // Database metrics + recordDbQueryDuration(query: string, duration: number) { + this.dbQueryDuration + .labels({ query }) + .observe(duration); + } + + setDbConnectionPoolSize(size: number) { + this.dbConnectionPoolSize.set(size); + } + + // Redis metrics + recordRedisOperationDuration(operation: string, duration: number) { + this.redisOperationDuration + .labels({ operation }) + .observe(duration); + } + + // Application metrics + async updateActiveUsers() { + // Assuming active users are those who logged in within the last 24 hours + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const activeUserCount = await this.userRepository.count({ + where: { + lastLoginAt: { $gte: twentyFourHoursAgo } as any, // Adjust based on your User entity + }, + }); + this.activeUsers.set(activeUserCount); + } + + incrementJwtVerificationFailures(reason: string) { + this.jwtVerificationFailures + .labels({ reason }) + .inc(); + } +} \ No newline at end of file