Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions app/backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<DependenciesResponse> {
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' })
Expand Down
48 changes: 48 additions & 0 deletions app/backend/src/health/health.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -114,6 +124,44 @@ export class HealthService {
};
}

async getDependenciesHealth(): Promise<DependenciesResponse> {
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,
Expand Down
70 changes: 70 additions & 0 deletions app/backend/test/health.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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<App>;

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');
},
);
});
});
19 changes: 19 additions & 0 deletions app/frontend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading