fix: enhance health check functionality with detailed memory diagnostics and stable liveness probe

This commit is contained in:
Linus Rath
2026-03-19 18:50:59 +01:00
parent 44896dee3e
commit a009e5ae32
2 changed files with 124 additions and 35 deletions
+26 -35
View File
@@ -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 });
+98
View File
@@ -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' });
});
});