From 264bf1bf266685fdcab17e3c3a096b2f694e91e5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 20 May 2026 17:01:31 +0300 Subject: [PATCH 1/5] feat(app): add SkipResponseValidation decorator to bypass response validation --- src/shared/decorators/index.ts | 1 + .../decorators/skip-response-validation.decorator.ts | 4 ++++ .../interceptors/zod-validation.interceptor.ts | 12 ++++++++++++ 3 files changed, 17 insertions(+) create mode 100644 src/shared/decorators/skip-response-validation.decorator.ts diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts index baf933f..132aa07 100644 --- a/src/shared/decorators/index.ts +++ b/src/shared/decorators/index.ts @@ -1,3 +1,4 @@ export { ApiBaseController } from './api-controller.decorator'; export { IS_PUBLIC_KEY, Public } from './public.decorator'; export * from './user.decorator'; +export { SkipResponseValidation } from './skip-response-validation.decorator'; diff --git a/src/shared/decorators/skip-response-validation.decorator.ts b/src/shared/decorators/skip-response-validation.decorator.ts new file mode 100644 index 0000000..c8c2225 --- /dev/null +++ b/src/shared/decorators/skip-response-validation.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_RESPONSE_VALIDATION_KEY = 'SKIP_RESPONSE_VALIDATION_KEY'; +export const SkipResponseValidation = () => SetMetadata(SKIP_RESPONSE_VALIDATION_KEY, true); diff --git a/src/shared/interceptors/zod-validation.interceptor.ts b/src/shared/interceptors/zod-validation.interceptor.ts index 54b4cd5..6ab1781 100644 --- a/src/shared/interceptors/zod-validation.interceptor.ts +++ b/src/shared/interceptors/zod-validation.interceptor.ts @@ -9,6 +9,7 @@ import { Reflector } from '@nestjs/core'; import { map, Observable } from 'rxjs'; import { BaseException } from '@shared/error'; import { z } from 'zod/v4'; +import { SKIP_RESPONSE_VALIDATION_KEY } from '@shared/decorators/skip-response-validation.decorator'; export const ZOD_RESPONSE_TOKEN = 'ZOD_RESPONSE_TOKEN'; @@ -18,6 +19,17 @@ export class ZodValidationInterceptor implements NestInterceptor): Observable { const handler = context.getHandler(); + const controller = context.getClass(); + + const isSkipped = this.reflector.getAllAndOverride(SKIP_RESPONSE_VALIDATION_KEY, [ + handler, + controller, + ]); + + if (isSkipped) { + return next.handle(); + } + const metadata = this.reflector.get<{ schema: z.ZodTypeAny } | undefined>( ZOD_RESPONSE_TOKEN, handler, From 6419bb1e9748203dc68eaa1114cbb052d03ef869 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 20 May 2026 17:04:06 +0300 Subject: [PATCH 2/5] feat(metrics): add MetricsModule with endpoint for Prometheus metrics --- libs/metrics/metrics.controller.ts | 13 +++++++++++++ libs/metrics/metrics.module.ts | 17 +++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 +++ src/app.module.ts | 11 ++--------- 5 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 libs/metrics/metrics.controller.ts create mode 100644 libs/metrics/metrics.module.ts diff --git a/libs/metrics/metrics.controller.ts b/libs/metrics/metrics.controller.ts new file mode 100644 index 0000000..b3267fd --- /dev/null +++ b/libs/metrics/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import * as client from 'prom-client'; +import { SkipResponseValidation } from '@shared/decorators'; + +@Controller() +export class MetricsController { + @Get('dump') + @Header('Content-Type', client.register.contentType) + @SkipResponseValidation() + async getMetrics() { + return client.register.metrics(); + } +} diff --git a/libs/metrics/metrics.module.ts b/libs/metrics/metrics.module.ts new file mode 100644 index 0000000..b1a04ef --- /dev/null +++ b/libs/metrics/metrics.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PrometheusModule } from '@willsoto/nestjs-prometheus'; +import { MetricsController } from './metrics.controller'; + +@Module({ + imports: [ + PrometheusModule.registerAsync({ + useFactory: () => ({ + defaultMetrics: { + enabled: process.env.NODE_ENV !== 'test', + }, + }), + }), + ], + controllers: [MetricsController], +}) +export class MetricsModule {} diff --git a/package.json b/package.json index 3d4302f..c0f4bd2 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "postgres": "^3.4.9", + "prom-client": "^15.1.3", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "transliteration": "^2.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef2954e..44f45a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: postgres: specifier: ^3.4.9 version: 3.4.9 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 diff --git a/src/app.module.ts b/src/app.module.ts index 538199e..c646c24 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,7 +5,6 @@ import { ConfigService } from '@nestjs/config'; import * as schema from './shared/entities'; import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe } from 'nestjs-zod'; -import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { HealthModule } from '@libs/health'; import { UserModule } from './user'; import { GlobalExceptionFilter } from '@shared/error'; @@ -24,18 +23,12 @@ import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { DatabaseHealthService } from '@libs/database'; import { ZodValidationInterceptor } from '@shared/interceptors/zod-validation.interceptor'; +import { MetricsModule } from '../libs/metrics/metrics.module'; @Module({ imports: [ ConfigModule, - PrometheusModule.registerAsync({ - useFactory: () => ({ - path: 'dump', - defaultMetrics: { - enabled: process.env.NODE_ENV !== 'test', - }, - }), - }), + MetricsModule, DatabaseModule.registerAsync({ global: true, inject: [ConfigService], From 7ed8727650844429b350a7161aee5806ab78b300 Mon Sep 17 00:00:00 2001 From: soorq Date: Sun, 31 May 2026 19:54:42 +0300 Subject: [PATCH 3/5] refactor: enchance metrics bump module, implement global path --- libs/metrics/metrics.controller.ts | 13 ------------- libs/metrics/metrics.module.ts | 17 ----------------- libs/metrics/src/index.ts | 1 + libs/metrics/src/metrics.controller.ts | 25 +++++++++++++++++++++++++ libs/metrics/src/metrics.module.ts | 15 +++++++++++++++ libs/metrics/tsconfig.lib.json | 9 +++++++++ nest-cli.json | 9 +++++++++ src/app.module.ts | 6 +++--- tsconfig.json | 2 ++ 9 files changed, 64 insertions(+), 33 deletions(-) delete mode 100644 libs/metrics/metrics.controller.ts delete mode 100644 libs/metrics/metrics.module.ts create mode 100644 libs/metrics/src/index.ts create mode 100644 libs/metrics/src/metrics.controller.ts create mode 100644 libs/metrics/src/metrics.module.ts create mode 100644 libs/metrics/tsconfig.lib.json diff --git a/libs/metrics/metrics.controller.ts b/libs/metrics/metrics.controller.ts deleted file mode 100644 index b3267fd..0000000 --- a/libs/metrics/metrics.controller.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller, Get, Header } from '@nestjs/common'; -import * as client from 'prom-client'; -import { SkipResponseValidation } from '@shared/decorators'; - -@Controller() -export class MetricsController { - @Get('dump') - @Header('Content-Type', client.register.contentType) - @SkipResponseValidation() - async getMetrics() { - return client.register.metrics(); - } -} diff --git a/libs/metrics/metrics.module.ts b/libs/metrics/metrics.module.ts deleted file mode 100644 index b1a04ef..0000000 --- a/libs/metrics/metrics.module.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PrometheusModule } from '@willsoto/nestjs-prometheus'; -import { MetricsController } from './metrics.controller'; - -@Module({ - imports: [ - PrometheusModule.registerAsync({ - useFactory: () => ({ - defaultMetrics: { - enabled: process.env.NODE_ENV !== 'test', - }, - }), - }), - ], - controllers: [MetricsController], -}) -export class MetricsModule {} diff --git a/libs/metrics/src/index.ts b/libs/metrics/src/index.ts new file mode 100644 index 0000000..3841f24 --- /dev/null +++ b/libs/metrics/src/index.ts @@ -0,0 +1 @@ +export * from './metrics.module'; diff --git a/libs/metrics/src/metrics.controller.ts b/libs/metrics/src/metrics.controller.ts new file mode 100644 index 0000000..e4ee172 --- /dev/null +++ b/libs/metrics/src/metrics.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { SkipResponseValidation } from '@shared/decorators'; +import * as client from 'prom-client'; + +@Controller('metrics') +export class MetricsController { + @Get('system') + @Header('Content-Type', client.register.contentType) + @SkipResponseValidation() + async getMetrics() { + return client.register.metrics(); + } + + /** TODO: добавить, чтоб тут была сборка http запросов + * почитай, как сделать правильно, там есть метка le + * образно, /v1/users/me 10ms le=29 + * То есть это системная метрика, где контекст для приложения + + * Как седалешь удалить этот комментарий! + */ + @Get() + @Header('Content-Type', client.register.contentType) + @SkipResponseValidation() + async getHttp() {} +} diff --git a/libs/metrics/src/metrics.module.ts b/libs/metrics/src/metrics.module.ts new file mode 100644 index 0000000..2812803 --- /dev/null +++ b/libs/metrics/src/metrics.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { PrometheusModule } from '@willsoto/nestjs-prometheus'; +import { MetricsController } from './metrics.controller'; + +@Module({ + imports: [ + PrometheusModule.register({ + controller: MetricsController, + defaultMetrics: { + enabled: process.env.NODE_ENV !== 'test', + }, + }), + ], +}) +export class MetricsModule {} diff --git a/libs/metrics/tsconfig.lib.json b/libs/metrics/tsconfig.lib.json new file mode 100644 index 0000000..c821435 --- /dev/null +++ b/libs/metrics/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/metrics" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/nest-cli.json b/nest-cli.json index 110f81c..631e79c 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -60,6 +60,15 @@ "compilerOptions": { "tsConfigPath": "libs/imagor/tsconfig.lib.json" } + }, + "metrics": { + "type": "library", + "root": "libs/metrics", + "entryFile": "index", + "sourceRoot": "libs/metrics/src", + "compilerOptions": { + "tsConfigPath": "libs/metrics/tsconfig.lib.json" + } } } } diff --git a/src/app.module.ts b/src/app.module.ts index c646c24..f4a1146 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,13 +22,12 @@ import { S3Service } from '@libs/s3'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { DatabaseHealthService } from '@libs/database'; -import { ZodValidationInterceptor } from '@shared/interceptors/zod-validation.interceptor'; -import { MetricsModule } from '../libs/metrics/metrics.module'; +import { ZodValidationInterceptor } from '@shared/interceptors'; +import { MetricsModule } from '@libs/metrics'; @Module({ imports: [ ConfigModule, - MetricsModule, DatabaseModule.registerAsync({ global: true, inject: [ConfigService], @@ -58,6 +57,7 @@ import { MetricsModule } from '../libs/metrics/metrics.module'; UserModule, TeamsModule, ProjectsModule, + MetricsModule, BullBoardModule.forRoot({ route: '/queues', boardOptions: { diff --git a/tsconfig.json b/tsconfig.json index 4884f1b..21038d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,8 @@ "@libs/health/*": ["./libs/health/src/*"], "@libs/imagor": ["./libs/imagor/src"], "@libs/imagor/*": ["./libs/imagor/src/*"], + "@libs/metrics": ["./libs/metrics/src"], + "@libs/metrics/*": ["./libs/metrics/src/*"], "@libs/s3": ["./libs/s3/src"], "@libs/s3/*": ["./libs/s3/src/*"], "@shared/*": ["./src/shared/*"], From f849e94d13dae6fd59232bada32d62a54f3b0c85 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 7 Jun 2026 22:19:12 +0300 Subject: [PATCH 4/5] feat(metrics): add HTTP metrics interceptor for tracking request duration --- infra/dev/compose.dev.yaml | 2 +- libs/metrics/src/metrics.controller.ts | 23 +++------ libs/metrics/src/metrics.module.ts | 16 +++++- .../interceptors/http-metrics.interceptor.ts | 51 +++++++++++++++++++ src/shared/interceptors/index.ts | 1 + 5 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 src/shared/interceptors/http-metrics.interceptor.ts diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index c4413e3..fc46b1b 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -6,7 +6,7 @@ services: api: hostname: api container_name: api - image: ghcr.io/task-tracker-lab/task-tracker-backend:dev + image: ghcr.io/task-tracker-lab/backend:dev env_file: - .env ports: diff --git a/libs/metrics/src/metrics.controller.ts b/libs/metrics/src/metrics.controller.ts index e4ee172..c359368 100644 --- a/libs/metrics/src/metrics.controller.ts +++ b/libs/metrics/src/metrics.controller.ts @@ -1,25 +1,14 @@ -import { Controller, Get, Header } from '@nestjs/common'; +import { Controller, Get, Res } from '@nestjs/common'; import { SkipResponseValidation } from '@shared/decorators'; import * as client from 'prom-client'; +import { FastifyReply } from 'fastify'; @Controller('metrics') export class MetricsController { - @Get('system') - @Header('Content-Type', client.register.contentType) - @SkipResponseValidation() - async getMetrics() { - return client.register.metrics(); - } - - /** TODO: добавить, чтоб тут была сборка http запросов - * почитай, как сделать правильно, там есть метка le - * образно, /v1/users/me 10ms le=29 - * То есть это системная метрика, где контекст для приложения - - * Как седалешь удалить этот комментарий! - */ @Get() - @Header('Content-Type', client.register.contentType) @SkipResponseValidation() - async getHttp() {} + async getMetrics(@Res() reply: FastifyReply) { + const metrics = await client.register.metrics(); + reply.type(client.register.contentType).send(metrics); + } } diff --git a/libs/metrics/src/metrics.module.ts b/libs/metrics/src/metrics.module.ts index 2812803..545da6e 100644 --- a/libs/metrics/src/metrics.module.ts +++ b/libs/metrics/src/metrics.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; -import { PrometheusModule } from '@willsoto/nestjs-prometheus'; +import { makeHistogramProvider, PrometheusModule } from '@willsoto/nestjs-prometheus'; import { MetricsController } from './metrics.controller'; +import { HttpMetricsInterceptor } from '@shared/interceptors'; +import { APP_INTERCEPTOR } from '@nestjs/core'; @Module({ imports: [ @@ -11,5 +13,17 @@ import { MetricsController } from './metrics.controller'; }, }), ], + providers: [ + makeHistogramProvider({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.005, 0.01, 0.05, 0.1, 0.5, 1, 2.5, 5], + }), + { + provide: APP_INTERCEPTOR, + useClass: HttpMetricsInterceptor, + }, + ], }) export class MetricsModule {} diff --git a/src/shared/interceptors/http-metrics.interceptor.ts b/src/shared/interceptors/http-metrics.interceptor.ts new file mode 100644 index 0000000..02a99de --- /dev/null +++ b/src/shared/interceptors/http-metrics.interceptor.ts @@ -0,0 +1,51 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Observable, throwError } from 'rxjs'; +import { tap, catchError } from 'rxjs/operators'; +import { Histogram } from 'prom-client'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { FastifyReply, FastifyRequest } from 'fastify'; + +@Injectable() +export class HttpMetricsInterceptor implements NestInterceptor { + constructor( + @InjectMetric('http_request_duration_seconds') + private readonly histogram: Histogram, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const ctx = context.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const end = this.histogram.startTimer(); + + return next.handle().pipe( + tap(() => { + this.recordMetrics(request, response, end); + }), + catchError((err) => { + this.recordMetrics(request, response, end, err); + return throwError(() => err); + }), + ); + } + + private recordMetrics( + req: FastifyRequest, + res: FastifyReply, + end: (labels?: any) => number, + err?: any, + ) { + const route = req.routeOptions?.url || req.url; + + if (route === '/metrics') return; + + const statusCode = err ? err.status || err.statusCode || 500 : res.statusCode; + + end({ + method: req.method, + route, + status: statusCode.toString(), + }); + } +} diff --git a/src/shared/interceptors/index.ts b/src/shared/interceptors/index.ts index c1f555e..e73d0be 100644 --- a/src/shared/interceptors/index.ts +++ b/src/shared/interceptors/index.ts @@ -1 +1,2 @@ export * from './zod-validation.interceptor'; +export * from './http-metrics.interceptor'; From 1dcb381da207fa590a1960989ad1d298ce4282fd Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 7 Jun 2026 22:45:38 +0300 Subject: [PATCH 5/5] chore: fix conflicts --- libs/metrics/src/metrics.controller.ts | 4 ++-- src/shared/interceptors/zod-validation.interceptor.ts | 11 ----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/libs/metrics/src/metrics.controller.ts b/libs/metrics/src/metrics.controller.ts index c359368..a7587be 100644 --- a/libs/metrics/src/metrics.controller.ts +++ b/libs/metrics/src/metrics.controller.ts @@ -1,12 +1,12 @@ import { Controller, Get, Res } from '@nestjs/common'; -import { SkipResponseValidation } from '@shared/decorators'; import * as client from 'prom-client'; import { FastifyReply } from 'fastify'; +import { SkipZodValidation } from '@shared/decorators'; @Controller('metrics') export class MetricsController { @Get() - @SkipResponseValidation() + @SkipZodValidation() async getMetrics(@Res() reply: FastifyReply) { const metrics = await client.register.metrics(); reply.type(client.register.contentType).send(metrics); diff --git a/src/shared/interceptors/zod-validation.interceptor.ts b/src/shared/interceptors/zod-validation.interceptor.ts index 2424b60..ef89975 100644 --- a/src/shared/interceptors/zod-validation.interceptor.ts +++ b/src/shared/interceptors/zod-validation.interceptor.ts @@ -19,17 +19,6 @@ export class ZodValidationInterceptor implements NestInterceptor): Observable { const handler = context.getHandler(); - const controller = context.getClass(); - - const isSkipped = this.reflector.getAllAndOverride(SKIP_RESPONSE_VALIDATION_KEY, [ - handler, - controller, - ]); - - if (isSkipped) { - return next.handle(); - } - const metadata = this.reflector.get<{ schema: z.ZodTypeAny } | undefined>( ZOD_RESPONSE_TOKEN, handler,