From 1554633b59ddbc8fb9e70ede9874196f233be36d Mon Sep 17 00:00:00 2001 From: Nexha-dev Date: Fri, 26 Jun 2026 13:20:09 +0000 Subject: [PATCH] feat(health): add weighted health scoring algorithm for deployment monitoring Closes #761 --- .../app/api/deployments/[id]/health/route.ts | 21 +++- .../src/services/health-score.service.ts | 105 ++++++++++++++++++ .../backend/tests/health/health-score.test.ts | 94 ++++++++++++++++ 3 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/services/health-score.service.ts create mode 100644 apps/backend/tests/health/health-score.test.ts diff --git a/apps/backend/src/app/api/deployments/[id]/health/route.ts b/apps/backend/src/app/api/deployments/[id]/health/route.ts index 553c98b3..c96e3fdd 100644 --- a/apps/backend/src/app/api/deployments/[id]/health/route.ts +++ b/apps/backend/src/app/api/deployments/[id]/health/route.ts @@ -1,11 +1,28 @@ import { NextRequest, NextResponse } from 'next/server'; import { withDeploymentAuth } from '@/lib/api/with-auth'; import { healthMonitorService } from '@/services/health-monitor.service'; +import { healthScoreService } from '@/services/health-score.service'; +import { VercelService } from '@/services/vercel.service'; + +const vercelService = new VercelService(); export const GET = withDeploymentAuth(async (_req: NextRequest, { params }) => { try { - const health = await healthMonitorService.checkDeploymentHealth(params.id); - return NextResponse.json(health); + const [health, scoreResult] = await Promise.all([ + healthMonitorService.checkDeploymentHealth(params.id), + // Use Vercel circuit state as a proxy for Soroban RPC connectivity + healthScoreService.computeScore( + params.id, + vercelService.breaker.currentState === 'CLOSED' + ), + ]); + + return NextResponse.json({ + ...health, + score: scoreResult.score, + breakdown: scoreResult.breakdown, + circuitState: vercelService.breaker.currentState, + }); } catch (error: any) { console.error('Error checking deployment health:', error); return NextResponse.json( diff --git a/apps/backend/src/services/health-score.service.ts b/apps/backend/src/services/health-score.service.ts new file mode 100644 index 00000000..da191460 --- /dev/null +++ b/apps/backend/src/services/health-score.service.ts @@ -0,0 +1,105 @@ +import { analyticsService } from './analytics.service'; + +/** Weights must sum to 1.0 */ +const WEIGHTS = { + uptime: 0.4, + latency: 0.3, + errorRate: 0.2, + rpc: 0.1, +} as const; + +/** + * Latency thresholds for score mapping (ms). + * p95 <= GOOD_LATENCY_MS → full latency score. + * p95 >= BAD_LATENCY_MS → zero latency score. + * Linear interpolation between thresholds. + */ +const GOOD_LATENCY_MS = 200; +const BAD_LATENCY_MS = 2_000; + +export interface HealthScoreBreakdown { + uptime: number; + latency: number; + errorRate: number; + rpc: number; +} + +export interface HealthScoreResult { + /** Weighted overall score 0–100. */ + score: number; + breakdown: HealthScoreBreakdown; +} + +export class HealthScoreService { + /** + * Compute a weighted health score for a deployment from its last 24 h of + * analytics data plus a live Soroban RPC connectivity probe. + */ + async computeScore( + deploymentId: string, + sorobanRpcHealthy: boolean + ): Promise { + const since = new Date(Date.now() - 24 * 60 * 60 * 1_000); + + const [uptimeRows, latencyRows, errorRows] = await Promise.all([ + analyticsService.getAnalytics(deploymentId, 'uptime_check', since), + analyticsService.getAnalytics(deploymentId, 'response_time_ms', since), + analyticsService.getAnalytics(deploymentId, 'error', since), + ]); + + // ── Uptime score (0–100) ────────────────────────────────────────────── + let uptimeScore = 100; + if (uptimeRows.length > 0) { + const upCount = uptimeRows.filter((r) => r.metricValue === 1).length; + uptimeScore = (upCount / uptimeRows.length) * 100; + } + + // ── Latency score from p95 (0–100) ──────────────────────────────────── + let latencyScore = 100; + if (latencyRows.length > 0) { + const sorted = latencyRows.map((r) => r.metricValue).sort((a, b) => a - b); + const p95 = sorted[Math.floor(sorted.length * 0.95)]; + if (p95 >= BAD_LATENCY_MS) { + latencyScore = 0; + } else if (p95 > GOOD_LATENCY_MS) { + latencyScore = ((BAD_LATENCY_MS - p95) / (BAD_LATENCY_MS - GOOD_LATENCY_MS)) * 100; + } + } + + // ── Error rate score (0–100) ────────────────────────────────────────── + let errorRateScore = 100; + if (errorRows.length > 0 || uptimeRows.length > 0) { + const totalRequests = uptimeRows.length + errorRows.length; + if (totalRequests > 0) { + const errorRate = errorRows.length / totalRequests; + errorRateScore = Math.max(0, (1 - errorRate) * 100); + } + } + + // ── RPC connectivity score (0–100) ──────────────────────────────────── + const rpcScore = sorobanRpcHealthy ? 100 : 0; + + // ── Weighted aggregate ──────────────────────────────────────────────── + const score = + round(uptimeScore) * WEIGHTS.uptime + + round(latencyScore) * WEIGHTS.latency + + round(errorRateScore) * WEIGHTS.errorRate + + rpcScore * WEIGHTS.rpc; + + return { + score: round(score), + breakdown: { + uptime: round(uptimeScore), + latency: round(latencyScore), + errorRate: round(errorRateScore), + rpc: rpcScore, + }, + }; + } +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +export const healthScoreService = new HealthScoreService(); diff --git a/apps/backend/tests/health/health-score.test.ts b/apps/backend/tests/health/health-score.test.ts new file mode 100644 index 00000000..f7d2761b --- /dev/null +++ b/apps/backend/tests/health/health-score.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HealthScoreService } from '@/services/health-score.service'; +import * as analyticsModule from '@/services/analytics.service'; + +const mockGetAnalytics = vi.spyOn(analyticsModule.analyticsService, 'getAnalytics'); + +function makeRows(type: string, values: number[]) { + return values.map((v, i) => ({ + id: `${i}`, + metricType: type, + metricValue: v, + recordedAt: new Date(), + })); +} + +describe('HealthScoreService.computeScore', () => { + const svc = new HealthScoreService(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 100 when all metrics are perfect', async () => { + mockGetAnalytics.mockImplementation(async (_id, type) => { + if (type === 'uptime_check') return makeRows('uptime_check', [1, 1, 1, 1]); + if (type === 'response_time_ms') return makeRows('response_time_ms', [100, 120, 130, 110]); + return []; // no errors + }); + + const result = await svc.computeScore('dep-1', true); + expect(result.score).toBe(100); + expect(result.breakdown.uptime).toBe(100); + expect(result.breakdown.latency).toBe(100); + expect(result.breakdown.errorRate).toBe(100); + expect(result.breakdown.rpc).toBe(100); + }); + + it('returns 0 for rpc component when RPC is unhealthy', async () => { + mockGetAnalytics.mockResolvedValue([]); + const result = await svc.computeScore('dep-1', false); + expect(result.breakdown.rpc).toBe(0); + // rpc weight is 0.1, so score should be 90 when everything else perfect + expect(result.score).toBe(90); + }); + + it('computes uptime score proportionally', async () => { + mockGetAnalytics.mockImplementation(async (_id, type) => { + if (type === 'uptime_check') return makeRows('uptime_check', [1, 1, 0, 0]); // 50% uptime + return []; + }); + const result = await svc.computeScore('dep-1', true); + expect(result.breakdown.uptime).toBe(50); + // score = 50*0.4 + 100*0.3 + 100*0.2 + 100*0.1 = 20 + 30 + 20 + 10 = 80 + expect(result.score).toBe(80); + }); + + it('scores p95 latency at 0 when above bad threshold', async () => { + const highLatency = Array(100).fill(3000); // all 3000ms → p95 = 3000ms >> 2000ms + mockGetAnalytics.mockImplementation(async (_id, type) => { + if (type === 'response_time_ms') return makeRows('response_time_ms', highLatency); + return []; + }); + const result = await svc.computeScore('dep-1', true); + expect(result.breakdown.latency).toBe(0); + }); + + it('interpolates latency score between thresholds', async () => { + // p95 = 1100ms; midpoint between 200 and 2000 → score = (2000-1100)/(2000-200)*100 = 50 + const values = Array(100).fill(1100); + mockGetAnalytics.mockImplementation(async (_id, type) => { + if (type === 'response_time_ms') return makeRows('response_time_ms', values); + return []; + }); + const result = await svc.computeScore('dep-1', true); + expect(result.breakdown.latency).toBeCloseTo(50, 0); + }); + + it('scores error rate correctly', async () => { + mockGetAnalytics.mockImplementation(async (_id, type) => { + if (type === 'uptime_check') return makeRows('uptime_check', [1, 1, 1, 1]); // 4 requests + if (type === 'error') return makeRows('error', [1, 1]); // 2 errors → 33% error rate + return []; + }); + const result = await svc.computeScore('dep-1', true); + // errorRate = 2/(4+2) = 0.333 → score = (1-0.333)*100 ≈ 66.67 + expect(result.breakdown.errorRate).toBeCloseTo(66.67, 0); + }); + + it('returns 100 for all components when no data (assumes healthy)', async () => { + mockGetAnalytics.mockResolvedValue([]); + const result = await svc.computeScore('dep-empty', true); + expect(result.score).toBe(100); + }); +});