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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 00:33:44 +07:00
parent 3c6ed4c82a
commit 801e29e65c
9 changed files with 212 additions and 5 deletions

View File

@@ -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",

View File

@@ -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() };
}
}

View File

@@ -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,

View File

@@ -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'),
]);
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1 @@
export { HealthModule } from './health.module';

View File

@@ -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<HealthIndicatorResult> {
try {
await this.prisma.$queryRawUnsafe('SELECT 1');
return this.getStatus(key, true);
} catch {
throw new HealthCheckError('Database check failed', this.getStatus(key, false));
}
}
}

View File

@@ -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<HealthIndicatorResult> {
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));
}
}
}

118
pnpm-lock.yaml generated
View File

@@ -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: {}