Опирается на правила: R-OBS-CFG-1R-OBS-CFG-4 и R-OBS-CFG-X1R-OBS-CFG-X3 из Observability Style Guide → раздел 5. Конфигурация.

Важно знать

  • Отдельный management-порт (:9090) реализуется вторым NestFactory.create(ManagementModule) внутри bootstrap — сетевая изоляция scraping-трафика от business-трафика.
  • /metrics и /health/* — только на management-порту. Ingress публикует исключительно business-порт (:3000).
  • pino-pretty — локально через transport: { target: 'pino-pretty' } при NODE_ENV !== 'production'; в проде pino пишет newline-delimited JSON в stdout без transport.
  • register.setDefaultLabels применяется один раз при старте; все метрики наследуют service/env/version без явного указания в каждой.
  • collectDefaultMetrics() подключается один раз и даёт nodejs_eventloop_lag_seconds, heap, GC, libuv handles — USE-метрики для Node без кода.
  • redact в pinoHttp — единственное место PII-фильтрации, не разбросанный по сервисам logger.info({ email: '...' }).
  • Конфигурация observability собирается в четырёх точках: main.ts (два приложения), ManagementModule, LoggerModule, tracing.ts. Эта статья про первые три.

В Spring Boot actuator и business-приложение делят один процесс, порт разделяется через management.server.port. В NestJS нет встроенного management-layer — паттерн: два NestApplication в одном Node-процессе на разных портах. Это же позволяет явно перечислить, что доступно на каждом порту, не полагаясь на список exclusions.

Отдельный management-порт

R-OBS-CFG-1: business-трафик и scraping не должны мешать друг другу; management-порт закрывается network policy в K8s.

// 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();

ManagementModule регистрирует только /metrics, /health/live, /health/ready и /info. Никаких business-контроллеров.

// 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 {}

Что это даёт в K8s:

  • NetworkPolicy разрешает Prometheus scraper на 9090, Ingress публикует только 3000.
  • Scraping раз в 15 секунд × N реплик не заходит в business event loop.
  • Уязвимость в actuator-like endpoint не достижима через публичный Ingress.

Explicit список endpoints

R-OBS-CFG-2: management-приложение публикует ровно то, что нужно — не wildcard.

В Java wildcard management.endpoints.web.exposure.include: '*' — риск. В NestJS аналогичный риск — поставить в ManagementModule все контроллеры включая Swagger и debug-dumps, решив что «порт внутренний».

Правило: ManagementModule содержит только:

EndpointЧто делает
GET /metricsPrometheus scraping через prom-client registry
GET /health/liveLiveness probe — только процесс
GET /health/readyReadiness probe — БД и критичные зависимости
GET /infogit-sha, версия, build-time

Swagger (/docs), debug-контроллеры, env-dump — не попадают в ManagementModule (R-OBS-CFG-X1). Если Swagger нужен в dev — включается условно по NODE_ENV:

// 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: default labels и collectDefaultMetrics

R-OBS-CFG-3: стандартные dimensions и USE-метрики подключаются при старте один раз.

// 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 });
}
// 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);
  // ...
}

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

collectDefaultMetrics() регистрирует:

  • nodejs_eventloop_lag_seconds — сатурация event loop (USE: saturation).
  • nodejs_heap_size_used_bytes / nodejs_heap_size_total_bytes — heap utilization.
  • nodejs_gc_duration_seconds — GC overhead.
  • process_cpu_user_seconds_total — CPU usage.

Пример бизнес-метрик для OrderService:

// 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,
});

export const paymentProcessingDuration = new Histogram({
  name: 'payment_processing_seconds',
  help: 'Payment processing latency',
  buckets: [0.1, 0.5, 1, 5, 30],
  labelNames: ['method'] as const,
});

default labels применяются ко всем этим метрикам автоматически — не нужно передавать service/env/version при каждом .inc() / .observe().

RED-histogram для HTTP

R-OBS-MTR-3: RED-метрики через Histogram с шаблоном роута, не сырым URL.

// 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],
});

@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';
          const statusClass = `${Math.floor(res.statusCode / 100)}xx`;
          end({ method: req.method, route, status_class: statusClass });
        },
        error: (err) => {
          const route = req.route?.path ?? 'unknown';
          end({ method: req.method, route, status_class: '5xx' });
        },
      }),
    );
  }
}

req.route?.path возвращает шаблон /orders/:id, не конкретный URL /orders/550e8400-.... Это принципиально: route — низкая cardinality (R-OBS-MTR-7), raw URL — высокая (OOM в Prometheus).

NODE_ENV-aware pino transport

R-OBS-CFG-4: текстовый вывод в dev, JSON в prod.

// 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: undefined — pino пишет newline-delimited JSON напрямую в stdout. Loki/Datadog/ELK парсят его без regex. pino-pretty добавляет overhead форматирования — в проде не нужен.

redact в LoggerModule — единственное место фильтрации PII. Всё что попадает через merge-объект (this.logger.info({ req }, '...')) автоматически проходит через redact (R-OBS-LOG-X1).

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

MetricsController

R-OBS-CFG-2: endpoint /metrics только в ManagementModule.

// 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());
  }
}

@willsoto/nestjs-prometheus делает то же самое через PrometheusModule.register(). При использовании PrometheusModule этот контроллер не нужен — достаточно импортировать модуль в ManagementModule.

Что запрещено

АнтипаттернПравилоЧто взамен
/metrics и /health на business-порту (:3000)R-OBS-CFG-X2отдельный ManagementModule на :9090
Swagger /docs в ManagementModule без ограниченийR-OBS-CFG-X1только в AppModule под NODE_ENV !== 'production'
register.setDefaultLabels после создания метрикR-OBS-CFG-3вызвать до NestFactory.create в main.ts
transport: pino-pretty в NODE_ENV=productionR-OBS-CFG-4undefined в проде, JSON через stdout
collectDefaultMetrics() без явного registerR-OBS-MTR-3collectDefaultMetrics({ register })
Сырой req.url как label в RED-histogramR-OBS-MTR-X1req.route?.path — шаблон роута
redact в каждом logger.info(...) вручнуюR-OBS-LOG-X1централизованный redact в pinoHttp

Куда дальше

  • Context propagation — AsyncLocalStorage, genReqId, userId в guard.
  • Health checks — liveness/readiness, custom HealthIndicator с TTL-кешем.
  • Logging — merge-объект, { err } для стека, уровни по семантике.
  • Metrics — бизнес-метрики Counter/Histogram, cardinality, именование.
  • SLO и алерты — SLO recording rules, multi-window burn-rate alerts.
  • Tracing — NodeSDK, startActiveSpan, sampling 1–10%.