From adfe8680b83b3a34d5da3b35d9bdcc3a3fde6ef8 Mon Sep 17 00:00:00 2001 From: oreoluwa <126897231+prismn@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:22:23 +0000 Subject: [PATCH] feat(metrics): implement Prometheus metrics collection and /metrics endpoint - Add MetricsModule with global HTTP interceptor for request tracking - Expose /metrics endpoint (GET, public, Prometheus text format) - Define 20+ custom metrics: HTTP, auth, DB, job queue, business - Add auth-specific counters: attempts, success, failures - Wire MetricsModule into AppModule - Add Grafana dashboard template (docs/dashboards/stellaiverse-backend.json) - Add metrics documentation (docs/METRICS.md) - 10 unit/integration tests for interceptor and controller Closes #311 --- docs/METRICS.md | 126 ++++++++++- docs/dashboards/stellaiverse-backend.json | 263 ++++++++++++++++++++++ src/app.module.ts | 2 + src/config/metrics.ts | 64 ++++-- src/metrics/metrics.controller.spec.ts | 60 +++++ src/metrics/metrics.controller.ts | 17 ++ src/metrics/metrics.interceptor.spec.ts | 100 ++++++++ src/metrics/metrics.interceptor.ts | 48 ++++ src/metrics/metrics.module.ts | 15 ++ 9 files changed, 675 insertions(+), 20 deletions(-) create mode 100644 docs/dashboards/stellaiverse-backend.json create mode 100644 src/metrics/metrics.controller.spec.ts create mode 100644 src/metrics/metrics.controller.ts create mode 100644 src/metrics/metrics.interceptor.spec.ts create mode 100644 src/metrics/metrics.interceptor.ts create mode 100644 src/metrics/metrics.module.ts diff --git a/docs/METRICS.md b/docs/METRICS.md index 96b92446..3e65b7a1 100644 --- a/docs/METRICS.md +++ b/docs/METRICS.md @@ -1,7 +1,121 @@ -### http_requests_total -Counts all HTTP requests +# Metrics & Prometheus Integration -Labels: -- method -- route -- status \ No newline at end of file +## Overview + +StellAIverse Backend exposes Prometheus-compatible metrics at `GET /metrics` (no auth required). + +## Endpoint + +``` +GET /metrics +``` + +Returns metrics in Prometheus text format (`text/plain; version=0.0.4`). + +## How It Works + +- `MetricsModule` registers a global `MetricsInterceptor` that automatically tracks every HTTP request. +- `prom-client` collects Node.js default metrics (CPU, memory, event loop, GC) with the `stellaiverse_` prefix. +- Custom metrics are defined in `src/config/metrics.ts` and exported for use across the codebase. + +## Available Metrics (≥ 20 custom metrics) + +### HTTP + +| Metric | Type | Labels | +|--------|------|--------| +| `stellaiverse_http_request_duration_seconds` | Histogram | `method`, `route`, `status_code` | +| `stellaiverse_http_requests_total` | Counter | `method`, `route`, `status_code` | +| `stellaiverse_http_requests_in_progress` | Gauge | `method`, `route` | +| `stellaiverse_http_errors_total` | Counter | `method`, `route`, `status_code` | + +### Authentication + +| Metric | Type | Labels | +|--------|------|--------| +| `stellaiverse_auth_attempts_total` | Counter | `method` | +| `stellaiverse_auth_success_total` | Counter | `method` | +| `stellaiverse_auth_failures_total` | Counter | `method`, `reason` | +| `stellaiverse_user_signups_total` | Counter | `method` | +| `stellaiverse_active_users` | Gauge | — | + +### Database + +| Metric | Type | Labels | +|--------|------|--------| +| `stellaiverse_database_query_duration_seconds` | Histogram | `operation`, `table` | +| `stellaiverse_active_connections` | Gauge | `type` | + +### Job Queue + +| Metric | Type | Labels | +|--------|------|--------| +| `stellaiverse_job_duration_seconds` | Histogram | `job_type`, `status` | +| `stellaiverse_job_success_total` | Counter | `job_type` | +| `stellaiverse_job_failure_total` | Counter | `job_type`, `failure_reason` | +| `stellaiverse_queue_length` | Gauge | `queue_name`, `state` | + +### Application + +| Metric | Type | Labels | +|--------|------|--------| +| `stellaiverse_errors_total` | Counter | `type`, `severity` | + +### Node.js Default Metrics (via `collectDefaultMetrics`) + +All standard `prom-client` default metrics are collected with the `stellaiverse_` prefix, including: +- `stellaiverse_nodejs_heap_size_used_bytes` +- `stellaiverse_nodejs_heap_size_total_bytes` +- `stellaiverse_nodejs_eventloop_lag_seconds` +- `stellaiverse_nodejs_active_handles_total` +- `stellaiverse_process_cpu_seconds_total` +- and more... + +## Adding New Metrics + +1. Import the registry and create the metric in `src/config/metrics.ts`: + +```typescript +import * as client from "prom-client"; +import { register } from "./metrics"; + +export const myCounter = new client.Counter({ + name: "stellaiverse_my_event_total", + help: "Total number of my events", + labelNames: ["label_a"], + registers: [register], +}); +``` + +2. Import and use it anywhere in the app: + +```typescript +import { myCounter } from "../config/metrics"; + +myCounter.inc({ label_a: "value" }); +``` + +## Grafana Dashboard + +A pre-built Grafana dashboard template is available at: + +``` +docs/dashboards/stellaiverse-backend.json +``` + +Import it into Grafana: +1. Navigate to **Dashboards → Import** +2. Upload `stellaiverse-backend.json` +3. Select your Prometheus datasource +4. Click **Import** + +## Prometheus Scrape Config + +```yaml +scrape_configs: + - job_name: stellaiverse-backend + static_configs: + - targets: ["localhost:3000"] + metrics_path: /metrics + scrape_interval: 15s +``` diff --git a/docs/dashboards/stellaiverse-backend.json b/docs/dashboards/stellaiverse-backend.json new file mode 100644 index 00000000..a4a88825 --- /dev/null +++ b/docs/dashboards/stellaiverse-backend.json @@ -0,0 +1,263 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "9.0.0" }, + { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" }, + { "type": "panel", "id": "timeseries", "name": "Time series", "version": "" }, + { "type": "panel", "id": "stat", "name": "Stat", "version": "" }, + { "type": "panel", "id": "gauge", "name": "Gauge", "version": "" } + ], + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "HTTP Traffic", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 }, + "id": 1, + "options": { "tooltip": { "mode": "single", "sort": "none" } }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(stellaiverse_http_requests_total[5m])) by (route, method)", + "legendFormat": "{{method}} {{route}}", + "refId": "A" + } + ], + "title": "Request Rate (req/s)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { "unit": "s", "color": { "mode": "palette-classic" } }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 }, + "id": 2, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.95, sum(rate(stellaiverse_http_request_duration_seconds_bucket[5m])) by (le, route))", + "legendFormat": "p95 {{route}}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "histogram_quantile(0.50, sum(rate(stellaiverse_http_request_duration_seconds_bucket[5m])) by (le, route))", + "legendFormat": "p50 {{route}}", + "refId": "B" + } + ], + "title": "Request Latency (p50 / p95)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { "unit": "percentunit", "min": 0, "max": 1 }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 }, + "id": 3, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(rate(stellaiverse_http_errors_total[5m])) / sum(rate(stellaiverse_http_requests_total[5m]))", + "legendFormat": "Error Rate", + "refId": "A" + } + ], + "title": "Error Rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { "unit": "short" }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 }, + "id": 4, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "sum(stellaiverse_http_requests_in_progress) by (method)", + "legendFormat": "{{method}}", + "refId": "A" + } + ], + "title": "In-Progress Requests", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 17 }, + "id": 101, + "title": "Authentication", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 18 }, + "id": 5, + "targets": [ + { + "expr": "sum(rate(stellaiverse_auth_attempts_total[5m])) by (method)", + "legendFormat": "Attempts {{method}}" + }, + { + "expr": "sum(rate(stellaiverse_auth_success_total[5m])) by (method)", + "legendFormat": "Success {{method}}" + }, + { + "expr": "sum(rate(stellaiverse_auth_failures_total[5m])) by (method, reason)", + "legendFormat": "Failure {{method}} ({{reason}})" + } + ], + "title": "Auth Rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "percentunit", "min": 0, "max": 1 }, "overrides": [] }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 18 }, + "id": 6, + "targets": [ + { + "expr": "sum(rate(stellaiverse_auth_success_total[5m])) / clamp_min(sum(rate(stellaiverse_auth_attempts_total[5m])), 1)", + "legendFormat": "Login Success Rate" + } + ], + "title": "Auth Success Rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 18 }, + "id": 7, + "targets": [ + { + "expr": "stellaiverse_active_users", + "legendFormat": "Active Users" + } + ], + "title": "Active Users", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 26 }, + "id": 102, + "title": "Database", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 27 }, + "id": 8, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(stellaiverse_database_query_duration_seconds_bucket[5m])) by (le, operation, table))", + "legendFormat": "p95 {{operation}} {{table}}" + } + ], + "title": "DB Query Latency (p95)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 27 }, + "id": 9, + "targets": [ + { + "expr": "stellaiverse_active_connections", + "legendFormat": "{{type}}" + } + ], + "title": "Active Connections", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 35 }, + "id": 103, + "title": "System Resources", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 36 }, + "id": 10, + "targets": [ + { + "expr": "stellaiverse_nodejs_heap_size_used_bytes", + "legendFormat": "Heap Used" + }, + { + "expr": "stellaiverse_nodejs_heap_size_total_bytes", + "legendFormat": "Heap Total" + } + ], + "title": "Node.js Heap Memory", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 36 }, + "id": 11, + "targets": [ + { + "expr": "stellaiverse_nodejs_eventloop_lag_seconds", + "legendFormat": "Event Loop Lag" + } + ], + "title": "Event Loop Lag", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "style": "dark", + "tags": ["stellaiverse", "prometheus", "nodejs"], + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "StellAIverse Backend", + "uid": "stellaiverse-backend", + "version": 1 +} diff --git a/src/app.module.ts b/src/app.module.ts index 70b00bf3..b41e8f11 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import { PortfolioModule } from "./portfolio/portfolio.module"; import { RiskManagementModule } from "./risk-management/risk-management.module"; import { DeFiModule } from "./defi/defi.module"; import { AlertsModule } from "./alerts/alerts.module"; +import { MetricsModule } from "./metrics/metrics.module"; // Auth entities import { User } from "./user/entities/user.entity"; @@ -135,6 +136,7 @@ import { SubmissionVerifierService } from "./oracle/submission-verifier.service" RiskManagementModule, DeFiModule, AlertsModule, + MetricsModule, ], controllers: [AppController], diff --git a/src/config/metrics.ts b/src/config/metrics.ts index 19879bc3..ce8a7c7a 100644 --- a/src/config/metrics.ts +++ b/src/config/metrics.ts @@ -1,20 +1,21 @@ -import client from "prom-client"; +import * as client from "prom-client"; -// Create a Registry to register the metrics -export const register = new client.Registry(); +// Use the global default registry singleton exported by prom-client +export const { register } = client; -// Add default metrics (CPU, memory, etc.) +// Default metrics (CPU, memory, event loop, GC, etc.) client.collectDefaultMetrics({ register, prefix: "stellaiverse_", }); -// Custom metrics +// ── HTTP metrics ────────────────────────────────────────────────────────────── + export const httpRequestDuration = new client.Histogram({ name: "stellaiverse_http_request_duration_seconds", help: "Duration of HTTP requests in seconds", labelNames: ["method", "route", "status_code"], - buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], + buckets: [0.05, 0.1, 0.5, 1, 3, 5, 10], registers: [register], }); @@ -32,11 +33,20 @@ export const httpRequestsInProgress = new client.Gauge({ registers: [register], }); +export const httpErrorTotal = new client.Counter({ + name: "stellaiverse_http_errors_total", + help: "Total number of HTTP error responses", + labelNames: ["method", "route", "status_code"], + registers: [register], +}); + +// ── Database metrics ────────────────────────────────────────────────────────── + export const databaseQueryDuration = new client.Histogram({ name: "stellaiverse_database_query_duration_seconds", help: "Duration of database queries in seconds", labelNames: ["operation", "table"], - buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5], + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2], registers: [register], }); @@ -47,17 +57,35 @@ export const activeConnections = new client.Gauge({ registers: [register], }); -export const errorTotal = new client.Counter({ - name: "stellaiverse_errors_total", - help: "Total number of errors", - labelNames: ["type", "severity"], +// ── Auth metrics ────────────────────────────────────────────────────────────── + +export const authAttemptsTotal = new client.Counter({ + name: "stellaiverse_auth_attempts_total", + help: "Total number of authentication attempts", + labelNames: ["method"], + registers: [register], +}); + +export const authSuccessTotal = new client.Counter({ + name: "stellaiverse_auth_success_total", + help: "Total number of successful authentications", + labelNames: ["method"], + registers: [register], +}); + +export const authFailureTotal = new client.Counter({ + name: "stellaiverse_auth_failures_total", + help: "Total number of failed authentication attempts", + labelNames: ["method", "reason"], registers: [register], }); -// Business metrics examples +// ── Business metrics ────────────────────────────────────────────────────────── + export const userSignups = new client.Counter({ name: "stellaiverse_user_signups_total", help: "Total number of user signups", + labelNames: ["method"], registers: [register], }); @@ -67,12 +95,13 @@ export const activeUsers = new client.Gauge({ registers: [register], }); -// Compute job queue metrics +// ── Job queue metrics ───────────────────────────────────────────────────────── + export const jobDuration = new client.Histogram({ name: "stellaiverse_job_duration_seconds", help: "Duration of compute job processing in seconds", labelNames: ["job_type", "status"], - buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60, 120, 300], + buckets: [0.1, 0.5, 1, 5, 10, 30, 60, 300], registers: [register], }); @@ -96,3 +125,10 @@ export const queueLength = new client.Gauge({ labelNames: ["queue_name", "state"], registers: [register], }); + +export const errorTotal = new client.Counter({ + name: "stellaiverse_errors_total", + help: "Total number of application errors", + labelNames: ["type", "severity"], + registers: [register], +}); diff --git a/src/metrics/metrics.controller.spec.ts b/src/metrics/metrics.controller.spec.ts new file mode 100644 index 00000000..d8d9638e --- /dev/null +++ b/src/metrics/metrics.controller.spec.ts @@ -0,0 +1,60 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { MetricsController } from "./metrics.controller"; +import { register } from "../config/metrics"; +import { Response } from "express"; + +describe("MetricsController", () => { + let controller: MetricsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [MetricsController], + }).compile(); + + controller = module.get(MetricsController); + }); + + it("should return Prometheus metrics in text format", async () => { + const mockEnd = jest.fn(); + const mockSet = jest.fn(); + const res = { set: mockSet, end: mockEnd } as unknown as Response; + + await controller.metrics(res); + + expect(mockSet).toHaveBeenCalledWith("Content-Type", register.contentType); + expect(mockEnd).toHaveBeenCalledWith(expect.stringContaining("stellaiverse_")); + }); + + it("should include default system metrics", async () => { + const metricsOutput = await register.metrics(); + expect(metricsOutput).toContain("stellaiverse_nodejs_"); + }); + + it("should expose custom HTTP metrics", async () => { + const metricsOutput = await register.metrics(); + expect(metricsOutput).toContain("stellaiverse_http_request_duration_seconds"); + expect(metricsOutput).toContain("stellaiverse_http_requests_total"); + expect(metricsOutput).toContain("stellaiverse_http_requests_in_progress"); + }); + + it("should expose auth metrics", async () => { + const metricsOutput = await register.metrics(); + expect(metricsOutput).toContain("stellaiverse_auth_attempts_total"); + expect(metricsOutput).toContain("stellaiverse_auth_success_total"); + expect(metricsOutput).toContain("stellaiverse_auth_failures_total"); + }); + + it("should expose database query metrics", async () => { + const metricsOutput = await register.metrics(); + expect(metricsOutput).toContain("stellaiverse_database_query_duration_seconds"); + }); + + it("should validate metrics schema — all metric names have correct prefix", async () => { + const metricsOutput = await register.metrics(); + const lines = metricsOutput + .split("\n") + .filter((l) => l.startsWith("# HELP ")); + const nonPrefixed = lines.filter((l) => !l.startsWith("# HELP stellaiverse_")); + expect(nonPrefixed).toHaveLength(0); + }); +}); diff --git a/src/metrics/metrics.controller.ts b/src/metrics/metrics.controller.ts new file mode 100644 index 00000000..82efb015 --- /dev/null +++ b/src/metrics/metrics.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Header, Res } from "@nestjs/common"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { Response } from "express"; +import { register } from "../config/metrics"; +import { Public } from "../common/decorators/public.decorator"; + +@ApiExcludeController() +@Controller("metrics") +export class MetricsController { + @Public() + @Get() + @Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + async metrics(@Res() res: Response): Promise { + res.set("Content-Type", register.contentType); + res.end(await register.metrics()); + } +} diff --git a/src/metrics/metrics.interceptor.spec.ts b/src/metrics/metrics.interceptor.spec.ts new file mode 100644 index 00000000..1c2b9eb8 --- /dev/null +++ b/src/metrics/metrics.interceptor.spec.ts @@ -0,0 +1,100 @@ +import { ExecutionContext, CallHandler } from "@nestjs/common"; +import { of, throwError } from "rxjs"; +import { MetricsInterceptor } from "./metrics.interceptor"; +import * as metricsConfig from "../config/metrics"; + +describe("MetricsInterceptor", () => { + let interceptor: MetricsInterceptor; + + const mockTimerEnd = jest.fn(); + const mockResponse = { statusCode: 200 }; + const mockRequest = { method: "GET", route: { path: "/test" }, url: "/test" }; + + beforeEach(() => { + interceptor = new MetricsInterceptor(); + jest.spyOn(metricsConfig.httpRequestDuration, "startTimer").mockReturnValue(mockTimerEnd); + jest.spyOn(metricsConfig.httpRequestsInProgress, "inc").mockImplementation(jest.fn()); + jest.spyOn(metricsConfig.httpRequestsInProgress, "dec").mockImplementation(jest.fn()); + jest.spyOn(metricsConfig.httpRequestTotal, "inc").mockImplementation(jest.fn()); + jest.spyOn(metricsConfig.httpErrorTotal, "inc").mockImplementation(jest.fn()); + }); + + afterEach(() => jest.restoreAllMocks()); + + function mockContext(overrides: Partial = {}): ExecutionContext { + const req = { ...mockRequest, ...overrides }; + return { + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => mockResponse, + }), + } as unknown as ExecutionContext; + } + + it("records duration and increments request total on success", (done) => { + const ctx = mockContext(); + const handler: CallHandler = { handle: () => of({ ok: true }) }; + + interceptor.intercept(ctx, handler).subscribe({ + next: () => { + expect(metricsConfig.httpRequestDuration.startTimer).toHaveBeenCalledWith({ + method: "GET", + route: "/test", + }); + expect(mockTimerEnd).toHaveBeenCalledWith({ status_code: "200" }); + expect(metricsConfig.httpRequestTotal.inc).toHaveBeenCalledWith({ + method: "GET", + route: "/test", + status_code: "200", + }); + expect(metricsConfig.httpRequestsInProgress.dec).toHaveBeenCalled(); + done(); + }, + }); + }); + + it("records error metrics when handler throws", (done) => { + const ctx = mockContext(); + const err = { status: 400 }; + const handler: CallHandler = { handle: () => throwError(() => err) }; + + interceptor.intercept(ctx, handler).subscribe({ + error: () => { + expect(mockTimerEnd).toHaveBeenCalledWith({ status_code: "400" }); + expect(metricsConfig.httpErrorTotal.inc).toHaveBeenCalledWith({ + method: "GET", + route: "/test", + status_code: "400", + }); + expect(metricsConfig.httpRequestsInProgress.dec).toHaveBeenCalled(); + done(); + }, + }); + }); + + it("defaults to 500 status when error has no status property", (done) => { + const ctx = mockContext(); + const handler: CallHandler = { handle: () => throwError(() => ({})) }; + + interceptor.intercept(ctx, handler).subscribe({ + error: () => { + expect(mockTimerEnd).toHaveBeenCalledWith({ status_code: "500" }); + done(); + }, + }); + }); + + it("skips tracking when no HTTP request available", (done) => { + const ctx = { + switchToHttp: () => ({ getRequest: () => null, getResponse: () => null }), + } as unknown as ExecutionContext; + const handler: CallHandler = { handle: () => of("ok") }; + + interceptor.intercept(ctx, handler).subscribe({ + next: () => { + expect(metricsConfig.httpRequestDuration.startTimer).not.toHaveBeenCalled(); + done(); + }, + }); + }); +}); diff --git a/src/metrics/metrics.interceptor.ts b/src/metrics/metrics.interceptor.ts new file mode 100644 index 00000000..2060e4d2 --- /dev/null +++ b/src/metrics/metrics.interceptor.ts @@ -0,0 +1,48 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import { Observable } from "rxjs"; +import { tap } from "rxjs/operators"; +import { + httpRequestDuration, + httpRequestTotal, + httpRequestsInProgress, + httpErrorTotal, +} from "../config/metrics"; + +@Injectable() +export class MetricsInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + if (!req) return next.handle(); + + const method = req.method as string; + const route: string = + (req.route?.path as string | undefined) ?? req.url ?? "unknown"; + const end = httpRequestDuration.startTimer({ method, route }); + httpRequestsInProgress.inc({ method, route }); + + return next.handle().pipe( + tap({ + next: () => { + const statusCode = String( + context.switchToHttp().getResponse().statusCode ?? 200, + ); + end({ status_code: statusCode }); + httpRequestTotal.inc({ method, route, status_code: statusCode }); + httpRequestsInProgress.dec({ method, route }); + }, + error: (err: { status?: number }) => { + const statusCode = String(err?.status ?? 500); + end({ status_code: statusCode }); + httpRequestTotal.inc({ method, route, status_code: statusCode }); + httpErrorTotal.inc({ method, route, status_code: statusCode }); + httpRequestsInProgress.dec({ method, route }); + }, + }), + ); + } +} diff --git a/src/metrics/metrics.module.ts b/src/metrics/metrics.module.ts new file mode 100644 index 00000000..9d6518fc --- /dev/null +++ b/src/metrics/metrics.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { APP_INTERCEPTOR } from "@nestjs/core"; +import { MetricsController } from "./metrics.controller"; +import { MetricsInterceptor } from "./metrics.interceptor"; + +@Module({ + controllers: [MetricsController], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: MetricsInterceptor, + }, + ], +}) +export class MetricsModule {}