diff --git a/app/backend/src/health/health.controller.ts b/app/backend/src/health/health.controller.ts index 5e2d724f..1d29c075 100644 --- a/app/backend/src/health/health.controller.ts +++ b/app/backend/src/health/health.controller.ts @@ -9,7 +9,11 @@ import { import { Response } from 'express'; import { RequestWithRequestId } from '../middleware/request-correlation.middleware'; import { HealthService } from './health.service'; -import { LivenessResponse, ReadinessResponse } from './health.service'; +import { + LivenessResponse, + ReadinessResponse, + DependenciesResponse, +} from './health.service'; import { API_VERSIONS } from '../common/constants/api-version.constants'; import { Public } from '../common/decorators/public.decorator'; import { Throttle } from '@nestjs/throttler'; @@ -109,7 +113,27 @@ export class HealthController { return readiness; } - + @Public() + @Get('dependencies') + @Version(API_VERSIONS.V1) + @ApiOperation({ + summary: 'Dependency health with on-chain latency', + description: + 'Reports the time taken for an on-chain RPC call as onchain_rpc_ms. Status is "degraded" if it exceeds 5s.', + }) + @ApiOkResponse({ description: 'Dependency health retrieved.' }) + @ApiServiceUnavailableResponse({ + description: 'On-chain dependency is down.', + }) + async dependencies( + @Res({ passthrough: true }) res: Response, + ): Promise { + const result = await this.healthService.getDependenciesHealth(); + if (result.status === 'down') { + res.status(HttpStatus.SERVICE_UNAVAILABLE); // only "down" is a 503 + } + return result; + } @Get('error') @Version(API_VERSIONS.V1) @ApiOperation({ summary: 'Trigger an error for testing' }) diff --git a/app/backend/src/health/health.service.ts b/app/backend/src/health/health.service.ts index 9fe1fd5c..6853b445 100644 --- a/app/backend/src/health/health.service.ts +++ b/app/backend/src/health/health.service.ts @@ -38,6 +38,16 @@ export interface ReadinessResponse { }; } +export interface DependenciesResponse { + status: 'ok' | 'degraded' | 'down'; // degraded = alive but slow + service: 'backend'; + timestamp: string; + checks: { + onchain_rpc_ms: number; // the number the issue asks for + onchain: 'up' | 'down'; + }; +} + @Injectable() export class HealthService { constructor( @@ -114,6 +124,44 @@ export class HealthService { }; } + async getDependenciesHealth(): Promise { + const ONCHAIN_THRESHOLD_MS = 5000; // 5s = the "too slow" line from the issue + + const startTime = Date.now(); // stopwatch START + let onchainStatus: 'up' | 'down' = 'up'; + + try { + // The one RPC call we time. Swap to getAdmin() if the maintainer asks. + await this.onchainAdapter.getContractMetadata(); + } catch (error) { + onchainStatus = 'down'; + const message = + error instanceof Error ? error.message : 'Unknown on-chain error'; + this.logger.warn('Dependencies on-chain check failed', 'HealthService', { + error: message, + }); + } + + const onchainRpcMs = Date.now() - startTime; // stopwatch STOP → elapsed ms + + // Decide overall status: dead > slow > healthy + let status: 'ok' | 'degraded' | 'down' = 'ok'; + if (onchainStatus === 'down') { + status = 'down'; + } else if (onchainRpcMs > ONCHAIN_THRESHOLD_MS) { + status = 'degraded'; + } + + return { + status, + service: 'backend', + timestamp: new Date().toISOString(), + checks: { + onchain_rpc_ms: onchainRpcMs, + onchain: onchainStatus, + }, + }; + } logHealthCheck(requestId?: string) { this.logger.log('Health check endpoint accessed', 'HealthService', { requestId, diff --git a/app/backend/test/health.e2e-spec.ts b/app/backend/test/health.e2e-spec.ts new file mode 100644 index 00000000..9fa56e9b --- /dev/null +++ b/app/backend/test/health.e2e-spec.ts @@ -0,0 +1,70 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { ConfigService } from '@nestjs/config'; +import { RedisService } from '@liaoliaots/nestjs-redis'; +import { HealthController } from './../src/health/health.controller'; +import { HealthService } from './../src/health/health.service'; +import { PrismaService } from './../src/prisma/prisma.service'; +import { LoggerService } from './../src/logger/logger.service'; +import { ONCHAIN_ADAPTER_TOKEN } from './../src/onchain/onchain.adapter'; + +describe('Health dependencies (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], // the horn + providers: [ + HealthService, // the wiring behind it (real logic runs) + // Fake on-chain adapter: instant, no real network call. + { + provide: ONCHAIN_ADAPTER_TOKEN, + useValue: { + getContractMetadata: () => + Promise.resolve({ + version: '1.0.0', + name: 'AidEscrow', + timestamp: new Date(), + }), + }, + }, + // Empty stubs for deps our method doesn't use, but the + // service still asks for at construction time. + { provide: ConfigService, useValue: { get: () => undefined } }, + { + provide: LoggerService, + useValue: { log: () => {}, warn: () => {}, error: () => {} }, + }, + { provide: PrismaService, useValue: {} }, + { provide: RedisService, useValue: {} }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('/health/dependencies (GET) reports onchain_rpc_ms', () => { + return request(app.getHttpServer()) + .get('/health/dependencies') + .expect(200) + .expect( + (res: { + body: { + status: string; + checks: { onchain: string; onchain_rpc_ms: number }; + }; + }) => { + expect(res.body.status).toBe('ok'); + expect(res.body.checks.onchain).toBe('up'); + expect(typeof res.body.checks.onchain_rpc_ms).toBe('number'); + }, + ); + }); +}); diff --git a/app/frontend/openapi.json b/app/frontend/openapi.json index c7f11262..6c0148c1 100644 --- a/app/frontend/openapi.json +++ b/app/frontend/openapi.json @@ -157,6 +157,25 @@ ] } }, + "/api/v1/health/dependencies": { + "get": { + "description": "Reports the time taken for an on-chain RPC call as onchain_rpc_ms. Status is \"degraded\" if it exceeds 5s.", + "operationId": "HealthController_dependencies_v1", + "parameters": [], + "responses": { + "200": { + "description": "Dependency health retrieved." + }, + "503": { + "description": "On-chain dependency is down." + } + }, + "summary": "Dependency health with on-chain latency", + "tags": [ + "Health" + ] + } + }, "/api/v1/health/error": { "get": { "operationId": "HealthController_triggerError_v1",