Skip to content

Commit 1ae5cf4

Browse files
committed
feat(metrics): export Prometheus metrics
1 parent 04a0b28 commit 1ae5cf4

14 files changed

+301
-11
lines changed

config-example.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ server:
44
trustProxy:
55
- loopback
66
clusters: 0
7+
metrics:
8+
hostname: 127.0.0.1
9+
basePort: 2020
10+
allowedIps: []
711
services:
812
database:
913
type: mariadb

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@
5858
"object-path": "^0.11.8",
5959
"patch-package": "^6.4.7",
6060
"postinstall-postinstall": "^2.1.0",
61+
"prom-client": "^15.1.3",
6162
"proxy-addr": "^2.0.7",
6263
"proxy-agent": "^5.0.0",
6364
"randomstring": "^1.2.2",
6465
"rate-limiter-flexible": "^2.3.8",
66+
"response-time": "^2.3.3",
6567
"rxjs": "^7.5.6",
6668
"serialize-javascript": "^6.0.0",
6769
"socket.io-msgpack-parser": "^3.0.1",
@@ -92,6 +94,7 @@
9294
"@types/object-path": "^0.11.1",
9395
"@types/proxy-addr": "^2.0.0",
9496
"@types/randomstring": "^1.1.8",
97+
"@types/response-time": "^2.3.8",
9598
"@types/serialize-javascript": "^5.0.2",
9699
"@types/toposort": "^2.0.3",
97100
"@types/unzipper": "^0.10.5",

src/app.module.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AppService } from "./app.service";
55
import { ErrorFilter } from "./error.filter";
66
import { RecaptchaFilter } from "./recaptcha.filter";
77
import { AuthMiddleware } from "./auth/auth.middleware";
8+
import { MetricsMiddleware } from "./metrics/metrics.middleware";
89

910
import { SharedModule } from "./shared.module";
1011
import { RedisModule } from "./redis/redis.module";
@@ -24,6 +25,7 @@ import { DiscussionModule } from "./discussion/discussion.module";
2425
import { MigrationModule } from "./migration/migration.module";
2526
import { EventReportModule } from "./event-report/event-report.module";
2627
import { HomepageModule } from "./homepage/homepage.module";
28+
import { MetricsModule } from "./metrics/metrics.module";
2729

2830
@Module({
2931
imports: [
@@ -44,7 +46,8 @@ import { HomepageModule } from "./homepage/homepage.module";
4446
forwardRef(() => DiscussionModule),
4547
forwardRef(() => EventReportModule),
4648
forwardRef(() => HomepageModule),
47-
forwardRef(() => MigrationModule)
49+
forwardRef(() => MigrationModule),
50+
forwardRef(() => MetricsModule)
4851
],
4952
controllers: [AppController],
5053
providers: [AppService, ErrorFilter, RecaptchaFilter]
@@ -55,5 +58,9 @@ export class AppModule implements NestModule {
5558
path: "*",
5659
method: RequestMethod.ALL
5760
});
61+
consumer.apply(MetricsMiddleware).forRoutes({
62+
path: "*",
63+
method: RequestMethod.ALL
64+
});
5865
}
5966
}

src/config/config.schema.ts

+18
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ class ServerConfig {
3838
readonly clusters: number;
3939
}
4040

41+
class MetricsConfig {
42+
@IsIP()
43+
readonly hostname: string;
44+
45+
@IsPortNumber()
46+
readonly basePort: number;
47+
48+
@IsArray()
49+
@IsIP(undefined, { each: true })
50+
@IsOptional()
51+
readonly allowedIps?: string[];
52+
}
53+
4154
class ServicesConfigDatabase {
4255
@IsIn(["mysql", "mariadb"])
4356
readonly type: "mysql" | "mariadb";
@@ -543,6 +556,11 @@ export class AppConfig {
543556
@Type(() => ServerConfig)
544557
readonly server: ServerConfig;
545558

559+
@ValidateNested()
560+
@Type(() => MetricsConfig)
561+
@IsOptional()
562+
readonly metrics?: MetricsConfig;
563+
546564
@ValidateNested()
547565
@Type(() => ServicesConfig)
548566
readonly services: ServicesConfig;

src/error.filter.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@ import { Response } from "express"; // eslint-disable-line import/no-extraneous-
44

55
import { RequestWithSession } from "./auth/auth.middleware";
66
import { EventReportService, EventReportType } from "./event-report/event-report.service";
7+
import { MetricsService } from "./metrics/metrics.service";
78

89
const logger = new Logger("ErrorFilter");
910

1011
@Catch()
1112
export class ErrorFilter implements ExceptionFilter {
12-
constructor(private readonly eventReportService: EventReportService) {}
13+
constructor(
14+
private readonly eventReportService: EventReportService,
15+
private readonly metricsService: MetricsService
16+
) {}
17+
18+
private readonly metricErrorCount = this.metricsService.counter("syzoj_ng_error_count", ["error"]);
1319

1420
catch(error: Error, host: ArgumentsHost) {
1521
const contextType = host.getType();
@@ -32,6 +38,7 @@ export class ErrorFilter implements ExceptionFilter {
3238
logger.error(error.message, error.stack);
3339
} else logger.error(error);
3440

41+
this.metricErrorCount.inc({ error: error.constructor.name });
3542
this.eventReportService.report({
3643
type: EventReportType.Error,
3744
error,

src/judge/judge-queue.service.ts

+26-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Redis } from "ioredis";
44

55
import { logger } from "@/logger";
66
import { RedisService } from "@/redis/redis.service";
7+
import { MetricsService } from "@/metrics/metrics.service";
78

89
import { JudgeTaskService } from "./judge-task-service.interface";
910
import { JudgeTaskProgress } from "./judge-task-progress.interface";
@@ -28,6 +29,10 @@ export interface JudgeTaskMeta {
2829
type: JudgeTaskType;
2930
}
3031

32+
export interface QueuedJudgeTaskMeta extends JudgeTaskMeta {
33+
enqueueTime: number;
34+
}
35+
3136
// eslint-disable-next-line @typescript-eslint/no-empty-interface
3237
export interface JudgeTaskExtraInfo {}
3338

@@ -61,11 +66,17 @@ export class JudgeQueueService {
6166
private readonly taskServices: Map<JudgeTaskType, JudgeTaskService<JudgeTaskProgress, JudgeTaskExtraInfo>> =
6267
new Map();
6368

64-
constructor(private readonly redisService: RedisService) {
69+
constructor(private readonly redisService: RedisService, private readonly metricsService: MetricsService) {
6570
this.redisForPush = this.redisService.getClient();
6671
this.redisForConsume = this.redisService.getClient();
6772
}
6873

74+
private readonly metricJudgeTaskQueueTime = this.metricsService.histogram(
75+
"syzoj_ng_judge_task_queue_time_seconds",
76+
this.metricsService.histogram.BUCKETS_TIME_10M_30,
77+
["type", "priority_type"]
78+
);
79+
6980
registerTaskType<TaskProgress>(
7081
taskType: JudgeTaskType,
7182
service: JudgeTaskService<TaskProgress, JudgeTaskExtraInfo>
@@ -81,7 +92,8 @@ export class JudgeQueueService {
8192
priority,
8293
JSON.stringify({
8394
taskId,
84-
type
95+
type,
96+
enqueueTime: Date.now()
8597
})
8698
);
8799
}
@@ -102,7 +114,8 @@ export class JudgeQueueService {
102114

103115
const [, taskJson, priorityString] = redisResponse;
104116
const priority = Number(priorityString);
105-
const taskMeta: JudgeTaskMeta = JSON.parse(taskJson);
117+
const taskMeta: QueuedJudgeTaskMeta = JSON.parse(taskJson);
118+
const dequeuedTime = Date.now();
106119
const task = await this.taskServices.get(taskMeta.type).getTaskToBeSentToJudgeByTaskId(taskMeta.taskId, priority);
107120
if (!task) {
108121
logger.verbose(
@@ -111,6 +124,16 @@ export class JudgeQueueService {
111124
return null;
112125
}
113126

127+
if (taskMeta.enqueueTime) {
128+
this.metricJudgeTaskQueueTime.observe(
129+
{
130+
type: task.type,
131+
priority_type: task.priorityType
132+
},
133+
(Date.now() - dequeuedTime) / 1000
134+
);
135+
}
136+
114137
logger.verbose(
115138
`Consumed judge task { taskId: ${task.taskId}, type: ${task.type}, priority: ${priority} (${
116139
JudgeTaskPriorityType[task.priorityType]

src/judge/judge.module.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
44
import { RedisModule } from "@/redis/redis.module";
55
import { FileModule } from "@/file/file.module";
66
import { EventReportModule } from "@/event-report/event-report.module";
7+
import { MetricsModule } from "@/metrics/metrics.module";
78

89
import { JudgeQueueService } from "./judge-queue.service";
910
import { JudgeGateway } from "./judge.gateway";
@@ -16,7 +17,8 @@ import { JudgeClientEntity } from "./judge-client.entity";
1617
TypeOrmModule.forFeature([JudgeClientEntity]),
1718
forwardRef(() => RedisModule),
1819
forwardRef(() => FileModule),
19-
forwardRef(() => EventReportModule)
20+
forwardRef(() => EventReportModule),
21+
forwardRef(() => MetricsModule)
2022
],
2123
controllers: [JudgeClientController],
2224
providers: [JudgeGateway, JudgeClientService, JudgeQueueService],

src/metrics/metrics.middleware.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NestMiddleware, Injectable } from "@nestjs/common";
2+
3+
import { Request, Response } from "express"; // eslint-disable-line import/no-extraneous-dependencies
4+
import responseTime from "response-time";
5+
6+
import { MetricsService } from "./metrics.service";
7+
8+
@Injectable()
9+
export class MetricsMiddleware implements NestMiddleware {
10+
constructor(private readonly metricsSerivce: MetricsService) {}
11+
12+
private readonly metricRequestLatency = this.metricsSerivce.histogram(
13+
"syzoj_ng_request_latency_seconds",
14+
this.metricsSerivce.histogram.BUCKETS_TIME_5S_10,
15+
["api"]
16+
);
17+
18+
private readonly responseTimeMiddleware = responseTime((req, res, time) => {
19+
if (!req.url || !(res.statusCode >= 200 && res.statusCode < 400)) return;
20+
const url = new URL(req.url);
21+
this.metricRequestLatency.observe({ api: url.pathname }, time / 1000);
22+
});
23+
24+
async use(req: Request, res: Response, next: () => void) {
25+
this.responseTimeMiddleware(req, res, next);
26+
}
27+
}

src/metrics/metrics.module.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from "@nestjs/common";
2+
3+
import { MetricsService } from "./metrics.service";
4+
5+
@Module({
6+
providers: [MetricsService],
7+
exports: [MetricsService]
8+
})
9+
export class MetricsModule {}

src/metrics/metrics.service.ts

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import cluster from "cluster";
2+
import http from "http";
3+
4+
import { Injectable, Logger } from "@nestjs/common";
5+
6+
import PromClient from "prom-client";
7+
8+
import { ConfigService } from "@/config/config.service";
9+
10+
@Injectable()
11+
export class MetricsService {
12+
private readonly registry = new PromClient.Registry();
13+
14+
private readonly processName = cluster.isPrimary ? "Master" : `Worker #${cluster.worker.id}`;
15+
private readonly logger = new Logger(`${MetricsService.name}/${this.processName}`);
16+
17+
constructor(private readonly configService: ConfigService) {
18+
const config = this.configService.config.metrics;
19+
if (!config) {
20+
this.logger.warn("Metrics not configured");
21+
return;
22+
}
23+
24+
PromClient.collectDefaultMetrics({ register: this.registry });
25+
26+
const processName = cluster.isPrimary ? "Master" : `Worker #${cluster.worker.id}`;
27+
const port = config.basePort + (cluster.isPrimary ? 0 : cluster.worker.id);
28+
http
29+
.createServer(async (req, res) => {
30+
try {
31+
if (config.allowedIps?.length > 0 && !config.allowedIps.includes(req.socket.remoteAddress!)) {
32+
res.writeHead(403).end();
33+
return;
34+
}
35+
res.writeHead(200, {
36+
"Content-Type": this.registry.contentType
37+
});
38+
res.write(await this.registry.metrics());
39+
res.end();
40+
} catch (e) {
41+
this.logger.error(`Failed to serve metrics request: ${e instanceof Error ? e.stack : String(e)}`);
42+
try {
43+
res.writeHead(500).end();
44+
} catch {
45+
res.end();
46+
}
47+
}
48+
})
49+
.listen(port, config.hostname, () => {
50+
this.logger.log(`Metrics server is listening on ${config.hostname}:${port} (${processName})`);
51+
});
52+
}
53+
54+
counter = <T extends string>(name: string, labelNames: readonly T[] = []) =>
55+
new PromClient.Counter({
56+
name,
57+
help: name,
58+
labelNames,
59+
registers: [this.registry]
60+
});
61+
62+
gauge = <T extends string>(name: string, labelNames: readonly T[] = []) =>
63+
new PromClient.Gauge({
64+
name,
65+
help: name,
66+
labelNames,
67+
registers: [this.registry]
68+
});
69+
70+
histogram = Object.assign(
71+
<T extends string>(name: string, buckets: number[], labelNames: readonly T[] = []) =>
72+
new PromClient.Histogram({
73+
name,
74+
help: name,
75+
buckets,
76+
labelNames,
77+
registers: [this.registry]
78+
}),
79+
{
80+
linearBuckets: PromClient.linearBuckets,
81+
exponentialBuckets: PromClient.exponentialBuckets,
82+
BUCKETS_TIME_10M_30: PromClient.exponentialBuckets(0.05, 1.368, 30),
83+
BUCKETS_TIME_5S_10: PromClient.exponentialBuckets(0.03, 1.79, 10)
84+
}
85+
);
86+
}

0 commit comments

Comments
 (0)