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 { 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';
Expand Down Expand Up @@ -54,6 +55,7 @@ const featureFlags = loadFeatureFlags();
InvoicesModule,
ReportingModule,
HealthModule,
MetricsModule,

// ✅ always include read replicas (or wrap if needed)
ReadReplicaModule,
Expand Down
127 changes: 127 additions & 0 deletions src/kpi.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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);

Check failure on line 55 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `.spyOn(mockRepo,·'count').mockResolvedValueOnce(10).mockResolvedValueOnce(50)` with `⏎········.spyOn(mockRepo,·'count')⏎········.mockResolvedValueOnce(10)⏎········.mockResolvedValueOnce(50)⏎········`
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')

Check failure on line 71 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎········`
.mockImplementation((options: any) => {
if (options.where.status === PaymentStatus.SUCCEEDED) return Promise.resolve(95);

Check failure on line 73 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `··········` with `········`
if (options.where.status === PaymentStatus.FAILED) return Promise.resolve(5);

Check failure on line 74 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `··`
return Promise.resolve(0);

Check failure on line 75 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `··········` with `········`
});

Check failure on line 76 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `··`

await kpiService.calculatePaymentSuccessRate();

expect(gaugeSpy).toHaveBeenCalledWith(95);
});

it('should handle zero total payments', async () => {

Check failure on line 83 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `·`
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();

Check failure on line 115 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `.spyOn(kpiService,·'calculateEnrollmentConversionRate')` with `⏎········.spyOn(kpiService,·'calculateEnrollmentConversionRate')⏎········`
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();
});
});
});

Check failure on line 127 in src/kpi.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎`
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -283,6 +284,11 @@ async function bootstrapWorker(): Promise<void> {
// =========================
app.useGlobalInterceptors(new LocaleInterceptor());

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

// =========================
// SWAGGER
// =========================
Expand Down
2 changes: 1 addition & 1 deletion src/utils/masking/field-masking.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
* Fully redacts a value.
*/
export function maskFull(_value: unknown): string {
return '[REDACTED]';
return '[REDACTED]';

Check failure on line 39 in src/utils/masking/field-masking.util.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `·`
}

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

@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());
}
}
Loading
Loading