Create NestJS health check library

Reading Time: 6 minutes

Loading

Introduction

NestJS applications in my company implement health checking for k8s liveness probe. These applications check the following items are up and running: backend, database, redis, bull queue and external servers. Eventually, different backend teams encapsulate similar endpoint and services in a heath check module and import it to the application. This approach is not DRY and I decided to create a heath check library and reuse it in applications to replace the custom health check module.

In this blog post, I show the codes to create a nestjs health check library and use it in a sample application to check the health of backend, Postgres database, redis, bull queues and doc website of nestjs and angular.

let's go

Create health check library

First, create a new project called nestjs-health. Rename src/ to lib/ and update tsconfig.json and package.json respectively.

// Directory tree
lib
├── controllers
│   ├── health.controller.ts
│   └── index.ts
├── health-module-definition.ts
├── health.module.ts
├── index.ts
├── indicators
│   ├── bull-queue.health.ts
│   └── index.ts
├── interfaces
│   ├── health-module-options.interface.ts
│   └── index.ts
├── services
│   ├── health.service.ts
│   └── index.ts
└── types
    ├── index.ts
    └── redis-connection-options.type.ts
// tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "es2017",
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./lib",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false,
    "esModuleInterop": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "noImplicitThis": true,
    "noUnusedParameters": true,
    "noUnusedLocals": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["lib/**/*"],
  "exclude": ["dist", "test", "node_modules"]
}

Update outDir to ./dist and rootDir to ./lib. Include ['lib/**/*'] and excludes ["dist", "test", "node_modules"] from compilation

Add "main": "./dist/index.js" to package.json

Install dependencies

npm i --save-exact --save-dev @nestjs/terminus
npm i --save-exact @nestjs/microservices @nestjs/axios @nestjs/bull bull @nestjs/typeorm ts-node

Then, move some “dependencies” to “peerDependencies”

"peerDependencies": {
    "@nestjs/common": "^9.0.0",
    "@nestjs/core": "^9.0.0",
    "@nestjs/microservices": "^9.1.5",
    "@nestjs/axios": "^0.1.0",
    "@nestjs/bull": "^0.6.1",
    "bull": "^4.10.1",
    "@nestjs/typeorm": "^9.0.1",
    "ts-node": "^10.0.0"
}

Define HealthModuleOptions interface

First, we define HealthModuleOptions interface that enables us to pass configurations from application to the library

// redis-connection-options.type.ts

import { RedisOptions } from '@nestjs/microservices';

export type RedisConnectOptions = RedisOptions['options'];
// health-module-options.interface.ts

import { HealthIndicatorFunction } from '@nestjs/terminus';
import { RedisConnectOptions } from '../types';

export interface HealthModuleOptions {
  app: string;
  backendUrl: string;
  shouldCheckDatabase?: boolean;
  queueNames?: string[];
  redisOptions?: RedisConnectOptions;
  indicatorFunctions?: HealthIndicatorFunction[];
}
  • app – key of the backend application
  • backendUrl: URL of the backend application, for example, http://localhost:3000
  • shouldCheckDatabase: determine whether or not ping the database that uses TypeORM
  • queueNames: Name array of bull queues
  • indicatorFunctions: custom indicator functions designed to check components’ health

Define Health Module Definition

Define a ConfigurableModuleBuilder to export ConfigurationModuleClass and MODULE_OPTIONS_TOKEN.

Invoke setClassMethodName to override the static methods of HealthModule to forRoot and forRootAsync

import { ConfigurableModuleBuilder } from '@nestjs/common';
import { HealthModuleOptions } from './interfaces';

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<HealthModuleOptions>()
  .setClassMethodName('forRoot')
  .build();

Create module for the health check library

Create HealthModule that extends ConfigurableModuleClass

import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { ConfigurableModuleClass } from './health-module-definition';

@Module({
  imports: [TerminusModule],
  controllers: [],
  providers: [],
  exports: [],
})
export class HealthModule extends ConfigurableModuleClass {}

controllers, providers and exports are empty arrays but we will update them after generating services and controller.

Generate custom health indicator

The following code perform health check on arbitrary number of bull queues. A queue is up and can accept jobs when it connects to redis and redis status is ready.

// indicators/bull-queue.health.ts

import { getQueueToken } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { Queue } from 'bull';

@Injectable()
export class BullQueueHealthIndicator extends HealthIndicator {
  constructor(private moduleRef: ModuleRef) {
    super();
  }

  async isHealthy(queues: string[]): Promise<HealthIndicatorResult> {
    const promiseResults = await this.checkQueuesReady(queues);
    const errorResults = this.filterErrors(promiseResults);

    if (errorResults.length) {
      throw new HealthCheckError('Bull queue failed', this.getStatus('bull', false, { errors: errorResults }));
    }

    return this.getStatus('bull', true);
  }

  private filterErrors(promiseResults: PromiseSettledResult<boolean>[]): string[] {
    const errorResults: string[] = [];
    for (const promiseResult of promiseResults) {
      if (promiseResult.status === 'rejected') {
        if (promiseResult.reason instanceof Error) {
          errorResults.push(promiseResult.reason.message);
        } else {
          errorResults.push(promiseResult.reason);
        }
      }
    }
    return errorResults;
  }

  private async checkQueuesReady(queues: string[]) {
    const promises = queues.map(async (name) => {
      const queueToken = this.moduleRef.get(getQueueToken(name), { strict: false });
      if ((queueToken as Queue).isReady) {
        const queue = await (queueToken as Queue).isReady();
        const isEveryClientReady = queue.clients.every((client) => client.status === 'ready');
        if (!isEveryClientReady) {
          throw new Error(`${name} - some redis clients are not ready`);
        }
        return true;
      }
      throw new Error(`${name} is not a bull queue`);
    });

    return Promise.allSettled(promises);
  }
}

Create health checking service

In the health service, I inject MODULE_OPTIONS_TOKEN injection token to obtain a reference to HealthModuleOptions. Then, I examine the options to determine the components to perform health check.

// services/health.service.ts

import { MODULE_OPTIONS_TOKEN } from '../health-module-definition';
import { HealthModuleOptions } from '../interfaces';
import { BullQueueHealthIndicator } from '../indicators';

@Injectable()
export class HealthService {
  constructor(
    @Inject(MODULE_OPTIONS_TOKEN) private options: HealthModuleOptions,
    private health: HealthCheckService,
    private http: HttpHealthIndicator,
    private db: TypeOrmHealthIndicator,
    private bull: BullQueueHealthIndicator,
    private microservice: MicroserviceHealthIndicator,
  ) {}

  check(): Promise<HealthCheckResult> {
    const indicatorFunctions: HealthIndicatorFunction[] = [];

    indicatorFunctions.push(() => this.http.pingCheck(this.options.app, this.options.backendUrl));

    if (this.options.shouldCheckDatabase) {
      indicatorFunctions.push(() => this.db.pingCheck('database'));
    }

    if (this.options.redisOptions) {
      indicatorFunctions.push(() =>
        this.microservice.pingCheck<RedisOptions>('redis', {
          transport: Transport.REDIS,
          options: this.options.redisOptions,
          timeout: 5000,
        }),
      );
    }

    if (this.options.queueNames?.length) {
      indicatorFunctions.push(() => this.bull.isHealthy(this.options.queueNames));
    }

    if (this.options.indicatorFunctions?.length) {
      indicatorFunctions.push(...this.options.indicatorFunctions);
    }

    return this.health.check(indicatorFunctions);
  }
}

Create health controller

The controller has one endpoint, /health, that injects HeathService to check the readiness of the components.

import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckResult } from '@nestjs/terminus';
import { HealthService } from '../services';

@Controller('health')
export class HealthController {
  constructor(private health: HealthService) {}

  @Get()
  @HealthCheck()
  check(): Promise<HealthCheckResult> {
    return this.health.check();
  }
}

Register controller and services

Now, we register new controller and services in HealthModule

// heath.module.ts

import { Module } from '@nestjs/common';
... other import statements ...
import { HealthService } from './services';
import { HealthController } from './controllers';
import { BullQueueHealthIndicator } from './indicators';

@Module({
  imports: [TerminusModule],
  controllers: [HealthController],
  providers: [HealthService, BullQueueHealthIndicator],
  exports: [HealthService],
})
export class HealthModule extends ConfigurableModuleClass {}

Finally, add index.ts to export controllers, interfaces, services, types and the health module

./index.ts

export * from './interfaces';
export * from './services';
export * from './health.module';
export * from './types';

I finished the implementation of the library and will publish it to npmjs.com. Npm publish is not part of the scope of this article.

Use case of health checking

I would continue to use nestjs-health-terminus project to demonstrate how to health check the following resources:

  • backend app
  • Postgres database that uses TypeORM
  • redis
  • bull queues
  • just for fun, ping docs.nestjs.com and angular.io/docs are alive

Clone or fork the Nestjs project

You can find the sample code of nestjs bull queue in this repo: https://github.com/railsstudent/nestjs-health-terminus

First, copy .env.example to .env to load environment variables of redis and Postgres database

REDIS_PORT=6379
REDIS_HOST=localhost
PORT=3000
NODE_DEV=development
BACKEND_DOMAIN=http://localhost:3000
DATABASE_CONNECT=postgres
DATABASE_HOST=localhost
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres
DATABASE_NAME=testDB
DATABASE_PORT=5432

Second, run docker-compose to create and run redis and Postgres

docker-compose up -d

Install dependencies

Install nestjs-health library

npm install --save-exact nestjs-health

Import health module to AppModule

First, create health.config.ts in src/configs folder

import { ConfigService } from '@nestjs/config';
import { HttpHealthIndicator } from '@nestjs/terminus';
import { HealthModule } from 'nestjs-health';

export const healthConfig = HealthModule.forRootAsync({
  inject: [ConfigService, HttpHealthIndicator],
  useFactory: (configService: ConfigService, http: HttpHealthIndicator) => {
    return {
      app: 'nestjs-health-terminus',
      backendUrl: configService.get<string>('BACKEND_DOMAIN', ''),
      shouldCheckDatabase: true,
      queueNames: ['fibonacci', 'prime'],
      redisOptions: {
        host: configService.get<string>('REDIS_HOST', 'localhost'),
        port: configService.get<number>('REDIS_PORT', 0),
      },
      indicatorFunctions: [
        () => http.pingCheck('nestjs-docs', 'https://docs.nestjs.com'),
        () => http.pingCheck('angular-docs', 'https://angular.io/docs'),
      ],
    };
  },
});

heathConfig variable configures dependent resources that require health checking.

Next, import healthConfig in AppModule

@Module({
  imports: [
    ... other modules...
    healthConfig,
  ]
})
export class AppModule {}

Invoke health check endpoint

Run docker-compose to start redis. Since both fibonacci and prime queues can connect to redis, the health check status should be “up”

http://localhost:3000/health

Kill redis and call the same endpoint to display a different response.

docker stop <container id of redis>

redis and bull display health check error messages.

Kill Postgres and call the same endpoint to display a new response.

docker stop <container id of Postgres>

Final Thoughts

In this post, I show how to author a NestJS library to encapsulate health check services and controller in a dynamic module. Then, I publish the library to npmjs and install it into NestJS application to check the health of backend application, database, redis, bull queues and external servers. Finally, the library provides a defined /health endpoint that displays the status of components.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in NestJS and other technologies.

Resources:

  1. NestJS Heath Library: https://github.com/railsstudent/nestjs-health
  2. Repo: https://github.com/railsstudent/nestjs-health-terminus
  3. npm package: https://www.npmjs.com/package/nestjs-health
  4. Terminus: https://docs.nestjs.com/recipes/terminus
  5. Dynamic Module: https://docs.nestjs.com/fundamentals/dynamic-modules#dynamic-modules