feat(metrics): Prometheus queue metrics for BullMQ (RFC-004 Phase 3 WS3a)

Adds a 5 s polling collector that publishes BullMQ queue depth as the
goodgo_queue_depth gauge (labels: queue, state) and a
goodgo_queue_job_outcomes_total counter for processor hooks. The collector
fails-soft on Redis errors so a queue blip cannot crash the app.

- New constants: QUEUE_DEPTH_GAUGE, QUEUE_JOB_OUTCOMES_TOTAL,
  QUEUE_METRICS_QUEUE_NAMES (extend as Phase 2 adds queues)
- New QueueMetricsCollector with injectable timer/clock for tests
- MetricsModule.withQueueMetrics() dynamic module wires queue tokens via
  getQueueToken + factory provider; re-imports BullModule.registerQueue so
  ordering between MetricsModule and feature modules does not matter
- AppModule mounts MetricsModule.withQueueMetrics() alongside MetricsModule
- 4 unit tests cover sample → gauge mapping, Redis-down fail-soft,
  recordJobOutcome, and timer init/destroy

Bull Board UI mount split into WS3b (needs @bull-board/* deps).

Refs: GOO-175

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 14:46:32 +07:00
parent 83659a4c8b
commit a569765993
5 changed files with 286 additions and 2 deletions

View File

@@ -1,10 +1,24 @@
import { Module } from '@nestjs/common';
import { BullModule, getQueueToken } from '@nestjs/bullmq';
import { Module, type DynamicModule } from '@nestjs/common';
import {
makeCounterProvider,
makeHistogramProvider,
makeGaugeProvider,
} from '@willsoto/nestjs-prometheus';
import type { Queue } from 'bullmq';
import { MetricsService } from './infrastructure/metrics.service';
import {
QueueMetricsCollector,
QUEUE_METRICS_COLLECTOR_OPTIONS,
QUEUE_METRICS_COLLECTOR_QUEUES,
type QueueLike,
type QueueMetricsCollectorOptions,
} from './infrastructure/queue-metrics.collector';
import {
QUEUE_DEPTH_GAUGE,
QUEUE_JOB_OUTCOMES_TOTAL,
QUEUE_METRICS_QUEUE_NAMES,
} from './infrastructure/queue-metrics.constants';
import {
GOODGO_API_REQUEST_DURATION,
GOODGO_LISTINGS_CREATED_TOTAL,
@@ -141,4 +155,48 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
controllers: [WebVitalsController],
exports: [MetricsService, HttpMetricsInterceptor],
})
export class MetricsModule {}
export class MetricsModule {
/**
* Register the queue-metrics collector with a fixed list of BullMQ queue
* names. Each name must already be registered via BullModule.registerQueue
* somewhere in the app (root or feature module).
*
* RFC-004 Phase 3 — workstream 3a.
*/
static withQueueMetrics(
queueNames: readonly string[] = QUEUE_METRICS_QUEUE_NAMES,
options: QueueMetricsCollectorOptions = {},
): DynamicModule {
const queueTokens = queueNames.map((name) => getQueueToken(name));
return {
module: MetricsModule,
imports: [
// Re-register each queue here so the collector can resolve them via
// BullMQ's standard token even if MetricsModule is imported before
// the feature module that owns the queue. BullMQ deduplicates the
// queue instance under the hood.
...queueNames.map((name) => BullModule.registerQueue({ name })),
],
providers: [
makeGaugeProvider({
name: QUEUE_DEPTH_GAUGE,
help: 'BullMQ queue depth by state (waiting, active, completed, failed, delayed)',
labelNames: ['queue', 'state'],
}),
makeCounterProvider({
name: QUEUE_JOB_OUTCOMES_TOTAL,
help: 'BullMQ job outcomes (completed, failed) by queue',
labelNames: ['queue', 'outcome'],
}),
{
provide: QUEUE_METRICS_COLLECTOR_QUEUES,
inject: queueTokens,
useFactory: (...queues: Queue[]): QueueLike[] => queues,
},
{ provide: QUEUE_METRICS_COLLECTOR_OPTIONS, useValue: options },
QueueMetricsCollector,
],
exports: [QueueMetricsCollector],
};
}
}