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
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.8",
"@nestjs/platform-socket.io": "^10.3.8",
"@nestjs/schedule": "^4.0.2",
"@nestjs/swagger": "^7.3.1",
"@nestjs/websockets": "^10.3.8",
"@nestjs/platform-socket.io": "^10.3.8",
"@prisma/client": "^5.14.0",
"@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1",
Expand All @@ -44,6 +44,7 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.7.5",
Expand Down Expand Up @@ -73,13 +74,19 @@
"typescript": "^5.4.5"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
Expand Down
2 changes: 2 additions & 0 deletions server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { InvitationsModule } from './modules/invitations/invitations.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { LeaderboardsModule } from './modules/leaderboards/leaderboards.module';
import { WebSocketGatewayModule } from './websocket/websocket.module';
import { MetricsModule } from './metrics/metrics.module';
import appConfig from './config/app.config';
import databaseConfig from './config/database.config';
import jwtConfig from './config/jwt.config';
Expand All @@ -29,6 +30,7 @@ import redisConfig from './config/redis.config';
ScheduleModule.forRoot(),
DatabaseModule,
WebSocketGatewayModule,
MetricsModule,
AuthModule,
UsersModule,
GamesModule,
Expand Down
2 changes: 1 addition & 1 deletion server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async function bootstrap() {
const port = configService.get<number>('PORT', 3001);
const clientOrigin = configService.get<string>('CLIENT_ORIGIN', 'http://localhost:5173');

app.setGlobalPrefix('api');
app.setGlobalPrefix('api', { exclude: ['metrics'] }); // /metrics is cluster-internal (not proxied by nginx)

app.enableCors({
origin: clientOrigin,
Expand Down
37 changes: 37 additions & 0 deletions server/src/metrics/metrics.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Controller, Get, Header, Module } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { Injectable } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { PrismaService } from '../database/prisma.service';
import { gamesActive, registry } from './metrics';

@Controller('metrics')
export class MetricsController {
@Get()
@Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
async metrics(): Promise<string> {
return registry.metrics();
}
}

@Injectable()
export class MetricsSampler {
constructor(private readonly prisma: PrismaService) {}

/** One tiny indexed count every 30s keeps the gauge honest across restarts. */
@Interval(30_000)
async sampleActiveGames(): Promise<void> {
try {
gamesActive.set(await this.prisma.game.count({ where: { status: 'active' } }));
} catch {
// Sampling must never crash the app; the next tick retries.
}
}
}

@Module({
imports: [DatabaseModule],
controllers: [MetricsController],
providers: [MetricsSampler],
})
export class MetricsModule {}
34 changes: 34 additions & 0 deletions server/src/metrics/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Prometheus metrics, hand-picked for a 1 vCPU host: what is being used
* (games, sockets) and what can degrade it (Stockfish work). No default
* process metrics: the scrape must stay tiny.
*/
import { Gauge, Histogram, Registry } from 'prom-client';

export const registry = new Registry();

export const gamesActive = new Gauge({
name: 'chesskernel_games_active',
help: 'Games currently in active status',
registers: [registry],
});

export const socketConnections = new Gauge({
name: 'chesskernel_socket_connections',
help: 'Socket.IO clients currently connected',
registers: [registry],
});

export const stockfishRunning = new Gauge({
name: 'chesskernel_stockfish_running',
help: 'Stockfish evaluations currently in flight',
registers: [registry],
});

export const stockfishDuration = new Histogram({
name: 'chesskernel_stockfish_duration_seconds',
help: 'Stockfish call duration by operation',
labelNames: ['op'],
buckets: [0.25, 1, 3, 10, 30],
registers: [registry],
});
21 changes: 17 additions & 4 deletions server/src/modules/analysis/stockfish.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { spawn, ChildProcess } from 'child_process';
import { stockfishDuration, stockfishRunning } from '../../metrics/metrics';
import { join } from 'path';
import { existsSync } from 'fs';

Expand Down Expand Up @@ -39,7 +40,7 @@ export class StockfishService implements OnModuleDestroy {
}

async evaluatePosition(fen: string, depth = 18): Promise<StockfishResult> {
return new Promise((resolve, reject) => {
return this.timed('evaluate', () => new Promise((resolve, reject) => {
let process: ChildProcess;
try {
process = spawn(this.findStockfish(), [], { stdio: 'pipe' });
Expand Down Expand Up @@ -103,7 +104,19 @@ export class StockfishService implements OnModuleDestroy {
].join('\n') + '\n';

process.stdin?.write(commands);
});
}));
}

/** Tracks in-flight count and duration of every engine call. */
private async timed<T>(op: string, run: () => Promise<T>): Promise<T> {
stockfishRunning.inc();
const stop = stockfishDuration.startTimer({ op });
try {
return await run();
} finally {
stop();
stockfishRunning.dec();
}
}

async getBestMove(
Expand All @@ -112,7 +125,7 @@ export class StockfishService implements OnModuleDestroy {
moveTimeMs: number,
depth?: number,
): Promise<string> {
return new Promise((resolve, reject) => {
return this.timed('bestmove', () => new Promise((resolve, reject) => {
let process: ChildProcess;
try {
process = spawn(this.findStockfish(), [], { stdio: 'pipe' });
Expand Down Expand Up @@ -165,6 +178,6 @@ export class StockfishService implements OnModuleDestroy {
].join('\n') + '\n';

process.stdin?.write(commands);
});
}));
}
}
3 changes: 3 additions & 0 deletions server/src/websocket/game.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { socketConnections } from '../metrics/metrics';
import { UseGuards, Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
Expand Down Expand Up @@ -45,6 +46,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {
) {}

async handleConnection(socket: Socket) {
socketConnections.inc();
const token = socket.handshake.auth?.token as string | undefined;
if (!token) {
socket.disconnect(true);
Expand All @@ -65,6 +67,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {
}

async handleDisconnect(socket: Socket) {
socketConnections.dec();
const userId = socket.data.userId as string | undefined;
if (!userId) return;

Expand Down
Loading