← Back to the section

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 {}
EndpointWhat it does
GET /metricsPrometheus scraping via the prom-client registry
GET /health/liveLiveness probe — checks that the process is alive
GET /health/readyReadiness probe — DB and critical dependencies
GET /infogit-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 one bootstrap() give two independent applications on different ports.
  • ManagementModule on :9090 contains only /metrics, /health/live, /health/ready and /info.
  • setDefaultLabels is called once before creating metrics — all metrics get service/env/version automatically.
  • 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.
  • redact in pinoHttp — centralized filtering of personal data.
  • 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.