diff --git a/package-lock.json b/package-lock.json index 43da8c0..3c4fa1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@prisma/client": "^5.7.1", "@types/cron": "^2.4.0", "@types/multer": "^1.4.11", + "@willsoto/nestjs-prometheus": "^6.0.1", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -989,6 +990,15 @@ "npm": ">=5.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pdf-lib/standard-fonts": { "version": "1.0.0", "license": "MIT", @@ -1642,6 +1652,15 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@willsoto/nestjs-prometheus": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@willsoto/nestjs-prometheus/-/nestjs-prometheus-6.0.1.tgz", + "integrity": "sha512-4N3N6/EE9Qxyo/efOmj5XIQPLjCaYEtFcSvs4OM6hRFoxEWa96adGkoQnzYGsJ6GM+txMCx+w6NfOEOxLjlzPQ==", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "prom-client": "^15.0.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "dev": true, @@ -2008,6 +2027,12 @@ "node": ">=8" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "peer": true + }, "node_modules/bl": { "version": "4.1.0", "dev": true, @@ -5776,6 +5801,19 @@ "node": ">=0.4.0" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "peer": true, + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "license": "MIT", @@ -6876,6 +6914,15 @@ "version": "4.0.0", "license": "ISC" }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "peer": true, + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/terser": { "version": "5.24.0", "dev": true, diff --git a/package.json b/package.json index 516371a..4fcca84 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@prisma/client": "^5.7.1", "@types/cron": "^2.4.0", "@types/multer": "^1.4.11", + "@willsoto/nestjs-prometheus": "^6.0.1", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", diff --git a/src/app.module.ts b/src/app.module.ts index 57c1960..4ea6eb7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { TaskModule } from "./modules/task/task.module"; import { AchievementsModule } from "./modules/achievements/achievements.module"; import { ThesesModule } from "./modules/theses/theses.module"; import { KafkaModule } from "./config/kafka/kafka.module"; +import { MetricsModule } from "./config/metrics/metrics.module"; @Module({ imports: [ @@ -32,6 +33,7 @@ import { KafkaModule } from "./config/kafka/kafka.module"; AchievementsModule, ThesesModule, KafkaModule, + MetricsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/config/metrics/interceptors/request.prom.interceptor.ts b/src/config/metrics/interceptors/request.prom.interceptor.ts new file mode 100644 index 0000000..e871cf8 --- /dev/null +++ b/src/config/metrics/interceptors/request.prom.interceptor.ts @@ -0,0 +1,92 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor, OnModuleInit } from "@nestjs/common"; +import { Counter, Gauge, Histogram } from "prom-client"; +import { Observable, tap } from "rxjs"; + +@Injectable() +export class PrometheusInterceptor implements NestInterceptor, OnModuleInit { + onModuleInit() { + this.requestSuccessHistogram.reset(); + this.requestFailHistogram.reset(); + this.failureCounter.reset(); + } + // status code 2XX + private readonly requestSuccessHistogram = new Histogram({ + name: "nestjs_success_requests", + help: "NestJs success requests - duration in seconds", + labelNames: ["handler", "controller", "method"], + buckets: [0.0001, 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.09, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + }); + + // status code != 2XX + private readonly requestFailHistogram = new Histogram({ + name: "nestjs_fail_requests", + help: "NestJs fail requests - duration in seconds", + labelNames: ["handler", "controller", "method"], + buckets: [0.0001, 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.09, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + }); + + private readonly failureCounter = new Counter({ + name: "nestjs_requests_failed_count", + help: "NestJs requests that failed", + labelNames: ["handler", "controller", "error", "method"], + }); + + static registerServiceInfo(serviceInfo: { domain: string; name: string; version: string }): PrometheusInterceptor { + new Gauge({ + name: "nestjs_info", + help: "NestJs service version info", + labelNames: ["domain", "name", "version"], + }).set( + { + domain: serviceInfo.domain, + name: `${serviceInfo.domain}.${serviceInfo.name}`, + version: serviceInfo.version, + }, + 1 + ); + + return new PrometheusInterceptor(); + } + + // metrics url 요청은 트래킹 필요 x + private isAvailableMetricsUrl(url: string): boolean { + const excludePaths = "metrics"; + if (url.includes(excludePaths)) { + return false; + } + return true; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const originUrl = context.switchToHttp().getRequest().url.toString(); + + const method = context.switchToHttp().getRequest().method.toString(); + const labels = { + controller: context.getClass().name, + handler: context.getHandler().name, + method: method, + }; + + try { + const requestSuccessTimer = this.requestSuccessHistogram.startTimer(labels); + const requestFailTimer = this.requestFailHistogram.startTimer(labels); + return next.handle().pipe( + tap({ + next: () => { + if (this.isAvailableMetricsUrl(originUrl)) { + requestSuccessTimer(); + } + // Handle the next event here + }, + error: () => { + if (this.isAvailableMetricsUrl(originUrl)) { + requestFailTimer(); + this.failureCounter.labels({ ...labels }).inc(1); + } + // Handle the error event here + }, + }) + ); + } catch (error) {} + } +} diff --git a/src/config/metrics/interceptors/response.prom.interceptor.ts b/src/config/metrics/interceptors/response.prom.interceptor.ts new file mode 100644 index 0000000..6de03fc --- /dev/null +++ b/src/config/metrics/interceptors/response.prom.interceptor.ts @@ -0,0 +1,22 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; +import { defaultIfEmpty, map } from "rxjs"; + +@Injectable() +export class ResponseInterceptor implements NestInterceptor { + intercept(_context: ExecutionContext, next: CallHandler) { + const req: Request = _context.switchToHttp().getRequest(); + + const excludePaths = ["/api/capa-metrics", "/capa-metrics"]; + + return next + .handle() + .pipe(defaultIfEmpty(null)) + .pipe( + map((result) => { + if (excludePaths.includes(req.url)) { + return result; + } + }) + ); + } +} diff --git a/src/config/metrics/metrics.module.ts b/src/config/metrics/metrics.module.ts new file mode 100644 index 0000000..28bebc0 --- /dev/null +++ b/src/config/metrics/metrics.module.ts @@ -0,0 +1,15 @@ +// metrics.module.ts +import { Module } from "@nestjs/common"; +import { PrometheusModule as Prometheus } from "@willsoto/nestjs-prometheus"; + +@Module({ + imports: [ + Prometheus.register({ + path: "/metrics", + defaultMetrics: { + enabled: true, + }, + }), + ], +}) +export class MetricsModule {} diff --git a/src/main.ts b/src/main.ts index c34958f..41522f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,8 @@ import { ValidationPipe, VersioningType } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { winstonLogger } from "./config/logger/winston/winston.config"; +import { ResponseInterceptor } from "./config/metrics/interceptors/response.prom.interceptor"; +import { PrometheusInterceptor } from "./config/metrics/interceptors/request.prom.interceptor"; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -23,7 +25,8 @@ async function bootstrap() { }, }) ); - + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalInterceptors(new PrometheusInterceptor()); app.enableCors({ origin: "*", credentials: true,