Опирается на правила:
R-OBS-MTR-1…R-OBS-MTR-7иR-OBS-MTR-X1…R-OBS-MTR-X4из Observability Style Guide → раздел 2. Metrics.
Важно знать
- prom-client через
@willsoto/nestjs-prometheus; endpoint/metricsдля scraping.- Стандартные labels
service/env/versionчерезregister.setDefaultLabels(...)один раз — не дублировать в каждой метрике.- RED для HTTP через
Histogramв interceptor с labelroute— шаблон (/orders/:id), не сырой URL.- USE для resources через
collectDefaultMetrics(): event loop lag, heap, GC, плюсGaugeна пулы pg.- Custom business metrics —
Counter,Histogram,Gauge; имена snake_case с единицей измерения.- Низкая cardinality в labels:
payment_method(CARD/SBP/CRYPTO) — ОК;user_id/order_id→ OOM./metricsне в публичной сети — только internal scraper через network policy / отдельный management-порт.
Метрики дают понимание поведения сервиса под нагрузкой в реальном времени. RED (Rate, Errors, Duration) — для request-driven потоков, USE (Utilization, Saturation, Errors) — для ресурсов, business metrics — для домена. В Node-стеке точка входа — prom-client.
Подключение
R-OBS-MTR-1: зависимости:
npm install prom-client @willsoto/nestjs-prometheus
Регистрация в AppModule:
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
@Module({
imports: [
PrometheusModule.register({
path: '/metrics',
defaultMetrics: { enabled: true },
}),
],
})
export class AppModule {}
После старта GET /metrics отдаёт текстовый формат Prometheus. Prometheus scraper тянет каждые 15 секунд, складывает в TSDB.
Стандартные labels
R-OBS-MTR-2: три label на каждой метрике, выставляются один раз при инициализации:
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" и сравнивать поведение разных deploy через version. Без этих label невозможно разграничить production от staging на одной dashboard.
Не дублировать service/env/version в конструкторах метрик — setDefaultLabels применяется глобально ко всем time series в register.
RED для HTTP — через interceptor
R-OBS-MTR-3: автоматического RED в NestJS нет (в отличие от Spring Boot Actuator), поэтому подключается через 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: '5xx' }),
}),
);
}
private route(req: Request): string {
return (req.route?.path as string | undefined) ?? req.path;
}
private statusClass(status: number): string {
if (status < 400) return '2xx';
if (status < 500) return '4xx';
return '5xx';
}
}
Label route — шаблон роута (/orders/:id), не сырой URL (/orders/42f3d...). Сырой URL мгновенно взрывает cardinality.
PromQL-запросы к этой метрике:
# Rate
sum(rate(http_server_requests_seconds_count[5m])) by (route, method)
# Errors
sum(rate(http_server_requests_seconds_count{status_class="5xx"}[5m])) by (route)
# Duration p95
histogram_quantile(0.95, sum by (le, route) (rate(http_server_requests_seconds_bucket[5m])))
USE для resources — автоматически
R-OBS-MTR-4: collectDefaultMetrics() из prom-client собирает Node.js runtime без настройки.
| Метрика | Что показывает |
|---|---|
nodejs_eventloop_lag_seconds | saturation event loop — главный сигнал перегрузки |
nodejs_heap_size_used_bytes | utilization heap |
nodejs_heap_size_total_bytes | потолок heap в V8 |
nodejs_gc_duration_seconds | время GC-пауз |
nodejs_active_handles_total | активные handles (сокеты, таймеры) |
collectDefaultMetrics включён через PrometheusModule.register({ defaultMetrics: { enabled: true } }) — отдельно вызывать не нужно.
Дополнительно: насыщение пула подключений pg явно не экспортируется 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
Custom business metrics
R-OBS-MTR-5: бизнес-события инструментируются через классы с DI.
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);
}
}
Применение в handler:
@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,customer_registered_total.Gauge— текущее значение.active_orders_count,product_stock_units.Histogram— распределение с bucket-ами.payment_processing_seconds,order_amount_rubles.
DistributionSummary в prom-client нет — Histogram закрывает обе задачи.
Имена метрик — snake_case с единицей
R-OBS-MTR-6: соглашение 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
R-OBS-MTR-7: label value — это категория, не уникальный идентификатор.
// ХОРОШО — низкая cardinality
this.orderCreatedTotal.inc({ channel: 'web' }); // 3–5 значений
this.paymentDuration.observe({ payment_method: 'SBP' }, duration); // 3–5 значений
// ПЛОХО — high cardinality, взрыв time series
new Counter({
name: 'order_created_total',
labelNames: ['order_id', 'customer_id'] as const, // миллионы значений → OOM
});
Prometheus хранит отдельный time series для каждой уникальной комбинации label values. Миллион order_id × миллион customer_id = триллион потенциальных time series. Scraper упадёт с OOM задолго до этого.
Для high-cardinality observability — distributed tracing (Tempo/Jaeger), не метрики. Один span на запрос хранит произвольные атрибуты (order.id, customer.id) без взрыва хранилища. См. Tracing.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
user_id / order_id / request_id как label value | R-OBS-MTR-X1 | бизнес-категории (channel, payment_method) |
service_name=foo вместо service=foo | R-OBS-MTR-X2 | register.setDefaultLabels({ service }) глобально |
Метрики вне экспортируемого register | R-OBS-MTR-X3 | все метрики через общий register / DI |
/metrics публично в сети | R-OBS-MTR-X4 | NetworkPolicy / отдельный management-порт :9090 |
Дублирование service/env/version в каждой метрике | R-OBS-MTR-2 | setDefaultLabels один раз при инициализации |
route = сырой URL (/orders/42f3d...) | R-OBS-MTR-3 | шаблон роута (/orders/:id) из req.route.path |
paymentTime без единицы | R-OBS-MTR-6 | payment_processing_seconds |
orderCreatedCount (camelCase, нет _total) | R-OBS-MTR-6 | order_created_total |
Куда дальше
- Observability → раздел 2. Metrics — нормативные формулировки правил.
- Logging — nestjs-pino, structured JSON, DI-логгер.
- Tracing — high-cardinality observability через spans; OTel автоинструментация.
- SLO и алерты — multi-window burn rate, error budget по метрикам.
- Конфигурация — management-порт, изоляция
/metrics, exposed endpoints. - Health checks — liveness vs readiness; технический статус vs бизнес-метрики.
- Context propagation — AsyncLocalStorage, requestId, userId в guard.