diff --git a/src/app.module.ts b/src/app.module.ts index b003a35..95bd72d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Logger, Module } from '@nestjs/common'; import { APP_FILTER } from '@nestjs/core'; import { CallModule } from './modules/call/call.module'; +import { HealthModule } from './modules/health/health.module'; import { ConfigModule, ConfigService } from '@nestjs/config'; import configuration from './config/configuration'; import { SequelizeModule, SequelizeModuleOptions } from '@nestjs/sequelize'; @@ -79,6 +80,7 @@ const defaultDbConfig = ( }), CallModule, SharedModule, + HealthModule, ], controllers: [], providers: [ diff --git a/src/externals/avatar/avatar.service.ts b/src/externals/avatar/avatar.service.ts index 6dd7f5b..d442314 100644 --- a/src/externals/avatar/avatar.service.ts +++ b/src/externals/avatar/avatar.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { GetObjectCommand, S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'; import { ConfigService } from '@nestjs/config'; @Injectable() @@ -24,6 +24,26 @@ export class AvatarService { }); } + async checkBucket(timeoutMs = 1000) { + const bucket = this.configService.get('avatar.bucket'); + const client = this.AvatarS3Instance(); + const start = Date.now(); + const head = client.send( + new HeadBucketCommand({ Bucket: bucket }) + ); + + const timer = new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), timeoutMs), + ); + + try { + await Promise.race([head, timer]); + return { status: 'ok', ping: Date.now() - start }; + } catch (err: any) { + return { status: 'error', error: err?.message || String(err) }; + } + } + async getDownloadUrl(avatarKey: string): Promise { const s3Client = this.AvatarS3Instance(); const bucket = this.configService.get('avatar.bucket'); diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts new file mode 100644 index 0000000..90df678 --- /dev/null +++ b/src/modules/health/health.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, HttpCode } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { HealthService } from './health.service'; + +@ApiTags('Health') +@Controller('healthz') +export class HealthController { + constructor(private readonly healthService: HealthService) {} + + @Get() + @HttpCode(200) + @ApiOperation({ summary: 'Health check endpoint' }) + @ApiOkResponse({ description: 'Service is healthy' }) + async health() { + return this.healthService.check(); + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100644 index 0000000..2cb2b40 --- /dev/null +++ b/src/modules/health/health.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; +import { AvatarService } from '../../externals/avatar/avatar.service'; + +@Module({ + controllers: [HealthController], + providers: [HealthService, AvatarService], + exports: [HealthService], +}) +export class HealthModule {} diff --git a/src/modules/health/health.service.spec.ts b/src/modules/health/health.service.spec.ts new file mode 100644 index 0000000..4a402ec --- /dev/null +++ b/src/modules/health/health.service.spec.ts @@ -0,0 +1,80 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Sequelize } from 'sequelize-typescript'; +import { AvatarService } from '../../externals/avatar/avatar.service'; +import { HealthService } from './health.service'; + +describe('HealthService', () => { + let service: HealthService; + let sequelize: DeepMocked; + let avatarService: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HealthService], + }) + .useMocker(createMock) + .compile(); + + service = module.get(HealthService); + avatarService = module.get>(AvatarService) + sequelize = module.get>(Sequelize); + }); + + describe('check', () => { + it('When DB and S3 succeed, then it returns ok payload', async () => { + avatarService.checkBucket.mockResolvedValue({ status: 'ok', ping: 1 }); + sequelize.authenticate.mockResolvedValue(undefined); + + const result = await service.check(); + + expect(result).toStrictEqual({ + timestamp: expect.any(String), + uptime: expect.any(Number), + status: 'ok', + db: { + status: 'ok', + ping: expect.any(Number) + }, + s3: { + status: 'ok', + ping: 1 + } + }) + }); + + it('When DB fails, then it marks degraded and retains S3', async () => { + sequelize.authenticate.mockRejectedValue(new Error('db-down')); + avatarService.checkBucket.mockResolvedValue({ status: 'ok', ping: 1 }); + + const result = await service.check(); + + expect(result).toStrictEqual({ + timestamp: expect.any(String), + uptime: expect.any(Number), + status: 'degraded', + s3: { + status: 'ok', + ping: 1 + } + }) + }); + + it('When S3 fails, then it marks degraded and retains DB', async () => { + avatarService.checkBucket.mockRejectedValue(new Error('failing')); + sequelize.authenticate.mockResolvedValue(undefined); + + const result = await service.check(); + + expect(result).toStrictEqual({ + timestamp: expect.any(String), + uptime: expect.any(Number), + status: 'degraded', + db: { + status: 'ok', + ping: expect.any(Number) + }, + }); + }); + }); +}); diff --git a/src/modules/health/health.service.ts b/src/modules/health/health.service.ts new file mode 100644 index 0000000..080a492 --- /dev/null +++ b/src/modules/health/health.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Sequelize } from 'sequelize-typescript'; +import { AvatarService } from '../../externals/avatar/avatar.service'; + +type CheckResult = { status: 'ok' | 'error' | 'unknown'; ping?: number; error?: string }; +type HealthPayload = { + status: 'ok' | 'degraded'; + uptime: number; + timestamp: string; + db?: CheckResult; + s3?: CheckResult; +}; + +@Injectable() +export class HealthService { + private readonly logger = new Logger(HealthService.name); + + constructor( + private readonly sequelize: Sequelize, + private readonly avatarService: AvatarService, + ) {} + + private async checkDb(): Promise { + const start = Date.now(); + await this.sequelize.authenticate(); + return { status: 'ok', ping: Date.now() - start }; + } + + async check(): Promise { + const payload: HealthPayload = { + status: 'ok', + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }; + + const checks = { + db: this.checkDb(), + s3: this.avatarService.checkBucket(), + } as const; + + const results = await Promise.allSettled( + Object.entries(checks).map(async ([name, promise]) => { + const value = await promise; + return [name, value] as const; + }), + ); + + for (const r of results) { + if (r.status === 'fulfilled') { + const [name, value] = r.value; + payload[name] = value; + if (value.status !== 'ok') payload.status = 'degraded'; + } else { + payload.status = 'degraded'; + } + } + + this.logger.debug(`Health check performed: ${JSON.stringify(payload)}`); + return payload; + } +}