Когда сервис начинает вести себя странно в продакшене — медленно отвечает, падает под нагрузкой, теряет запросы — журналы помогают найти конкретную ошибку, но не дают общей картины. Метрики решают другую задачу: они показывают, как сервис ведёт себя во времени. Сколько запросов в секунду? Какой процент завершился ошибкой? Насколько загружена память?
В Node.js-стеке для этого используют prom-client — официальный клиент Prometheus. В NestJS он подключается через @willsoto/nestjs-prometheus.
Подключение
Установить два пакета:
npm install prom-client @willsoto/nestjs-prometheus
PrometheusModule регистрируется в отдельном management-модуле на порту :9090 — не в AppModule. Это важно: метрики не должны быть доступны через публичный порт сервиса.
collectDefaultMetrics вызывается один раз в main.ts до NestFactory.create:
import { register, collectDefaultMetrics } from 'prom-client';
collectDefaultMetrics({ register });
// дальше — NestFactory.create(...)
Prometheus scraper обращается к /metrics на management-порту каждые 15 секунд и забирает накопленные данные в свою базу.
Стандартные labels — один раз для всех метрик
Метрики из разных окружений попадают в одну базу Prometheus. Без labels невозможно отфильтровать production от staging или сравнить поведение двух версий одного сервиса.
Вместо того чтобы добавлять service, env, version к каждой метрике вручную, они выставляются один раз через setDefaultLabels:
import { register } from 'prom-client';
register.setDefaultLabels({
service: process.env.SERVICE_NAME ?? 'order-service',
env: process.env.NODE_ENV ?? 'dev',
version: process.env.APP_VERSION ?? 'unknown',
});
После этого Grafana позволяет писать запросы вроде service="order-service", env="prod" и сравнивать деплои через version. Повторно указывать эти три label в каждой метрике не нужно — setDefaultLabels применяется глобально.
RED-метрики для HTTP-запросов
RED — три вопроса о здоровье request-driven сервиса:
- Rate — сколько запросов в секунду?
- Errors — какой процент завершился ошибкой?
- Duration — как долго ждут клиенты?
В Spring Boot это работает из коробки через Actuator. В NestJS автоматического сбора нет, поэтому его добавляют через interceptor с одним Histogram:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Histogram } from 'prom-client';
import { Observable, tap } from 'rxjs';
import { Request, Response } from 'express';
const httpRequestDuration = new Histogram({
name: 'http_server_requests_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'route', 'status_class'] as const,
buckets: [0.05, 0.1, 0.5, 1, 5],
});
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = ctx.switchToHttp().getRequest<Request>();
const res = ctx.switchToHttp().getResponse<Response>();
const end = httpRequestDuration.startTimer({ method: req.method });
return next.handle().pipe(
tap({
next: () => end({ route: this.route(req), status_class: this.statusClass(res.statusCode) }),
error: () => end({ route: this.route(req), status_class: 'server_error' }),
}),
);
}
private route(req: Request): string {
return (req.route?.path as string | undefined) ?? req.path;
}
private statusClass(status: number): string {
if (status < 400) return 'success';
if (status < 500) return 'client_error';
return 'server_error';
}
}
Label route должен содержать шаблон роута — /orders/:id, а не реальный URL /orders/42f3d.... Реальные URL уникальны для каждого запроса: если их записывать в label, Prometheus создаст отдельный time series для каждого URL, и база моментально разрастётся до неуправляемых размеров.
PromQL-запросы к этой метрике:
# Rate
sum(rate(http_server_requests_seconds_count[5m])) by (route, method)
# Errors
sum(rate(http_server_requests_seconds_count{status_class="server_error"}[5m])) by (route)
# Duration p95
histogram_quantile(0.95, sum by (le, route) (rate(http_server_requests_seconds_bucket[5m])))
USE-метрики для ресурсов — через collectDefaultMetrics
USE — три вопроса о здоровье ресурсов:
- Utilization — насколько занят ресурс?
- Saturation — есть ли очередь ожидания?
- Errors — есть ли ошибки на уровне ресурса?
collectDefaultMetrics() из prom-client собирает метрики Node.js runtime без дополнительной настройки:
| Метрика | Что показывает |
|---|---|
nodejs_eventloop_lag_seconds | загрузка event loop — главный сигнал перегрузки |
nodejs_heap_size_used_bytes | занятая память heap |
nodejs_heap_size_total_bytes | максимальный heap в V8 |
nodejs_gc_duration_seconds | время пауз сборщика мусора |
nodejs_active_handles_total | активные handles: сокеты, таймеры |
Насыщение пула подключений к PostgreSQL pg не экспортируется автоматически, поэтому его добавляют вручную через Gauge:
import { Gauge } from 'prom-client';
import { Pool } from 'pg';
function registerPgPoolMetrics(pool: Pool, poolName: string): void {
new Gauge({
name: 'pg_pool_total_connections',
help: 'Total connections in pg pool',
labelNames: ['pool'] as const,
collect() { this.set({ pool: poolName }, pool.totalCount); },
});
new Gauge({
name: 'pg_pool_idle_connections',
help: 'Idle connections in pg pool',
labelNames: ['pool'] as const,
collect() { this.set({ pool: poolName }, pool.idleCount); },
});
new Gauge({
name: 'pg_pool_waiting_count',
help: 'Requests waiting for a connection',
labelNames: ['pool'] as const,
collect() { this.set({ pool: poolName }, pool.waitingCount); },
});
}
Когда waitingCount > 0 — запросы стоят в очереди на соединение. Это saturation-сигнал, на него стоит поставить алерт:
- alert: PgPoolSaturated
expr: pg_pool_waiting_count > 0
for: 2m
Бизнес-метрики
Технические метрики показывают здоровье инфраструктуры. Бизнес-метрики показывают, что происходит внутри домена: сколько заказов создано, как долго обрабатываются платежи, какой канал популярнее.
В NestJS бизнес-метрики оформляют как Injectable-сервис:
import { Injectable } from '@nestjs/common';
import { Counter, Histogram } from 'prom-client';
@Injectable()
export class OrderMetrics {
private readonly orderCreatedTotal = new Counter({
name: 'order_created_total',
help: 'Orders created',
labelNames: ['channel'] as const,
});
private readonly paymentDuration = new Histogram({
name: 'payment_processing_seconds',
help: 'Payment processing latency',
labelNames: ['payment_method'] as const,
buckets: [0.1, 0.5, 1, 5],
});
private readonly orderAmountRubles = new Histogram({
name: 'order_amount_rubles',
help: 'Order amount distribution',
buckets: [100, 500, 1_000, 5_000, 10_000, 50_000],
});
orderCreated(channel: 'web' | 'mobile' | 'api'): void {
this.orderCreatedTotal.inc({ channel });
}
recordPayment(durationSeconds: number, method: 'CARD' | 'SBP' | 'CASH'): void {
this.paymentDuration.observe({ payment_method: method }, durationSeconds);
}
recordOrderAmount(amountRubles: number): void {
this.orderAmountRubles.observe(amountRubles);
}
}
Вызов из обработчика команды:
@Injectable()
export class CreateOrderHandler {
constructor(
private readonly orders: OrderRepository,
private readonly metrics: OrderMetrics,
) {}
async handle(cmd: CreateOrderCommand): Promise<Order> {
const order = await this.orders.save(Order.create(cmd));
this.metrics.orderCreated(cmd.channel);
this.metrics.recordOrderAmount(order.totalAmount);
return order;
}
}
Три типа метрик на выбор:
Counter— только растёт. Для событий:order_created_total,payment_failed_total.Gauge— текущее значение, может уменьшаться. Для состояния:active_orders_count,product_stock_units.Histogram— распределение значений по диапазонам. Для времени и сумм:payment_processing_seconds,order_amount_rubles.
DistributionSummary в prom-client нет — Histogram покрывает обе задачи.
Имена метрик
Prometheus ожидает имена в snake_case с единицей измерения в конце:
order_created_total — Counter с суффиксом _total
payment_processing_seconds — Histogram с _seconds
product_stock_units — Gauge, единица — units
order_amount_rubles — Histogram с единицей валюты
Частые ошибки:
orderCreatedCount — camelCase, нет _total
paymentTime — без единицы
prom-client не проверяет имена на соответствие конвенции — нарушение обнаруживается только в Grafana, когда метрики от разных сервисов не совпадают.
Cardinality в labels: почему важна низкая
Каждая уникальная комбинация значений labels создаёт отдельный time series в базе Prometheus. Если в label попадают уникальные идентификаторы — user_id, order_id, request_id — количество time series растёт вместе с трафиком. При большой нагрузке это приводит к нехватке памяти и падению Prometheus.
Правило: значение label — это категория, не идентификатор.
// Хорошо — несколько фиксированных значений
this.orderCreatedTotal.inc({ channel: 'web' }); // web / mobile / api
this.paymentDuration.observe({ payment_method: 'SBP' }, duration); // CARD / SBP / CASH
// Плохо — уникальные значения на каждый запрос
new Counter({
name: 'order_created_total',
labelNames: ['order_id', 'customer_id'] as const, // миллионы значений → нехватка памяти
});
Если нужно отследить конкретный запрос или пользователя — это задача для трассировки, не метрик. Один span хранит произвольные атрибуты (order.id, customer.id) без роста хранилища.
Коротко
prom-client+@willsoto/nestjs-prometheus— стандартный стек метрик в NestJS.register.setDefaultLabels({ service, env, version })— один раз при старте, не в каждой метрике.- RED для HTTP — один
Histogramв interceptor; labelroute= шаблон (/orders/:id), не реальный URL. collectDefaultMetrics()даёт event loop lag, heap, GC без дополнительного кода.- Метрики пула pg —
GaugeсtotalCount,idleCount,waitingCount;waitingCount > 0— алерт. - Бизнес-метрики —
Counter/Gauge/Histogramв Injectable-сервисе, вызов из обработчиков команд. - Имена:
snake_case, единица в конце (_seconds,_total,_rubles). - Labels — только категории с малым числом значений; уникальные идентификаторы → в трассировку.
/metrics— только на management-порту:9090, не через публичный порт сервиса.
Что почитать дальше
- Tracing — high-cardinality observability через spans; OpenTelemetry автоинструментация.
- Logging — nestjs-pino, structured JSON, DI-логгер.
- SLO и алерты — multi-window burn rate, error budget.
- Health checks — liveness vs readiness.
- Конфигурация — management-порт, изоляция
/metrics.