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 { NextResponse } from 'next/server';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Health check thresholds
|
const MEMORY_WARNING_THRESHOLD = 0.85;
|
||||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
const MEMORY_CRITICAL_THRESHOLD = 0.95;
|
||||||
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
|
|
||||||
|
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 {
|
interface HealthStatus {
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
@@ -14,6 +25,7 @@ interface HealthStatus {
|
|||||||
memory?: {
|
memory?: {
|
||||||
heapUsed: number;
|
heapUsed: number;
|
||||||
heapTotal: number;
|
heapTotal: number;
|
||||||
|
heapSizeLimit: number;
|
||||||
rss: number;
|
rss: number;
|
||||||
external: number;
|
external: number;
|
||||||
heapUsagePercent: number;
|
heapUsagePercent: number;
|
||||||
@@ -27,14 +39,9 @@ interface HealthStatus {
|
|||||||
/**
|
/**
|
||||||
* Health check endpoint for container orchestration
|
* Health check endpoint for container orchestration
|
||||||
*
|
*
|
||||||
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
|
* GET /api/health - Liveness probe for container orchestration
|
||||||
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
|
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
|
||||||
* HEAD /api/health - Lightweight health check (status code only)
|
* HEAD /api/health - Lightweight liveness probe (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
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const memUsage = process.memoryUsage();
|
const memUsage = process.memoryUsage();
|
||||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
|
||||||
// Determine health status based on memory usage
|
|
||||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let httpStatus = 200;
|
|
||||||
|
|
||||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||||
status = 'unhealthy';
|
status = 'degraded';
|
||||||
httpStatus = 503;
|
warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||||
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
||||||
status = 'degraded';
|
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 = {
|
const response: HealthStatus = {
|
||||||
status,
|
status: detailed ? status : 'healthy',
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'unhealthy') {
|
|
||||||
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detailed information if requested
|
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
response.uptime = process.uptime();
|
response.uptime = process.uptime();
|
||||||
response.version = process.env.npm_package_version || '0.1.0';
|
response.version = process.env.npm_package_version || '0.1.0';
|
||||||
response.memory = {
|
response.memory = {
|
||||||
heapUsed: memUsage.heapUsed,
|
heapUsed: memUsage.heapUsed,
|
||||||
heapTotal: memUsage.heapTotal,
|
heapTotal: memUsage.heapTotal,
|
||||||
|
heapSizeLimit,
|
||||||
rss: memUsage.rss,
|
rss: memUsage.rss,
|
||||||
external: memUsage.external,
|
external: memUsage.external,
|
||||||
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
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, {
|
return NextResponse.json(response, {
|
||||||
status: httpStatus,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function HEAD() {
|
export async function HEAD() {
|
||||||
try {
|
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 });
|
return new Response(null, { status: 200 });
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(null, { status: 503 });
|
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