When an application runs in production, questions arise: is it working right now? Has response time grown? What happened three minutes ago? Three tools help answer them: health probes, metrics and logs. This article is about how to assemble them in NestJS correctly the first time.
Why you need a separate port for metrics
In Spring Boot there is a built-in management port — it's enough to set management.server.port: 9090 and Actuator moves there automatically. NestJS has nothing of the sort: everything you add lives on the same port as the business API.
This creates a problem. Prometheus polls /metrics on every service replica every 15 seconds. If that endpoint is exposed through the public Ingress — it's reachable from the outside. If it lives on the same port as the API — the scraping traffic presses on the same event loop as user requests.
The solution in NestJS is two separate applications in one process:
// 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();
The business API runs on port 3000, and only that is published by the Ingress. ManagementModule on 9090 is reachable only inside the cluster — the Prometheus scraper gets to it, external traffic does not.
ManagementModule — only what you need
ManagementModule registers exactly four endpoints and nothing extra:
// 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 {}
| Endpoint | What it does |
|---|---|
GET /metrics | Prometheus scraping via the prom-client registry |
GET /health/live | Liveness probe — checks that the process is alive |
GET /health/ready | Readiness probe — DB and critical dependencies |
GET /info | git-sha, version, build time |
Swagger, debug controllers, env-dump — none of this belongs in ManagementModule. If you need Swagger in development — add it to AppModule under a condition:
// 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: metrics from scratch
prom-client is the standard library for Prometheus in Node.js. For metrics to be useful, you need two things: standard Node metrics and shared labels on all metrics.
Standard metrics are nodejs_eventloop_lag_seconds, heap usage, GC and CPU. They are wired up with a single line and give a baseline load profile without writing any code:
// 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 must be called before creating any metric — otherwise already-registered metrics won't get these labels. That's why the call goes before 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);
// ...
}
After this every metric — both standard and business — automatically gets service, env, version without passing them explicitly in each .inc() or .observe().
Business metrics: Counter and Histogram
For business logic you need a Counter (an increasing counter) and a Histogram (a distribution of times):
// 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 metrics for HTTP requests
For each HTTP request it's useful to know three things: the rate (Requests), the errors (Errors) and the latency (Duration) — this is the RED pattern. In NestJS it's implemented by an 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) });
},
}),
);
}
}
An important detail: req.route?.path returns the template /orders/:id, not the concrete URL /orders/550e8400-.... This is fundamental — unique UUIDs in a label create thousands of time series and can exhaust Prometheus memory.
The /metrics endpoint
If you don't use PrometheusModule from @willsoto/nestjs-prometheus, the controller is written by hand:
// 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());
}
}
If you use PrometheusModule.register() in ManagementModule — this controller isn't needed, the module registers it automatically.
Logs: pino with a NODE_ENV-aware transport
pino is the fastest JSON logger for Node.js. In NestJS it's wired up through 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 {}
The key decisions in this configuration:
transport: pino-pretty only in development. In production transport: undefined — pino writes newline-delimited JSON straight to stdout. Loki, Datadog, ELK parse it without any additional setup. pino-pretty adds formatting overhead — it's not needed in production.
redact is the single place for filtering personal data. Everything that reaches the log through the merge object automatically passes through redact. You don't have to remember about filtering in each this.logger.info(...).
LOG_LEVEL from env lets you temporarily raise the level to debug in production without a deploy — just change the environment variable and restart the pod.
Common mistakes
setDefaultLabels after creating metrics — the labels don't apply to already-registered metrics. Call it before NestFactory.create.
/metrics on the business port — in such a configuration the endpoint will be reachable through the Ingress. Move it to a separate port.
pino-pretty in production — adds formatting that log collectors don't need and reduces performance.
Raw req.url in a metric label — high cardinality kills Prometheus. Use req.route?.path.
collectDefaultMetrics() without an explicit register — without specifying the registry, metrics may not land in the intended registry, especially if you have several registries.
In short
- NestJS has no built-in management port: two
NestFactory.create()calls in onebootstrap()give two independent applications on different ports. ManagementModuleon:9090contains only/metrics,/health/live,/health/readyand/info.setDefaultLabelsis called once before creating metrics — all metrics getservice/env/versionautomatically.collectDefaultMetrics()gives the baseline Node metrics: event loop lag, heap, GC, CPU.- In the RED histogram for HTTP use
req.route?.path(the template), not the raw URL. pino-pretty— only in development; in production pino writes JSON to stdout.redactinpinoHttp— centralized filtering of personal data.
What to read next
- Context propagation — AsyncLocalStorage, genReqId, requestId in a guard.
- Health checks — liveness/readiness, custom HealthIndicator with a TTL cache.
- Logging — the merge object,
{ err }for the stack, levels by semantics. - Metrics — Counter/Histogram business metrics, naming.
- Tracing — NodeSDK,
startActiveSpan, sampling.