fix: enhance health check functionality with detailed memory diagnostics and stable liveness probe
This commit is contained in:
+26
-35
@@ -1,10 +1,21 @@
|
||||
import v8 from 'node:v8';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Health check thresholds
|
||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
||||
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
|
||||
const MEMORY_WARNING_THRESHOLD = 0.85;
|
||||
const MEMORY_CRITICAL_THRESHOLD = 0.95;
|
||||
|
||||
function getHeapUsagePercent(heapUsed: number, heapTotal: number): number {
|
||||
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||
const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal;
|
||||
|
||||
if (denominator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (heapUsed / denominator) * 100;
|
||||
}
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
@@ -14,6 +25,7 @@ interface HealthStatus {
|
||||
memory?: {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
heapSizeLimit: number;
|
||||
rss: number;
|
||||
external: number;
|
||||
heapUsagePercent: number;
|
||||
@@ -27,14 +39,9 @@ interface HealthStatus {
|
||||
/**
|
||||
* Health check endpoint for container orchestration
|
||||
*
|
||||
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
|
||||
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
|
||||
* HEAD /api/health - Lightweight health check (status code only)
|
||||
*
|
||||
* Health status based on Node.js heap usage:
|
||||
* - Healthy (200): < 85% heap usage
|
||||
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
|
||||
* - Unhealthy (503): > 95% heap usage
|
||||
* GET /api/health - Liveness probe for container orchestration
|
||||
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
|
||||
* HEAD /api/health - Lightweight liveness probe (status code only)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
||||
|
||||
// Determine health status based on memory usage
|
||||
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
|
||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||
const warnings: string[] = [];
|
||||
let httpStatus = 200;
|
||||
|
||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||
status = 'unhealthy';
|
||||
httpStatus = 503;
|
||||
status = 'degraded';
|
||||
warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
||||
status = 'degraded';
|
||||
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`);
|
||||
warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||
}
|
||||
|
||||
// Build response
|
||||
const response: HealthStatus = {
|
||||
status,
|
||||
status: detailed ? status : 'healthy',
|
||||
timestamp,
|
||||
};
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// Add detailed information if requested
|
||||
if (detailed) {
|
||||
response.uptime = process.uptime();
|
||||
response.version = process.env.npm_package_version || '0.1.0';
|
||||
response.memory = {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
heapSizeLimit,
|
||||
rss: memUsage.rss,
|
||||
external: memUsage.external,
|
||||
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
||||
@@ -87,10 +87,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Health check', { status, detailed });
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: httpStatus,
|
||||
status: 200,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
|
||||
*/
|
||||
export async function HEAD() {
|
||||
try {
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
||||
|
||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
} catch {
|
||||
return new Response(null, { status: 503 });
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const loggerError = vi.fn();
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown, init?: { status?: number; headers?: unknown }) => ({
|
||||
status: init?.status ?? 200,
|
||||
headers: init?.headers,
|
||||
json: async () => data,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: {
|
||||
error: loggerError,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('health route', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
loggerError.mockReset();
|
||||
});
|
||||
|
||||
it('returns healthy for the basic liveness probe even when heap usage is high', async () => {
|
||||
vi.spyOn(process, 'memoryUsage').mockReturnValue({
|
||||
rss: 120_000_000,
|
||||
heapTotal: 45_000_000,
|
||||
heapUsed: 43_000_000,
|
||||
external: 8_000_000,
|
||||
arrayBuffers: 1_000_000,
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/health/route');
|
||||
const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toMatchObject({
|
||||
status: 'healthy',
|
||||
});
|
||||
expect(payload.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns degraded diagnostics in detailed mode without failing the probe', async () => {
|
||||
vi.spyOn(process, 'memoryUsage').mockReturnValue({
|
||||
rss: 120_000_000,
|
||||
heapTotal: 4_100_000_000,
|
||||
heapUsed: 4_000_000_000,
|
||||
external: 8_000_000,
|
||||
arrayBuffers: 1_000_000,
|
||||
});
|
||||
vi.spyOn(process, 'uptime').mockReturnValue(123.45);
|
||||
|
||||
const { GET } = await import('@/app/api/health/route');
|
||||
const response = await GET({ nextUrl: new URL('http://localhost/api/health?detailed=true') } as never);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.status).toBe('degraded');
|
||||
expect(payload.memory).toMatchObject({
|
||||
heapUsed: 4_000_000_000,
|
||||
heapTotal: 4_100_000_000,
|
||||
rss: 120_000_000,
|
||||
external: 8_000_000,
|
||||
});
|
||||
expect(payload.memory.heapSizeLimit).toBeGreaterThan(0);
|
||||
expect(payload.warnings).toEqual([
|
||||
expect.stringContaining('V8 heap usage is high'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps HEAD as a stable liveness probe', async () => {
|
||||
const { HEAD } = await import('@/app/api/health/route');
|
||||
const response = await HEAD();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 503 when collecting health diagnostics throws', async () => {
|
||||
vi.spyOn(process, 'memoryUsage').mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/health/route');
|
||||
const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(payload).toMatchObject({
|
||||
status: 'unhealthy',
|
||||
reason: 'boom',
|
||||
});
|
||||
expect(loggerError).toHaveBeenCalledWith('Health check failed', { error: 'boom' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user