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:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/metrics/metrics.constants.ts
Normal file
12
apps/api/src/modules/metrics/metrics.constants.ts
Normal 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';
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user