Когда приложение запущено в продакшене, возникают вопросы: работает ли оно прямо сейчас? Не выросло ли время ответа? Что произошло три минуты назад? Ответить на них помогают три инструмента: 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 /metrics | Prometheus scraping через prom-client registry |
GET /health/live | Liveness probe — проверяет, что процесс жив |
GET /health/ready | Readiness probe — БД и критичные зависимости |
GET /info | git-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.