← назад к разделу

Когда сервис начинает вести себя странно в продакшене — медленно отвечает, падает под нагрузкой, теряет запросы — журналы помогают найти конкретную ошибку, но не дают общей картины. Метрики решают другую задачу: они показывают, как сервис ведёт себя во времени. Сколько запросов в секунду? Какой процент завершился ошибкой? Насколько загружена память?

В 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; label route = шаблон (/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.