Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -23,6 +24,7 @@ import { TagsModule } from "./tags/tags.module"
AuthModule,
GatewaysModule,
HealthModule,
MetricsModule,
StreamsModule,
TagsModule,
],
Expand Down
2 changes: 2 additions & 0 deletions api/src/gateways/gateways.module.ts
Original file line number Diff line number Diff line change
@@ -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"

/**
Expand All @@ -10,6 +11,7 @@ import { StreamsGateway } from "./streams.gateway"
*/
@Module({
imports: [
MetricsModule,
JwtModule.registerAsync({
useFactory: () => {
const secret = process.env.JWT_SECRET
Expand Down
12 changes: 10 additions & 2 deletions api/src/gateways/streams.gateway.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Logger } from "@nestjs/common"
import { Logger, Optional } from "@nestjs/common"
import { JwtService } from "@nestjs/jwt"
import {
ConnectedSocket,
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -80,6 +84,9 @@ export class StreamsGateway
const payload = await this.jwtService.verifyAsync<JwtPayload>(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)})`,
)
Expand All @@ -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")})`,
)
Expand Down
50 changes: 50 additions & 0 deletions api/src/metrics/metrics.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>; _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<string, string>; _body: string }
return { res, setHeader, end }
}

describe("MetricsController", () => {
let controller: MetricsController
let metricsService: Partial<MetricsService>

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>(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)
})
})
25 changes: 25 additions & 0 deletions api/src/metrics/metrics.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const [body, contentType] = await Promise.all([
this.metricsService.getMetrics(),
Promise.resolve(this.metricsService.contentType),
])
res.setHeader("Content-Type", contentType)
res.end(body)
}
}
10 changes: 10 additions & 0 deletions api/src/metrics/metrics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
66 changes: 66 additions & 0 deletions api/src/metrics/metrics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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_")
})
})
52 changes: 52 additions & 0 deletions api/src/metrics/metrics.service.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return this.registry.metrics()
}

get contentType(): string {
return this.registry.contentType
}
}
19 changes: 18 additions & 1 deletion api/src/middleware/request-logger.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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/]

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))
Expand Down Expand Up @@ -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()
Expand Down
Loading