Опирается на правила:
R-OBS-CFG-1…R-OBS-CFG-4иR-OBS-CFG-X1…R-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 /metrics | Prometheus scraping через prom-client registry |
GET /health/live | Liveness probe — только процесс |
GET /health/ready | Readiness probe — БД и критичные зависимости |
GET /info | git-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=production | R-OBS-CFG-4 | undefined в проде, JSON через stdout |
collectDefaultMetrics() без явного register | R-OBS-MTR-3 | collectDefaultMetrics({ register }) |
Сырой req.url как label в RED-histogram | R-OBS-MTR-X1 | req.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%.