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

Когда приложение запущено в продакшене, возникают вопросы: работает ли оно прямо сейчас? Не выросло ли время ответа? Что произошло три минуты назад? Ответить на них помогают три инструмента: health-пробы, метрики и логи. Эта статья о том, как собрать их в NestJS правильно с первого раза.

Зачем нужен отдельный порт для метрик

В Spring Boot есть встроенный management-порт — достаточно прописать management.server.port: 9090 и Actuator переедет туда автоматически. В NestJS ничего похожего нет: всё, что вы добавляете, живёт на одном порту с бизнес-API.

Это создаёт проблему. Prometheus каждые 15 секунд опрашивает /metrics по всем репликам сервиса. Если этот эндпоинт доступен через публичный Ingress — он достижим снаружи. Если он живёт на том же порту, что и API, — scraping-трафик давит на тот же event loop, что и пользовательские запросы.

Решение в NestJS — два отдельных приложения в одном процессе:

// src/main.ts
import './tracing';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ManagementModule } from './management/management.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api');
  await app.listen(process.env.PORT ?? 3000);

  const mgmt = await NestFactory.create(ManagementModule);
  mgmt.setGlobalPrefix('');
  await mgmt.listen(process.env.MANAGEMENT_PORT ?? 9090);
}

bootstrap();

Бизнес-API работает на порту 3000, Ingress публикует только его. ManagementModule на 9090 доступен только внутри кластера — Prometheus scraper доберётся, внешний трафик нет.

ManagementModule — только то, что нужно

ManagementModule регистрирует ровно четыре эндпоинта и ничего лишнего:

// src/management/management.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
import { HealthController } from './health.controller';
import { MetricsController } from './metrics.controller';
import { InfoController } from './info.controller';

@Module({
  imports: [TerminusModule, PrometheusModule.register()],
  controllers: [HealthController, MetricsController, InfoController],
})
export class ManagementModule {}
ЭндпоинтЧто делает
GET /metricsPrometheus scraping через prom-client registry
GET /health/liveLiveness probe — проверяет, что процесс жив
GET /health/readyReadiness probe — БД и критичные зависимости
GET /infogit-sha, версия, время сборки

Swagger, debug-контроллеры, env-dump — всё это в ManagementModule не нужно. Если Swagger нужен в разработке — добавьте его в AppModule под условие:

// src/main.ts
if (process.env.NODE_ENV !== 'production') {
  const { DocumentBuilder, SwaggerModule } = await import('@nestjs/swagger');
  const config = new DocumentBuilder().setTitle('Order Service').build();
  SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, config));
}

prom-client: метрики с нуля

prom-client — стандартная библиотека для Prometheus в Node.js. Чтобы метрики были полезны, нужно две вещи: стандартные метрики Node и общие labels на всех метриках.

Стандартные метрики — это nodejs_eventloop_lag_seconds, heap-использование, GC и CPU. Они подключаются одной строкой и дают базовый профиль нагрузки без написания кода:

// src/metrics/metrics.bootstrap.ts
import { Registry, collectDefaultMetrics } from 'prom-client';

export function bootstrapMetrics(registry: Registry): void {
  registry.setDefaultLabels({
    service: process.env.SERVICE_NAME ?? 'order-service',
    env: process.env.NODE_ENV ?? 'development',
    version: process.env.APP_VERSION ?? 'unknown',
  });

  collectDefaultMetrics({ register: registry });
}

setDefaultLabels нужно вызвать до создания любых метрик — иначе уже зарегистрированные метрики не получат эти labels. Поэтому вызов идёт до NestFactory.create:

// src/main.ts
import { register } from 'prom-client';
import { bootstrapMetrics } from './metrics/metrics.bootstrap';

async function bootstrap() {
  bootstrapMetrics(register);

  const app = await NestFactory.create(AppModule);
  // ...
}

После этого все метрики — и стандартные, и бизнесовые — автоматически получают service, env, version без явной передачи в каждом .inc() или .observe().

Бизнес-метрики: Counter и Histogram

Для бизнес-логики нужны Counter (нарастающий счётчик) и Histogram (распределение времён):

// src/metrics/order.metrics.ts
import { Counter, Histogram } from 'prom-client';

export const orderCreatedTotal = new Counter({
  name: 'order_created_total',
  help: 'Total orders created',
  labelNames: ['type'] as const,
});

export const orderProcessingDuration = new Histogram({
  name: 'order_processing_seconds',
  help: 'Order processing latency',
  buckets: [0.05, 0.1, 0.5, 1, 5],
  labelNames: ['status'] as const,
});

RED-метрики для HTTP-запросов

Для каждого HTTP-запроса полезно знать три вещи: частоту (Requests), ошибки (Errors) и задержку (Duration) — это паттерн RED. В NestJS его реализует interceptor:

// src/common/interceptors/http-metrics.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { Histogram } from 'prom-client';

const httpServerRequests = new Histogram({
  name: 'http_server_requests_seconds',
  help: 'HTTP request duration',
  labelNames: ['method', 'route', 'status_class'] as const,
  buckets: [0.05, 0.1, 0.25, 0.5, 1, 5],
});

function toStatusClass(code: number): string {
  if (code < 400) return 'success';
  if (code < 500) return 'client_error';
  return 'server_error';
}

@Injectable()
export class HttpMetricsInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest();
    const end = httpServerRequests.startTimer();

    return next.handle().pipe(
      tap({
        next: () => {
          const res = ctx.switchToHttp().getResponse();
          const route = req.route?.path ?? 'unknown';
          end({ method: req.method, route, status_class: toStatusClass(res.statusCode) });
        },
        error: (err) => {
          const route = req.route?.path ?? 'unknown';
          const code = (err as { status?: number })?.status ?? 500;
          end({ method: req.method, route, status_class: toStatusClass(code) });
        },
      }),
    );
  }
}

Важная деталь: req.route?.path возвращает шаблон /orders/:id, а не конкретный URL /orders/550e8400-.... Это принципиально — уникальные UUID в label создают тысячи временных рядов и могут исчерпать память Prometheus.

Эндпоинт /metrics

Если вы не используете PrometheusModule из @willsoto/nestjs-prometheus, контроллер пишется вручную:

// src/management/metrics.controller.ts
import { Controller, Get, Header, Res } from '@nestjs/common';
import { Response } from 'express';
import { register } from 'prom-client';

@Controller('metrics')
export class MetricsController {
  @Get()
  @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
  async metrics(@Res() res: Response): Promise<void> {
    res.send(await register.metrics());
  }
}

Если используете PrometheusModule.register() в ManagementModule — этот контроллер не нужен, модуль регистрирует его автоматически.

Логи: pino с NODE_ENV-aware транспортом

pino — самый быстрый JSON-логгер для Node.js. В NestJS его подключают через nestjs-pino:

// src/app.module.ts
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        genReqId: (req) => req.headers['x-request-id'] ?? randomUUID(),
        level: process.env.LOG_LEVEL ?? 'info',
        redact: {
          paths: ['req.headers.authorization', '*.password', '*.email', '*.phone'],
          censor: '[REDACTED]',
        },
        transport: process.env.NODE_ENV !== 'production'
          ? {
              target: 'pino-pretty',
              options: { colorize: true, translateTime: 'HH:MM:ss.l', ignore: 'pid,hostname' },
            }
          : undefined,
        serializers: {
          err: (err) => ({
            type: err.constructor.name,
            message: err.message,
            stack: err.stack,
          }),
        },
      },
    }),
  ],
})
export class AppModule {}

Ключевые решения в этой конфигурации:

transport: pino-pretty только в разработке. В продакшене transport: undefined — pino пишет newline-delimited JSON напрямую в stdout. Loki, Datadog, ELK парсят его без дополнительных настроек. pino-pretty добавляет overhead форматирования — в продакшене он не нужен.

redact — единственное место фильтрации персональных данных. Всё что попадает в лог через merge-объект автоматически проходит через redact. Не нужно помнить про фильтрацию в каждом this.logger.info(...).

LOG_LEVEL из env позволяет временно поднять уровень debug в продакшене без деплоя — достаточно изменить переменную среды и перезапустить pod.

Частые ошибки

setDefaultLabels после создания метрик — labels не применяются к уже зарегистрированным метрикам. Вызывайте до NestFactory.create.

/metrics на бизнес-порту — в такой конфигурации эндпоинт будет доступен через Ingress. Выносите на отдельный порт.

pino-pretty в продакшене — добавляет форматирование, которое не нужно сборщикам логов, и снижает производительность.

Сырой req.url в label метрики — высокая кардинальность убивает Prometheus. Используйте req.route?.path.

collectDefaultMetrics() без явного register — без указания registry метрики могут не попасть в нужный реестр, особенно если у вас несколько реестров.

Коротко

  • В NestJS нет встроенного management-порта: два NestFactory.create() в одном bootstrap() дают два независимых приложения на разных портах.
  • ManagementModule на :9090 содержит только /metrics, /health/live, /health/ready и /info.
  • setDefaultLabels вызывается один раз до создания метрик — все метрики получают service/env/version автоматически.
  • collectDefaultMetrics() даёт базовые метрики Node: event loop lag, heap, GC, CPU.
  • В RED-histogram для HTTP используйте req.route?.path (шаблон), а не сырой URL.
  • pino-pretty — только в разработке, в продакшене pino пишет JSON в stdout.
  • redact в pinoHttp — централизованная фильтрация персональных данных.

Что почитать дальше

  • Context propagation — AsyncLocalStorage, genReqId, requestId в guard.
  • Health checks — liveness/readiness, custom HealthIndicator с TTL-кешем.
  • Logging — merge-объект, { err } для стека, уровни по семантике.
  • Metrics — бизнес-метрики Counter/Histogram, именование.
  • Tracing — NodeSDK, startActiveSpan, sampling.