Health Checks & Observability

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), @nestjs/terminus 12.1.0, TypeScript 6 (6.0.x), Node.js 24 LTS (Terminus needs Node 20.19, 22.12 or 24 and newer, @opentelemetry/sdk-node 18.19 or 20.6 and newer, @prometheus-io/client 22, 24 or 26 and newer), Express 5 and Fastify 5, as of 2026-10-02. The observability libraries are the OpenTelemetry JavaScript SDK 2.x (@opentelemetry/sdk-node 0.222.0, API 1.9.1, @opentelemetry/auto-instrumentations-node 0.80.0), @prometheus-io/client 0.16.1 (the successor of prom-client) and @nestjs/observe 0.3.6, which is pre-1.0 and changes quickly. The examples use the ESM template of Nest 12; in the output blocks process ids, ports and timestamps are shortened.

A deployed application has to answer two different questions. The first is can I send it traffic right now?, which an orchestrator or load balancer asks every few seconds and expects a yes or no. The second is what is it doing and why is it slow or failing?, which people ask after the fact by looking at traces, metrics, logs and errors. Health checks answer the first, observability answers the second, and the two meet at one practical point: the probe endpoints must stay out of your telemetry, and your telemetry must not make the probes slow. In the Bookshelf application that grows across this section, the same HTTP process exposes /health/* for the cluster, /metrics for Prometheus, exports spans through OpenTelemetry (or to NestJS Observe), and writes the logs described in Logging.

The signals and who consumes them

Signal Question it answers Typical consumer Tool used on this page

Health check

Is this instance able to serve traffic (or is it still alive)?

Kubernetes probes, load balancers, uptime monitors

@nestjs/terminus

Metrics

How much, how fast, how often, right now and over time?

Prometheus, Grafana dashboards and alerts

@prometheus-io/client, or OpenTelemetry metrics

Traces

Where did the time of this one request go, across services?

Tempo, Jaeger, any OTLP backend

OpenTelemetry SDK, OTLP, Collector

Logs

What did the code say while it ran?

Loki, Elasticsearch, a log service

Nest Logger, see Logging

Errors and APM

Which defects are new, how many users did they hit, which release introduced them?

A hosted service

NestJS Observe (@nestjs/observe)

The figure shows how the pieces fit when the SDK exports to a Collector, which is the setup most teams converge on. The application only knows one OTLP endpoint; where traces, metrics and logs finally go is configuration of the Collector, not code.

flowchart LR subgraph APP["Bookshelf API (one Node.js process)"] NEST["NestJS application"] SDK["OpenTelemetry SDK
started with --import"] PROM["/metrics endpoint
prometheus client"] HEALTH["/health/live /health/ready
@nestjs/terminus"] NEST --- SDK NEST --- PROM NEST --- HEALTH end KUBE["Kubernetes probes"] -->|"HTTP GET"| HEALTH SDK -->|"OTLP over HTTP 4318"| COL subgraph COL["OpenTelemetry Collector"] RCV["receivers: otlp"] --> PRC["processors: memory_limiter, batch"] --> EXP["exporters"] end EXP -->|"traces"| TEMPO["Tempo or Jaeger"] EXP -->|"metrics"| PMS["Prometheus"] EXP -->|"logs"| LOKI["Loki"] PROM -->|"scrape every 15 s"| PMS PMS --> GRAF["Grafana dashboards and alerts"] TEMPO --> GRAF LOKI --> GRAF SDK -.->|"HTTPS, alternative"| OBS["NestJS Observe"]
Figure 1. The application exports OTLP to a Collector, which fans telemetry out to backends; Prometheus can also scrape the application directly

Health checks with @nestjs/terminus

A health check is an HTTP endpoint whose status code summarises the state of the application and the things it depends on. @nestjs/terminus does not invent a protocol: it gives you a HealthCheckService that runs a list of indicators, a @HealthCheck() decorator for the controller method, a set of ready-made indicators for the usual dependencies (HTTP services, databases, other microservices, memory, disk) and a small API to write your own. Terminus is a separate package, versioned with Nest (12.0.0 on 2026-08-31, 12.1.0 on 2026-09-20), and since 12.0.0 it is ESM-only.

Installing and registering the module

npm install @nestjs/terminus
# only for HttpHealthIndicator:
npm install @nestjs/axios axios

TerminusModule provides HealthCheckService, HealthIndicatorService and every built-in indicator. Without options you import the class itself; with options you use forRoot() or forRootAsync(). The options are few:

Option Default Meaning

errorLogStyle

'json'

How a failed check is logged: 'json' prints the summary as JSON, 'pretty' prints formatted boxes that highlight failed indicators.

logger

the Nest logger

A class extending or implementing LoggerService (with forRoot()), a logger instance (with forRootAsync()), true for the default Nest logger or false to silence Terminus completely.

gracefulShutdownTimeoutMs

0

How long to keep the process alive after SIGTERM while the check reports shutting_down; see Graceful shutdown.

health.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TerminusModule } from '@nestjs/terminus';
import { BookCatalogHealthIndicator } from './book-catalog.health.js';
import { CacheHealthIndicator } from './cache.health.js';
import { ExternalController } from './external.controller.js';
import { HealthState } from './health.state.js';
import { LivenessController } from './liveness.controller.js';
import { ReadinessController } from './readiness.controller.js';
import { SearchIndexHealthIndicator } from './search-index.health.js';
import { StartupController } from './startup.controller.js';

@Module({
  imports: [
    ConfigModule,
    TerminusModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        errorLogStyle: 'json',
        // Longer than the readiness probe period; see "Graceful shutdown".
        gracefulShutdownTimeoutMs: config.get<number>('SHUTDOWN_DELAY_MS', 5000),
      }),
    }),
  ],
  controllers: [LivenessController, ReadinessController, StartupController, ExternalController],
  providers: [
    HealthState,
    BookCatalogHealthIndicator,
    SearchIndexHealthIndicator,
    CacheHealthIndicator,
  ],
  exports: [HealthState],
})
export class HealthModule {}

forRootAsync() takes the usual useFactory/inject, useClass or useExisting shapes, so the shutdown delay can come from ConfigService (see Configuration). The HTTP indicator no longer needs HttpModule imported into your module: since Terminus 12.0.0 it builds its own HttpService from @nestjs/axios instead of resolving one from the container, and the examples on this page, which do not import HttpModule, ran as shown. If you want the indicator to use an HttpService you configured yourself (a proxy, an auth header), pass it as the httpClient option of pingCheck().

The first check and the response shape

The smallest useful health endpoint is a liveness probe: it runs no indicators, so it answers ok as long as the event loop is alive and Nest can route a request.

liveness.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';

// Liveness: "is this process able to make progress?"  No dependencies in here:
// a failing database must never make Kubernetes restart the pods.
@Controller('health/live')
export class LivenessController {
  constructor(private readonly health: HealthCheckService) {}

  @Get()
  @HealthCheck()
  live() {
    return this.health.check([]);
  }
}
curl -i http://localhost:3000/health/live
HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, must-revalidate
Content-Type: application/json; charset=utf-8
{ "status": "ok", "info": {}, "error": {}, "details": {} }

Every check returns a HealthCheckResult (the type is exported) with four fields:

Field Content

status

'ok' when every indicator is up, 'degraded' when none is down but at least one is degraded, 'error' when at least one is down, and 'shutting_down' once the application started its shutdown sequence (it wins over the other three).

info

The indicators that are up or degraded, in other words still serving.

error

The indicators that are down.

details

Every indicator, whatever its state.

The HTTP status follows the overall status: ok and degraded answer 200, error and shutting_down answer 503. A 500 is something else, covered in Custom indicators with HealthIndicatorService.

@HealthCheck() takes two options. noCache (default true) sets Cache-Control: no-cache, no-store, must-revalidate so that no proxy answers a probe from a stale copy (the header above); pass false if you want to set your own with @Header(). swaggerDocumentation (default true) documents the endpoint when @nestjs/swagger is installed, see OpenAPI & Swagger; pass false to keep probe routes out of the public API document.

Built-in indicators

Indicator Method Notes

HttpHealthIndicator

pingCheck(key, url, axiosOptions?), responseCheck(key, url, predicate, axiosOptions?)

Needs @nestjs/axios and axios. A 2xx answer is up and any other status is down (the check reports the axios message and the status), unless you pass axios' validateStatus; responseCheck() lets you decide with a sync or async predicate on the response. Since 12.1.0 the URL can be a URL object. Like the other built-ins it is a transient-scoped provider.

TypeOrmHealthIndicator

pingCheck(key, { connection? })

Runs a trivial query (SELECT 1) on the default or the given DataSource.

MongooseHealthIndicator

pingCheck(key, { connection? })

Pings the database; since 12.0.0 it no longer only inspects readyState.

SequelizeHealthIndicator, MikroOrmHealthIndicator

pingCheck(key, { connection? })

Same pattern for those ORMs.

PrismaHealthIndicator

pingCheck(key, prismaClient)

You pass the client instance.

MicroserviceHealthIndicator

pingCheck(key, { transport, options, timeout? })

Creates a client for the given transport, sends a ping and closes the client again, also when the ping fails.

GRPCHealthIndicator

checkService(key, service, options?)

Calls the standard gRPC health service of another service.

MemoryHealthIndicator

checkHeap(key, bytes), checkRSS(key, bytes)

Down when heapUsed (V8 heap in use) or the resident set size is above the threshold.

DiskHealthIndicator

checkStorage(key, { path, threshold or thresholdPercent })

Down when the used space of the volume containing path exceeds an absolute number of bytes or a fraction (0.5 is 50 percent).

All the indicators that talk to something (HttpHealthIndicator.pingCheck() aside) return a HealthCheckAttempt, so you can chain .withTimeout() and .cacheFor(), covered in Timeouts and caching. The per-indicator timeout option is deprecated in favour of .withTimeout().

The next controller combines indicators that call other processes: an HTTP service that must answer 2xx, an HTTP service where only a 204 counts as healthy, and the notifications microservice of the Bookshelf (see Microservices Fundamentals) over TCP.

external.controller.ts
import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Transport } from '@nestjs/microservices';
import {
  HealthCheck,
  HealthCheckService,
  HttpHealthIndicator,
  MicroserviceHealthIndicator,
} from '@nestjs/terminus';

// Built-in indicators that talk to other processes.
@Controller('health/external')
export class ExternalController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly http: HttpHealthIndicator,
    private readonly microservice: MicroserviceHealthIndicator,
    private readonly config: ConfigService,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    const upstream = this.config.getOrThrow<string>('UPSTREAM_URL');
    return this.health.check([
      // 2xx/3xx means up
      () => this.http.pingCheck('upstream', `${upstream}/ping`),
      // you decide what "healthy" means: here only 204 counts
      () =>
        this.http.responseCheck(
          'upstream-204',
          `${upstream}/no-content`,
          (res) => res.status === 204,
        ),
      // a ClientProxy is created, sent a ping and closed again
      () =>
        this.microservice.pingCheck('notifications', {
          transport: Transport.TCP,
          options: {
            host: this.config.get('NOTIFICATIONS_HOST', '127.0.0.1'),
            port: this.config.getOrThrow<number>('NOTIFICATIONS_PORT'),
          },
          timeout: 500,
        }),
    ]);
  }
}

Run against a local server that answered 200 on /ping and /no-content (so the 204 predicate failed), with a real TCP microservice listening, the check answered 503 and listed exactly the indicator that failed:

{
  "status": "error",
  "info": {
    "upstream": { "status": "up" },
    "notifications": { "responseTime": 10, "status": "up" }
  },
  "error": {
    "upstream-204": { "status": "down" }
  },
  "details": {
    "upstream": { "status": "up" },
    "notifications": { "responseTime": 10, "status": "up" },
    "upstream-204": { "status": "down" }
  }
}

A non-2xx answer is a down state carrying the status; a 404 produces { "message": "Request failed with status code 404", "statusCode": 404, "statusText": "Not Found", "status": "down" }. When a service legitimately answers with another status, pass axios' validateStatus (the same call with validateStatus: (status) ⇒ status === 404 reported up):

Accepting a non-2xx status
() => this.http.pingCheck('legacy-api', `${upstream}/status`, { validateStatus: (status) => status < 500 }),

With nothing listening on the microservice port, the notifications entry becomes { "message": "connect ECONNREFUSED 127.0.0.1:1", "responseTime": 1, "status": "down" }: a transport error is a down state with its message, not a crash.

Databases are checked with pingCheck(). With more than one TypeORM connection, inject each DataSource and pass it through the connection option; the example also shows the Mongoose indicator and an absolute disk threshold (the Mongoose route needs a MongoDB server to answer).

databases.controller.ts
import { Controller, Get, Module } from '@nestjs/common';
import { InjectDataSource, TypeOrmModule } from '@nestjs/typeorm';
import { HealthCheck, HealthCheckService, TerminusModule, TypeOrmHealthIndicator, MongooseHealthIndicator, DiskHealthIndicator } from '@nestjs/terminus';
import type { DataSource } from 'typeorm';

// Two TypeORM connections: pass each DataSource through the `connection` option.
@Controller('health/databases')
export class DatabasesHealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    @InjectDataSource('reporting') private readonly reporting: DataSource,
    @InjectDataSource() private readonly primary: DataSource,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('primary', { connection: this.primary }).withTimeout(1000),
      () => this.db.pingCheck('reporting', { connection: this.reporting }).withTimeout(1000),
    ]);
  }
}

@Module({
  imports: [
    TerminusModule,
    TypeOrmModule.forRoot({ type: 'better-sqlite3', database: ':memory:' }),
    TypeOrmModule.forRoot({ name: 'reporting', type: 'better-sqlite3', database: ':memory:' }),
  ],
  controllers: [DatabasesHealthController],
})
export class DatabasesHealthModule {}

// Needs a MongoDB connection to answer: Mongoose and fixed-size disk checks.
@Controller('health/mongo')
export class MongoHealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly mongo: MongooseHealthIndicator,
    private readonly disk: DiskHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.mongo.pingCheck('activity-feed').withTimeout(1000),
      // absolute threshold: down when more than 10 GiB are used on /var/lib/data
      () => this.disk.checkStorage('data-volume', { path: '/var/lib/data', threshold: 10 * 1024 ** 3 }),
    ]);
  }
}

Memory and disk checks appear in the readiness controller in the next sections. Choose their thresholds from what the container is given, not from what the machine has: a 512 MiB container limit with a 512 MiB heap threshold reports healthy right until the kernel kills the process.

Custom indicators with HealthIndicatorService

When no built-in indicator fits, write your own. Terminus 12 has one way to do it: inject HealthIndicatorService (provided by TerminusModule), call check(key) to get a session for that key, and let the session produce the result with one of four methods.

Method Result

up(data?)

The indicator is up. data is an object merged into the result or a string stored as message.

down(data?)

The indicator is down; the overall status becomes error and the HTTP status 503.

degraded(data?)

Still serving but impaired; the overall status becomes degraded, the HTTP status stays 200.

attempt(fn)

Runs fn, which receives { signal } (an AbortSignal): up when it resolves (the returned object is merged into the result), down when it throws (the error message is stored). The time taken is added as responseTime. Returns a HealthCheckAttempt for chaining.

The keys status, responseTime and cachedResponse are reserved; the type system rejects status in the extra data of up(), down() and degraded().

attempt() is the right default whenever the indicator wraps an operation that can throw, such as a call to another service:

book-catalog.health.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HealthIndicatorService } from '@nestjs/terminus';

// attempt(): run an operation; it resolves -> up, it throws -> down.
// The AbortSignal is aborted when withTimeout() expires, so pass it on.
@Injectable()
export class BookCatalogHealthIndicator {
  constructor(
    private readonly healthIndicatorService: HealthIndicatorService,
    private readonly config: ConfigService,
  ) {}

  isHealthy(key: string) {
    const url = this.config.getOrThrow<string>('CATALOG_URL');
    return this.healthIndicatorService
      .check(key)
      .attempt(async ({ signal }) => {
        const response = await fetch(`${url}/books/count`, { signal });
        if (!response.ok) {
          throw new Error(`catalog answered ${response.status}`);
        }
        const { count } = (await response.json()) as { count: number };
        return { books: count }; // appended to the indicator result
      })
      .withTimeout(1000);
  }
}

When the catalog answers 503 the indicator reports { "message": "catalog answered 503", "responseTime": 1, "status": "down" }. When it takes longer than one second, withTimeout(1000) aborts the signal passed to fetch and the indicator reports { "message": "timeout of 1000ms exceeded", "responseTime": 1002, "status": "down" }. Passing the signal to the operation matters: without it the abandoned request keeps running in the background after the check gave up.

Use up() and down() when healthy means more than did not throw, for example when the answer is a statistic:

search-index.health.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicatorService } from '@nestjs/terminus';

export interface SearchIndexStats {
  documents: number;
  staleDocuments: number;
}

export const SEARCH_INDEX = Symbol('SEARCH_INDEX');

// up()/down(): you decide the state yourself; nothing is thrown.
@Injectable()
export class SearchIndexHealthIndicator {
  constructor(private readonly healthIndicatorService: HealthIndicatorService) {}

  async isHealthy(key: string, stats: () => Promise<SearchIndexStats>) {
    const indicator = this.healthIndicatorService.check(key);
    const { documents, staleDocuments } = await stats();

    if (documents === 0) {
      return indicator.down('search index is empty'); // string -> { message }
    }
    if (staleDocuments > 100) {
      return indicator.down({ staleDocuments }); // object -> merged into the result
    }
    return indicator.up({ documents });
  }
}

A string argument is stored as message, an object is merged. With an empty index the readiness endpoint answered 503 and error held { "search": { "message": "search index is empty", "status": "down" } }; with 500 stale documents it held { "search": { "staleDocuments": 500, "status": "down" } }.

Degraded state

Not every problem should take an instance out of rotation. An unreachable cache slows the application down without breaking it, and removing every instance from the load balancer because Redis is down would turn a slowdown into an outage. A degraded indicator keeps the endpoint at 200, is listed under info, and makes the overall status degraded, so monitoring sees it while the orchestrator keeps routing traffic:

cache.health.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicatorService } from '@nestjs/terminus';

// degraded(): still serving, but impaired. Overall status "degraded", HTTP 200.
@Injectable()
export class CacheHealthIndicator {
  constructor(private readonly healthIndicatorService: HealthIndicatorService) {}

  async isHealthy(key: string, isConnected: () => Promise<boolean>) {
    const indicator = this.healthIndicatorService.check(key);
    if (!(await isConnected())) {
      return indicator.degraded('cache unreachable, serving without cache');
    }
    return indicator.up();
  }
}

With the cache switched off, the readiness endpoint answers 200 with "status": "degraded" and "info": { "cache": { "message": "cache unreachable, serving without cache", "status": "degraded" } }. If any other indicator is down at the same time the overall status is error regardless: down always wins over degraded.

An error thrown inside up(), down() or degraded() is a bug

An indicator that returns its result with up(), down() or degraded() reports its state by returning. If it throws instead, Terminus does not treat that as unhealthy; the whole check fails with an unhandled exception and the client receives a 500, not a 503:

A thrown error is not a down state
// Pitfall: an indicator built with up()/down()/degraded() that THROWS is treated as
// a bug, not as a "down" state: the whole check aborts with a 500 instead of a 503.
@Injectable()
export class ThrowingIndicator {
  constructor(private readonly indicators: HealthIndicatorService) {}

  async isHealthy(key: string) {
    const indicator = this.indicators.check(key);
    const stats = await this.loadStats(); // rejects when the index is unreachable
    return stats.documents > 0 ? indicator.up() : indicator.down();
  }

  private async loadStats(): Promise<{ documents: number }> {
    throw new Error('search index unreachable'); // use attempt() to turn this into "down"
  }
}
{ "statusCode": 500, "message": "Internal server error" }

An orchestrator that treats any non-2xx probe answer as a failure will still react, but the response carries no detail and the error is logged by Nest’s exception handler as a stack trace, not as a failed health check. Wrap anything that can throw in attempt().

Migrating legacy indicators

Terminus 12 removed the legacy API deprecated in 11: custom indicators can no longer extend HealthIndicator and can no longer throw a HealthCheckError to report a failure, and neither name is exported any more (HealthIndicator and HealthCheckError are not among the package’s exports). Code written for Terminus 10 and earlier, which most blog posts and the book still show, looked like this:

Legacy style, no longer available
@Injectable()
export class DogHealthIndicator extends HealthIndicator {
  async isHealthy(key: string) {
    const bad = await this.dogs.getBadboys();
    const result = this.getStatus(key, bad.length === 0, { badboys: bad.length });
    if (bad.length > 0) throw new HealthCheckError('Dog check failed', result);
    return result;
  }
}

The replacement is the HealthIndicatorService pattern above: inject the service, return indicator.down({ badboys }) or indicator.up() instead of throwing, or move the operation into attempt(). The HealthIndicatorService API arrived with Terminus 11 (January 2025); version 12 (August 2026) removed the old one and added degraded(), cacheFor() and responseTime.

Timeouts and caching

Every HealthCheckAttempt (your attempt() indicators and the database, microservice and gRPC indicators) can be configured before it runs:

  • withTimeout(ms) marks the indicator down when the time is up and aborts the AbortSignal handed to your function.

  • cacheFor(ms) caches the result for that long. The cache is shared across requests and keyed by the indicator key; concurrent checks share one in-flight execution; a cached answer carries "cachedResponse": true.

The readiness controller below puts the database check behind both. Probes arrive every few seconds from every pod, and a query per probe per pod adds up; five seconds of caching makes it one query per five seconds per instance.

readiness.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  DiskHealthIndicator,
  HealthCheck,
  HealthCheckService,
  MemoryHealthIndicator,
  TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import { BookCatalogHealthIndicator } from './book-catalog.health.js';
import { CacheHealthIndicator } from './cache.health.js';
import { HealthState } from './health.state.js';
import { SearchIndexHealthIndicator } from './search-index.health.js';

// Readiness: "should this instance receive traffic right now?"
@Controller('health/ready')
export class ReadinessController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly memory: MemoryHealthIndicator,
    private readonly disk: DiskHealthIndicator,
    private readonly catalog: BookCatalogHealthIndicator,
    private readonly search: SearchIndexHealthIndicator,
    private readonly cache: CacheHealthIndicator,
    private readonly state: HealthState,
  ) {}

  @Get()
  @HealthCheck()
  ready() {
    return this.health.check([
      // an attempt can be passed directly or wrapped in a function
      () => this.db.pingCheck('database').withTimeout(1500).cacheFor(5000),
      () => this.catalog.isHealthy('catalog'),
      () =>
        this.search.isHealthy('search', async () => ({
          documents: this.state.searchDocuments,
          staleDocuments: this.state.staleDocuments,
        })),
      () => this.cache.isHealthy('cache', async () => this.state.cacheConnected),
      () => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024),
      () => this.disk.checkStorage('storage', { path: '/', thresholdPercent: 0.98 }),
    ]);
  }
}

Two consecutive calls produce, for the database entry, first { "responseTime": 0, "status": "up" } and then { "responseTime": 0, "cachedResponse": true, "status": "up" }. A healthy readiness answer looks like this (the info object repeats the same entries and is omitted):

{
  "status": "ok",
  "error": {},
  "details": {
    "database": { "responseTime": 5, "status": "up" },
    "catalog": { "books": 3, "responseTime": 9, "status": "up" },
    "search": { "documents": 42, "status": "up" },
    "cache": { "status": "up" },
    "memory_heap": { "status": "up" },
    "storage": { "status": "up" }
  }
}

Caching has a cost: a database that dies right after a check is reported healthy for up to the cache time. Keep the cache shorter than the probe’s failureThreshold times its period, or the orchestrator reacts later than it would have.

Readiness, liveness and startup probes

Kubernetes (and most platforms) distinguish three questions, and answering them with one endpoint is the most common health check mistake.

Probe Question What to check What a failure does

Liveness

Is the process stuck beyond recovery?

Nothing external. A controller that runs no indicators, as above.

The container is restarted.

Readiness

Should this instance receive traffic now?

The dependencies a request needs: database, required downstream services, memory pressure. Optional dependencies belong in degraded().

The pod is removed from the Service endpoints; no restart.

Startup

Has the application finished starting?

A flag set when warm-up work (migrations, caches, model loading) is done.

Liveness and readiness are held back until it passes; the pod is restarted if it never does.

The reason to keep dependencies out of liveness: when the database is down, every pod fails a database-backed liveness probe at once, Kubernetes restarts all of them, and a database outage becomes a full application outage that outlasts the database’s recovery because the pods are busy starting. The readiness controller above is the place for dependencies. The startup probe is a small controller of its own:

startup.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, HealthIndicatorService } from '@nestjs/terminus';
import { HealthState } from './health.state.js';

// Startup probe: "has the application finished warming up?"
@Controller('health/startup')
export class StartupController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly indicators: HealthIndicatorService,
    private readonly state: HealthState,
  ) {}

  @Get()
  @HealthCheck()
  startup() {
    return this.health.check([
      async () => {
        const indicator = this.indicators.check('warmup');
        return this.state.warmedUp ? indicator.up() : indicator.down('still warming up');
      },
    ]);
  }
}

The sequence: /health/startup answers 503 with { "warmup": { "message": "still warming up", "status": "down" } } until the flag is set and 200 afterwards.

The Deployment fragment below wires the three endpoints to probes. The field names are the Kubernetes ones documented on the linked page.

deployment-probes.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookshelf-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: bookshelf-api
  template:
    metadata:
      labels:
        app: bookshelf-api
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          image: registry.example.com/bookshelf-api:1.0.0
          ports:
            - containerPort: 3000
          env:
            - name: SHUTDOWN_DELAY_MS
              value: "12000"
            - name: OTEL_SERVICE_NAME
              value: bookshelf-api
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: http://otel-collector:4318
          startupProbe:
            httpGet: { path: /health/startup, port: 3000 }
            periodSeconds: 2
            failureThreshold: 30
          livenessProbe:
            httpGet: { path: /health/live, port: 3000 }
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /health/ready, port: 3000 }
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 2

Two numbers in that manifest depend on each other. The readiness probe period (5 seconds) times its failureThreshold (2) is how long Kubernetes takes to notice that a pod stopped being ready, and SHUTDOWN_DELAY_MS (12000, in the manifest) must be longer than that, as explained next. For the Kubernetes side of probes, rollouts and preStop hooks see Health Probes and Lifecycle and Deployment Strategies; the planned Deployment page shows the whole Dockerfile, Compose and Kubernetes setup for the Bookshelf.

Graceful shutdown

When Kubernetes stops a pod it sends SIGTERM and, at the same moment, starts removing the pod from the Service endpoints. Those two things are not synchronised: for a few seconds the pod can still receive new requests from load balancers that have not yet noticed. A process that exits immediately on SIGTERM drops those requests.

Terminus handles this with gracefulShutdownTimeoutMs. When the shutdown sequence starts, the executor flips to a shutting_down state, so every health check answers 503 and the readiness probe fails; on SIGTERM it then waits for the configured time before letting the application close, which gives the endpoint controllers time to stop routing to the pod while in-flight requests finish. The option needs shutdown hooks, because Terminus implements beforeApplicationShutdown() (see Lifecycle Events & Shutdown for the sequence of hooks):

Enabling shutdown hooks (the module is configured in health.module.ts above)
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // without this, Terminus never sees SIGTERM
await app.listen(3000);

A one-endpoint application with gracefulShutdownTimeoutMs: 1500, started as a child process and sent real signals, behaves like this:

before signal : 200 ok
after SIGTERM (+300 ms): 503 shutting_down
process exited after 1509 ms (code=null, signal=SIGTERM)
LOG Received termination signal SIGTERM
LOG Awaiting 1500ms before shutdown
LOG Timeout reached, shutting down now

With SIGINT (what Ctrl+C sends) the check switches to shutting_down as well, but the application closes at once: 300 ms after the signal the port already refused connections. The delay is applied to SIGTERM only, because that is what orchestrators send, so a local Ctrl+C does not make you wait. Fastify behaves the same. For the best result make SHUTDOWN_DELAY_MS longer than the readiness probe period times its failure threshold, and keep terminationGracePeriodSeconds longer than the delay plus the time your longest request needs.

Logging failed checks

By default Terminus logs an error through the Nest logger whenever a check fails (with the check summary as JSON). A custom LoggerService installed through setLogger() receives Health Check has failed! followed by the JSON of all indicators, the failing ones included. You can change the format, replace the logger for Terminus alone, or switch the output off:

terminus-options.ts (logger classes and modules)
// A custom logger for Terminus only. It extends ConsoleLogger, so you override
// just the parts you need. Transient scope gives Terminus its own instance.
@Injectable({ scope: Scope.TRANSIENT })
export class TerminusLogger extends ConsoleLogger {
  error(message: any, stack?: string, context?: string): void;
  error(message: any, ...optionalParams: any[]): void;
  error(message: unknown, ...rest: unknown[]): void {
    // e.g. forward to your structured logger; here we just tag the line
    super.error(`[health] ${String(message)}`, ...(rest as []));
  }
}

@Module({
  imports: [TerminusModule.forRoot({ logger: TerminusLogger, errorLogStyle: 'pretty' })],
})
export class PrettyHealthModule {}

@Module({ imports: [TerminusModule.forRoot({ logger: false })] })
export class QuietHealthModule {}

With the pretty style and the TerminusLogger above, a failing indicator printed:

ERROR [HealthCheckService] [health] Health Check has failed!

❌ index
message: index is empty
status: down

Pass logger: false to TerminusModule.forRoot() to silence Terminus altogether; the QuietHealthModule above does exactly that. In a system that ships structured logs (see Logging) the json style is the one to keep, because it is a single line a log pipeline can parse.

Keeping probes out of the way

Health endpoints are called constantly by machines that have no session and no token, so they interact with everything that is global in the application:

  • Global prefix. A prefix such as api would move /health/live to /api/health/live. Probes and the Prometheus scraper are usually configured once, outside the application, so exclude them: app.setGlobalPrefix('api', { exclude: ['health/(.)', 'metrics'] }). With exclude: ['health/(.)'] the probe answers at /health/live and /api/health/live is a 404. See Versioning & Global Prefix (the exclude option has always been an array; the array form for the prefix itself is a 12.1.0 release-note item that the published 12.1.2 packages did not implement as of 2026-10-02).

  • Authentication, authorization and rate limiting. Global guards apply to probe routes too. Mark them public in your guard (see Authentication and Rate Limiting & Encryption); a throttler that answers 429 to the kubelet takes pods out of rotation.

  • Telemetry. Probes are the most frequent requests of the application and the least interesting traces. Both OpenTelemetry (see Skipping health and metrics endpoints) and NestJS Observe (its http.ignore option) can skip them.

  • Express and Fastify. Nothing in the controllers changes with the platform: the same code answers with the same JSON, the same status codes and the same Cache-Control header on FastifyAdapter, and the shutdown behaviour above is the same on both. See Fastify & HTTP Platforms.

OpenTelemetry

OpenTelemetry (OTel) is the vendor-neutral standard for traces, metrics and logs: an API your code calls, an SDK that processes the data, instrumentation libraries that create spans for HTTP servers, database drivers and so on, and the OTLP wire protocol with which everything is shipped. Your code depends on the small @opentelemetry/api package; the SDK is wired once at startup, which is why swapping the backend (Tempo today, a vendor tomorrow) does not touch application code.

The status of the JavaScript signals matters when planning: the OpenTelemetry JavaScript status page lists traces and metrics as stable and logs as under development, which is why this page uses Nest’s own logger for logs and only correlates them with traces (see Correlating logs with traces).

The SDK must start before Nest

Instrumentation works by patching modules (http, express, database drivers, …​) when they are loaded. A module that was imported before the SDK started has already been evaluated and is not patched. That makes the start order a correctness requirement, not a style choice: the SDK has to be initialised before any module it should instrument is imported.

This is what happens when the order is wrong. Express is imported first and the SDK second; the application serves a request:

== SDK imported after express:
   1   name: 'GET'                      <- only the outgoing client span
== SDK started with --import:
   1   name: 'GET /ping'                <- server span with the route
   2   name: 'request handler - /ping'
   1   name: 'middleware - patched'
   1   name: 'GET'

No error, no warning: the server spans are simply missing. Nest itself loads @nestjs/platform-express lazily when NestFactory.create() runs, so a static import of the SDK file in main.ts happens to work for Express, but anything your own code imports before the SDK (a database driver in a shared module, for instance) is lost, so do not rely on it.

The robust way is to put the SDK in its own file and preload it with the Node command line, so it runs before the application’s first line. In an ESM application (the Nest 12 template) this means --import plus registering the loader hook that patches modules loaded with import:

instrumentation.ts
// Loaded with `node --import ./dist/instrumentation.js dist/main.js`
// so that it runs BEFORE any application module is evaluated.
import { register } from 'node:module';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

// ESM only: let OpenTelemetry patch modules that are loaded with `import`.
register('@opentelemetry/instrumentation/hook.mjs', import.meta.url);

const sdk = new NodeSDK({
  // Exporter, service name, sampler... come from OTEL_* environment variables
  // (OTEL_SERVICE_NAME, OTEL_TRACES_EXPORTER, OTEL_EXPORTER_OTLP_ENDPOINT, ...).
  instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();

// Flush pending spans on SIGTERM; Nest's own shutdown hooks run in parallel.
process.once('SIGTERM', () => {
  sdk.shutdown().catch((error: unknown) => console.error('OTel shutdown failed', error));
});
nest build
node --import ./dist/instrumentation.js dist/main.js

The SDK is configured through the standard OTEL_* environment variables, which is what you set in the container or the Deployment, so the file above contains no endpoint, no service name and no exporter. The main ones:

Variable Effect

OTEL_SERVICE_NAME

The service.name resource attribute that identifies the application in the backend.

OTEL_TRACES_EXPORTER

otlp (the default when unset), console or none.

OTEL_EXPORTER_OTLP_ENDPOINT

Base URL of the OTLP receiver; the trace exporter appends /v1/traces.

OTEL_TRACES_SAMPLER and OTEL_TRACES_SAMPLER_ARG

Sampling, for example parentbased_traceidratio with 0.1 for 10 percent of new traces.

OTEL_NODE_ENABLED_INSTRUMENTATIONS, OTEL_NODE_DISABLED_INSTRUMENTATIONS

Comma-separated instrumentation names to enable or disable, such as http,undici.

OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_HEADERS, OTEL_RESOURCE_ATTRIBUTES, OTEL_LOG_LEVEL

Protocol, auth headers for a hosted backend, extra resource attributes, SDK diagnostics.

Running the application with OTEL_TRACES_EXPORTER=console prints each finished span on stdout, which is the quickest way to see that instrumentation works:

OTEL_SERVICE_NAME=bookshelf-api OTEL_TRACES_EXPORTER=console \
  node --import ./dist/instrumentation.js dist/main.js

Three variants of the start-up exist:

  • ESM with the hook inside the file (above, --import): the form used on this page.

  • Zero code: node --experimental-loader=@opentelemetry/instrumentation/hook.mjs --import @opentelemetry/auto-instrumentations-node/register dist/main.js starts the SDK with the default instrumentations and no file of your own. It produces the same spans as the file above (with an ExperimentalWarning for --experimental-loader). Without the loader hook the HTTP server spans are named just GET and carry no route.

  • CommonJS (a project compiled with module: commonjs): no hook is needed; preload with --require. Nest 12 packages are ESM, and a CommonJS application still loads them through require(esm), which the instrumentation handles.

instrumentation.ts for a CommonJS build, started with node --require ./dist/instrumentation.js dist/main.js
// node --require ./dist/instrumentation.js dist/main.js
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({ instrumentations: [getNodeAutoInstrumentations()] });
sdk.start();

With Nest’s nest start in development you can pass the same flag through NODE_OPTIONS or nest start --exec "node --import ./dist/instrumentation.js"; the point is that the preload file is a separate entry, not an import inside AppModule.

Exporting with OTLP

For production, create the exporters explicitly when you need more than the environment variables express: a resource with service name and version, a batch processor, and a Prometheus metrics reader. The versions of @opentelemetry/resources and @opentelemetry/sdk-trace-base must be the 2.x line that sdk-node 0.222 uses; a project that also depends on older OpenTelemetry packages (an Apollo Gateway, for example, brings in 1.x copies) can end up with two copies and mismatched types, so install them explicitly and check npm ls @opentelemetry/resources.

instrumentation-otlp.ts
// node --import ./dist/instrumentation-otlp.js dist/main.js
import { register } from 'node:module';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

register('@opentelemetry/instrumentation/hook.mjs', import.meta.url);

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'bookshelf-api',
    [ATTR_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
  }),
  // No url: the exporter reads OTEL_EXPORTER_OTLP_ENDPOINT (and appends /v1/traces).
  spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
  // Metrics are pulled by Prometheus from http://localhost:9464/metrics.
  metricReaders: [new PrometheusExporter({ port: Number(process.env.OTEL_PROMETHEUS_PORT ?? 9464) })],
  instrumentations: [
    getNodeAutoInstrumentations({
      // Very chatty and rarely useful for an HTTP API:
      '@opentelemetry/instrumentation-fs': { enabled: false },
      '@opentelemetry/instrumentation-net': { enabled: false },
      '@opentelemetry/instrumentation-dns': { enabled: false },
      // Probes and scrapes are the most frequent and least interesting requests.
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (request) => /^\/(health|metrics)/.test(request.url ?? ''),
      },
    }),
  ],
});
sdk.start();

process.once('SIGTERM', () => {
  sdk.shutdown().catch((error: unknown) => console.error('OTel shutdown failed', error));
});

The exporter can be tried without a Collector by pointing OTEL_EXPORTER_OTLP_ENDPOINT at a small HTTP server that records what it receives. After the application handles two requests and receives SIGTERM, the receiver has one POST /v1/traces with Content-Type: application/json (OTLP over HTTP with JSON encoding, the default of this exporter) and the resource attribute service.name = bookshelf-api. The spans in it, grouped by instrumentation scope:

[@opentelemetry/instrumentation-http]      GET /books/:id   (server span, one per request)
[@opentelemetry/instrumentation-express]   request handler - /books/:id, middleware - jsonParser, ...
[@opentelemetry/instrumentation-router]    request handler - /books/:id, middleware - patched
[@opentelemetry/instrumentation-undici]    GET              (client span of the test's fetch)
[bookshelf-api]                            books.findOne    (manual span, ERROR status for the 404)
[bookshelf-api.nest]                       BooksController.findOne   (interceptor span)

Two details deserve a sentence. The client span and the server spans of the same call share one trace id, which proves that context propagation worked end to end: Node’s fetch carries the traceparent header and the server instrumentation reads it (see Propagating context between services). And the spans were delivered only because the process received SIGTERM and the handler called sdk.shutdown(): the batch processor holds spans in memory and flushes on an interval, so a process that exits without shutting the SDK down loses the last seconds of telemetry. An application that just closes the Nest app and exits exports nothing.

Metrics through OpenTelemetry

The same SDK object can also produce metrics. The file above registers PrometheusExporter as a metric reader, which exposes the OpenTelemetry metrics on http://localhost:9464/metrics for Prometheus to scrape; the counter and histogram of the next sections appear there as bookshelf_books_viewed_total, bookshelf_books_lookup_duration_count, _sum and _bucket (dots become underscores and counters get _total). To push metrics over OTLP instead, use a PeriodicExportingMetricReader with an OTLP metric exporter. Which path to prefer is covered in Prometheus metrics and Grafana.

The Collector

The Collector is a standalone process that receives OTLP, processes it and exports it to one or more backends. Running one between your applications and the backends moves retries, batching, filtering (drop health-check spans), sampling, and credentials out of every service. A minimal configuration that accepts OTLP, protects its own memory, batches, and exports traces to Tempo and metrics for Prometheus to scrape:

otel-collector.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 400
  batch: {}

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889
  debug:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/tempo, debug]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]

The component names (otlp, memory_limiter, batch, otlp/tempo, prometheus, debug) and the service.pipelines structure are the ones in the Collector documentation. The application points at it with OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318, as in the Deployment fragment earlier.

What the auto-instrumentation gives you on Nest 12

getNodeAutoInstrumentations() enables dozens of instrumentations; the ones that matter for a Nest HTTP service are http (the server span, and client spans for outgoing http requests), undici (client spans for fetch), express and router (Express 5 routes through the router package, so both appear), and the driver instrumentations (pg, mysql2, mongodb, ioredis, amqplib, …​). Three practical points follow.

There is no Nest instrumentation for Nest 12. @opentelemetry/instrumentation-nestjs-core (0.68.0 at the time of writing), which is part of the bundle and used to emit one span per controller handler, declares >=4.0.0 <12 as its supported @nestjs/core range in its code. On @nestjs/core 12.1.2 it produced no spans at all, and no warning. The Express request handler - /books/:id span still tells you the route, but not the controller or method. If you want Nest-level spans, create them yourself; an interceptor is the natural place (see Interceptors):

handler-span.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { context, SpanStatusCode, trace } from '@opentelemetry/api';
import { Observable, tap } from 'rxjs';

const tracer = trace.getTracer('bookshelf-api.nest');

// A Nest-aware span per route handler: what @opentelemetry/instrumentation-nestjs-core
// provided before it stopped at @nestjs/core 11 (its supported range is <12).
@Injectable()
export class HandlerSpanInterceptor implements NestInterceptor {
  intercept(executionContext: ExecutionContext, next: CallHandler): Observable<unknown> {
    const name = `${executionContext.getClass().name}.${executionContext.getHandler().name}`;
    const span = tracer.startSpan(name, { attributes: { 'nestjs.type': 'handler' } });
    const withSpan = trace.setSpan(context.active(), span);

    return new Observable((subscriber) =>
      // The handler runs on subscription: subscribe inside the span's context.
      context.with(withSpan, () =>
        next
          .handle()
          .pipe(
            tap({
              error: (error: Error) => {
                span.recordException(error);
                span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
              },
              finalize: () => span.end(),
            }),
          )
          .subscribe(subscriber),
      ),
    );
  }
}

Fastify needs a different instrumentation. On FastifyAdapter the http instrumentation still produces the server span (it carries http.route as /books/:id once the route is matched), but the Express-based spans naturally do not exist, and the bundle no longer includes a Fastify instrumentation. The official Fastify one is @fastify/otel, registered in the same file through registerOnInitialization:

instrumentation-fastify.ts
import { register } from 'node:module';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { FastifyOtelInstrumentation } from '@fastify/otel';
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';

register('@opentelemetry/instrumentation/hook.mjs', import.meta.url);

const exporter = new InMemorySpanExporter();
(globalThis as { __spans?: InMemorySpanExporter }).__spans = exporter;

new NodeSDK({
  spanProcessors: [new SimpleSpanProcessor(exporter)],
  instrumentations: [
    getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-fs': { enabled: false } }),
    // Registers its plugin on every Fastify instance created after this line.
    new FastifyOtelInstrumentation({ registerOnInitialization: true }),
  ],
}).start();

With it the trace contains a request span, handler - BooksController.findOne and the onRequest and onResponse hook spans of the Fastify lifecycle, all with the route. The Nest application itself is created as usual; with an explicit adapter the application options move to the third argument:

main.ts on Fastify
// With an explicit adapter the application options are the THIRD argument.
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: false });
await app.listen(0, '127.0.0.1');

The bundle is noisy. Instrumentations for fs, net and dns create many spans of little value for an HTTP API; instrumentation-otlp.ts disables them by name, and OTEL_NODE_ENABLED_INSTRUMENTATIONS=http,undici selects only the two that matter (only the HTTP-level spans and the manual ones are then produced). A smaller list also means less code patched at startup.

Skipping health and metrics endpoints

To keep probes and scrapes out of the traces, give @opentelemetry/instrumentation-http an ignoreIncomingRequestHook; it is the entry with that name in the getNodeAutoInstrumentations() call of instrumentation-otlp.ts above:

Ignoring probe and scrape requests (excerpt of instrumentation-otlp.ts)
      // Probes and scrapes are the most frequent and least interesting requests.
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (request) => /^\/(health|metrics)/.test(request.url ?? ''),
      },

Source: OpenTelemetry: Instrumentation (JavaScript). With it, a request to /health/live produces only the client span of the caller’s own fetch, and no server span.

Manual spans, attributes and metrics

Automatic spans show where a request went; manual spans and metrics add what your code knows: the book id, whether the book was found, how long the lookup took. They use the API package only (@opentelemetry/api), so the code runs unchanged when the SDK is absent (the calls are no-ops, which is also what makes unit tests of the service need no setup).

books.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { metrics, SpanStatusCode, trace } from '@opentelemetry/api';

// Instrumentation scope: the name identifies YOUR code in the backend.
const tracer = trace.getTracer('bookshelf-api');
const meter = metrics.getMeter('bookshelf-api');

const booksViewed = meter.createCounter('bookshelf.books.viewed', {
  description: 'Number of book detail views',
});
const lookupDuration = meter.createHistogram('bookshelf.books.lookup.duration', {
  description: 'Duration of the catalog lookup',
  unit: 'ms',
});

const catalog = new Map([['42', { id: '42', title: 'Dune' }]]);

@Injectable()
export class BooksService {
  findOne(id: string) {
    // startActiveSpan makes the span the active one inside the callback,
    // so nested spans and logs are linked to it automatically.
    return tracer.startActiveSpan('books.findOne', async (span) => {
      const startedAt = performance.now();
      try {
        span.setAttribute('book.id', id);
        const book = catalog.get(id);
        if (!book) {
          throw new NotFoundException(`Book ${id} not found`);
        }
        booksViewed.add(1, { 'book.found': true });
        return book;
      } catch (error) {
        span.recordException(error as Error);
        span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message });
        booksViewed.add(1, { 'book.found': false });
        throw error;
      } finally {
        lookupDuration.record(performance.now() - startedAt);
        span.end(); // always end the span, or it is never exported
      }
    });
  }
}

The details that matter:

  • startActiveSpan() makes the span the active one inside the callback, so spans created deeper (including the automatic ones for outgoing queries) become its children and the logger can read it (Correlating logs with traces). startSpan() creates a span but does not activate it.

  • Always end() the span in a finally; an unended span is never exported.

  • recordException() attaches the error as a span event, and setStatus({ code: SpanStatusCode.ERROR }) marks the span failed. Recording an exception does not mark the span as failed by itself. The span of a missing book (id 7) is exported with status code 2 (error) and the one of an existing book with the default status.

  • A NotFoundException is an expected outcome rather than a server fault. Whether it should mark the span as an error is a policy decision; the example marks it, and you may prefer to only set the error status for 5xx results.

  • Metric attributes must have low cardinality. book.found has two values; a book id as an attribute would create a time series per book.

The two instruments appeared on the Prometheus reader endpoint after two requests:

bookshelf_books_viewed_total{book_found="true",otel_scope_name="bookshelf-api"} 1
bookshelf_books_viewed_total{book_found="false",otel_scope_name="bookshelf-api"} 1
bookshelf_books_lookup_duration_count{otel_scope_name="bookshelf-api"} 2
bookshelf_books_lookup_duration_sum{otel_scope_name="bookshelf-api"} 0.52

Propagating context between services

Across a service boundary the trace id travels in the W3C traceparent HTTP header. The HTTP server instrumentation reads it from incoming requests and the undici and http client instrumentations add it to outgoing ones, so a call from one instrumented Nest service to another becomes one trace without code. A single process calling itself with fetch shows it: the exported undici client span and the GET /books/:id server span carry the same trace id.

Message brokers and other transports have no automatic carrier: for queues and microservice transports you inject the context into message metadata yourself with propagation.inject() and extract it on the consumer with propagation.extract(); the driver instrumentations (for example amqplib or kafkajs) do it when they are enabled and your client library is the instrumented one. See Microservice Transports and Events, Scheduling & Queues for the transports themselves.

Correlating logs with traces

The simplest log integration needs no logs SDK: write the active trace id into every log line so the backend can join logs and traces. With Nest’s ConsoleLogger that is a small subclass (see Logging for the logger extension points, including the JSON format and nestjs-pino, which add the same fields as proper structured properties):

otel-logger.ts
import { ConsoleLogger } from '@nestjs/common';
import { trace } from '@opentelemetry/api';

// Adds trace_id/span_id of the active span to every line, so logs and traces
// can be joined in the backend.
export class OtelConsoleLogger extends ConsoleLogger {
  protected formatMessage(
    logLevel: Parameters<ConsoleLogger['formatMessage']>[0],
    message: unknown,
    pidMessage: string,
    formattedLogLevel: string,
    contextMessage: string,
    timestampDiff: string,
  ): string {
    const spanContext = trace.getActiveSpan()?.spanContext();
    const suffix = spanContext ? ` trace_id=${spanContext.traceId} span_id=${spanContext.spanId}` : '';
    return (
      super.formatMessage(logLevel, message, pidMessage, formattedLogLevel, contextMessage, timestampDiff).trimEnd() +
      suffix +
      '\n'
    );
  }
}

Installed with NestFactory.create(AppModule, { logger: new OtelConsoleLogger() }), the logger prints:

LOG [BooksController] Looking up book 42 trace_id=5ddddd6d505d790674f174f208be8e58 span_id=23951b468e5da102

The same trace id is on the exported spans, so a log line in the backend links to its trace and the other way around. Lines written outside a span have no suffix. The logs API and the SDK for exporting log records over OTLP exist (@opentelemetry/api-logs, @opentelemetry/sdk-logs) but are still marked as under development in the JavaScript implementation, which is why this page stops at correlation.

Sampling and cost

Tracing every request is affordable at low volume and wasteful at high volume. Sampling is configured by the SDK, not by Nest: OTEL_TRACES_SAMPLER=parentbased_traceidratio with OTEL_TRACES_SAMPLER_ARG=0.1 keeps ten percent of new traces and follows the decision of the caller for continued ones (so a trace is never half-sampled across services). always_off and a ratio of 0 produce no spans, and a ratio of 1 or no sampler variable produces all of them (28 spans for two requests to the Bookshelf API). Tail-based sampling (keep every error and every slow trace) cannot be decided in the process and is a Collector feature.

Testing instrumentation in process

To assert on spans in a test or a local script without any backend, replace the exporter with an in-memory one. The SDK still has to start before the application modules, so the file is used the same way as the others, through --import:

instrumentation-memory.ts
// For tests and local verification: spans stay in the process.
import { register } from 'node:module';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';

register('@opentelemetry/instrumentation/hook.mjs', import.meta.url);

export const exporter = new InMemorySpanExporter();

const sdk = new NodeSDK({
  spanProcessors: [new SimpleSpanProcessor(exporter)],
  instrumentations: [getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-fs': { enabled: false } })],
});
sdk.start();

// Expose the exporter to code that runs later (the --import module is evaluated once).
(globalThis as { __spans?: InMemorySpanExporter }).__spans = exporter;

After one request on a Fastify application the exporter held the HTTP server span, the manual books.findOne span and the interceptor span:

@opentelemetry/instrumentation-net  | tcp.connect
bookshelf-api                       | books.findOne
bookshelf-api.nest                  | BooksController.findOne
@opentelemetry/instrumentation-http | GET
@opentelemetry/instrumentation-undici | GET

Prometheus metrics and Grafana

Prometheus pulls: it scrapes a text endpoint on a schedule, stores the series, and lets you query and alert on them with PromQL. For a Nest application that means exposing /metrics and choosing which metrics to put there. The data model, the four metric types and the instrumentation guidelines are explained in Data Model and Metric Types and Instrumenting Applications; this section only shows how they look in Nest.

The client library

The long-standing Node.js client, prom-client, was deprecated on npm in August 2026 with the message that it has been replaced by @prometheus-io/client, the same project moved under the Prometheus organisation (0.16.0 on 2026-08-24). It keeps the same API, drops support for Node 16, 18, 20, 21 and 23 (it requires ^22 || ^24 || >=26), and renames the cluster IPC message types, so a cluster primary and its workers must run the same version. New code should use @prometheus-io/client; existing code on prom-client 15.1.3 keeps working. The examples below use the new package.

npm install @prometheus-io/client

Source: prometheus/client_js (README and CHANGELOG).

The service owns a Registry, registers the default process metrics (CPU, memory, event loop lag, garbage collection), and exposes the application metrics:

metrics.service.ts
import { Injectable } from '@nestjs/common';
import { Counter, Histogram, Registry, collectDefaultMetrics } from '@prometheus-io/client';

@Injectable()
export class MetricsService {
  // A dedicated registry instead of the global one: no cross-test leakage.
  readonly registry = new Registry();

  readonly httpDuration = new Histogram({
    name: 'http_server_request_duration_seconds',
    help: 'Duration of HTTP requests handled by Nest controllers',
    labelNames: ['method', 'route', 'status_code'] as const,
    buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
    registers: [this.registry],
  });

  readonly booksCreated = new Counter({
    name: 'bookshelf_books_created_total',
    help: 'Books created through the API',
    registers: [this.registry],
  });

  constructor() {
    // process_cpu_*, nodejs_eventloop_lag_*, nodejs_heap_size_*, ...
    collectDefaultMetrics({ register: this.registry });
  }
}

Measuring every HTTP request

A global interceptor times every controller call into a histogram. Two decisions in it are worth understanding. The route label is the route pattern (/books/:id), never the URL (/books/42): a label value per book id creates an unbounded number of series and eventually exhausts Prometheus. And the status code is read when the response finishes, because exception filters run after the interceptor and set the final status late (a thrown NotFoundException is still a 200 when the interceptor’s own tap() runs):

http-metrics.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { MetricsService } from './metrics.service.js';

// Works on Express and Fastify: only the way to read the matched route differs.
@Injectable()
export class HttpMetricsInterceptor implements NestInterceptor {
  constructor(private readonly metrics: MetricsService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    if (context.getType() !== 'http') {
      return next.handle();
    }
    const http = context.switchToHttp();
    const request = http.getRequest();
    const response = http.getResponse();
    const raw = response.raw ?? response; // Fastify reply -> Node ServerResponse
    const end = this.metrics.httpDuration.startTimer();

    // 'finish' fires after exception filters ran, so the status code is final.
    raw.once('finish', () => {
      end({
        method: request.method,
        // Route PATTERN, never the URL: /books/42 must not become a label value.
        route: request.route?.path ?? request.routeOptions?.url ?? 'unmatched',
        status_code: String(raw.statusCode),
      });
    });
    return next.handle();
  }
}

The request.route?.path property exists on Express after routing, and request.routeOptions?.url is the Fastify equivalent; the same interceptor ran on both and produced identical series. Two limits of an interceptor-based approach: it only sees requests that reached a route handler, so unmatched URLs (404 from the router, which attackers love to generate) are not counted, and it does not see guard rejections that happen earlier in the request lifecycle (see Request Lifecycle). A middleware or the OpenTelemetry HTTP instrumentation covers those. The controller and module:

metrics.controller.ts and metrics.module.ts
import { Controller, Get, Header } from '@nestjs/common';
import { MetricsService } from './metrics.service.js';

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

  @Get()
  @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
  scrape(): Promise<string> {
    return this.metrics.registry.metrics();
  }
}

import { Global, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { HttpMetricsInterceptor } from './http-metrics.interceptor.js';
import { MetricsController } from './metrics.controller.js';
import { MetricsService } from './metrics.service.js';

@Global()
@Module({
  controllers: [MetricsController],
  providers: [MetricsService, { provide: APP_INTERCEPTOR, useClass: HttpMetricsInterceptor }],
  exports: [MetricsService],
})
export class MetricsModule {}

After two requests for an existing book, one for a missing book and one POST, the scrape returned (shortened; histogram buckets omitted):

# TYPE http_server_request_duration_seconds histogram
http_server_request_duration_seconds_count{method="GET",route="/books/:id",status_code="200"} 2
http_server_request_duration_seconds_count{method="GET",route="/books/:id",status_code="404"} 1
http_server_request_duration_seconds_count{method="POST",route="/books",status_code="201"} 1
# TYPE bookshelf_books_created_total counter
bookshelf_books_created_total 1
# TYPE nodejs_eventloop_lag_seconds gauge

The response had status 200 and Content-Type: text/plain; version=0.0.4; charset=utf-8 on Fastify (Express reorders the parameters but sends the same media type). The endpoint should not be public: restrict it with a network policy or serve it on a separate port (see Security and Production Operations), exclude it from the global prefix as shown for the health routes, and keep it out of traces.

@willsoto/nestjs-prometheus

If you prefer providers and decorators to hand-written services, @willsoto/nestjs-prometheus (6.1.1, peer dependency prom-client ^15) wraps the client in a Nest module: PrometheusModule.register() mounts /metrics and the default metrics, make*Provider() helpers create metrics as providers, and @InjectMetric() injects them. Because its peer dependency is still prom-client, it does not yet use @prometheus-io/client.

willsoto.ts
import { Controller, Get, Inject, Module } from '@nestjs/common';
import {
  InjectMetric,
  PrometheusModule,
  makeCounterProvider,
  makeHistogramProvider,
} from '@willsoto/nestjs-prometheus';
import type { Counter, Histogram } from 'prom-client';

@Controller('books')
export class WillsotoBooksController {
  constructor(
    @InjectMetric('bookshelf_books_viewed_total') private readonly viewed: Counter<string>,
    @InjectMetric('bookshelf_lookup_seconds') private readonly lookup: Histogram<string>,
  ) {}

  @Get()
  list() {
    const end = this.lookup.startTimer();
    this.viewed.inc();
    end();
    return [];
  }
}

@Module({
  imports: [
    PrometheusModule.register({
      path: '/metrics',
      defaultMetrics: { enabled: true },
      defaultLabels: { app: 'bookshelf-api' },
    }),
  ],
  controllers: [WillsotoBooksController],
  providers: [
    makeCounterProvider({ name: 'bookshelf_books_viewed_total', help: 'Book list views' }),
    makeHistogramProvider({ name: 'bookshelf_lookup_seconds', help: 'Catalog lookup time', buckets: [0.01, 0.1, 1] }),
  ],
})
export class WillsotoModule {}

With setGlobalPrefix('api', { exclude: ['metrics'] }), the book route answers under /api/books and /metrics stays at the root on both Express and Fastify, and the output carries the app="bookshelf-api" default label on every series. These metrics live in the global prom-client registry, so two applications created in one process (as in tests) share counters.

Scrape configuration, PromQL and Grafana

Prometheus needs to know where to scrape. The file below scrapes the application and the Collector’s Prometheus exporter:

prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: bookshelf-api
    metrics_path: /metrics
    static_configs:
      - targets: ['bookshelf-api:3000']
  - job_name: otel-collector
    static_configs:
      - targets: ['otel-collector:8889']

Once the series exist, the questions a service owner asks are PromQL queries. These were not executed (no Prometheus server was available), but they use only the metric and label names shown above:

# requests per second by route
sum by (route) (rate(http_server_request_duration_seconds_count[5m]))

# share of 5xx answers
sum(rate(http_server_request_duration_seconds_count{status_code=~"5.."}[5m]))
  / sum(rate(http_server_request_duration_seconds_count[5m]))

# 95th percentile latency per route
histogram_quantile(0.95,
  sum by (le, route) (rate(http_server_request_duration_seconds_bucket[5m])))

# event loop lag (default metric): is the process CPU-bound?
nodejs_eventloop_lag_seconds

Dashboards and alerts are built from those queries in Grafana (see Grafana Dashboards and Visualization and Grafana Alerting) or as Prometheus rules (see Recording and Alerting Rules and Alertmanager). The usual starting set for a Nest HTTP service is the request rate, the error ratio and the 95th percentile latency per route (the three queries above), the event loop lag and heap size from the default metrics, and a readiness-failure count if you expose one. Two Node-specific pitfalls: a Nest process with cluster or PM2 cluster mode has one registry per worker, so a scrape sees one random worker unless you use the client’s cluster support, which is why one process per container (see Performance Optimization) is easier to monitor; and the default metrics are collected at scrape time, not on an interval, so scrape intervals shorter than a few seconds only add load.

Choosing between the two metric paths: with the Prometheus client you decide every metric name and label and Prometheus pulls from the process; with OpenTelemetry metrics you instrument once with the vendor-neutral API and decide at the SDK or Collector whether to expose a Prometheus endpoint or push OTLP. Use the Prometheus client when Prometheus is the only consumer and you want the plainest operational story; use OpenTelemetry when traces and metrics should share attributes and one pipeline. Mixing both for the same quantity (an HTTP duration histogram from each) doubles the series and confuses dashboards.

NestJS Observe

NestJS Observe is the official hosted application performance monitoring service for Nest, with the @nestjs/observe SDK as the client. It is positioned as the zero-configuration alternative to the OpenTelemetry stack: install the SDK, supply a key pair, and requests, background jobs, errors, logs and traces appear in a dashboard, without a Collector to run or dashboards to build. The package is pre-1.0 (0.3.6 on 2026-10-02) and the service and its plans are commercial, so treat details such as the options and limits as moving targets and check the documentation before relying on them. Its integration point is the instrument option of NestFactory.create(), which exists since @nestjs/core 11.1.4; the SDK attaches to Nest’s own request lifecycle (controllers, guards, interceptors, resolvers, queue consumers) instead of patching modules, which is why, unlike OpenTelemetry, there is nothing that must be imported before the rest of the application.

The package installs and compiles on Nest 12.1.2, the three-step integration starts on Express and on Fastify, the SDK posts telemetry to the endpoint set by OBSERVE_ENDPOINT when that variable is set, and the manual API works without the hosted service. The hosted dashboard, alerts, SLOs, issues, the MCP server and the plans need an account; the paragraphs about them summarise the documentation.

Quick start

npm install @nestjs/observe

createObserveModule() returns a matched pair, the module and the instrument hook, bound to the same options; options that must be known before the application exists (traceIdGenerator, traceIdKey, attachTraceIdToLogs, sourceContext, skipInstrumentation) go there, everything else to ObserveModule.forRoot() or forRootAsync(). The API key pair is a credential and comes from configuration, never from source code:

app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { createObserveModule } from '@nestjs/observe';
import { BooksController } from './books.controller.js';
import { BooksService } from './books.service.js';

// createObserveModule() returns a matched pair bound to the same options:
// the module for `imports` and the hook for NestFactory.create({ instrument }).
export const { ObserveModule, ObserveInstrument } = createObserveModule({
  // Options that must be known before the application is created live here.
  traceIdKey: 'traceId',
  attachTraceIdToLogs: true,
  sourceContext: { linesOfContext: 5, maxFrames: 5 },
});

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    ObserveModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        appKey: config.getOrThrow<string>('OBSERVE_APP_KEY'),
        appSecret: config.getOrThrow<string>('OBSERVE_APP_SECRET'),
        serviceId: config.get<string>('SERVICE_ID', 'bookshelf-api'),
        serviceVersion: config.get<string>('GIT_SHA'),
        // Keep health probes and metric scrapes out of the telemetry (and the bill).
        http: { ignore: ['/health', '/health/live', '/health/ready', '/metrics'] },
        tracesSampleRate: 1.0,
        flushInterval: 1000, // ms; values below 1000 are raised to 1000
      }),
    }),
  ],
  controllers: [BooksController],
  providers: [BooksService],
})
export class AppModule {}
main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule, ObserveInstrument } from './app.module.js';

const app = await NestFactory.create(AppModule, { instrument: ObserveInstrument });
app.enableShutdownHooks();

With an explicit adapter the application options move to the third argument, as for the OpenTelemetry example on Fastify; the documentation warns that passing them in the adapter’s position silently drops them and the SDK never attaches:

main.ts on Fastify
// The adapter is the 2nd argument, so the application options (and `instrument`) are the 3rd.
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
  instrument: ObserveInstrument,
});

With a local HTTP server standing in for the service, after two requests (one existing book, one missing) the SDK, using OBSERVE_ENDPOINT to override the endpoint, sends one POST /applications/telemetry with the key pair in request headers and a body containing one snapshot per request: protocol (http), method, URL, status code, a trace id (a UUID v7 such as 01a0fda1-e689-7660-ae84-efa78cd263d7), the route, the duration, and a nested tree of spans. Automatic spans ("o":"auto") exist for BooksController.findOne, BooksService.findOne and BooksService.traceId with no code of ours; the span created through TracerService is marked manual with its tag; the failed lookup carries the error message and stack. The SDK logs Flush interval is too short (500ms). Setting to minimum of 1000ms., so flushInterval has a floor of one second.

What it instruments automatically

Without any code of your own the SDK reports HTTP, GraphQL, gRPC and @nestjs/microservices requests, WebSocket gateway messages, @nestjs/bullmq and @nestjs/bull jobs and @nestjs/schedule runs, a span for every instrumented provider method, database queries (detected at the pg, mysql2 and mongodb driver level, so TypeORM, Prisma driver adapters and Mongoose are covered, with every literal removed from the statement) and outbound HTTP (fetch, undici, node:http), plus runtime samples of memory, CPU, garbage collection and event loop delay. In the documented billing model the database-query and outbound-HTTP spans are detail of a method span and are not billed, while other spans, requests, errors and log lines each count as one event. Redaction of secrets in forwarded logs, captured request headers and error messages is on by default.

Manual spans, metrics and handled errors

TracerService is the manual half. It reads the trace from the ambient async context, so it throws when called outside a traced operation (except currentTraceId() and the metric methods):

books.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { TracerService } from '@nestjs/observe';

// Typing the request-scoped store makes setAttribute()/getAttribute() keys checked.
type RequestStore = { lastBook: string }; // a type alias: an interface lacks the index signature the constraint wants

@Injectable()
export class BooksService {
  private readonly books = new Map([['42', { id: '42', title: 'Dune' }]]);

  constructor(private readonly tracer: TracerService<RequestStore>) {}

  async findOne(id: string) {
    this.tracer.counter('bookshelf.books.lookups', { description: 'Number of book lookups' }).increment();

    return this.tracer.createSpan('books.findOne', async (span) => {
      span.addTags({ bookId: id });
      const book = this.books.get(id);
      if (!book) {
        // Handled here, so it would not be captured automatically if we recovered from it
        await this.tracer.captureError(new Error(`Book ${id} not found`), { bookId: id });
        throw new NotFoundException(`Book ${id} not found`);
      }
      this.tracer.setAttribute('lastBook', book.title);
      return book;
    });
  }

  traceId() {
    return this.tracer.currentTraceId();
  }
}

createSpan(name, callback) nests under the active span and times the callback; addTags() annotates one span; captureError() reports an error you handled yourself, which would otherwise be invisible because only errors that propagate out of a controller, job or span are captured automatically; counter(), gauge() and summary() are the custom metrics (they report into the dashboard’s Custom section and can be alerted on); setAttribute() and getAttribute() use a request-scoped store. Typing that store needs a type alias, not an interface: with an interface the TypeScript 6 compiler rejected TracerService<RequestStore> because an interface has no index signature (Type 'RequestStore' does not satisfy the constraint 'Record<string | symbol, unknown>'), although the documentation example uses an interface.

Distributed tracing

Within one service every span shares the trace id. Across services it depends on the channel: HTTP calls carry the id in the x-request-id header (the default generator adopts a well-formed incoming one, and the SDK adds it to outbound fetch, undici and, on Node 22.12 and later, node:http requests), BullMQ and Bull jobs inherit it through the job options, and on @nestjs/microservices 12.0.4 or later the TCP and Redis transports carry it in packet metadata with @nestjs/observe 0.3.0 or later. gRPC and the other transports need you to forward it with currentTraceId() (in gRPC metadata, or inside the message payload) and to override traceIdGenerator on the receiving side. With attachTraceIdToLogs (default true) every ConsoleLogger line written inside a traced execution ends with Trace ID: <id>, or carries a traceId field in JSON logging:

LOG [BooksController] Looking up book 42 (trace 01a0fda1-e689-7660-ae84-efa78cd263d7)   Trace ID: 01a0fda1-e689-7660-ae84-efa78cd263d7

Error monitoring

Unhandled errors are grouped server-side into defects by exception class and normalised stack shape, so a spike of identical errors is one entry, with first and last seen, the release that introduced it (from serviceVersion) and an occurrence count. Source context (a few lines of your source around each application frame, on by default and configured under createObserveModule({ sourceContext })) is shipped to the service; turn it off with sourceContext: false if no source code may leave your infrastructure. Expected exceptions such as NotFoundException appear as failed requests but do not count as new defects for alerting; only unhandled failures do. Every plan, including the free one, sends one new-error email per project (at most six a day).

Dashboard, MCP server and plans

The dashboard organises telemetry by team, project and application and nests views as analytics (aggregates per route, job or span), operation and execution (one request with its waterfall, error and logs). The views include requests, services (time per class and method), errors, jobs, spans, traces, users, custom metrics, releases, logs and a profiler, plus a service map inferred from query and outbound-call spans, and a button that turns a failing execution into a prompt for a coding agent. Alerts, SLOs with error budgets and burn-rate rules, and issues with automatic fix verification are built on the same data.

The MCP server (Streamable HTTP, POST https://<api-host>/mcp, authenticated with a personal token created in the dashboard and acting with the creating user’s permissions) lets an MCP client such as Claude Code query regressions, slow operations, error groups, traces, jobs and alerts, and, through a few write tools, create a project, an application and an API key. Everything else, including alert rules and error-group status, stays read-only over MCP.

Usage is metered in observability events (one per request, job run, error, log line or trace span you send). As described in the documentation on 2026-10-02:

Plan Included events per month Retention Notable features

Free

300k (hard limit)

3 days

Error monitoring, tracing, auto-instrumentation

Pro

25M (overage billed)

30 days

Adds log forwarding and alerting

Scale

200M (overage billed)

90 days

Adds SSO and SLOs

Enterprise

Negotiated

Negotiated

All of the above

Sources: NestJS: Observability - Dashboard, MCP server and Overview (plans and billing). Prices and limits change; check the service before choosing a plan.

Observe or OpenTelemetry

NestJS Observe OpenTelemetry stack

Setup

One SDK, one module, one instrument option, an API key

SDK file started before Nest, exporters, usually a Collector and one or more backends you run or buy

Vocabulary

Nest classes and methods: OrdersService.recalculate

HTTP routes and library spans; Nest-level spans are yours to create on Nest 12

Lock-in

A hosted service with its own SDK and protocol

Vendor-neutral OTLP; the backend is replaceable

Data location

Telemetry (and optionally source and logs) leaves for the service

Wherever you send it, including your own network

Maturity

Pre-1.0 package, new service

Stable API and SDK for traces and metrics, a large ecosystem

They can run side by side in one process (Observe attaches through instrument, OpenTelemetry through patched modules), but doing so doubles the overhead and the number of places that record the same request; pick one for tracing. Neither replaces the health endpoints, which exist for the orchestrator.

Putting it together in the Bookshelf

A reasonable production baseline for the Bookshelf API:

  • Three probe endpoints (/health/live, /health/ready, /health/startup) from Terminus, excluded from the global prefix, the guards and the telemetry; the readiness check holds the database (withTimeout and a short cacheFor) and required services, the cache is degraded(), and SHUTDOWN_DELAY_MS is set to cover the probe lag.

  • One tracing path: the OpenTelemetry SDK preloaded with --import and exporting OTLP to a Collector, or NestJS Observe if a hosted service is acceptable.

  • Metrics for Prometheus: the default process metrics plus the request histogram by route pattern, and a handful of business counters with low-cardinality labels; /metrics reachable only from the monitoring network.

  • Logs that carry the trace id (see Logging), so a trace links to the lines written during it.

  • Alerts on symptoms users feel (error ratio, latency percentile) and on readiness failures, not on every internal metric.

The Docker and Kubernetes side (images, Compose, probes, preStop hooks, autoscaling on the metrics above) is in Deployment, Observability: Logging and Metrics and Resources, Logging and Monitoring. For the same topic in another framework see FastAPI: Performance and Observability.

Where the book is outdated

  • The book presents @nestjs/terminus as a proxy. It is a health-check library: it runs indicators and turns their states into one HTTP answer, and it proxies nothing.

  • Health checks in the older style extend HealthIndicator and throw HealthCheckError; neither exists in Terminus 12. Use HealthIndicatorService with up(), down(), degraded() and attempt().

  • The per-indicator timeout option is replaced by .withTimeout(); .cacheFor(), degraded() and responseTime did not exist.

  • The book’s OpenTelemetry chapter uses the Jaeger exporter, the Resource class and SemanticResourceAttributes. Current code exports OTLP, builds the resource with resourceFromAttributes() and uses the ATTR_* constants of @opentelemetry/semantic-conventions, as in instrumentation-otlp.ts above, and the SDK must start before Nest and Express are imported.

  • Tracing recipes built on @opentelemetry/instrumentation-nestjs-core do not produce spans on Nest 12 (see above), and there is no mention of NestJS Observe, which did not exist when the book was written.

  • Prometheus examples that import prom-client predate the move of the client to @prometheus-io/client.

What changed recently

  • Terminus 11 (January 2025) introduced HealthIndicatorService; Terminus 12.0.0 (2026-08-31) removed the legacy indicators, added attempt(), degraded(), cacheFor(), responseTime and ESM-only packaging; 12.1.0 (2026-09-20) fixed a memory leak in attempt().withTimeout() and accepts a URL in HttpHealthIndicator.pingCheck().

  • The instrument application option (since @nestjs/core 11.1.4) is how @nestjs/observe attaches; the observability documentation chapter is new in the September 2026 documentation restructure.

  • @opentelemetry/instrumentation-nestjs-core has not followed Nest 12 (supported range below 12); @fastify/otel replaces the Fastify instrumentation of the contrib bundle.

  • prom-client was deprecated in August 2026 in favour of @prometheus-io/client (0.16.x).