2026-04-15-4
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { UsageService } from './usage.service';
|
||||
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('usage')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Controller('usage')
|
||||
export class UsageController {
|
||||
constructor(private usageService: UsageService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({ summary: 'Get current month usage summary' })
|
||||
async getSummary(@Req() req: any) {
|
||||
const tenant = req.tenant;
|
||||
const summary = await this.usageService.getUsageSummary(tenant.id);
|
||||
|
||||
// Limits
|
||||
const limits = {
|
||||
FREE: 8000,
|
||||
PRO: 50000,
|
||||
ENTERPRISE: 1000000,
|
||||
};
|
||||
|
||||
const limit = limits[tenant.plan] || 8000;
|
||||
|
||||
return {
|
||||
...summary,
|
||||
limit,
|
||||
percentage: Math.min(((summary.monthlyUsage / limit) * 100), 100).toFixed(1),
|
||||
plan: tenant.plan,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
@ApiOperation({ summary: 'Get daily request volume history' })
|
||||
async getHistory(@Req() req: any, @Query('days') days: number = 30) {
|
||||
const tenantId = req.tenant.id;
|
||||
return this.usageService.getUsageHistory(tenantId, days);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('usage_logs')
|
||||
export class UsageLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
apiKeyId: string;
|
||||
|
||||
@Column()
|
||||
endpoint: string; // e.g., '/maps/style.json', '/geocoding/search'
|
||||
|
||||
@Column()
|
||||
method: string; // 'GET', 'POST', etc.
|
||||
|
||||
@Column({ type: 'int', default: 200 })
|
||||
statusCode: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
responseTimeMs: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
userAgent: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
ipAddress: string;
|
||||
|
||||
@Index()
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { UsageService } from './usage.service';
|
||||
import { TenantPlan } from '../auth/entities/tenant.entity';
|
||||
|
||||
// Quota Limits per Plan
|
||||
const QUOTA_LIMITS: Record<TenantPlan, number> = {
|
||||
[TenantPlan.FREE]: 8000,
|
||||
[TenantPlan.PRO]: 50000,
|
||||
[TenantPlan.ENTERPRISE]: 1000000,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsageInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(UsageInterceptor.name);
|
||||
constructor(private usageService: UsageService) { }
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const tenant = request.tenant;
|
||||
const apiKey = request.apiKey; // Set by ApiKeyGuard
|
||||
|
||||
// 1. Quota Enforcement (Skip for Management requests / internal)
|
||||
if (tenant && apiKey && !request.url.includes('/auth/management')) {
|
||||
this.logger.debug(`📊 Monitoring request for Tenant: ${tenant.id}`);
|
||||
const plan = tenant.plan || TenantPlan.FREE;
|
||||
const limit = QUOTA_LIMITS[plan];
|
||||
|
||||
const { allowed, used } = await this.usageService.checkQuota(tenant.id, limit);
|
||||
|
||||
if (!allowed) {
|
||||
throw new HttpException({
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: 'Monthly API usage quota exceeded',
|
||||
used,
|
||||
limit,
|
||||
upgrade_url: 'https://map-dashbord.intaleqapp.com/#billing'
|
||||
}, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
// 2. Async Recording
|
||||
if (tenant && apiKey) {
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
|
||||
this.logger.log(`📈 Usage Recorded: ${request.method} ${request.url.split('?')[0]} for Tenant ${tenant.id}`);
|
||||
this.usageService.recordRequest({
|
||||
tenantId: tenant.id,
|
||||
apiKeyId: apiKey.id,
|
||||
endpoint: request.url.split('?')[0],
|
||||
method: request.method,
|
||||
statusCode: response.statusCode,
|
||||
responseTimeMs,
|
||||
userAgent: request.headers['user-agent'],
|
||||
ipAddress: request.ip || request.headers['x-forwarded-for'],
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsageLog } from './usage.entity';
|
||||
import { UsageService } from './usage.service';
|
||||
import { UsageController } from './usage.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UsageLog]),
|
||||
],
|
||||
providers: [UsageService],
|
||||
controllers: [UsageController],
|
||||
exports: [UsageService],
|
||||
})
|
||||
export class UsageModule {}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UsageLog } from './usage.entity';
|
||||
import { RedisService } from '../common/redis.service';
|
||||
|
||||
@Injectable()
|
||||
export class UsageService {
|
||||
private readonly logger = new Logger(UsageService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UsageLog)
|
||||
private usageRepository: Repository<UsageLog>,
|
||||
private redisService: RedisService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record a request in both PostgreSQL (for history) and Redis (for instant quota)
|
||||
*/
|
||||
async recordRequest(data: {
|
||||
tenantId: string;
|
||||
apiKeyId: string;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
}) {
|
||||
// 1. Log to PostgreSQL (Async)
|
||||
this.usageRepository.save(data).catch((err) => {
|
||||
this.logger.error(`Failed to save usage log to DB: ${err.message}`);
|
||||
});
|
||||
|
||||
// 2. Increment Redis Counter for the current month
|
||||
const monthKey = this.getMonthlyKey(data.tenantId);
|
||||
try {
|
||||
await this.redisService.getClient().incr(monthKey);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to increment Redis usage counter: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current month usage from Redis
|
||||
*/
|
||||
async getMonthlyUsage(tenantId: string): Promise<number> {
|
||||
const key = this.getMonthlyKey(tenantId);
|
||||
const val = await this.redisService.getClient().get(key);
|
||||
return val ? parseInt(val, 10) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tenant has exceeded their monthly quota
|
||||
*/
|
||||
async checkQuota(tenantId: string, limit: number): Promise<{ allowed: boolean; used: number }> {
|
||||
const used = await this.getMonthlyUsage(tenantId);
|
||||
return {
|
||||
allowed: used < limit,
|
||||
used,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage history for charts
|
||||
*/
|
||||
async getUsageHistory(tenantId: string, days: number = 30) {
|
||||
return this.usageRepository
|
||||
.createQueryBuilder('usage')
|
||||
.select("DATE_TRUNC('day', usage.createdAt)", 'date')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('usage.tenantId = :tenantId', { tenantId })
|
||||
.andWhere("usage.createdAt >= NOW() - (:days || ' days')::INTERVAL", { days: days.toString() })
|
||||
.groupBy("DATE_TRUNC('day', usage.createdAt)")
|
||||
.orderBy('date', 'ASC')
|
||||
.getRawMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time summary for the dashboard
|
||||
*/
|
||||
async getUsageSummary(tenantId: string) {
|
||||
const monthlyUsage = await this.getMonthlyUsage(tenantId);
|
||||
|
||||
// Get daily stats and performance metrics
|
||||
const stats = await this.usageRepository
|
||||
.createQueryBuilder('usage')
|
||||
.select('COUNT(*)', 'totalToday')
|
||||
.addSelect('AVG(usage.responseTimeMs)', 'avgLatency')
|
||||
.addSelect('COUNT(CASE WHEN usage.statusCode >= 200 AND usage.statusCode < 300 THEN 1 END)', 'successCount')
|
||||
.where('usage.tenantId = :tenantId', { tenantId })
|
||||
.andWhere('usage.createdAt >= CURRENT_DATE')
|
||||
.getRawOne();
|
||||
|
||||
const totalToday = parseInt(stats.totalToday || '0', 10);
|
||||
const avgLatency = Math.round(parseFloat(stats.avgLatency || '0'));
|
||||
const successRate = totalToday > 0
|
||||
? Math.round((parseInt(stats.successCount || '0', 10) / totalToday) * 100)
|
||||
: 100;
|
||||
|
||||
return {
|
||||
monthlyUsage,
|
||||
totalToday,
|
||||
avgLatency,
|
||||
successRate,
|
||||
lastUpdated: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
private getMonthlyKey(tenantId: string): string {
|
||||
const now = new Date();
|
||||
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
return `usage:${tenantId}:${yearMonth}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user