feat: M0-07 Observability — MetricsInterceptor + admin metrics AAPI
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 36s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 36s
This commit is contained in:
parent
c7052ee48e
commit
2bd416c807
@ -0,0 +1,8 @@
|
||||
CREATE TABLE ApiMetric (
|
||||
id VARCHAR(191) NOT NULL, path VARCHAR(255) NOT NULL, method VARCHAR(10) NOT NULL,
|
||||
statusCode INT NOT NULL, duration INT NOT NULL,
|
||||
userId VARCHAR(100), ip VARCHAR(45),
|
||||
createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX ApiMetric_path_idx(path), INDEX ApiMetric_createdAt_idx(createdAt),
|
||||
PRIMARY KEY (id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@ -948,3 +948,17 @@ model ContentReport {
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model ApiMetric {
|
||||
id String @id @default(cuid())
|
||||
path String @db.VarChar(255)
|
||||
method String @db.VarChar(10)
|
||||
statusCode Int
|
||||
duration Int
|
||||
userId String? @db.VarChar(100)
|
||||
ip String? @db.VarChar(45)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([path])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
||||
import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module';
|
||||
import { AdminUsersModule } from './modules/admin-users/admin-users.module';
|
||||
import { ContentSafetyModule } from './modules/content-safety/content-safety.module';
|
||||
import { AdminMetricsModule } from './modules/admin-metrics/admin-metrics.module';
|
||||
import { AdminThrottleModule } from './modules/admin-throttle/admin-throttle.module';
|
||||
import { AppConfigModule } from './modules/config/config.module';
|
||||
import { AdminEventsModule } from './modules/admin-events/admin-events.module';
|
||||
@ -52,6 +53,7 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
||||
import { StrictValidationPipe } from './common/pipes/strict-validation.pipe';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import { TraceIdInterceptor } from './common/interceptors/trace-id.interceptor';
|
||||
import { MetricsInterceptor } from './common/interceptors/metrics.interceptor';
|
||||
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
|
||||
import { AppThrottleModule } from './common/throttle/throttle.module';
|
||||
|
||||
@ -105,6 +107,7 @@ import appleConfig from './config/apple.config';
|
||||
AdminDashboardModule,
|
||||
AdminUsersModule,
|
||||
ContentSafetyModule,
|
||||
AdminMetricsModule,
|
||||
AdminThrottleModule,
|
||||
AppConfigModule,
|
||||
AdminEventsModule,
|
||||
@ -140,6 +143,7 @@ import appleConfig from './config/apple.config';
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_PIPE, useClass: StrictValidationPipe },
|
||||
{ provide: APP_INTERCEPTOR, useClass: TraceIdInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: MetricsInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
||||
AiAnalysisWorker,
|
||||
|
||||
32
src/common/interceptors/metrics.interceptor.ts
Normal file
32
src/common/interceptors/metrics.interceptor.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class MetricsInterceptor implements NestInterceptor {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const start = Date.now();
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(async () => {
|
||||
const duration = Date.now() - start;
|
||||
const res = context.switchToHttp().getResponse();
|
||||
// Fire-and-forget: don't block the response
|
||||
this.prisma.apiMetric.create({
|
||||
data: {
|
||||
path: req.route?.path || req.url?.split('?')[0] || '/',
|
||||
method: req.method,
|
||||
statusCode: res.statusCode,
|
||||
duration,
|
||||
userId: req.user?.id || req.adminUser?.id || null,
|
||||
ip: req.ip || null,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
src/modules/admin-metrics/admin-metrics.controller.ts
Normal file
50
src/modules/admin-metrics/admin-metrics.controller.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
|
||||
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
|
||||
import { AdminRoles } from '../../common/decorators/admin-roles.decorator';
|
||||
import type { AdminRole } from '../../common/types/admin-role.enum';
|
||||
|
||||
@ApiTags('admin-metrics')
|
||||
@Controller('admin-api/metrics')
|
||||
@UseGuards(AdminAuthGuard, AdminRolesGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AdminMetricsController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get('overview')
|
||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||
@ApiOperation({ summary: '指标概览' })
|
||||
async overview() {
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const [todayCalls, avgDuration, errorCount] = await Promise.all([
|
||||
this.prisma.apiMetric.count({ where: { createdAt: { gte: today } } }),
|
||||
this.prisma.apiMetric.aggregate({ _avg: { duration: true }, where: { createdAt: { gte: today } } }),
|
||||
this.prisma.apiMetric.count({ where: { createdAt: { gte: today }, statusCode: { gte: 400 } } }),
|
||||
]);
|
||||
const total = await this.prisma.apiMetric.count();
|
||||
return { todayCalls, avgDuration: Math.round(avgDuration._avg.duration || 0), errorCount, total };
|
||||
}
|
||||
|
||||
@Get('top')
|
||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||
@ApiOperation({ summary: '接口耗时排行' })
|
||||
async top(@Query('limit') limit = 10) {
|
||||
const metrics = await this.prisma.apiMetric.groupBy({
|
||||
by: ['path', 'method'],
|
||||
_avg: { duration: true },
|
||||
_count: { id: true },
|
||||
orderBy: { _avg: { duration: 'desc' } },
|
||||
take: parseInt(String(limit)),
|
||||
});
|
||||
return metrics.map(m => ({ path: m.path, method: m.method, avgDuration: Math.round(m._avg.duration || 0), calls: m._count.id }));
|
||||
}
|
||||
|
||||
@Get('recent')
|
||||
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||
@ApiOperation({ summary: '最近请求' })
|
||||
async recent(@Query('limit') limit = 30) {
|
||||
return this.prisma.apiMetric.findMany({ orderBy: { createdAt: 'desc' }, take: parseInt(String(limit)) });
|
||||
}
|
||||
}
|
||||
7
src/modules/admin-metrics/admin-metrics.module.ts
Normal file
7
src/modules/admin-metrics/admin-metrics.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminMetricsController } from './admin-metrics.controller';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
|
||||
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
|
||||
@Module({ controllers: [AdminMetricsController], providers: [PrismaService, AdminAuthGuard, AdminRolesGuard] })
|
||||
export class AdminMetricsModule {}
|
||||
Loading…
x
Reference in New Issue
Block a user