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 { 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 { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
|
import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
|
||||||
@@ -8,7 +8,7 @@ import { AnalyticsModule } from '@modules/analytics';
|
|||||||
import { AuthModule } from '@modules/auth';
|
import { AuthModule } from '@modules/auth';
|
||||||
import { ListingsModule } from '@modules/listings';
|
import { ListingsModule } from '@modules/listings';
|
||||||
import { McpIntegrationModule } from '@modules/mcp';
|
import { McpIntegrationModule } from '@modules/mcp';
|
||||||
import { MetricsModule } from '@modules/metrics';
|
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||||
import { NotificationsModule } from '@modules/notifications';
|
import { NotificationsModule } from '@modules/notifications';
|
||||||
import { PaymentsModule } from '@modules/payments';
|
import { PaymentsModule } from '@modules/payments';
|
||||||
import { SearchModule } from '@modules/search';
|
import { SearchModule } from '@modules/search';
|
||||||
@@ -68,6 +68,10 @@ import { AppController } from './app.controller';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: ThrottlerBehindProxyGuard,
|
useClass: ThrottlerBehindProxyGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: HttpMetricsInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
@@ -1 +1,14 @@
|
|||||||
export { MetricsModule } from './metrics.module';
|
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,
|
makeHistogramProvider,
|
||||||
makeGaugeProvider,
|
makeGaugeProvider,
|
||||||
} from '@willsoto/nestjs-prometheus';
|
} 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -16,56 +29,63 @@ import {
|
|||||||
providers: [
|
providers: [
|
||||||
// ── HTTP Metrics ──
|
// ── HTTP Metrics ──
|
||||||
makeHistogramProvider({
|
makeHistogramProvider({
|
||||||
name: 'http_request_duration_seconds',
|
name: GOODGO_API_REQUEST_DURATION,
|
||||||
help: 'Duration of HTTP requests in seconds',
|
help: 'Duration of HTTP requests in seconds',
|
||||||
labelNames: ['method', 'route', 'status_code'],
|
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],
|
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
||||||
}),
|
}),
|
||||||
makeCounterProvider({
|
makeCounterProvider({
|
||||||
name: 'http_requests_total',
|
name: HTTP_REQUESTS_TOTAL,
|
||||||
help: 'Total number of HTTP requests',
|
help: 'Total number of HTTP requests',
|
||||||
labelNames: ['method', 'route', 'status_code'],
|
labelNames: ['method', 'route', 'status_code'],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ── Database Metrics ──
|
// ── Database Metrics ──
|
||||||
makeHistogramProvider({
|
makeHistogramProvider({
|
||||||
name: 'db_query_duration_seconds',
|
name: DB_QUERY_DURATION,
|
||||||
help: 'Duration of database queries in seconds',
|
help: 'Duration of database queries in seconds',
|
||||||
labelNames: ['operation', 'model'],
|
labelNames: ['operation', 'model'],
|
||||||
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
|
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
|
||||||
}),
|
}),
|
||||||
makeGaugeProvider({
|
makeGaugeProvider({
|
||||||
name: 'db_pool_active_connections',
|
name: DB_POOL_ACTIVE_CONNECTIONS,
|
||||||
help: 'Number of active database connections',
|
help: 'Number of active database connections',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ── Search Metrics ──
|
// ── Search Metrics ──
|
||||||
makeHistogramProvider({
|
makeHistogramProvider({
|
||||||
name: 'search_query_duration_seconds',
|
name: SEARCH_QUERY_DURATION,
|
||||||
help: 'Duration of search queries in seconds',
|
help: 'Duration of search queries in seconds',
|
||||||
labelNames: ['collection', 'type'],
|
labelNames: ['collection', 'type'],
|
||||||
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1],
|
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1],
|
||||||
}),
|
}),
|
||||||
|
makeCounterProvider({
|
||||||
// ── Cache Metrics ── (registered in SharedModule alongside CacheService)
|
name: GOODGO_SEARCH_QUERIES_TOTAL,
|
||||||
|
help: 'Total number of search queries',
|
||||||
|
labelNames: ['collection', 'type'],
|
||||||
|
}),
|
||||||
|
|
||||||
// ── Business Metrics ──
|
// ── Business Metrics ──
|
||||||
makeCounterProvider({
|
makeCounterProvider({
|
||||||
name: 'listings_created_total',
|
name: GOODGO_LISTINGS_CREATED_TOTAL,
|
||||||
help: 'Total number of listings created',
|
help: 'Total number of listings created',
|
||||||
labelNames: ['category'],
|
labelNames: ['category'],
|
||||||
}),
|
}),
|
||||||
makeCounterProvider({
|
makeCounterProvider({
|
||||||
name: 'payments_processed_total',
|
name: GOODGO_PAYMENTS_PROCESSED_TOTAL,
|
||||||
help: 'Total number of payments processed',
|
help: 'Total number of payments processed',
|
||||||
labelNames: ['status', 'method'],
|
labelNames: ['status', 'method'],
|
||||||
}),
|
}),
|
||||||
makeGaugeProvider({
|
makeGaugeProvider({
|
||||||
name: 'active_subscriptions',
|
name: GOODGO_ACTIVE_SUBSCRIPTIONS,
|
||||||
help: 'Number of active subscriptions',
|
help: 'Number of active subscriptions',
|
||||||
labelNames: ['plan'],
|
labelNames: ['plan'],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ── Services & Interceptors ──
|
||||||
|
MetricsService,
|
||||||
|
HttpMetricsInterceptor,
|
||||||
],
|
],
|
||||||
exports: [PrometheusModule],
|
exports: [PrometheusModule, MetricsService, HttpMetricsInterceptor],
|
||||||
})
|
})
|
||||||
export class MetricsModule {}
|
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