diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts index 26289b66..93ceafb5 100644 --- a/src/analytics/analytics.controller.ts +++ b/src/analytics/analytics.controller.ts @@ -1,22 +1,158 @@ -import { Controller, Get, Delete, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Delete, + Param, + Query, + ParseIntPipe, + DefaultValuePipe, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, + ApiParam, +} from '@nestjs/swagger'; import { AnalyticsService } from './analytics.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../types/prisma.types'; +@ApiTags('API Monitoring') +@ApiBearerAuth() @Controller('analytics') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) export class AnalyticsController { constructor(private readonly analytics: AnalyticsService) {} + /** + * Full monitoring dashboard — request counts, error rates, + * slow endpoints, and usage by user. + */ @Get() - getStats() { - return this.analytics.getStats(); + @ApiOperation({ + summary: 'API monitoring dashboard', + description: + 'Returns request counts, error rates, slow endpoints, and per-user usage for the given time window.', + }) + @ApiQuery({ + name: 'window', + required: false, + description: 'Time window in minutes (default: 60)', + example: 60, + }) + @ApiResponse({ status: 200, description: 'Monitoring stats returned successfully' }) + getStats( + @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, + ) { + return this.analytics.getStats(window); } + /** + * Endpoint-level breakdown — sorted by request volume. + */ + @Get('endpoints') + @ApiOperation({ + summary: 'Per-endpoint stats', + description: 'Request counts, error rates, and response time percentiles per endpoint.', + }) + @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) + @ApiResponse({ status: 200, description: 'Endpoint stats returned successfully' }) + getEndpoints( + @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, + ) { + return this.analytics.getEndpointStats(window); + } + + /** + * Slow endpoints — those exceeding the 1 s threshold. + */ + @Get('slow-endpoints') + @ApiOperation({ + summary: 'Slow endpoints', + description: 'Endpoints with average response time above 1000 ms.', + }) + @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) + @ApiResponse({ status: 200, description: 'Slow endpoints returned successfully' }) + getSlowEndpoints( + @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, + ) { + return this.analytics.getStats(window).slowEndpoints; + } + + /** + * Error rate summary — broken down by HTTP status code. + */ + @Get('errors') + @ApiOperation({ + summary: 'Error rate breakdown', + description: 'Error counts and rates grouped by HTTP status code.', + }) + @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) + @ApiResponse({ status: 200, description: 'Error stats returned successfully' }) + getErrors( + @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, + ) { + const stats = this.analytics.getStats(window); + return { + window: stats.window, + totalRequests: stats.totalRequests, + totalErrors: stats.totalErrors, + overallErrorRate: stats.overallErrorRate, + errorsByStatus: stats.errorsByStatus, + }; + } + + /** + * Top users by request volume. + */ + @Get('users') + @ApiOperation({ + summary: 'Usage by user', + description: 'Top users ranked by request count with error counts and avg response time.', + }) + @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) + @ApiResponse({ status: 200, description: 'User usage stats returned successfully' }) + getUsers( + @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, + ) { + return this.analytics.getStats(window).topUsers; + } + + /** + * Stats for a specific user. + */ + @Get('users/:userId') + @ApiOperation({ summary: 'Usage stats for a specific user' }) + @ApiParam({ name: 'userId', description: 'User ID (UUID)' }) + @ApiQuery({ name: 'window', required: false, description: 'Time window in minutes', example: 60 }) + @ApiResponse({ status: 200, description: 'User stats returned' }) + @ApiResponse({ status: 404, description: 'No data for this user in the given window' }) + getUserStats( + @Param('userId') userId: string, + @Query('window', new DefaultValuePipe(60), ParseIntPipe) window: number, + ) { + const stats = this.analytics.getUserStats(userId, window); + if (!stats) { + return { message: 'No data for this user in the given window', userId, window: `${window}m` }; + } + return stats; + } + + /** + * Reset all in-memory analytics data. + */ @Delete('reset') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset analytics data' }) + @ApiResponse({ status: 200, description: 'Analytics data cleared' }) reset() { this.analytics.reset(); return { message: 'Analytics reset' }; diff --git a/src/analytics/analytics.interceptor.ts b/src/analytics/analytics.interceptor.ts index 1a3fe39a..fc93b946 100644 --- a/src/analytics/analytics.interceptor.ts +++ b/src/analytics/analytics.interceptor.ts @@ -1,7 +1,9 @@ import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { tap, catchError } from 'rxjs/operators'; +import { throwError } from 'rxjs'; import { AnalyticsService } from './analytics.service'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; @Injectable() export class AnalyticsInterceptor implements NestInterceptor { @@ -12,14 +14,24 @@ export class AnalyticsInterceptor implements NestInterceptor { const res = context.switchToHttp().getResponse(); const start = Date.now(); + const record = (statusCode: number) => { + const user: AuthUserPayload | undefined = req.authUser; + this.analytics.record({ + endpoint: req.path, + method: req.method, + statusCode, + responseTime: Date.now() - start, + userId: user?.sub ?? null, + }); + }; + return next.handle().pipe( - tap(() => { - this.analytics.record({ - endpoint: req.path, - method: req.method, - statusCode: res.statusCode, - responseTime: Date.now() - start, - }); + tap(() => record(res.statusCode)), + catchError((err) => { + // Capture error status codes (e.g. thrown HttpExceptions) + const status: number = err?.status ?? err?.statusCode ?? 500; + record(status); + return throwError(() => err); }), ); } diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index 53feaa50..b973a391 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -1,17 +1,58 @@ import { Injectable } from '@nestjs/common'; -interface RequestRecord { +export interface RequestRecord { endpoint: string; method: string; statusCode: number; responseTime: number; + userId: string | null; timestamp: Date; } +export interface EndpointStats { + endpoint: string; + requestCount: number; + errorCount: number; + errorRate: number; + avgResponseTime: number; + p95ResponseTime: number; + p99ResponseTime: number; +} + +export interface UserUsageStats { + userId: string; + requestCount: number; + errorCount: number; + avgResponseTime: number; + lastSeen: Date; +} + +export interface SlowEndpoint { + endpoint: string; + avgResponseTime: number; + p95ResponseTime: number; + requestCount: number; +} + +export interface ApiMonitoringStats { + window: string; + totalRequests: number; + totalErrors: number; + overallErrorRate: number; + avgResponseTime: number; + requestsPerMinute: number; + topEndpoints: EndpointStats[]; + slowEndpoints: SlowEndpoint[]; + errorsByStatus: Array<{ statusCode: number; count: number; rate: number }>; + topUsers: UserUsageStats[]; +} + @Injectable() export class AnalyticsService { private records: RequestRecord[] = []; - private readonly MAX_RECORDS = 10000; + private readonly MAX_RECORDS = 50000; + // Slow endpoint threshold in ms + private readonly SLOW_THRESHOLD_MS = 1000; record(data: Omit): void { this.records.push({ ...data, timestamp: new Date() }); @@ -20,47 +61,185 @@ export class AnalyticsService { } } - getStats() { - const total = this.records.length; - if (total === 0) return { total: 0, endpoints: [], errors: [], avgResponseTime: 0 }; + /** + * Returns records within the given time window (minutes). + * Defaults to last 60 minutes. + */ + private getWindowedRecords(windowMinutes = 60): RequestRecord[] { + const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000); + return this.records.filter((r) => r.timestamp >= cutoff); + } + + private percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, idx)]; + } + + getStats(windowMinutes = 60): ApiMonitoringStats { + const records = this.getWindowedRecords(windowMinutes); + const total = records.length; + + if (total === 0) { + return { + window: `${windowMinutes}m`, + totalRequests: 0, + totalErrors: 0, + overallErrorRate: 0, + avgResponseTime: 0, + requestsPerMinute: 0, + topEndpoints: [], + slowEndpoints: [], + errorsByStatus: [], + topUsers: [], + }; + } + + // --- Aggregate by endpoint --- + const endpointMap = new Map< + string, + { count: number; errors: number; times: number[] } + >(); + + // --- Aggregate by user --- + const userMap = new Map< + string, + { count: number; errors: number; totalTime: number; lastSeen: Date } + >(); + + // --- Aggregate by status code --- + const statusMap = new Map(); - const endpointMap = new Map(); - const errorMap = new Map(); let totalTime = 0; + let totalErrors = 0; - for (const r of this.records) { + for (const r of records) { const key = `${r.method} ${r.endpoint}`; - const ep = endpointMap.get(key) ?? { count: 0, totalTime: 0 }; + const ep = endpointMap.get(key) ?? { count: 0, errors: 0, times: [] }; ep.count++; - ep.totalTime += r.responseTime; + ep.times.push(r.responseTime); + if (r.statusCode >= 400) ep.errors++; endpointMap.set(key, ep); totalTime += r.responseTime; + if (r.statusCode >= 400) totalErrors++; + + statusMap.set(r.statusCode, (statusMap.get(r.statusCode) ?? 0) + 1); - if (r.statusCode >= 400) { - errorMap.set(r.statusCode, (errorMap.get(r.statusCode) ?? 0) + 1); + if (r.userId) { + const u = userMap.get(r.userId) ?? { + count: 0, + errors: 0, + totalTime: 0, + lastSeen: r.timestamp, + }; + u.count++; + u.totalTime += r.responseTime; + if (r.statusCode >= 400) u.errors++; + if (r.timestamp > u.lastSeen) u.lastSeen = r.timestamp; + userMap.set(r.userId, u); } } - const endpoints = [...endpointMap.entries()] - .map(([endpoint, { count, totalTime: t }]) => ({ + // --- Build endpoint stats --- + const endpointStats: EndpointStats[] = [...endpointMap.entries()].map( + ([endpoint, { count, errors, times }]) => { + const sorted = [...times].sort((a, b) => a - b); + return { + endpoint, + requestCount: count, + errorCount: errors, + errorRate: parseFloat(((errors / count) * 100).toFixed(2)), + avgResponseTime: Math.round(times.reduce((a, b) => a + b, 0) / count), + p95ResponseTime: this.percentile(sorted, 95), + p99ResponseTime: this.percentile(sorted, 99), + }; + }, + ); + + const topEndpoints = [...endpointStats] + .sort((a, b) => b.requestCount - a.requestCount) + .slice(0, 10); + + const slowEndpoints: SlowEndpoint[] = endpointStats + .filter((e) => e.avgResponseTime >= this.SLOW_THRESHOLD_MS) + .sort((a, b) => b.avgResponseTime - a.avgResponseTime) + .slice(0, 10) + .map(({ endpoint, avgResponseTime, p95ResponseTime, requestCount }) => ({ endpoint, + avgResponseTime, + p95ResponseTime, + requestCount, + })); + + // --- Error breakdown by status --- + const errorsByStatus = [...statusMap.entries()] + .filter(([code]) => code >= 400) + .map(([statusCode, count]) => ({ + statusCode, count, - avgResponseTime: Math.round(t / count), + rate: parseFloat(((count / total) * 100).toFixed(2)), })) - .sort((a, b) => b.count - a.count) - .slice(0, 10); + .sort((a, b) => b.count - a.count); + + // --- Top users by request count --- + const topUsers: UserUsageStats[] = [...userMap.entries()] + .map(([userId, { count, errors, totalTime: ut, lastSeen }]) => ({ + userId, + requestCount: count, + errorCount: errors, + avgResponseTime: Math.round(ut / count), + lastSeen, + })) + .sort((a, b) => b.requestCount - a.requestCount) + .slice(0, 20); - const errors = [...errorMap.entries()].map(([statusCode, count]) => ({ - statusCode, - count, - })); + const windowMs = windowMinutes * 60 * 1000; + const requestsPerMinute = parseFloat(((total / windowMs) * 60000).toFixed(2)); return { - total, + window: `${windowMinutes}m`, + totalRequests: total, + totalErrors, + overallErrorRate: parseFloat(((totalErrors / total) * 100).toFixed(2)), avgResponseTime: Math.round(totalTime / total), - endpoints, - errors, + requestsPerMinute, + topEndpoints, + slowEndpoints, + errorsByStatus, + topUsers, + }; + } + + /** + * Per-endpoint breakdown with full stats. + */ + getEndpointStats(windowMinutes = 60): EndpointStats[] { + return this.getStats(windowMinutes).topEndpoints; + } + + /** + * Usage breakdown for a specific user. + */ + getUserStats(userId: string, windowMinutes = 60): UserUsageStats | null { + const records = this.getWindowedRecords(windowMinutes).filter( + (r) => r.userId === userId, + ); + if (records.length === 0) return null; + + const errors = records.filter((r) => r.statusCode >= 400).length; + const totalTime = records.reduce((s, r) => s + r.responseTime, 0); + const lastSeen = records.reduce( + (latest, r) => (r.timestamp > latest ? r.timestamp : latest), + records[0].timestamp, + ); + + return { + userId, + requestCount: records.length, + errorCount: errors, + avgResponseTime: Math.round(totalTime / records.length), + lastSeen, }; }