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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
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';

Check failure on line 28 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module './metrics/metrics.module' or its corresponding type declarations.

// ✅ keep BOTH modules
import { ReadReplicaModule } from './database/read-replica';
Expand Down Expand Up @@ -54,6 +55,7 @@
InvoicesModule,
ReportingModule,
HealthModule,
MetricsModule,

// ✅ always include read replicas (or wrap if needed)
ReadReplicaModule,
Expand Down
132 changes: 132 additions & 0 deletions src/kpi.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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>(KpiService);
metricsService = module.get<MetricsService>(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();
});
});
});
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
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';

Check failure on line 33 in src/main.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module './metrics/metrics.interceptor' or its corresponding type declarations.
// GLOBAL ENFORCEMENT IMPORT (IMPORTANT FOR YOUR TASK)
import { LocaleInterceptor } from './common/interceptors/locale.interceptor';

Expand Down Expand Up @@ -283,6 +284,11 @@
// =========================
app.useGlobalInterceptors(new LocaleInterceptor());

// =========================
// GLOBAL METRICS INTERCEPTOR
// =========================
app.useGlobalInterceptors(app.get(MetricsInterceptor));

// =========================
// SWAGGER
// =========================
Expand Down
166 changes: 166 additions & 0 deletions src/utils/masking/kpi.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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';

Check failure on line 5 in src/utils/masking/kpi.service.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module 'date-fns' or its corresponding type declarations.

import { MetricsService } from './metrics.service';
import { User } from '../users/entities/user.entity';

Check failure on line 8 in src/utils/masking/kpi.service.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module '../users/entities/user.entity' or its corresponding type declarations.
import { Course } from '../courses/entities/course.entity';

Check failure on line 9 in src/utils/masking/kpi.service.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module '../courses/entities/course.entity' or its corresponding type declarations.
import { Enrollment } from '../courses/entities/enrollment.entity';

Check failure on line 10 in src/utils/masking/kpi.service.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module '../courses/entities/enrollment.entity' or its corresponding type declarations.
import { Payment } from '../payments/entities/payment.entity';

Check failure on line 11 in src/utils/masking/kpi.service.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module '../payments/entities/payment.entity' or its corresponding type declarations.
import { UserActivity } from '../analytics/entities/user-activity.entity';

Check failure on line 12 in src/utils/masking/kpi.service.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module '../analytics/entities/user-activity.entity' or its corresponding type declarations.
import { PaymentStatus } from '../payments/enums/payment-status.enum';

Check failure on line 13 in src/utils/masking/kpi.service.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module '../payments/enums/payment-status.enum' or its corresponding type declarations.

@Injectable()
export class KpiService {
private readonly logger = new Logger(KpiService.name);

constructor(
private readonly metricsService: MetricsService,
@InjectRepository(User) private readonly userRepository: Repository<User>,
@InjectRepository(Course) private readonly courseRepository: Repository<Course>,
@InjectRepository(Enrollment) private readonly enrollmentRepository: Repository<Enrollment>,
@InjectRepository(Payment) private readonly paymentRepository: Repository<Payment>,
@InjectRepository(UserActivity)
private readonly userActivityRepository: Repository<UserActivity>,
) {}

@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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
// 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.');
}
}
20 changes: 20 additions & 0 deletions src/utils/masking/metrics.controller.ts
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 5 in src/utils/masking/metrics.controller.ts

View workflow job for this annotation

GitHub Actions / validate

Cannot find module '../rate-limiting/decorators/quota.decorator' or its corresponding type declarations.

@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<void> {
res.set('Content-Type', this.metricsService.getRegistry().contentType);
res.end(await this.metricsService.getMetrics());
}
}
29 changes: 29 additions & 0 deletions src/utils/masking/metrics.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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<unknown> {
const startTime = process.hrtime();
const request = context.switchToHttp().getRequest<Request>();

return next.handle().pipe(
tap(() => {
const response = context.switchToHttp().getResponse<Response>();
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);
}),
);
}
}
Loading
Loading