From e4968dbea7f2d1fe085704e9c4fe73b090b17c6c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 15 Jan 2025 20:29:43 +0100 Subject: [PATCH] Feature/extend health check endpoint by database and cache operations (#4188) * Extend health check endpoint by database and cache operations * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/health/health.controller.ts | 20 +++++++++++--- apps/api/src/app/health/health.module.ts | 4 +++ apps/api/src/app/health/health.service.ts | 27 ++++++++++++++++++- .../app/redis-cache/redis-cache.service.ts | 21 +++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0d392a1..2e2ff3900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extended the health check endpoint to include database and cache operations (experimental) - Refactored various `lodash` functions with native JavaScript equivalents - Improved the language localization for German (`de`) - Upgraded `prisma` from version `6.1.0` to `6.2.1` diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index 62ee20419..6ff09825b 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -3,13 +3,14 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce import { Controller, Get, - HttpCode, HttpException, HttpStatus, Param, + Res, UseInterceptors } from '@nestjs/common'; import { DataSource } from '@prisma/client'; +import { Response } from 'express'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { HealthService } from './health.service'; @@ -19,9 +20,20 @@ export class HealthController { public constructor(private readonly healthService: HealthService) {} @Get() - @HttpCode(HttpStatus.OK) - public getHealth() { - return { status: getReasonPhrase(StatusCodes.OK) }; + public async getHealth(@Res() response: Response) { + const databaseServiceHealthy = await this.healthService.isDatabaseHealthy(); + const redisCacheServiceHealthy = + await this.healthService.isRedisCacheHealthy(); + + if (databaseServiceHealthy && redisCacheServiceHealthy) { + return response + .status(HttpStatus.OK) + .json({ status: getReasonPhrase(StatusCodes.OK) }); + } else { + return response + .status(HttpStatus.SERVICE_UNAVAILABLE) + .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); + } } @Get('data-enhancer/:name') diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts index 6ed464401..b8c4d5810 100644 --- a/apps/api/src/app/health/health.module.ts +++ b/apps/api/src/app/health/health.module.ts @@ -1,6 +1,8 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; @@ -12,6 +14,8 @@ import { HealthService } from './health.service'; imports: [ DataEnhancerModule, DataProviderModule, + PropertyModule, + RedisCacheModule, TransformDataSourceInRequestModule ], providers: [HealthService] diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts index b0c811392..f08f33a1e 100644 --- a/apps/api/src/app/health/health.service.ts +++ b/apps/api/src/app/health/health.service.ts @@ -1,5 +1,8 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; @@ -8,7 +11,9 @@ import { DataSource } from '@prisma/client'; export class HealthService { public constructor( private readonly dataEnhancerService: DataEnhancerService, - private readonly dataProviderService: DataProviderService + private readonly dataProviderService: DataProviderService, + private readonly propertyService: PropertyService, + private readonly redisCacheService: RedisCacheService ) {} public async hasResponseFromDataEnhancer(aName: string) { @@ -18,4 +23,24 @@ export class HealthService { public async hasResponseFromDataProvider(aDataSource: DataSource) { return this.dataProviderService.checkQuote(aDataSource); } + + public async isDatabaseHealthy() { + try { + await this.propertyService.getByKey(PROPERTY_CURRENCIES); + + return true; + } catch { + return false; + } + } + + public async isRedisCacheHealthy() { + try { + const isHealthy = await this.redisCacheService.isHealthy(); + + return isHealthy; + } catch { + return false; + } + } } diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index c972c30a1..51db93ec6 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -7,6 +7,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { Milliseconds } from 'cache-manager'; import { RedisCache } from 'cache-manager-redis-yet'; import { createHash } from 'crypto'; +import ms from 'ms'; @Injectable() export class RedisCacheService { @@ -59,6 +60,26 @@ export class RedisCacheService { return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; } + public async isHealthy() { + try { + const client = this.cache.store.client; + + const isHealthy = await Promise.race([ + client.ping(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Redis health check timeout')), + ms('2 seconds') + ) + ) + ]); + + return isHealthy === 'PONG'; + } catch (error) { + return false; + } + } + public async remove(key: string) { return this.cache.del(key); }