返回 AiToEarn
queue-metrics.service.ts
根目录 / project / aitoearn-backend / libs / aitoearn-queue / src / queue-metrics.service.ts
1 import type { Queue } from 'bullmq'
2 import { getQueueToken } from '@nestjs/bullmq'
3 import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
4 import { ModuleRef } from '@nestjs/core'
5 import { QueueEvents } from 'bullmq'
6 import { Counter, Gauge, Summary } from 'prom-client'
7 import { QueueName } from './enums'
8 import { QueueConfig } from './queue.config'
9
10 const CALIBRATION_INTERVAL = 5 * 60 * 1000
11
12 @Injectable()
13 export class QueueMetricsService implements OnModuleInit, OnModuleDestroy {
14 private readonly logger = new Logger(QueueMetricsService.name)
15 private readonly queues: Array<{ name: string, queue: Queue }> = []
16 private readonly queueEventsInstances: QueueEvents[] = []
17 private calibrationTimer?: ReturnType<typeof setInterval>
18
19 private readonly jobCountGauge = new Gauge({
20 name: 'bullmq_job_count',
21 help: 'Number of jobs in the queue by state',
22 labelNames: ['queue', 'state'] as const,
23 })
24
25 private readonly activeCounter = new Counter({
26 name: 'bullmq_jobs_active',
27 help: 'Total number of jobs started processing',
28 labelNames: ['queue'] as const,
29 })
30
31 private readonly completedCounter = new Counter({
32 name: 'bullmq_jobs_completed',
33 help: 'Total number of completed jobs',
34 labelNames: ['queue'] as const,
35 })
36
37 private readonly failedCounter = new Counter({
38 name: 'bullmq_jobs_failed',
39 help: 'Total number of failed jobs',
40 labelNames: ['queue'] as const,
41 })
42
43 private readonly durationSummary = new Summary({
44 name: 'bullmq_job_duration_milliseconds',
45 help: 'Job processing duration in milliseconds',
46 labelNames: ['queue', 'status'] as const,
47 percentiles: [0.5, 0.9, 0.95, 0.99],
48 maxAgeSeconds: 600,
49 ageBuckets: 5,
50 })
51
52 private readonly waitDurationSummary = new Summary({
53 name: 'bullmq_job_wait_duration_milliseconds',
54 help: 'Job wait time in queue before processing in milliseconds',
55 labelNames: ['queue', 'status'] as const,
56 percentiles: [0.5, 0.9, 0.95, 0.99],
57 maxAgeSeconds: 600,
58 ageBuckets: 5,
59 })
60
61 private readonly attemptsSummary = new Summary({
62 name: 'bullmq_job_attempts',
63 help: 'Number of attempts per job',
64 labelNames: ['queue', 'status'] as const,
65 percentiles: [0.5, 0.9, 0.95, 0.99],
66 maxAgeSeconds: 600,
67 ageBuckets: 5,
68 })
69
70 constructor(
71 private readonly moduleRef: ModuleRef,
72 private readonly config: QueueConfig,
73 ) {}
74
75 async onModuleInit() {
76 for (const name of Object.values(QueueName)) {
77 try {
78 const token = getQueueToken(name)
79 const queue = this.moduleRef.get<Queue>(token, { strict: false })
80 if (queue) {
81 this.queues.push({ name, queue })
82 }
83 }
84 catch {
85 this.logger.warn(`Queue ${name} not found in module context`)
86 }
87 }
88 this.logger.log(`Registered ${this.queues.length} queues for metrics collection`)
89
90 await this.calibrateJobCounts()
91 this.setupQueueEvents()
92 this.calibrationTimer = setInterval(() => this.calibrateJobCounts(), CALIBRATION_INTERVAL)
93 }
94
95 async onModuleDestroy() {
96 if (this.calibrationTimer) {
97 clearInterval(this.calibrationTimer)
98 }
99 await Promise.allSettled(
100 this.queueEventsInstances.map(qe => qe.close()),
101 )
102 }
103
104 private setupQueueEvents() {
105 const redisConnection = this.buildRedisConnection()
106
107 for (const { name, queue } of this.queues) {
108 try {
109 const queueEvents = new QueueEvents(name, {
110 connection: redisConnection,
111 prefix: this.config.prefix,
112 })
113
114 queueEvents.on('active', ({ prev }) => {
115 this.activeCounter.labels(name).inc()
116 this.transitionJobCount(name, prev, 'active')
117 })
118
119 queueEvents.on('completed', async ({ jobId, prev }) => {
120 this.completedCounter.labels(name).inc()
121 this.transitionJobCount(name, prev, 'completed')
122 await this.observeJobMetrics(name, queue, jobId, 'completed')
123 })
124
125 queueEvents.on('failed', async ({ jobId, prev }) => {
126 this.failedCounter.labels(name).inc()
127 this.transitionJobCount(name, prev, 'failed')
128 await this.observeJobMetrics(name, queue, jobId, 'failed')
129 })
130
131 queueEvents.on('waiting', ({ prev }) => {
132 this.transitionJobCount(name, prev, 'waiting')
133 })
134
135 queueEvents.on('delayed', () => {
136 this.jobCountGauge.labels(name, 'delayed').inc()
137 })
138
139 queueEvents.on('paused', () => {
140 this.jobCountGauge.labels(name, 'paused').inc()
141 })
142
143 queueEvents.on('waiting-children', () => {
144 this.jobCountGauge.labels(name, 'waiting-children').inc()
145 })
146
147 this.queueEventsInstances.push(queueEvents)
148 }
149 catch (error) {
150 this.logger.warn(`Failed to create QueueEvents for ${name}: ${error}`)
151 }
152 }
153
154 this.logger.log(`Created ${this.queueEventsInstances.length} QueueEvents listeners`)
155 }
156
157 private transitionJobCount(queueName: string, prev: string | undefined, next: string) {
158 if (prev) {
159 this.jobCountGauge.labels(queueName, prev).dec()
160 }
161 this.jobCountGauge.labels(queueName, next).inc()
162 }
163
164 private async observeJobMetrics(queueName: string, queue: Queue, jobId: string, status: string) {
165 try {
166 const job = await queue.getJob(jobId)
167 if (!job)
168 return
169
170 if (job.processedOn && job.finishedOn) {
171 this.durationSummary.labels(queueName, status).observe(job.finishedOn - job.processedOn)
172 }
173 if (job.processedOn && job.timestamp) {
174 this.waitDurationSummary.labels(queueName, status).observe(job.processedOn - job.timestamp)
175 }
176 this.attemptsSummary.labels(queueName, status).observe(job.attemptsMade)
177 }
178 catch {
179 }
180 }
181
182 private async calibrateJobCounts() {
183 this.jobCountGauge.reset()
184 await Promise.allSettled(
185 this.queues.map(async ({ name, queue }) => {
186 const counts = await queue.getJobCounts()
187 for (const [state, count] of Object.entries(counts)) {
188 this.jobCountGauge.labels(name, state).set(count)
189 }
190 }),
191 )
192 }
193
194 private buildRedisConnection() {
195 const redis = this.config.redis
196 if ('nodes' in redis) {
197 const node = redis.nodes[0]
198 return {
199 host: node?.host ?? 'localhost',
200 port: Number(node?.port ?? 6379),
201 ...redis.options?.redisOptions,
202 }
203 }
204 return {
205 host: redis.host ?? 'localhost',
206 port: redis.port ?? 6379,
207 username: redis.username,
208 password: redis.password,
209 db: redis.db,
210 tls: redis.tls,
211 }
212 }
213 }
214
214 lines TYPESCRIPT