From 801e29e65c3ccf6a6a52bdf638270d510e408959 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 00:33:44 +0700 Subject: [PATCH] feat(api): add health check endpoints with @nestjs/terminus Add HealthModule with /health (liveness) and /ready (readiness) probes. Readiness checks DB (Prisma) and Redis connectivity. Replaces the basic /health endpoint in AppController. Co-Authored-By: Paperclip --- apps/api/package.json | 1 + apps/api/src/app.controller.ts | 5 - apps/api/src/app.module.ts | 2 + .../src/modules/health/health.controller.ts | 33 +++++ apps/api/src/modules/health/health.module.ts | 12 ++ apps/api/src/modules/health/index.ts | 1 + .../health/infrastructure/prisma.health.ts | 20 +++ .../health/infrastructure/redis.health.ts | 25 ++++ pnpm-lock.yaml | 118 ++++++++++++++++++ 9 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/modules/health/health.controller.ts create mode 100644 apps/api/src/modules/health/health.module.ts create mode 100644 apps/api/src/modules/health/index.ts create mode 100644 apps/api/src/modules/health/infrastructure/prisma.health.ts create mode 100644 apps/api/src/modules/health/infrastructure/redis.health.ts diff --git a/apps/api/package.json b/apps/api/package.json index be46119..adf7b52 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.0", "@nestjs/swagger": "^11.2.6", + "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@paralleldrive/cuid2": "^3.3.0", "@prisma/adapter-pg": "^7.7.0", diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index ab4d8dd..a9aafe4 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -6,9 +6,4 @@ export class AppController { root() { return { status: 'ok', service: 'goodgo-api' }; } - - @Get('health') - healthCheck() { - return { status: 'ok', service: 'goodgo-api', timestamp: new Date().toISOString() }; - } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2ff15fc..b22c8ec 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup'; import { AdminModule } from '@modules/admin'; import { AnalyticsModule } from '@modules/analytics'; import { AuthModule } from '@modules/auth'; +import { HealthModule } from '@modules/health'; import { ListingsModule } from '@modules/listings'; import { McpIntegrationModule } from '@modules/mcp'; import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics'; @@ -25,6 +26,7 @@ import { AppController } from './app.controller'; SentryModule.forRoot(), CqrsModule.forRoot(), SharedModule, + HealthModule, AuthModule, ListingsModule, ReviewsModule, diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts new file mode 100644 index 0000000..6421112 --- /dev/null +++ b/apps/api/src/modules/health/health.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get } from '@nestjs/common'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { PrismaHealthIndicator } from './infrastructure/prisma.health'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { RedisHealthIndicator } from './infrastructure/redis.health'; + +@Controller() +export class HealthController { + constructor( + private readonly health: HealthCheckService, + private readonly prismaHealth: PrismaHealthIndicator, + private readonly redisHealth: RedisHealthIndicator, + ) {} + + /** Liveness probe — always returns 200 if the process is running */ + @Get('health') + @HealthCheck() + liveness() { + return this.health.check([]); + } + + /** Readiness probe — checks DB and Redis connectivity */ + @Get('ready') + @HealthCheck() + readiness() { + return this.health.check([ + () => this.prismaHealth.isHealthy('database'), + () => this.redisHealth.isHealthy('redis'), + ]); + } +} diff --git a/apps/api/src/modules/health/health.module.ts b/apps/api/src/modules/health/health.module.ts new file mode 100644 index 0000000..1146305 --- /dev/null +++ b/apps/api/src/modules/health/health.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; +import { PrismaHealthIndicator } from './infrastructure/prisma.health'; +import { RedisHealthIndicator } from './infrastructure/redis.health'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], + providers: [PrismaHealthIndicator, RedisHealthIndicator], +}) +export class HealthModule {} diff --git a/apps/api/src/modules/health/index.ts b/apps/api/src/modules/health/index.ts new file mode 100644 index 0000000..1f435cb --- /dev/null +++ b/apps/api/src/modules/health/index.ts @@ -0,0 +1 @@ +export { HealthModule } from './health.module'; diff --git a/apps/api/src/modules/health/infrastructure/prisma.health.ts b/apps/api/src/modules/health/infrastructure/prisma.health.ts new file mode 100644 index 0000000..1ab24cc --- /dev/null +++ b/apps/api/src/modules/health/infrastructure/prisma.health.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; + +@Injectable() +export class PrismaHealthIndicator extends HealthIndicator { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async isHealthy(key: string): Promise { + try { + await this.prisma.$queryRawUnsafe('SELECT 1'); + return this.getStatus(key, true); + } catch { + throw new HealthCheckError('Database check failed', this.getStatus(key, false)); + } + } +} diff --git a/apps/api/src/modules/health/infrastructure/redis.health.ts b/apps/api/src/modules/health/infrastructure/redis.health.ts new file mode 100644 index 0000000..0b656db --- /dev/null +++ b/apps/api/src/modules/health/infrastructure/redis.health.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { RedisService } from '@modules/shared/infrastructure/redis.service'; + +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + constructor(private readonly redis: RedisService) { + super(); + } + + async isHealthy(key: string): Promise { + try { + const client = this.redis.getClient(); + const pong = await client.ping(); + const isHealthy = pong === 'PONG'; + const result = this.getStatus(key, isHealthy); + if (isHealthy) return result; + throw new HealthCheckError('Redis ping failed', result); + } catch (error) { + if (error instanceof HealthCheckError) throw error; + throw new HealthCheckError('Redis check failed', this.getStatus(key, false)); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56df104..0f87029 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: '@nestjs/swagger': specifier: ^11.2.6 version: 11.2.6(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + '@nestjs/terminus': + specifier: ^11.1.1 + version: 11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@prisma/client@7.7.0(prisma@7.7.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.2))(typescript@6.0.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2) @@ -1366,6 +1369,54 @@ packages: class-validator: optional: true + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + '@nestjs/testing@11.1.18': resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==} peerDependencies: @@ -3040,6 +3091,9 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -3182,6 +3236,10 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} @@ -3239,6 +3297,10 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001786: resolution: {integrity: sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==} @@ -3260,6 +3322,10 @@ packages: cheap-ruler@4.0.0: resolution: {integrity: sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==} + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -3290,6 +3356,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -5855,6 +5925,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type-fest@0.7.1: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} @@ -6141,6 +6215,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -7567,6 +7645,19 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.15.1 + '@nestjs/terminus@11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@prisma/client@7.7.0(prisma@7.7.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.2))(typescript@6.0.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + '@prisma/client': 7.7.0(prisma@7.7.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.2))(typescript@6.0.2) + '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18)': dependencies: '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -9510,6 +9601,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-colors@4.1.3: {} ansi-escapes@7.3.0: @@ -9635,6 +9730,17 @@ snapshots: bowser@2.14.1: {} + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 @@ -9700,6 +9806,8 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@6.3.0: {} + caniuse-lite@1.0.30001786: {} chai@6.2.2: {} @@ -9717,6 +9825,8 @@ snapshots: cheap-ruler@4.0.0: {} + check-disk-space@3.4.0: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -9755,6 +9865,8 @@ snapshots: dependencies: clsx: 2.1.1 + cli-boxes@2.2.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -12473,6 +12585,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.20.2: {} + type-fest@0.7.1: {} type-fest@5.5.0: @@ -12790,6 +12904,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + word-wrap@1.2.5: {} wordwrap@1.0.0: {}