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/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..a7587be --- /dev/null +++ b/libs/metrics/src/metrics.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import * as client from 'prom-client'; +import { FastifyReply } from 'fastify'; +import { SkipZodValidation } from '@shared/decorators'; + +@Controller('metrics') +export class MetricsController { + @Get() + @SkipZodValidation() + 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 new file mode 100644 index 0000000..545da6e --- /dev/null +++ b/libs/metrics/src/metrics.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +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: [ + PrometheusModule.register({ + controller: MetricsController, + defaultMetrics: { + enabled: process.env.NODE_ENV !== 'test', + }, + }), + ], + 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/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/package.json b/package.json index a339281..d0d6d99 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "passport-jwt": "^4.0.1", "passport-oauth2": "^1.8.0", "postgres": "^3.4.9", + "prom-client": "^15.1.3", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "ua-parser-js": "^2.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8800cf6..70e4ac5 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 0f482ee..3a82ac7 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'; @@ -22,18 +21,11 @@ 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'; +import { MetricsModule } from '@libs/metrics'; @Module({ imports: [ ConfigModule, - PrometheusModule.registerAsync({ - useFactory: () => ({ - path: 'dump', - defaultMetrics: { - enabled: process.env.NODE_ENV !== 'test', - }, - }), - }), DatabaseModule.registerAsync({ global: true, inject: [ConfigService], @@ -63,6 +55,7 @@ import { ZodValidationInterceptor } from '@shared/interceptors'; UserModule, TeamsModule, ProjectsModule, + MetricsModule, HealthModule.registerAsync({ inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], useFactory: (db: DatabaseHealthService, s3: S3Service, cache: ICacheService) => { 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/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'; 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/*"],