← Back to the section

When a service starts behaving strangely in production — responds slowly, falls over under load, drops requests — the logs help find a specific error but don't give the big picture. Metrics solve a different task: they show how the service behaves over time. How many requests per second? What percentage finished with an error? How loaded is the memory?

In the Node.js stack, prom-client — the official Prometheus client — is used for this. In NestJS it's wired up through @willsoto/nestjs-prometheus.

Wiring it up

Install two packages:

npm install prom-client @willsoto/nestjs-prometheus

PrometheusModule is registered in a separate management module on port :9090 — not in AppModule. This is important: metrics must not be reachable through the service's public port.

collectDefaultMetrics is called once in main.ts before NestFactory.create:

import { register, collectDefaultMetrics } from 'prom-client';

collectDefaultMetrics({ register });
// next — NestFactory.create(...)

The Prometheus scraper hits /metrics on the management port every 15 seconds and pulls the accumulated data into its database.

Standard labels — once for all metrics

Metrics from different environments land in a single Prometheus database. Without labels it's impossible to filter production from staging or compare the behavior of two versions of the same service.

Instead of adding service, env, version to each metric by hand, they're set once via setDefaultLabels:

import { register } from 'prom-client';

register.setDefaultLabels({
  service: process.env.SERVICE_NAME ?? 'order-service',
  env: process.env.NODE_ENV ?? 'dev',
  version: process.env.APP_VERSION ?? 'unknown',
});

After this, Grafana lets you write queries like service="order-service", env="prod" and compare deploys via version. There's no need to specify these three labels again in each metric — setDefaultLabels applies globally.

RED metrics for HTTP requests

RED — three questions about the health of a request-driven service:

  • Rate — how many requests per second?
  • Errors — what percentage finished with an error?
  • Duration — how long do clients wait?

In Spring Boot this works out of the box via Actuator. In NestJS there's no automatic collection, so it's added through an interceptor with a single Histogram:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Histogram } from 'prom-client';
import { Observable, tap } from 'rxjs';
import { Request, Response } from 'express';

const httpRequestDuration = new Histogram({
  name: 'http_server_requests_seconds',
  help: 'HTTP request duration',
  labelNames: ['method', 'route', 'status_class'] as const,
  buckets: [0.05, 0.1, 0.5, 1, 5],
});

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest<Request>();
    const res = ctx.switchToHttp().getResponse<Response>();
    const end = httpRequestDuration.startTimer({ method: req.method });

    return next.handle().pipe(
      tap({
        next: () => end({ route: this.route(req), status_class: this.statusClass(res.statusCode) }),
        error: () => end({ route: this.route(req), status_class: 'server_error' }),
      }),
    );
  }

  private route(req: Request): string {
    return (req.route?.path as string | undefined) ?? req.path;
  }

  private statusClass(status: number): string {
    if (status < 400) return 'success';
    if (status < 500) return 'client_error';
    return 'server_error';
  }
}

The route label must contain the route template/orders/:id, not the real URL /orders/42f3d.... Real URLs are unique for each request: if you record them in a label, Prometheus will create a separate time series for each URL, and the database will instantly grow to unmanageable sizes.

PromQL queries against this metric:

# Rate
sum(rate(http_server_requests_seconds_count[5m])) by (route, method)

# Errors
sum(rate(http_server_requests_seconds_count{status_class="server_error"}[5m])) by (route)

# Duration p95
histogram_quantile(0.95, sum by (le, route) (rate(http_server_requests_seconds_bucket[5m])))

USE metrics for resources — via collectDefaultMetrics

USE — three questions about the health of resources:

  • Utilization — how busy is the resource?
  • Saturation — is there a waiting queue?
  • Errors — are there errors at the resource level?

collectDefaultMetrics() from prom-client collects Node.js runtime metrics without any additional setup:

MetricWhat it shows
nodejs_eventloop_lag_secondsevent loop load — the main overload signal
nodejs_heap_size_used_bytesoccupied heap memory
nodejs_heap_size_total_bytesmaximum heap in V8
nodejs_gc_duration_secondsgarbage collector pause time
nodejs_active_handles_totalactive handles: sockets, timers

The saturation of the PostgreSQL connection pool pg is not exported automatically, so it's added by hand through a Gauge:

import { Gauge } from 'prom-client';
import { Pool } from 'pg';

function registerPgPoolMetrics(pool: Pool, poolName: string): void {
  new Gauge({
    name: 'pg_pool_total_connections',
    help: 'Total connections in pg pool',
    labelNames: ['pool'] as const,
    collect() { this.set({ pool: poolName }, pool.totalCount); },
  });

  new Gauge({
    name: 'pg_pool_idle_connections',
    help: 'Idle connections in pg pool',
    labelNames: ['pool'] as const,
    collect() { this.set({ pool: poolName }, pool.idleCount); },
  });

  new Gauge({
    name: 'pg_pool_waiting_count',
    help: 'Requests waiting for a connection',
    labelNames: ['pool'] as const,
    collect() { this.set({ pool: poolName }, pool.waitingCount); },
  });
}

When waitingCount > 0 — requests are queued waiting for a connection. This is a saturation signal, worth putting an alert on:

- alert: PgPoolSaturated
  expr: pg_pool_waiting_count > 0
  for: 2m

Business metrics

Technical metrics show the health of the infrastructure. Business metrics show what happens inside the domain: how many orders were created, how long payments take to process, which channel is more popular.

In NestJS, business metrics are formalized as an Injectable service:

import { Injectable } from '@nestjs/common';
import { Counter, Histogram } from 'prom-client';

@Injectable()
export class OrderMetrics {
  private readonly orderCreatedTotal = new Counter({
    name: 'order_created_total',
    help: 'Orders created',
    labelNames: ['channel'] as const,
  });

  private readonly paymentDuration = new Histogram({
    name: 'payment_processing_seconds',
    help: 'Payment processing latency',
    labelNames: ['payment_method'] as const,
    buckets: [0.1, 0.5, 1, 5],
  });

  private readonly orderAmountRubles = new Histogram({
    name: 'order_amount_rubles',
    help: 'Order amount distribution',
    buckets: [100, 500, 1_000, 5_000, 10_000, 50_000],
  });

  orderCreated(channel: 'web' | 'mobile' | 'api'): void {
    this.orderCreatedTotal.inc({ channel });
  }

  recordPayment(durationSeconds: number, method: 'CARD' | 'SBP' | 'CASH'): void {
    this.paymentDuration.observe({ payment_method: method }, durationSeconds);
  }

  recordOrderAmount(amountRubles: number): void {
    this.orderAmountRubles.observe(amountRubles);
  }
}

Calling it from a command handler:

@Injectable()
export class CreateOrderHandler {
  constructor(
    private readonly orders: OrderRepository,
    private readonly metrics: OrderMetrics,
  ) {}

  async handle(cmd: CreateOrderCommand): Promise<Order> {
    const order = await this.orders.save(Order.create(cmd));
    this.metrics.orderCreated(cmd.channel);
    this.metrics.recordOrderAmount(order.totalAmount);
    return order;
  }
}

Three types of metrics to choose from:

  • Counter — only grows. For events: order_created_total, payment_failed_total.
  • Gauge — a current value, can decrease. For state: active_orders_count, product_stock_units.
  • Histogram — a distribution of values across ranges. For times and amounts: payment_processing_seconds, order_amount_rubles.

There's no DistributionSummary in prom-clientHistogram covers both tasks.

Metric names

Prometheus expects names in snake_case with the unit of measurement at the end:

order_created_total           — Counter with the _total suffix
payment_processing_seconds    — Histogram with _seconds
product_stock_units           — Gauge, unit — units
order_amount_rubles           — Histogram with a currency unit

Common mistakes:

orderCreatedCount             — camelCase, no _total
paymentTime                   — no unit

prom-client doesn't check names against the convention — a violation is discovered only in Grafana, when metrics from different services don't line up.

Cardinality in labels: why low is important

Every unique combination of label values creates a separate time series in the Prometheus database. If unique identifiers — user_id, order_id, request_id — end up in a label, the number of time series grows together with the traffic. Under heavy load this leads to memory exhaustion and a Prometheus crash.

The rule: a label value is a category, not an identifier.

// Good — a few fixed values
this.orderCreatedTotal.inc({ channel: 'web' });          // web / mobile / api
this.paymentDuration.observe({ payment_method: 'SBP' }, duration);  // CARD / SBP / CASH

// Bad — unique values per request
new Counter({
  name: 'order_created_total',
  labelNames: ['order_id', 'customer_id'] as const,  // millions of values → memory exhaustion
});

If you need to track a specific request or user — that's a job for tracing, not metrics. A single span stores arbitrary attributes (order.id, customer.id) without storage growth.

In short

  • prom-client + @willsoto/nestjs-prometheus — the standard metrics stack in NestJS.
  • register.setDefaultLabels({ service, env, version }) — once at startup, not in each metric.
  • RED for HTTP — one Histogram in an interceptor; the route label = the template (/orders/:id), not the real URL.
  • collectDefaultMetrics() gives event loop lag, heap, GC without extra code.
  • pg pool metrics — a Gauge with totalCount, idleCount, waitingCount; waitingCount > 0 — alert.
  • Business metrics — Counter/Gauge/Histogram in an Injectable service, called from command handlers.
  • Names: snake_case, unit at the end (_seconds, _total, _rubles).
  • Labels — only categories with a small number of values; unique identifiers → into tracing.
  • /metrics — only on the management port :9090, not through the service's public port.
  • Tracing — high-cardinality observability through spans; OpenTelemetry auto-instrumentation.
  • Logging — nestjs-pino, structured JSON, a DI logger.
  • SLO and alerts — multi-window burn rate, error budget.
  • Health checks — liveness vs readiness.
  • Configuration — the management port, isolating /metrics.