From 4dcbf62ead678c368d574ed3769ef8c20006e055 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 25 Mar 2026 05:50:12 +0300 Subject: [PATCH] feat: add metrics --- package.json | 2 + src/core/app.module.ts | 2 + .../metrics/http-metrics.interceptor.ts | 62 +++++++++++++++++++ src/observability/metrics/metrics.module.ts | 46 ++++++++++++++ src/observability/observability.module.ts | 8 +++ yarn.lock | 30 +++++++++ 6 files changed, 150 insertions(+) create mode 100644 src/observability/metrics/http-metrics.interceptor.ts create mode 100644 src/observability/metrics/metrics.module.ts create mode 100644 src/observability/observability.module.ts diff --git a/package.json b/package.json index 97fae73..42b1c0e 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,12 @@ "@teacinema/common": "^1.0.0", "@teacinema/contracts": "^1.0.0", "@teacinema/passport": "^1.0.0", + "@willsoto/nestjs-prometheus": "^6.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cookie-parser": "^1.4.7", "cross-env": "^10.1.0", + "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/src/core/app.module.ts b/src/core/app.module.ts index 587c59c..a3db56f 100644 --- a/src/core/app.module.ts +++ b/src/core/app.module.ts @@ -5,6 +5,7 @@ import { PassportModule } from '@teacinema/passport' import { AccountModule } from '../modules/account/account.module' import { AuthModule } from '../modules/auth/auth.module' import { UsersModule } from '../modules/users/users.module' +import { ObservabilityModule } from '../observability/observability.module' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -20,6 +21,7 @@ import { getPassportConfig } from './config' '.env' ] }), + ObservabilityModule, AuthModule, AccountModule, UsersModule, diff --git a/src/observability/metrics/http-metrics.interceptor.ts b/src/observability/metrics/http-metrics.interceptor.ts new file mode 100644 index 0000000..c97ab8b --- /dev/null +++ b/src/observability/metrics/http-metrics.interceptor.ts @@ -0,0 +1,62 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor +} from '@nestjs/common' +import { InjectMetric } from '@willsoto/nestjs-prometheus' +import { Request, Response } from 'express' +import { Counter, Gauge, Histogram } from 'prom-client' +import { finalize, Observable } from 'rxjs' + +@Injectable() +export class HttpMetricsInterceptor implements NestInterceptor { + private readonly SERVICE_NAME: string + + public constructor( + @InjectMetric('http_request_duration_seconds') + private readonly histogram: Histogram, + @InjectMetric('http_requests_in_flight') + private readonly inFlight: Gauge, + @InjectMetric('http_request_total') + private readonly counter: Counter + ) { + this.SERVICE_NAME = 'gateway' + } + + public intercept( + context: ExecutionContext, + next: CallHandler + ): Observable { + const req = context.switchToHttp().getRequest() + const res = context.switchToHttp().getResponse() + + const method = req.method + const route = (req.route.path || 'unknown') as string + + this.inFlight.inc({ service: this.SERVICE_NAME }) + + const entTimer = this.histogram.startTimer() + + return next.handle().pipe( + finalize(() => { + const status = res.statusCode.toString() + this.counter.inc({ + service: this.SERVICE_NAME, + method, + route, + status + }) + + entTimer({ + service: this.SERVICE_NAME, + method, + route, + status + }) + + this.inFlight.dec({ service: this.SERVICE_NAME }) + }) + ) + } +} diff --git a/src/observability/metrics/metrics.module.ts b/src/observability/metrics/metrics.module.ts new file mode 100644 index 0000000..a064019 --- /dev/null +++ b/src/observability/metrics/metrics.module.ts @@ -0,0 +1,46 @@ +import { Global, Module } from '@nestjs/common' +import { APP_INTERCEPTOR } from '@nestjs/core' +import { + makeCounterProvider, + makeGaugeProvider, + makeHistogramProvider, + PrometheusModule +} from '@willsoto/nestjs-prometheus' + +import { HttpMetricsInterceptor } from './http-metrics.interceptor' + +@Global() +@Module({ + imports: [ + PrometheusModule.register({ + path: '/metrics', + defaultMetrics: { + enabled: true + } + }) + ], + providers: [ + makeHistogramProvider({ + name: 'http_request_duration_seconds', + help: 'HTTP request latency', + labelNames: ['service', 'method', 'route', 'status'], + buckets: [0.05, 0.1, 0.2, 0.3, 0.5, 1, 1.5, 2, 3, 5] + }), + makeGaugeProvider({ + name: 'http_requests_in_flight', + help: 'Current number of http requests in flight', + labelNames: ['service'] + }), + makeCounterProvider({ + name: 'http_request_total', + help: 'Total http requests', + labelNames: ['service', 'method', 'route', 'status'] + }), + HttpMetricsInterceptor, + { + provide: APP_INTERCEPTOR, + useClass: HttpMetricsInterceptor + } + ] +}) +export class MetricsModule {} diff --git a/src/observability/observability.module.ts b/src/observability/observability.module.ts new file mode 100644 index 0000000..857e458 --- /dev/null +++ b/src/observability/observability.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' + +import { MetricsModule } from './metrics/metrics.module' + +@Module({ + imports: [MetricsModule] +}) +export class ObservabilityModule {} diff --git a/yarn.lock b/yarn.lock index b7ec5fb..3c618a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1154,6 +1154,11 @@ dependencies: consola "^3.2.3" +"@opentelemetry/api@^1.4.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" + integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== + "@paralleldrive/cuid2@^2.2.2": version "2.3.1" resolved "https://registry.yarnpkg.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz#3d62ea9e7be867d3fa94b9897fab5b0ae187d784" @@ -1882,6 +1887,11 @@ "@webassemblyjs/ast" "1.14.1" "@xtuc/long" "4.2.2" +"@willsoto/nestjs-prometheus@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@willsoto/nestjs-prometheus/-/nestjs-prometheus-6.0.2.tgz#d603764a923848442ed092411716c0bf211de01f" + integrity sha512-ePyLZYdIrOOdlOWovzzMisIgviXqhPVzFpSMKNNhn6xajhRHeBsjAzSdpxZTc6pnjR9hw1lNAHyKnKl7lAPaVg== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -2132,6 +2142,11 @@ baseline-browser-mapping@^2.9.0: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz#3b6af0bc032445bca04de58caa9a87cfe921cbb3" integrity sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg== +bintrees@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8" + integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== + bl@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -4453,6 +4468,14 @@ pretty-format@30.2.0, pretty-format@^30.0.0: ansi-styles "^5.2.0" react-is "^18.3.1" +prom-client@^15.1.3: + version "15.1.3" + resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2" + integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g== + dependencies: + "@opentelemetry/api" "^1.4.0" + tdigest "^0.1.1" + protobufjs@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" @@ -4957,6 +4980,13 @@ tapable@^2.2.0, tapable@^2.2.1, tapable@^2.3.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== +tdigest@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced" + integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA== + dependencies: + bintrees "1.0.2" + terser-webpack-plugin@^5.3.16: version "5.3.16" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330"