Some checks failed
Security Scanning / Trivy Scan — API Image (push) Failing after 53s
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has started running
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 58s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build Web Image (push) Failing after 7s
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
API bootstrap fixes (DI wiring):
- analytics.module: add forwardRef(() => AdminModule) to import
AI_CONFIG_PROVIDER for GetListingAiAdviceHandler + GetProjectAiAdviceHandler
- listings.module: add PaymentsModule to imports so PAYMENT_INITIATOR is
resolvable by FeatureListingHandler
- metrics.module: register 3 missing Prometheus providers that MetricsService
injects (READ_MODEL_PROJECTOR_LAG_SECONDS / REFRESH_DURATION /
RECONCILIATION_DRIFT_TOTAL) — caused boot failure previously
- get-listing-ai-advice.handler: switch LISTING_REPOSITORY import from barrel
@modules/listings to direct internal path to break circular reference that
made the symbol evaluate as undefined at decorator time
- shared.module: comment out broken EVENT_BUS / OutboxService / OutboxRelay
providers (depend on @goodgo/contracts-events workspace pkg not yet wired)
CSRF middleware:
- Rewrite exclude logic as inline path-check inside the middleware itself.
Nest 11 + path-to-regexp v8 changed how MiddlewareConsumer.exclude() matches
against forRoutes('*') — the previous string patterns silently stopped
matching, causing every POST to /auth/login to return 403 CSRF Forbidden.
Inlined exempt list strips the /api/v1 prefix and checks against a Set.
Admin revenue stats:
- admin-stats.queries: use Prisma.sql template fragments for DATE_TRUNC unit
('day'|'month'). Passing the unit as a bind parameter caused Postgres error
42803 (column must appear in GROUP BY) because the planner treats $1 as an
opaque scalar and cannot prove SELECT and GROUP BY expressions are equal.
Admin audit-log page:
- SeverityPill: add ?? 'info' fallback — backend AuditLogEntry does not
include a `severity` field, so SEVERITY_CONFIG[undefined] was undefined
and .dir threw TypeError, crashing the whole audit-log page.
DB seed fixes:
- seed.ts: replace Vietnamese enum literals ('Sổ hồng', 'Sổ đỏ') with
correct enum keys ('SO_HONG', 'SO_DO') for the LegalStatus column
- seed-industrial-parks.ts: gate the standalone main() behind
require.main === module so importing the file from seed.ts doesn't
immediately close the pg.Pool used by the orchestrator
- scripts/seed-industrial-listings.ts: restore from tmp/ stash; was missing
from scripts/ causing seed.ts import to fail at startup
- migration 20260429010000_add_property_certificate_verified: Property table
was missing the certificateVerified column required by seed + Prisma schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
7.3 KiB
TypeScript
224 lines
7.3 KiB
TypeScript
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],
|
|
};
|
|
}
|
|
}
|