Metrics

Counters, gauges and histograms in a Nest app, rendered on a /metrics endpoint for a Prometheus scrape.

Syntaxnew Histogram({ name, help, labelNames: ['route'] as const, buckets: [0.1, 0.5, 1], registers: [registry] })

A log line describes one request. A metric describes all of them: a number per name and label set, cheap to store and cheap to graph. Three types cover nearly everything worth watching.

  • Counter — only increases, and starts from zero again when the process restarts. Requests served, jobs failed.
  • Gauge — moves both ways. Requests in flight, queue depth, open connections.
  • Histogram — counts observations into buckets, so you can ask what share of requests finished under 300 ms. Durations and sizes.

How it works

Prometheus pulls. Your app keeps the numbers in memory and renders them as text on an endpoint the collector scrapes every few seconds; nothing is pushed. The Node client is @prometheus-io/client, the renamed prom-client — the final release under the old name, 15.1.3, is marked deprecated on npm. A Registry holds the metrics, await registry.metrics() renders the text, and registry.contentType is the header to answer with.

In Nest

Build each metric in a factory provider, inject it where it is recorded, and expose one controller. Record HTTP durations in an interceptor so every route is covered without editing a single handler.

What trips people up

Each distinct label value is another time series. Never label with a user id, an order id or a raw path; use the route template and the status code.

Example

Example · typescript
// src/metrics/metrics.module.ts
import { Module } from '@nestjs/common';
import {
  Counter,
  Histogram,
  Registry,
  collectDefaultMetrics,
} from '@prometheus-io/client';
import { MetricsController } from './metrics.controller.js';
import { HTTP_DURATION, HTTP_FAILURES } from './metrics.tokens.js';

@Module({
  controllers: [MetricsController],
  providers: [
    {
      // One registry for the process. Using your own instead of the library's
      // global `register` keeps parallel tests from sharing metric state.
      provide: Registry,
      useFactory: (): Registry => {
        const registry = new Registry();
        // Event loop lag, heap, GC, file descriptors. Read on each scrape.
        collectDefaultMetrics({ register: registry, prefix: 'orders_' });
        return registry;
      },
    },
    {
      provide: HTTP_DURATION,
      inject: [Registry],
      useFactory: (registry: Registry) =>
        new Histogram({
          name: 'http_request_duration_seconds',
          help: 'HTTP request duration in seconds',
          // `as const` makes TypeScript reject an unknown label name later.
          labelNames: ['method', 'route', 'status'] as const,
          // Buckets are seconds and cumulative. Pick them around your SLO.
          buckets: [0.01, 0.05, 0.1, 0.3, 1, 3],
          registers: [registry],
        }),
    },
    {
      provide: HTTP_FAILURES,
      inject: [Registry],
      useFactory: (registry: Registry) =>
        new Counter({
          name: 'http_requests_failed_total',
          help: 'Requests that ended in a 5xx',
          labelNames: ['route'] as const,
          registers: [registry],
        }),
    },
  ],
  exports: [Registry, HTTP_DURATION, HTTP_FAILURES],
})
export class MetricsModule {}

// src/metrics/metrics.tokens.ts
// A token is the key the injector files a provider under. A metric has no
// class of its own to use, so give it a symbol.
export const HTTP_DURATION = Symbol('HTTP_DURATION');
export const HTTP_FAILURES = Symbol('HTTP_FAILURES');

When to use it

  • Alert when the 95th percentile of one route crosses its SLO, not when one request was slow.
  • Watch event loop lag and heap growth per deployment from the default metrics.
  • Graph queue depth next to worker throughput to size a consumer pool.
  • Count business events, such as refunds issued, without querying the database.

More examples

The scrape endpoint

The whole exposition side of Prometheus is this one handler. Two things to decide at the same time: keep it off the public ingress or behind an auth guard, since it leaks route names and traffic shape, and exclude it from your access log so a 15-second scrape does not dominate your logs.

Example · typescript
// src/metrics/metrics.controller.ts
import { Controller, Get, Header } from '@nestjs/common';
import { Registry, prometheusContentType } from '@prometheus-io/client';

@Controller('metrics')
export class MetricsController {
  constructor(private readonly registry: Registry) {}

  // prometheusContentType is 'text/plain; version=0.0.4; charset=utf-8'.
  // @Header() takes a literal, read once when the class is defined.
  @Get()
  @Header('content-type', prometheusContentType)
  async scrape(): Promise<string> {
    // metrics() returns a promise: default metrics and any gauge with a
    // collect() callback are gathered at scrape time.
    return this.registry.metrics();
  }
}

Recording durations in an interceptor

This gives you request rate, error rate and a latency distribution for every route from one class. Register it with { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor } so the injector supplies the metrics; note that guards reject before interceptors, so 401s and 403s are not counted here.

Example · typescript
// src/metrics/metrics.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  HttpException,
  Inject,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import type { Counter, Histogram } from '@prometheus-io/client';
import type { Request, Response } from 'express';
import { Observable, tap } from 'rxjs';
import { HTTP_DURATION, HTTP_FAILURES } from './metrics.tokens.js';

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  constructor(
    @Inject(HTTP_DURATION)
    private readonly duration: Histogram<'method' | 'route' | 'status'>,
    @Inject(HTTP_FAILURES)
    private readonly failures: Counter<'route'>,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    if (context.getType() !== 'http') {
      return next.handle();
    }

    const http = context.switchToHttp();
    const request = http.getRequest<Request>();
    const response = http.getResponse<Response>();
    // The handler name is a bounded label set; the request path is not.
    const route = `${context.getClass().name}.${context.getHandler().name}`;
    // startTimer returns a function that observes the elapsed seconds and
    // accepts the labels you did not know yet.
    const stop = this.duration.startTimer({ method: request.method, route });

    const record = (status: number): void => {
      stop({ status: String(status) });
      if (status >= 500) {
        this.failures.inc({ route });
      }
    };

    return next.handle().pipe(
      tap({
        next: () => record(response.statusCode),
        error: (error: unknown) =>
          record(error instanceof HttpException ? error.getStatus() : 500),
      }),
    );
  }
}

A gauge read at scrape time

Reach for collect() for anything you can ask for on demand: queue depth, pool size, rows pending migration. The alternative, a setInterval that writes to the gauge, keeps the process awake and reports values from up to one interval ago.

Example · typescript
// src/jobs/queue-metrics.provider.ts
import type { Provider } from '@nestjs/common';
import { Gauge, Registry } from '@prometheus-io/client';
import { getQueueToken } from '@nestjs/bullmq';
import type { Queue } from 'bullmq';

export const QUEUE_DEPTH = Symbol('QUEUE_DEPTH');

export const queueDepthProvider: Provider = {
  provide: QUEUE_DEPTH,
  inject: [Registry, getQueueToken('email')],
  useFactory: (registry: Registry, queue: Queue) =>
    new Gauge({
      name: 'email_queue_waiting_jobs',
      help: 'Jobs waiting in the email queue',
      registers: [registry],
      // collect() runs on every scrape, so the number is never stale and you
      // need no polling interval. It must not be an arrow function: `this`
      // has to be the gauge.
      async collect() {
        this.set(await queue.getWaitingCount());
      },
    }),
};

Discussion

  • Be the first to comment on this lesson.