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
21 changes: 19 additions & 2 deletions apps/backend/src/app/api/deployments/[id]/health/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
105 changes: 105 additions & 0 deletions apps/backend/src/services/health-score.service.ts
Original file line number Diff line number Diff line change
@@ -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<HealthScoreResult> {
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();
94 changes: 94 additions & 0 deletions apps/backend/tests/health/health-score.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading