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, GOODGO_PAYMENTS_PROCESSED_TOTAL, GOODGO_ACTIVE_SUBSCRIPTIONS, GOODGO_SEARCH_QUERIES_TOTAL, HTTP_REQUESTS_TOTAL, DB_QUERY_DURATION, DB_POOL_ACTIVE_CONNECTIONS, SEARCH_QUERY_DURATION, GOODGO_WS_CONNECTED_CLIENTS, GOODGO_WS_MESSAGES_TOTAL, READ_MODEL_PROJECTOR_LAG_SECONDS, READ_MODEL_REFRESH_DURATION_SECONDS, READ_MODEL_RECONCILIATION_DRIFT_TOTAL, WEB_VITALS_LCP, WEB_VITALS_FCP, WEB_VITALS_CLS, WEB_VITALS_TTFB, WEB_VITALS_INP, WEB_VITALS_TOTAL, } from './metrics.constants'; import { WebVitalsController } from './presentation/controllers/web-vitals.controller'; import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics.interceptor'; @Module({ imports: [], providers: [ // ── HTTP Metrics ── makeHistogramProvider({ name: GOODGO_API_REQUEST_DURATION, help: 'Duration of HTTP requests in seconds', labelNames: ['method', 'route', 'status_code'], buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], }), makeCounterProvider({ name: HTTP_REQUESTS_TOTAL, help: 'Total number of HTTP requests', labelNames: ['method', 'route', 'status_code'], }), // ── Database Metrics ── makeHistogramProvider({ name: DB_QUERY_DURATION, help: 'Duration of database queries in seconds', labelNames: ['operation', 'model'], buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5], }), makeGaugeProvider({ name: DB_POOL_ACTIVE_CONNECTIONS, help: 'Number of active database connections', }), // ── Search Metrics ── makeHistogramProvider({ name: SEARCH_QUERY_DURATION, help: 'Duration of search queries in seconds', labelNames: ['collection', 'type'], buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1], }), makeCounterProvider({ name: GOODGO_SEARCH_QUERIES_TOTAL, help: 'Total number of search queries', labelNames: ['collection', 'type'], }), // ── Business Metrics ── makeCounterProvider({ name: GOODGO_LISTINGS_CREATED_TOTAL, help: 'Total number of listings created', labelNames: ['category'], }), makeCounterProvider({ name: GOODGO_PAYMENTS_PROCESSED_TOTAL, help: 'Total number of payments processed', labelNames: ['status', 'method'], }), makeGaugeProvider({ name: GOODGO_ACTIVE_SUBSCRIPTIONS, help: 'Number of active subscriptions', labelNames: ['plan'], }), // ── WebSocket Metrics ── makeGaugeProvider({ name: GOODGO_WS_CONNECTED_CLIENTS, help: 'Number of active WebSocket clients', labelNames: ['namespace'], }), makeCounterProvider({ name: GOODGO_WS_MESSAGES_TOTAL, help: 'Total number of WebSocket messages emitted/received', labelNames: ['namespace', 'event', 'direction'], }), // ── Read-Model Metrics (RFC-003) ── makeGaugeProvider({ name: READ_MODEL_PROJECTOR_LAG_SECONDS, help: 'Projector replication lag in seconds, by read model', labelNames: ['read_model'], }), makeHistogramProvider({ name: READ_MODEL_REFRESH_DURATION_SECONDS, help: 'Materialized-view refresh duration in seconds', labelNames: ['read_model'], buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60], }), makeCounterProvider({ name: READ_MODEL_RECONCILIATION_DRIFT_TOTAL, help: 'Drift events detected during read-model reconciliation', labelNames: ['read_model', 'severity'], }), // ── Services & Interceptors ── MetricsService, HttpMetricsInterceptor, // ── Web Vitals / RUM Metrics ── makeHistogramProvider({ name: WEB_VITALS_LCP, help: 'Largest Contentful Paint in seconds', labelNames: ['rating', 'page'], buckets: [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 8, 10], }), makeHistogramProvider({ name: WEB_VITALS_FCP, help: 'First Contentful Paint in seconds', labelNames: ['rating', 'page'], buckets: [0.1, 0.5, 1, 1.5, 1.8, 2.5, 3, 4, 5, 8], }), makeHistogramProvider({ name: WEB_VITALS_CLS, help: 'Cumulative Layout Shift score (unitless)', labelNames: ['rating', 'page'], buckets: [0.01, 0.025, 0.05, 0.1, 0.15, 0.2, 0.25, 0.5, 1], }), makeHistogramProvider({ name: WEB_VITALS_TTFB, help: 'Time to First Byte in seconds', labelNames: ['rating', 'page'], buckets: [0.1, 0.2, 0.4, 0.6, 0.8, 1, 1.5, 2, 3, 5], }), makeHistogramProvider({ name: WEB_VITALS_INP, help: 'Interaction to Next Paint in seconds', labelNames: ['rating', 'page'], buckets: [0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 0.8, 1], }), makeCounterProvider({ name: WEB_VITALS_TOTAL, help: 'Total web vital events received', labelNames: ['name', 'rating'], }), ], controllers: [WebVitalsController], exports: [MetricsService, HttpMetricsInterceptor], }) 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], }; } }