feat(metrics): add MetricsService, HttpMetricsInterceptor, and metric constants

- Extract metric names into constants with goodgo_ prefix for business metrics
- Add MetricsService for type-safe metric recording
- Add HttpMetricsInterceptor for automatic request duration/count tracking
- Register interceptor globally via APP_INTERCEPTOR
- Include linter auto-fixes for test files

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 22:38:55 +07:00
parent 238c27c47a
commit 944d6262e7
7 changed files with 175 additions and 14 deletions

View File

@@ -1,5 +1,5 @@
import { type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { CqrsModule } from '@nestjs/cqrs';
import { ThrottlerModule } from '@nestjs/throttler';
import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
@@ -8,7 +8,7 @@ import { AnalyticsModule } from '@modules/analytics';
import { AuthModule } from '@modules/auth';
import { ListingsModule } from '@modules/listings';
import { McpIntegrationModule } from '@modules/mcp';
import { MetricsModule } from '@modules/metrics';
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
import { NotificationsModule } from '@modules/notifications';
import { PaymentsModule } from '@modules/payments';
import { SearchModule } from '@modules/search';
@@ -68,6 +68,10 @@ import { AppController } from './app.controller';
provide: APP_GUARD,
useClass: ThrottlerBehindProxyGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: HttpMetricsInterceptor,
},
],
})
export class AppModule implements NestModule {

View File

@@ -1 +1,14 @@
export { MetricsModule } from './metrics.module';
export { MetricsService } from './infrastructure/metrics.service';
export { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics.interceptor';
export {
GOODGO_LISTINGS_CREATED_TOTAL,
GOODGO_PAYMENTS_PROCESSED_TOTAL,
GOODGO_ACTIVE_SUBSCRIPTIONS,
GOODGO_SEARCH_QUERIES_TOTAL,
GOODGO_API_REQUEST_DURATION,
HTTP_REQUESTS_TOTAL,
DB_QUERY_DURATION,
DB_POOL_ACTIVE_CONNECTIONS,
SEARCH_QUERY_DURATION,
} from './metrics.constants';

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { type Counter, type Gauge, type Histogram } from 'prom-client';
import {
GOODGO_LISTINGS_CREATED_TOTAL,
GOODGO_PAYMENTS_PROCESSED_TOTAL,
GOODGO_ACTIVE_SUBSCRIPTIONS,
GOODGO_SEARCH_QUERIES_TOTAL,
GOODGO_API_REQUEST_DURATION,
HTTP_REQUESTS_TOTAL,
} from '../metrics.constants';
@Injectable()
export class MetricsService {
constructor(
@InjectMetric(GOODGO_LISTINGS_CREATED_TOTAL)
private readonly listingsCreatedCounter: Counter,
@InjectMetric(GOODGO_PAYMENTS_PROCESSED_TOTAL)
private readonly paymentsProcessedCounter: Counter,
@InjectMetric(GOODGO_ACTIVE_SUBSCRIPTIONS)
private readonly activeSubscriptionsGauge: Gauge,
@InjectMetric(GOODGO_SEARCH_QUERIES_TOTAL)
private readonly searchQueriesCounter: Counter,
@InjectMetric(GOODGO_API_REQUEST_DURATION)
private readonly requestDurationHistogram: Histogram,
@InjectMetric(HTTP_REQUESTS_TOTAL)
private readonly httpRequestsCounter: Counter,
) {}
/** Record a new listing creation. */
recordListingCreated(category: string): void {
this.listingsCreatedCounter.inc({ category });
}
/** Record a payment processing event. */
recordPaymentProcessed(status: string, method: string): void {
this.paymentsProcessedCounter.inc({ status, method });
}
/** Set the current number of active subscriptions for a plan tier. */
setActiveSubscriptions(plan: string, count: number): void {
this.activeSubscriptionsGauge.set({ plan }, count);
}
/** Record a search query. */
recordSearchQuery(collection: string, type: string): void {
this.searchQueriesCounter.inc({ collection, type });
}
/** Record HTTP request duration and count (used by interceptor). */
recordHttpRequest(
method: string,
route: string,
statusCode: number,
durationSeconds: number,
): void {
const labels = {
method,
route,
status_code: String(statusCode),
};
this.requestDurationHistogram.observe(labels, durationSeconds);
this.httpRequestsCounter.inc(labels);
}
}

View File

@@ -0,0 +1,12 @@
// ── Business Metrics (goodgo_ prefix) ──
export const GOODGO_LISTINGS_CREATED_TOTAL = 'goodgo_listings_created_total';
export const GOODGO_PAYMENTS_PROCESSED_TOTAL = 'goodgo_payments_processed_total';
export const GOODGO_ACTIVE_SUBSCRIPTIONS = 'goodgo_active_subscriptions';
export const GOODGO_SEARCH_QUERIES_TOTAL = 'goodgo_search_queries_total';
export const GOODGO_API_REQUEST_DURATION = 'goodgo_api_request_duration_seconds';
// ── Infrastructure Metrics ──
export const HTTP_REQUESTS_TOTAL = 'http_requests_total';
export const DB_QUERY_DURATION = 'db_query_duration_seconds';
export const DB_POOL_ACTIVE_CONNECTIONS = 'db_pool_active_connections';
export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds';

View File

@@ -5,6 +5,19 @@ import {
makeHistogramProvider,
makeGaugeProvider,
} from '@willsoto/nestjs-prometheus';
import { MetricsService } from './infrastructure/metrics.service';
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,
} from './metrics.constants';
import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics.interceptor';
@Module({
imports: [
@@ -16,56 +29,63 @@ import {
providers: [
// ── HTTP Metrics ──
makeHistogramProvider({
name: 'http_request_duration_seconds',
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',
name: HTTP_REQUESTS_TOTAL,
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code'],
}),
// ── Database Metrics ──
makeHistogramProvider({
name: 'db_query_duration_seconds',
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',
name: DB_POOL_ACTIVE_CONNECTIONS,
help: 'Number of active database connections',
}),
// ── Search Metrics ──
makeHistogramProvider({
name: 'search_query_duration_seconds',
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],
}),
// ── Cache Metrics ── (registered in SharedModule alongside CacheService)
makeCounterProvider({
name: GOODGO_SEARCH_QUERIES_TOTAL,
help: 'Total number of search queries',
labelNames: ['collection', 'type'],
}),
// ── Business Metrics ──
makeCounterProvider({
name: 'listings_created_total',
name: GOODGO_LISTINGS_CREATED_TOTAL,
help: 'Total number of listings created',
labelNames: ['category'],
}),
makeCounterProvider({
name: 'payments_processed_total',
name: GOODGO_PAYMENTS_PROCESSED_TOTAL,
help: 'Total number of payments processed',
labelNames: ['status', 'method'],
}),
makeGaugeProvider({
name: 'active_subscriptions',
name: GOODGO_ACTIVE_SUBSCRIPTIONS,
help: 'Number of active subscriptions',
labelNames: ['plan'],
}),
// ── Services & Interceptors ──
MetricsService,
HttpMetricsInterceptor,
],
exports: [PrometheusModule],
exports: [PrometheusModule, MetricsService, HttpMetricsInterceptor],
})
export class MetricsModule {}

View File

@@ -0,0 +1,47 @@
import {
Injectable,
type CallHandler,
type ExecutionContext,
type NestInterceptor,
} from '@nestjs/common';
import { type Request, type Response } from 'express';
import { type Observable, tap } from 'rxjs';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { MetricsService } from '../../infrastructure/metrics.service';
@Injectable()
export class HttpMetricsInterceptor implements NestInterceptor {
constructor(private readonly metricsService: MetricsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<Request>();
const startTime = process.hrtime.bigint();
return next.handle().pipe(
tap({
next: () => {
this.recordMetrics(request, httpContext.getResponse<Response>(), startTime);
},
error: () => {
this.recordMetrics(request, httpContext.getResponse<Response>(), startTime);
},
}),
);
}
private recordMetrics(
request: Request,
response: Response,
startTime: bigint,
): void {
const durationNs = Number(process.hrtime.bigint() - startTime);
const durationSeconds = durationNs / 1e9;
const route = (request.route as { path?: string })?.path ?? request.path;
const method = request.method;
const statusCode = response.statusCode;
this.metricsService.recordHttpRequest(method, route, statusCode, durationSeconds);
}
}

File diff suppressed because one or more lines are too long