Skip to content
This repository was archived by the owner on Jan 28, 2026. It is now read-only.

Commit afc6c55

Browse files
committed
feat: Implement Prometheus metrics collection for HTTP requests and login attempts, including custom metrics and interceptors
1 parent f135cda commit afc6c55

8 files changed

Lines changed: 289 additions & 4 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,31 @@ http://localhost:3000/admin/api/metrics
7979
- Путь `PROMETHEUS_PATH` указывается относительно корня приложения, но с учетом глобального префикса `admin/api`
8080
- Для доступа к метрикам без префикса установите `PROMETHEUS_PATH=/metrics` и используйте `http://localhost:3000/metrics`
8181

82+
**Доступные метрики:**
83+
84+
Приложение автоматически собирает следующие кастомные метрики:
85+
86+
1. **`http_requests_total`** - Общее количество HTTP запросов
87+
- Метки: `method` (HTTP метод), `route` (маршрут), `status_code` (код ответа)
88+
89+
2. **`http_request_duration_seconds`** - Длительность HTTP запросов в секундах
90+
- Метки: `method` (HTTP метод), `route` (маршрут)
91+
- Бакеты: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 секунд
92+
93+
3. **`http_errors_total`** - Общее количество HTTP ошибок
94+
- Метки: `method` (HTTP метод), `route` (маршрут), `status_code` (код ошибки), `error_type` (тип ошибки)
95+
96+
4. **`login_attempts_total`** - Общее количество попыток входа
97+
- Метки: `status` (success/failure), `reason` (invalid_credentials, user_not_found, user_blocked, unauthorized, unknown)
98+
99+
5. **`login_duration_seconds`** - Длительность попыток входа в секундах
100+
- Метки: `status` (success/failure)
101+
- Бакеты: 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5 секунд
102+
103+
Все метрики автоматически собираются:
104+
- HTTP метрики (1-3) - для всех HTTP запросов через глобальный interceptor
105+
- Метрики входа (4-5) - для попыток входа через LoginLoggingInterceptor
106+
82107
## Запуск
83108

84109
```bash

src/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import { AuthController } from './auth.controller';
77
import { JwtStrategy } from './strategies/jwt.strategy';
88
import { RefreshTokenGuard } from './guards/refresh-token.guard';
99
import { UserModule } from '../user/user.module';
10+
import { PrometheusModule } from '../prometheus/prometheus.module';
1011

1112
@Module({
1213
imports: [
1314
UserModule,
1415
PassportModule,
16+
PrometheusModule,
1517
JwtModule.registerAsync({
1618
imports: [ConfigModule],
1719
useFactory: async (configService: ConfigService) => ({

src/auth/interceptors/login-logging.interceptor.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,65 @@ import {
77
} from '@nestjs/common';
88
import { Observable, throwError } from 'rxjs';
99
import { tap, catchError } from 'rxjs/operators';
10+
import { PrometheusService } from '../../prometheus/prometheus.service';
1011

1112
/**
1213
* Интерцептор для логирования попыток входа
1314
* Логирует попытку входа, успешный вход и неуспешные попытки
15+
* Собирает метрики Prometheus для мониторинга
1416
*/
1517
@Injectable()
1618
export class LoginLoggingInterceptor implements NestInterceptor {
1719
private readonly logger = new Logger(LoginLoggingInterceptor.name);
1820

21+
constructor(private readonly prometheusService: PrometheusService) {}
22+
1923
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
2024
const request = context.switchToHttp().getRequest();
2125
const loginDto = request.body;
2226
const email = loginDto?.email;
27+
const startTime = Date.now();
2328

2429
if (email) {
2530
this.logger.log(`Попытка входа: ${email}`);
2631
}
2732

2833
return next.handle().pipe(
2934
tap((result) => {
35+
const duration = (Date.now() - startTime) / 1000;
36+
3037
if (email && result?.user?.id) {
3138
this.logger.log(`Успешный вход: ${email} (ID: ${result.user.id})`);
39+
40+
// Записываем метрики для успешного входа
41+
this.prometheusService.incrementLoginAttempts('success');
42+
this.prometheusService.recordLoginDuration('success', duration);
3243
}
3344
}),
3445
catchError((error) => {
46+
const duration = (Date.now() - startTime) / 1000;
47+
const errorMessage = error.message || 'Неверный email или пароль';
48+
3549
if (email) {
36-
this.logger.warn(
37-
`Неуспешный вход: ${email} - ${error.message || 'Неверный email или пароль'}`,
38-
);
50+
this.logger.warn(`Неуспешный вход: ${email} - ${errorMessage}`);
51+
}
52+
53+
// Определяем причину ошибки
54+
let reason = 'unknown';
55+
if (errorMessage.includes('email') || errorMessage.includes('пароль') || errorMessage.includes('password')) {
56+
reason = 'invalid_credentials';
57+
} else if (errorMessage.includes('не найден') || errorMessage.includes('not found')) {
58+
reason = 'user_not_found';
59+
} else if (errorMessage.includes('заблокирован') || errorMessage.includes('blocked')) {
60+
reason = 'user_blocked';
61+
} else if (error.status === 401) {
62+
reason = 'unauthorized';
3963
}
64+
65+
// Записываем метрики для неуспешного входа
66+
this.prometheusService.incrementLoginAttempts('failure', reason);
67+
this.prometheusService.recordLoginDuration('failure', duration);
68+
4069
return throwError(() => error);
4170
}),
4271
);

src/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { VersioningType, Logger } from '@nestjs/common';
44
import { AppModule } from './app.module';
55
import { json } from 'express';
66
import { ConfigService } from '@nestjs/config';
7+
import { PrometheusInterceptor } from './prometheus/prometheus.interceptor';
78

89
async function bootstrap() {
910
try {
@@ -84,6 +85,10 @@ async function bootstrap() {
8485

8586
app.setGlobalPrefix('admin/api');
8687

88+
// Глобальный interceptor для сбора метрик Prometheus
89+
const prometheusInterceptor = app.get(PrometheusInterceptor);
90+
app.useGlobalInterceptors(prometheusInterceptor);
91+
8792
const config = new DocumentBuilder()
8893
.setTitle('Atom DBRO Admin Backend API')
8994
.setDescription('API для админ панели Atom DBRO')
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
Injectable,
3+
NestInterceptor,
4+
ExecutionContext,
5+
CallHandler,
6+
} from '@nestjs/common';
7+
import { Observable, throwError } from 'rxjs';
8+
import { tap, catchError } from 'rxjs/operators';
9+
import { PrometheusService } from './prometheus.service';
10+
11+
@Injectable()
12+
export class PrometheusInterceptor implements NestInterceptor {
13+
constructor(private readonly prometheusService: PrometheusService) {}
14+
15+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
16+
const request = context.switchToHttp().getRequest();
17+
const response = context.switchToHttp().getResponse();
18+
const method = request.method;
19+
const route = this.getRoute(request);
20+
const startTime = Date.now();
21+
22+
// Пропускаем сбор метрик для самого endpoint /metrics
23+
if (route === '/metrics' || request.path === '/metrics' || request.originalUrl?.includes('/metrics')) {
24+
return next.handle();
25+
}
26+
27+
return next.handle().pipe(
28+
tap((data) => {
29+
const duration = (Date.now() - startTime) / 1000;
30+
// Используем статус код из response, если доступен, иначе 200
31+
const statusCode = response.statusCode || 200;
32+
33+
// Записываем метрики для успешных запросов
34+
this.prometheusService.incrementHttpRequests(method, route, statusCode);
35+
this.prometheusService.recordHttpRequestDuration(method, route, duration);
36+
}),
37+
catchError((error) => {
38+
const duration = (Date.now() - startTime) / 1000;
39+
const statusCode = error.status || error.statusCode || response.statusCode || 500;
40+
const errorType = error.constructor?.name || 'UnknownError';
41+
42+
// Записываем метрики для ошибок
43+
this.prometheusService.incrementHttpRequests(method, route, statusCode);
44+
this.prometheusService.recordHttpRequestDuration(method, route, duration);
45+
this.prometheusService.incrementHttpErrors(method, route, statusCode, errorType);
46+
47+
return throwError(() => error);
48+
}),
49+
);
50+
}
51+
52+
/**
53+
* Получает нормализованный маршрут из запроса
54+
*/
55+
private getRoute(request: any): string {
56+
// Используем route.path если доступен (NestJS)
57+
if (request.route?.path) {
58+
return request.route.path;
59+
}
60+
61+
// Используем originalUrl или path
62+
const url = request.originalUrl || request.url || '/';
63+
64+
// Убираем query параметры
65+
const path = url.split('?')[0];
66+
67+
// Нормализуем путь (убираем версию API и префикс для более чистых метрик)
68+
let normalizedPath = path.replace(/^\/admin\/api\/v\d+\//, '/');
69+
normalizedPath = normalizedPath.replace(/^\/admin\/api\//, '/');
70+
71+
// Заменяем ID параметры на :id для группировки
72+
normalizedPath = normalizedPath.replace(/\/\d+/g, '/:id');
73+
normalizedPath = normalizedPath.replace(/\/[a-f0-9-]{36}/gi, '/:id'); // UUID
74+
75+
return normalizedPath || '/';
76+
}
77+
}
78+
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Counter, Histogram } from 'prom-client';
2+
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
3+
4+
/**
5+
* Метрика: Общее количество HTTP запросов
6+
*/
7+
export const httpRequestsTotal = makeCounterProvider({
8+
name: 'http_requests_total',
9+
help: 'Total number of HTTP requests',
10+
labelNames: ['method', 'route', 'status_code'],
11+
});
12+
13+
/**
14+
* Метрика: Длительность HTTP запросов
15+
*/
16+
export const httpRequestDuration = makeHistogramProvider({
17+
name: 'http_request_duration_seconds',
18+
help: 'Duration of HTTP requests in seconds',
19+
labelNames: ['method', 'route'],
20+
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
21+
});
22+
23+
/**
24+
* Метрика: Общее количество HTTP ошибок
25+
*/
26+
export const httpErrorsTotal = makeCounterProvider({
27+
name: 'http_errors_total',
28+
help: 'Total number of HTTP errors',
29+
labelNames: ['method', 'route', 'status_code', 'error_type'],
30+
});
31+
32+
/**
33+
* Метрика: Общее количество попыток входа
34+
*/
35+
export const loginAttemptsTotal = makeCounterProvider({
36+
name: 'login_attempts_total',
37+
help: 'Total number of login attempts',
38+
labelNames: ['status', 'reason'],
39+
});
40+
41+
/**
42+
* Метрика: Длительность попыток входа
43+
*/
44+
export const loginDuration = makeHistogramProvider({
45+
name: 'login_duration_seconds',
46+
help: 'Duration of login attempts in seconds',
47+
labelNames: ['status'],
48+
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5],
49+
});
50+

src/prometheus/prometheus.module.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { Module } from '@nestjs/common';
22
import { ConfigModule, ConfigService } from '@nestjs/config';
33
import { PrometheusModule as PrometheusModuleLib } from '@willsoto/nestjs-prometheus';
4+
import { PrometheusService } from './prometheus.service';
5+
import { PrometheusInterceptor } from './prometheus.interceptor';
6+
import {
7+
httpRequestsTotal,
8+
httpRequestDuration,
9+
httpErrorsTotal,
10+
loginAttemptsTotal,
11+
loginDuration,
12+
} from './prometheus.metrics';
413

514
@Module({
615
imports: [
@@ -33,7 +42,16 @@ import { PrometheusModule as PrometheusModuleLib } from '@willsoto/nestjs-promet
3342
inject: [ConfigService],
3443
}),
3544
],
36-
exports: [PrometheusModuleLib],
45+
providers: [
46+
httpRequestsTotal,
47+
httpRequestDuration,
48+
httpErrorsTotal,
49+
loginAttemptsTotal,
50+
loginDuration,
51+
PrometheusService,
52+
PrometheusInterceptor,
53+
],
54+
exports: [PrometheusModuleLib, PrometheusService, PrometheusInterceptor],
3755
})
3856
export class PrometheusModule {}
3957

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectMetric } from '@willsoto/nestjs-prometheus';
3+
import { Counter, Histogram } from 'prom-client';
4+
5+
@Injectable()
6+
export class PrometheusService {
7+
constructor(
8+
@InjectMetric('http_requests_total')
9+
private readonly httpRequestsTotal: Counter<string>,
10+
@InjectMetric('http_request_duration_seconds')
11+
private readonly httpRequestDuration: Histogram<string>,
12+
@InjectMetric('http_errors_total')
13+
private readonly httpErrorsTotal: Counter<string>,
14+
@InjectMetric('login_attempts_total')
15+
private readonly loginAttemptsTotal: Counter<string>,
16+
@InjectMetric('login_duration_seconds')
17+
private readonly loginDuration: Histogram<string>,
18+
) {}
19+
20+
/**
21+
* Увеличивает счетчик HTTP запросов
22+
*/
23+
incrementHttpRequests(method: string, route: string, statusCode: number) {
24+
this.httpRequestsTotal.inc({
25+
method,
26+
route,
27+
status_code: statusCode.toString(),
28+
});
29+
}
30+
31+
/**
32+
* Записывает длительность HTTP запроса
33+
*/
34+
recordHttpRequestDuration(method: string, route: string, duration: number) {
35+
this.httpRequestDuration.observe(
36+
{
37+
method,
38+
route,
39+
},
40+
duration,
41+
);
42+
}
43+
44+
/**
45+
* Увеличивает счетчик HTTP ошибок
46+
*/
47+
incrementHttpErrors(method: string, route: string, statusCode: number, errorType?: string) {
48+
this.httpErrorsTotal.inc({
49+
method,
50+
route,
51+
status_code: statusCode.toString(),
52+
error_type: errorType || 'unknown',
53+
});
54+
}
55+
56+
/**
57+
* Увеличивает счетчик попыток входа
58+
*/
59+
incrementLoginAttempts(status: 'success' | 'failure', reason?: string) {
60+
this.loginAttemptsTotal.inc({
61+
status,
62+
reason: reason || 'unknown',
63+
});
64+
}
65+
66+
/**
67+
* Записывает длительность попытки входа
68+
*/
69+
recordLoginDuration(status: 'success' | 'failure', duration: number) {
70+
this.loginDuration.observe(
71+
{
72+
status,
73+
},
74+
duration,
75+
);
76+
}
77+
}
78+

0 commit comments

Comments
 (0)