diff --git a/api/package.json b/api/package.json index df60186..0253ff0 100644 --- a/api/package.json +++ b/api/package.json @@ -29,6 +29,7 @@ "compression": "^1.8.1", "helmet": "8.1.0", "pg": "^8.20.0", + "prom-client": "^15.1.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sanitize-html": "^2.17.4", diff --git a/api/src/app.module.ts b/api/src/app.module.ts index b8f24a7..819cddb 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -6,6 +6,7 @@ import { AuditModule } from "./audit/audit.module" import { AuthModule } from "./auth/auth.module" import { GatewaysModule } from "./gateways/gateways.module" import { HealthModule } from "./health/health.module" +import { MetricsModule } from "./metrics/metrics.module" import { RequestLoggerMiddleware } from "./middleware/request-logger.middleware" import { StreamsModule } from "./streams/streams.module" import { TagsModule } from "./tags/tags.module" @@ -23,6 +24,7 @@ import { TagsModule } from "./tags/tags.module" AuthModule, GatewaysModule, HealthModule, + MetricsModule, StreamsModule, TagsModule, ], diff --git a/api/src/gateways/gateways.module.ts b/api/src/gateways/gateways.module.ts index 7531876..8c55062 100644 --- a/api/src/gateways/gateways.module.ts +++ b/api/src/gateways/gateways.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common" import { JwtModule } from "@nestjs/jwt" +import { MetricsModule } from "../metrics/metrics.module" import { StreamsGateway } from "./streams.gateway" /** @@ -10,6 +11,7 @@ import { StreamsGateway } from "./streams.gateway" */ @Module({ imports: [ + MetricsModule, JwtModule.registerAsync({ useFactory: () => { const secret = process.env.JWT_SECRET diff --git a/api/src/gateways/streams.gateway.ts b/api/src/gateways/streams.gateway.ts index b6c2147..691e71b 100644 --- a/api/src/gateways/streams.gateway.ts +++ b/api/src/gateways/streams.gateway.ts @@ -1,4 +1,4 @@ -import { Logger } from "@nestjs/common" +import { Logger, Optional } from "@nestjs/common" import { JwtService } from "@nestjs/jwt" import { ConnectedSocket, @@ -10,6 +10,7 @@ import { WebSocketServer, } from "@nestjs/websockets" import type { Server, Socket } from "socket.io" +import { MetricsService } from "../metrics/metrics.service" import { STREAM_EVENTS, StreamErrorPayload, @@ -59,7 +60,10 @@ export class StreamsGateway @WebSocketServer() public server!: Server - constructor(private readonly jwtService: JwtService) {} + constructor( + private readonly jwtService: JwtService, + @Optional() private readonly metricsService?: MetricsService, + ) {} afterInit(_server: Server): void { this.logger.log("StreamsGateway initialised on namespace /streams") @@ -80,6 +84,9 @@ export class StreamsGateway const payload = await this.jwtService.verifyAsync(token) client.data.userId = payload.sub + this.metricsService?.websocketConnectionsTotal.inc() + this.metricsService?.websocketActiveConnections.inc() + this.logger.log( `client ${client.id} connected (user=${String(payload.sub)})`, ) @@ -101,6 +108,7 @@ export class StreamsGateway // `try/catch` exists to make sure a buggy logger never throws back // into the framework and crashes the worker. try { + this.metricsService?.websocketActiveConnections.dec() this.logger.log( `client ${client.id} disconnected (user=${String(client.data?.userId ?? "anon")})`, ) diff --git a/api/src/metrics/metrics.controller.spec.ts b/api/src/metrics/metrics.controller.spec.ts new file mode 100644 index 0000000..561a67a --- /dev/null +++ b/api/src/metrics/metrics.controller.spec.ts @@ -0,0 +1,50 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { MetricsController } from "./metrics.controller" +import { MetricsService } from "./metrics.service" +import { Response } from "express" + +function makeRes(): { res: Response & { _headers: Record; _body: string }; setHeader: jest.Mock; end: jest.Mock } { + const setHeader = jest.fn() + const end = jest.fn() + const res = { setHeader, end, _headers: {}, _body: "" } as unknown as Response & { _headers: Record; _body: string } + return { res, setHeader, end } +} + +describe("MetricsController", () => { + let controller: MetricsController + let metricsService: Partial + + beforeEach(async () => { + metricsService = { + getMetrics: jest.fn().mockResolvedValue("# HELP http_requests_total\nhttp_requests_total 0\n"), + contentType: "text/plain; version=0.0.4; charset=utf-8", + } + + const module: TestingModule = await Test.createTestingModule({ + controllers: [MetricsController], + providers: [{ provide: MetricsService, useValue: metricsService }], + }).compile() + + controller = module.get(MetricsController) + }) + + it("sets Content-Type header and ends response with metrics body", async () => { + const { res, setHeader, end } = makeRes() + + await controller.getMetrics(res) + + expect(setHeader).toHaveBeenCalledWith( + "Content-Type", + "text/plain; version=0.0.4; charset=utf-8", + ) + expect(end).toHaveBeenCalledWith( + "# HELP http_requests_total\nhttp_requests_total 0\n", + ) + }) + + it("calls metricsService.getMetrics once per request", async () => { + const { res } = makeRes() + await controller.getMetrics(res) + expect(metricsService.getMetrics).toHaveBeenCalledTimes(1) + }) +}) diff --git a/api/src/metrics/metrics.controller.ts b/api/src/metrics/metrics.controller.ts new file mode 100644 index 0000000..0395ca5 --- /dev/null +++ b/api/src/metrics/metrics.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Header, Res } from "@nestjs/common" +import { ApiExcludeEndpoint } from "@nestjs/swagger" +import { Response } from "express" +import { MetricsService } from "./metrics.service" + +@Controller("metrics") +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + /** + * Exposes Prometheus-format metrics for scraping. + * Excluded from Swagger to avoid confusion with REST endpoints. + */ + @Get() + @Header("Cache-Control", "no-cache, no-store, must-revalidate") + @ApiExcludeEndpoint() + async getMetrics(@Res() res: Response): Promise { + const [body, contentType] = await Promise.all([ + this.metricsService.getMetrics(), + Promise.resolve(this.metricsService.contentType), + ]) + res.setHeader("Content-Type", contentType) + res.end(body) + } +} diff --git a/api/src/metrics/metrics.module.ts b/api/src/metrics/metrics.module.ts new file mode 100644 index 0000000..e2f7c52 --- /dev/null +++ b/api/src/metrics/metrics.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common" +import { MetricsController } from "./metrics.controller" +import { MetricsService } from "./metrics.service" + +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/api/src/metrics/metrics.service.spec.ts b/api/src/metrics/metrics.service.spec.ts new file mode 100644 index 0000000..1ae2297 --- /dev/null +++ b/api/src/metrics/metrics.service.spec.ts @@ -0,0 +1,66 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { MetricsService } from "./metrics.service" + +describe("MetricsService", () => { + let service: MetricsService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MetricsService], + }).compile() + + service = module.get(MetricsService) + // Initialise default metrics (normally called by NestJS lifecycle) + service.onModuleInit() + }) + + afterEach(() => { + // Clear the registry between tests to avoid duplicate-metric errors + service.registry.clear() + }) + + it("returns prometheus text format from getMetrics()", async () => { + const output = await service.getMetrics() + expect(typeof output).toBe("string") + // prom-client text format always starts with "# HELP" lines + expect(output).toMatch(/# HELP/) + }) + + it("contentType is text/plain with version", () => { + expect(service.contentType).toContain("text/plain") + }) + + it("increments httpRequestsTotal counter", async () => { + service.httpRequestsTotal.inc({ method: "GET", path: "/health", status_code: "200" }) + const output = await service.getMetrics() + expect(output).toContain("http_requests_total") + expect(output).toMatch(/http_requests_total\{[^}]+\} 1/) + }) + + it("observes httpRequestDurationSeconds histogram", async () => { + service.httpRequestDurationSeconds.observe( + { method: "GET", path: "/health", status_code: "200" }, + 0.05, + ) + const output = await service.getMetrics() + expect(output).toContain("http_request_duration_seconds") + expect(output).toContain("http_request_duration_seconds_sum") + }) + + it("increments and decrements websocket gauges correctly", async () => { + service.websocketConnectionsTotal.inc() + service.websocketActiveConnections.inc() + service.websocketActiveConnections.dec() + + const output = await service.getMetrics() + expect(output).toContain("websocket_connections_total") + expect(output).toContain("websocket_active_connections") + expect(output).toMatch(/websocket_connections_total \d/) + }) + + it("includes default Node.js process metrics", async () => { + const output = await service.getMetrics() + // Default metrics collected by collectDefaultMetrics + expect(output).toContain("process_") + }) +}) diff --git a/api/src/metrics/metrics.service.ts b/api/src/metrics/metrics.service.ts new file mode 100644 index 0000000..caa86dd --- /dev/null +++ b/api/src/metrics/metrics.service.ts @@ -0,0 +1,52 @@ +import { Injectable, OnModuleInit } from "@nestjs/common" +import { + collectDefaultMetrics, + Counter, + Histogram, + Gauge, + Registry, +} from "prom-client" + +@Injectable() +export class MetricsService implements OnModuleInit { + readonly registry = new Registry() + + readonly httpRequestsTotal = new Counter({ + name: "http_requests_total", + help: "Total number of HTTP requests", + labelNames: ["method", "path", "status_code"], + registers: [this.registry], + }) + + readonly httpRequestDurationSeconds = new Histogram({ + name: "http_request_duration_seconds", + help: "HTTP request duration in seconds", + labelNames: ["method", "path", "status_code"], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + registers: [this.registry], + }) + + readonly websocketConnectionsTotal = new Counter({ + name: "websocket_connections_total", + help: "Total number of WebSocket connections established", + registers: [this.registry], + }) + + readonly websocketActiveConnections = new Gauge({ + name: "websocket_active_connections", + help: "Current number of active WebSocket connections", + registers: [this.registry], + }) + + onModuleInit(): void { + collectDefaultMetrics({ register: this.registry }) + } + + async getMetrics(): Promise { + return this.registry.metrics() + } + + get contentType(): string { + return this.registry.contentType + } +} diff --git a/api/src/middleware/request-logger.middleware.ts b/api/src/middleware/request-logger.middleware.ts index c19cae8..c1d34bf 100644 --- a/api/src/middleware/request-logger.middleware.ts +++ b/api/src/middleware/request-logger.middleware.ts @@ -1,5 +1,6 @@ -import { Injectable, NestMiddleware } from "@nestjs/common" +import { Injectable, NestMiddleware, Optional } from "@nestjs/common" import { Request, Response, NextFunction } from "express" +import { MetricsService } from "../metrics/metrics.service" const SENSITIVE_PATH_PATTERNS: RegExp[] = [/^\/auth\b/] @@ -7,6 +8,8 @@ export type AuthedRequest = Request & { user?: { id: number | string } } @Injectable() export class RequestLoggerMiddleware implements NestMiddleware { + constructor(@Optional() private readonly metricsService?: MetricsService) {} + use(req: AuthedRequest, res: Response, next: NextFunction): void { const start = process.hrtime.bigint() const isSensitive = SENSITIVE_PATH_PATTERNS.some((re) => re.test(req.path)) @@ -42,6 +45,20 @@ export class RequestLoggerMiddleware implements NestMiddleware { } else { console.log(line) } + + // Skip /metrics path to avoid self-instrumentation noise + if (this.metricsService && req.path !== "/metrics") { + const labels = { + method: req.method, + path: req.route?.path ?? req.path, + status_code: String(res.statusCode), + } + this.metricsService.httpRequestsTotal.inc(labels) + this.metricsService.httpRequestDurationSeconds.observe( + labels, + durationMs / 1000, + ) + } }) next()