| 1 | import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common' |
| 2 | import type { Request, Response } from 'express' |
| 3 | import { Injectable } from '@nestjs/common' |
| 4 | import { Counter, Gauge, Histogram } from 'prom-client' |
| 5 | import { tap } from 'rxjs' |
| 6 | |
| 7 | const EXCLUDED_PATHS = new Set(['/metrics', '/health']) |
| 8 | |
| 9 | const httpRequestDuration = new Histogram({ |
| 10 | name: 'http_request_duration_seconds', |
| 11 | help: 'Duration of HTTP requests in seconds', |
| 12 | labelNames: ['method', 'route', 'status_code'] as const, |
| 13 | buckets: [0.003, 0.03, 0.1, 0.3, 1.5, 10], |
| 14 | }) |
| 15 | |
| 16 | const httpRequestTotal = new Counter({ |
| 17 | name: 'http_requests_total', |
| 18 | help: 'Total number of HTTP requests', |
| 19 | labelNames: ['method', 'route', 'status_code'] as const, |
| 20 | }) |
| 21 | |
| 22 | const httpRequestsInFlight = new Gauge({ |
| 23 | name: 'http_requests_in_flight', |
| 24 | help: 'Number of HTTP requests currently being processed', |
| 25 | labelNames: ['method'] as const, |
| 26 | }) |
| 27 | |
| 28 | @Injectable() |
| 29 | export class HttpMetricsInterceptor implements NestInterceptor { |
| 30 | intercept(context: ExecutionContext, next: CallHandler) { |
| 31 | if (context.getType() !== 'http') |
| 32 | return next.handle() |
| 33 | |
| 34 | const req = context.switchToHttp().getRequest<Request>() |
| 35 | if (EXCLUDED_PATHS.has(req.path)) |
| 36 | return next.handle() |
| 37 | |
| 38 | const res = context.switchToHttp().getResponse<Response>() |
| 39 | const route = req.route |
| 40 | ? this.normalizeRoute(`${req.baseUrl}${req.route.path}`) |
| 41 | : this.getRouteFromMetadata(context) |
| 42 | |
| 43 | const end = httpRequestDuration.startTimer() |
| 44 | httpRequestsInFlight.inc({ method: req.method }) |
| 45 | |
| 46 | const recordMetrics = () => { |
| 47 | const statusCode = String(res.statusCode) |
| 48 | const labels = { method: req.method, route, status_code: statusCode } |
| 49 | |
| 50 | end(labels) |
| 51 | httpRequestTotal.inc(labels) |
| 52 | httpRequestsInFlight.dec({ method: req.method }) |
| 53 | } |
| 54 | |
| 55 | return next.handle().pipe( |
| 56 | tap({ next: recordMetrics, error: recordMetrics }), |
| 57 | ) |
| 58 | } |
| 59 | |
| 60 | private normalizeRoute(raw: string): string { |
| 61 | return raw.replace(/\/+/g, '/').replace(/\/$/, '') || '/' |
| 62 | } |
| 63 | |
| 64 | private getRouteFromMetadata(context: ExecutionContext): string { |
| 65 | const controllerPath = Reflect.getMetadata('path', context.getClass()) || '' |
| 66 | const handlerPath = Reflect.getMetadata('path', context.getHandler()) || '' |
| 67 | const prefix = Array.isArray(controllerPath) ? controllerPath[0] : controllerPath |
| 68 | const handler = Array.isArray(handlerPath) ? handlerPath[0] : handlerPath |
| 69 | return this.normalizeRoute(`/${prefix}/${handler}`) |
| 70 | } |
| 71 | } |
| 72 |