Опирается на правила: R-OBS-MTR-1R-OBS-MTR-7 и R-OBS-MTR-X1R-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 с label route — шаблон (/orders/:id), не сырой URL.
  • USE для resources через collectDefaultMetrics(): event loop lag, heap, GC, плюс Gauge на пулы pg.
  • Custom business metricsCounter, 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_secondssaturation event loop — главный сигнал перегрузки
nodejs_heap_size_used_bytesutilization 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 valueR-OBS-MTR-X1бизнес-категории (channel, payment_method)
service_name=foo вместо service=fooR-OBS-MTR-X2register.setDefaultLabels({ service }) глобально
Метрики вне экспортируемого registerR-OBS-MTR-X3все метрики через общий register / DI
/metrics публично в сетиR-OBS-MTR-X4NetworkPolicy / отдельный management-порт :9090
Дублирование service/env/version в каждой метрикеR-OBS-MTR-2setDefaultLabels один раз при инициализации
route = сырой URL (/orders/42f3d...)R-OBS-MTR-3шаблон роута (/orders/:id) из req.route.path
paymentTime без единицыR-OBS-MTR-6payment_processing_seconds
orderCreatedCount (camelCase, нет _total)R-OBS-MTR-6order_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.