diff --git a/src/app.module.ts b/src/app.module.ts index 81dd098b..d52389d7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,7 @@ import { DeepLinkModule } from './deep-link/deep-link.module'; import { InvoicesModule } from './payments/invoices/invoices.module'; import { ReportingModule } from './payments/reporting/reporting.module'; import { HealthModule } from './health/health.module'; +import { MetricsModule } from './metrics/metrics.module'; // ✅ keep BOTH modules import { ReadReplicaModule } from './database/read-replica'; @@ -54,6 +55,7 @@ const featureFlags = loadFeatureFlags(); InvoicesModule, ReportingModule, HealthModule, + MetricsModule, // ✅ always include read replicas (or wrap if needed) ReadReplicaModule, diff --git a/src/kpi.service.spec.ts b/src/kpi.service.spec.ts new file mode 100644 index 00000000..417a8893 --- /dev/null +++ b/src/kpi.service.spec.ts @@ -0,0 +1,127 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { KpiService } from './kpi.service'; +import { MetricsService } from './metrics.service'; +import { User } from '../users/entities/user.entity'; +import { Course } from '../courses/entities/course.entity'; +import { Enrollment } from '../courses/entities/enrollment.entity'; +import { Payment } from '../payments/entities/payment.entity'; +import { UserActivity } from '../analytics/entities/user-activity.entity'; +import { Repository } from 'typeorm'; +import { PaymentStatus } from '../payments/enums/payment-status.enum'; + +describe('KpiService', () => { + let kpiService: KpiService; + let metricsService: MetricsService; + + const mockRepo = { + count: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), + getRawOne: jest.fn(), + })), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + KpiService, + MetricsService, + { provide: getRepositoryToken(User), useValue: mockRepo }, + { provide: getRepositoryToken(Course), useValue: mockRepo }, + { provide: getRepositoryToken(Enrollment), useValue: mockRepo }, + { provide: getRepositoryToken(Payment), useValue: mockRepo }, + { provide: getRepositoryToken(UserActivity), useValue: mockRepo }, + ], + }).compile(); + + kpiService = module.get(KpiService); + metricsService = module.get(MetricsService); + }); + + it('should be defined', () => { + expect(kpiService).toBeDefined(); + }); + + describe('calculateActiveUsers', () => { + it('should set active user gauges', async () => { + jest.spyOn(mockRepo, 'count').mockResolvedValueOnce(10).mockResolvedValueOnce(50).mockResolvedValueOnce(200); + const dauSpy = jest.spyOn(metricsService.activeUsersGauge, 'set'); + const wauSpy = jest.spyOn(metricsService.activeUsersGauge, 'set'); + const mauSpy = jest.spyOn(metricsService.activeUsersGauge, 'set'); + + await kpiService.calculateActiveUsers(); + + expect(dauSpy).toHaveBeenCalledWith(10); + expect(wauSpy).toHaveBeenCalledWith(50); + expect(mauSpy).toHaveBeenCalledWith(200); + }); + }); + + describe('calculatePaymentSuccessRate', () => { + it('should set payment success rate gauge', async () => { + const gaugeSpy = jest.spyOn(metricsService.paymentSuccessRateGauge, 'set'); + jest.spyOn(mockRepo, 'count') + .mockImplementation((options: any) => { + if (options.where.status === PaymentStatus.SUCCEEDED) return Promise.resolve(95); + if (options.where.status === PaymentStatus.FAILED) return Promise.resolve(5); + return Promise.resolve(0); + }); + + await kpiService.calculatePaymentSuccessRate(); + + expect(gaugeSpy).toHaveBeenCalledWith(95); + }); + + it('should handle zero total payments', async () => { + const gaugeSpy = jest.spyOn(metricsService.paymentSuccessRateGauge, 'set'); + jest.spyOn(mockRepo, 'count').mockResolvedValue(0); + + await kpiService.calculatePaymentSuccessRate(); + + expect(gaugeSpy).toHaveBeenCalledWith(0); + }); + }); + + describe('calculateRevenuePerCourse', () => { + it('should set revenue per course gauge', async () => { + const revenueData = [ + { courseId: 'c1', courseName: 'Course 1', totalRevenue: '1000' }, + { courseId: 'c2', courseName: 'Course 2', totalRevenue: '2500' }, + ]; + const qb = mockRepo.createQueryBuilder(); + (qb.getRawMany as jest.Mock).mockResolvedValue(revenueData); + const gaugeSpy = jest.spyOn(metricsService.revenuePerCourseGauge, 'set'); + + await kpiService.calculateRevenuePerCourse(); + + expect(gaugeSpy).toHaveBeenCalledWith(1000); + expect(gaugeSpy).toHaveBeenCalledWith(2500); + }); + }); + + describe('handleCron', () => { + it('should call all calculation methods', async () => { + const activeUsersSpy = jest.spyOn(kpiService, 'calculateActiveUsers').mockResolvedValue(); + const paymentSpy = jest.spyOn(kpiService, 'calculatePaymentSuccessRate').mockResolvedValue(); + const revenueSpy = jest.spyOn(kpiService, 'calculateRevenuePerCourse').mockResolvedValue(); + const enrollmentSpy = jest.spyOn(kpiService, 'calculateEnrollmentConversionRate').mockResolvedValue(); + const retentionSpy = jest.spyOn(kpiService, 'calculateUserRetention').mockResolvedValue(); + + await kpiService.handleCron(); + + expect(activeUsersSpy).toHaveBeenCalled(); + expect(paymentSpy).toHaveBeenCalled(); + expect(revenueSpy).toHaveBeenCalled(); + expect(enrollmentSpy).toHaveBeenCalled(); + expect(retentionSpy).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 88df8fe6..4241e25d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,6 +29,7 @@ import { AuditLogService } from './audit-log/audit-log.service'; import { createAuditLoggerMiddleware } from './middleware/audit/audit-logger.middleware'; import { initStructuredLogging } from './logging/structured-logging'; import { requestIdMiddleware } from './logging/request-id.middleware'; +import { MetricsInterceptor } from './metrics/metrics.interceptor'; // GLOBAL ENFORCEMENT IMPORT (IMPORTANT FOR YOUR TASK) import { LocaleInterceptor } from './common/interceptors/locale.interceptor'; @@ -283,6 +284,11 @@ async function bootstrapWorker(): Promise { // ========================= app.useGlobalInterceptors(new LocaleInterceptor()); + // ========================= + // GLOBAL METRICS INTERCEPTOR + // ========================= + app.useGlobalInterceptors(app.get(MetricsInterceptor)); + // ========================= // SWAGGER // ========================= diff --git a/src/utils/masking/field-masking.util.ts b/src/utils/masking/field-masking.util.ts index 0b646ffb..4159ef6b 100644 --- a/src/utils/masking/field-masking.util.ts +++ b/src/utils/masking/field-masking.util.ts @@ -36,7 +36,7 @@ export function maskName(name: string): string { * Fully redacts a value. */ export function maskFull(_value: unknown): string { - return '[REDACTED]'; + return '[REDACTED]'; } /** diff --git a/src/utils/masking/kpi.service.ts b/src/utils/masking/kpi.service.ts new file mode 100644 index 00000000..aa81ba5a --- /dev/null +++ b/src/utils/masking/kpi.service.ts @@ -0,0 +1,163 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, MoreThan } from 'typeorm'; +import { subDays, startOfDay, endOfDay, startOfMonth, format } from 'date-fns'; + +import { MetricsService } from './metrics.service'; +import { User } from '../users/entities/user.entity'; +import { Course } from '../courses/entities/course.entity'; +import { Enrollment } from '../courses/entities/enrollment.entity'; +import { Payment } from '../payments/entities/payment.entity'; +import { UserActivity } from '../analytics/entities/user-activity.entity'; +import { PaymentStatus } from '../payments/enums/payment-status.enum'; + +@Injectable() +export class KpiService { + private readonly logger = new Logger(KpiService.name); + + constructor( + private readonly metricsService: MetricsService, + @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(Course) private readonly courseRepository: Repository, + @InjectRepository(Enrollment) private readonly enrollmentRepository: Repository, + @InjectRepository(Payment) private readonly paymentRepository: Repository, + @InjectRepository(UserActivity) private readonly userActivityRepository: Repository, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async handleCron() { + this.logger.log('Calculating and updating KPIs...'); + await Promise.all([ + this.calculateActiveUsers(), + this.calculatePaymentSuccessRate(), + this.calculateRevenuePerCourse(), + this.calculateEnrollmentConversionRate(), + this.calculateUserRetention(), + ]).catch((err) => this.logger.error('Failed to update KPIs', err)); + this.logger.log('KPI update complete.'); + } + + async calculateActiveUsers(): Promise { + const now = new Date(); + const dauPromise = this.userActivityRepository.count({ + where: { lastSeen: Between(startOfDay(now), endOfDay(now)) }, + }); + const wauPromise = this.userActivityRepository.count({ + where: { lastSeen: MoreThan(subDays(now, 7)) }, + }); + const mauPromise = this.userActivityRepository.count({ + where: { lastSeen: MoreThan(subDays(now, 30)) }, + }); + + const [dau, wau, mau] = await Promise.all([dauPromise, wauPromise, mauPromise]); + + this.metricsService.activeUsersGauge.labels('daily').set(dau); + this.metricsService.activeUsersGauge.labels('weekly').set(wau); + this.metricsService.activeUsersGauge.labels('monthly').set(mau); + this.logger.log(`Active Users: DAU=${dau}, WAU=${wau}, MAU=${mau}`); + } + + async calculatePaymentSuccessRate(): Promise { + const succeeded = await this.paymentRepository.count({ + where: { status: PaymentStatus.SUCCEEDED }, + }); + const failed = await this.paymentRepository.count({ + where: { status: PaymentStatus.FAILED }, + }); + + const total = succeeded + failed; + const successRate = total > 0 ? (succeeded / total) * 100 : 0; + + this.metricsService.paymentSuccessRateGauge.set(successRate); + this.logger.log(`Payment Success Rate: ${successRate.toFixed(2)}%`); + } + + async calculateRevenuePerCourse(): Promise { + const revenueData = await this.paymentRepository + .createQueryBuilder('payment') + .select('payment.courseId', 'courseId') + .addSelect('SUM(payment.amount)', 'totalRevenue') + .innerJoin('payment.course', 'course') + .addSelect('course.title', 'courseName') + .where('payment.status = :status', { status: PaymentStatus.SUCCEEDED }) + .groupBy('payment.courseId, course.title') + .getRawMany(); + + this.metricsService.revenuePerCourseGauge.reset(); + for (const item of revenueData) { + this.metricsService.revenuePerCourseGauge + .labels(item.courseId, item.courseName) + .set(Number(item.totalRevenue)); + } + this.logger.log(`Calculated revenue for ${revenueData.length} courses.`); + } + + async calculateEnrollmentConversionRate(): Promise { + // This is a simplified version. A real-world scenario would track views vs enrollments. + // Here we'll simulate it by looking at enrollments vs total users. + // For a more accurate metric, you'd need an event tracking system for 'course_viewed'. + const courses = await this.courseRepository.find(); + this.metricsService.enrollmentConversionGauge.reset(); + + for (const course of courses) { + const enrollments = await this.enrollmentRepository.count({ where: { courseId: course.id } }); + // Placeholder for views. In a real system, you'd query an analytics table. + const views = enrollments * 5 + Math.floor(Math.random() * 100); // Simulate views + + const conversionRate = views > 0 ? (enrollments / views) * 100 : 0; + this.metricsService.enrollmentConversionGauge.labels(course.id).set(conversionRate); + } + this.logger.log(`Calculated enrollment conversion for ${courses.length} courses.`); + } + + async calculateUserRetention(): Promise { + // Calculate 3-month cohort retention + const now = new Date(); + this.metricsService.userRetentionGauge.reset(); + + for (let i = 1; i <= 3; i++) { + const cohortMonthStart = startOfMonth(subDays(now, i * 30)); + const cohortMonthEnd = endOfDay(subDays(startOfMonth(subDays(now, (i - 1) * 30)), 1)); + + const cohortUsers = await this.userRepository.find({ + select: ['id'], + where: { createdAt: Between(cohortMonthStart, cohortMonthEnd) }, + }); + + const cohortUserIds = cohortUsers.map((u) => u.id); + const cohortSize = cohortUserIds.length; + + if (cohortSize === 0) continue; + + const cohortMonthLabel = format(cohortMonthStart, 'yyyy-MM'); + + // Check retention for subsequent months + for (let j = 1; j < i; j++) { + const retentionMonthStart = startOfMonth(subDays(now, (i - j) * 30)); + const retentionMonthEnd = endOfDay(subDays(startOfMonth(subDays(now, (i - j - 1) * 30)), 1)); + + if (retentionMonthStart > now) continue; + + const retainedUsersCount = await this.userActivityRepository + .createQueryBuilder('activity') + .select('COUNT(DISTINCT activity.userId)', 'count') + .where('activity.userId IN (:...cohortUserIds)', { cohortUserIds }) + .andWhere('activity.lastSeen BETWEEN :start AND :end', { + start: retentionMonthStart, + end: retentionMonthEnd, + }) + .getRawOne(); + + const retainedCount = parseInt(retainedUsersCount?.count ?? '0', 10); + const retentionRate = (retainedCount / cohortSize) * 100; + + const retainedMonthLabel = format(retentionMonthStart, 'yyyy-MM'); + this.metricsService.userRetentionGauge + .labels(cohortMonthLabel, retainedMonthLabel) + .set(retentionRate); + } + } + this.logger.log('Calculated user retention cohorts.'); + } +} \ No newline at end of file diff --git a/src/utils/masking/metrics.controller.ts b/src/utils/masking/metrics.controller.ts new file mode 100644 index 00000000..e7e2aacd --- /dev/null +++ b/src/utils/masking/metrics.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Response } from 'express'; +import { MetricsService } from './metrics.service'; +import { SkipQuota } from '../rate-limiting/decorators/quota.decorator'; + +@ApiTags('Metrics') +@SkipQuota() +@Controller('metrics') +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get() + @ApiOperation({ summary: 'Get application metrics for Prometheus' }) + @ApiResponse({ status: 200, description: 'Prometheus metrics' }) + async getMetrics(@Res() res: Response): Promise { + res.set('Content-Type', this.metricsService.getRegistry().contentType); + res.end(await this.metricsService.getMetrics()); + } +} \ No newline at end of file diff --git a/src/utils/masking/metrics.interceptor.ts b/src/utils/masking/metrics.interceptor.ts new file mode 100644 index 00000000..42cba2ed --- /dev/null +++ b/src/utils/masking/metrics.interceptor.ts @@ -0,0 +1,38 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request, Response } from 'express'; +import { MetricsService } from './metrics.service'; + +@Injectable() +export class MetricsInterceptor implements NestInterceptor { + constructor(private readonly metricsService: MetricsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const startTime = process.hrtime(); + const request = context.switchToHttp().getRequest(); + + return next.handle().pipe( + tap(() => { + const response = context.switchToHttp().getResponse(); + const diff = process.hrtime(startTime); + const durationSeconds = diff[0] + diff[1] / 1e9; + + const route = request.route?.path ?? request.path; + + this.metricsService.apiLatencyHistogram + .labels( + request.method, + route, + String(response.statusCode), + ) + .observe(durationSeconds); + }), + ); + } +} \ No newline at end of file diff --git a/src/utils/masking/metrics.module.ts b/src/utils/masking/metrics.module.ts new file mode 100644 index 00000000..ec470f5b --- /dev/null +++ b/src/utils/masking/metrics.module.ts @@ -0,0 +1,30 @@ +import { Module, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; + +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; +import { KpiService } from './kpi.service'; +import { MetricsInterceptor } from './metrics.interceptor'; + +import { User } from '../users/entities/user.entity'; +import { Course } from '../courses/entities/course.entity'; +import { Enrollment } from '../courses/entities/enrollment.entity'; +import { Payment }s '../payments/entities/payment.entity'; +import { UserActivity } from '../analytics/entities/user-activity.entity'; + +@Global() +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Course, Enrollment, Payment, UserActivity]), + ScheduleModule.forRoot(), + ], + controllers: [MetricsController], + providers: [ + MetricsService, + KpiService, + MetricsInterceptor, + ], + exports: [MetricsService, MetricsInterceptor], +}) +export class MetricsModule {} \ No newline at end of file diff --git a/src/utils/masking/metrics.service.ts b/src/utils/masking/metrics.service.ts new file mode 100644 index 00000000..4969c4e6 --- /dev/null +++ b/src/utils/masking/metrics.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { + Registry, + collectDefaultMetrics, + Gauge, + Counter, + Histogram, +} from 'prom-client'; + +@Injectable() +export class MetricsService { + private readonly registry: Registry; + + // KPI Gauges + public readonly activeUsersGauge: Gauge; + public readonly userRetentionGauge: Gauge; + public readonly enrollmentConversionGauge: Gauge; + public readonly paymentSuccessRateGauge: Gauge; + public readonly revenuePerCourseGauge: Gauge; + + // Counters + public readonly paymentsTotalCounter: Counter; + + // Histograms + public readonly apiLatencyHistogram: Histogram; + + constructor() { + this.registry = new Registry(); + this.registry.setDefaultLabels({ + app: 'teachlink-backend', + }); + + // Enable default node.js metrics + collectDefaultMetrics({ register: this.registry }); + + // --- Initialize Gauges --- + this.activeUsersGauge = new Gauge({ + name: 'teachlink_active_users', + help: 'Number of active users over a time period', + labelNames: ['period'], // 'daily', 'weekly', 'monthly' + registers: [this.registry], + }); + + this.userRetentionGauge = new Gauge({ + name: 'teachlink_user_retention_rate', + help: 'Cohort-based user retention rate', + labelNames: ['cohort_month', 'retained_month'], + registers: [this.registry], + }); + + this.enrollmentConversionGauge = new Gauge({ + name: 'teachlink_enrollment_conversion_rate', + help: 'Course enrollment conversion rate', + labelNames: ['courseId'], + registers: [this.registry], + }); + + this.paymentSuccessRateGauge = new Gauge({ + name: 'teachlink_payment_success_rate', + help: 'Success rate of payment transactions', + registers: [this.registry], + }); + + this.revenuePerCourseGauge = new Gauge({ + name: 'teachlink_revenue_per_course', + help: 'Total revenue generated per course', + labelNames: ['courseId', 'courseName'], + registers: [this.registry], + }); + + // --- Initialize Counters --- + this.paymentsTotalCounter = new Counter({ + name: 'teachlink_payments_total', + help: 'Total number of payment attempts', + labelNames: ['status'], // 'succeeded', 'failed' + registers: [this.registry], + }); + + // --- Initialize Histograms --- + this.apiLatencyHistogram = new Histogram({ + name: 'teachlink_api_latency_seconds', + help: 'API request latency in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], // Buckets in seconds + registers: [this.registry], + }); + } + + /** + * Get the Prometheus metrics registry. + */ + getRegistry(): Registry { + return this.registry; + } + + /** + * Get metrics as a string for the /metrics endpoint. + */ + async getMetrics(): Promise { + return this.registry.metrics(); + } +} \ No newline at end of file