Skip to content
Merged
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
142 changes: 139 additions & 3 deletions src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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' };
Expand Down
28 changes: 20 additions & 8 deletions src/analytics/analytics.interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
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 {
constructor(private readonly analytics: AnalyticsService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {

Check warning on line 12 in src/analytics/analytics.interceptor.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

Unexpected any. Specify a different type
const req = context.switchToHttp().getRequest();
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);
}),
);
}
Expand Down
Loading
Loading