Add application health endpoint

This commit is contained in:
jb
2026-07-30 11:36:15 -04:00
parent 3f440dd3c9
commit 6186534ac8
7 changed files with 185 additions and 4 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ services:
- CMD - CMD
- node - node
- -e - -e
- fetch('http://127.0.0.1:3000/api/health/database').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 8 retries: 8
+1 -1
View File
@@ -25,7 +25,7 @@ services:
- CMD - CMD
- node - node
- -e - -e
- fetch('http://127.0.0.1:3000/api/health/database').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 8 retries: 8
+1 -1
View File
@@ -108,7 +108,7 @@ The workflow:
4. creates a timestamped JSON backup if the staging database exists; 4. creates a timestamped JSON backup if the staging database exists;
5. applies additive, repeatable migrations; 5. applies additive, repeatable migrations;
6. starts the application and scheduler with the new image; 6. starts the application and scheduler with the new image;
7. waits for `/api/health/database` to report healthy; 7. waits for `/api/health` to report healthy;
8. retains the logs and exits unsuccessfully if health verification fails. 8. retains the logs and exits unsuccessfully if health verification fails.
The outage covers backup, migration, and container startup. Migrations may also The outage covers backup, migration, and container startup. Migrations may also
+1 -1
View File
@@ -73,6 +73,7 @@ describe('isLaunchGateExempt', () => {
'/contact', '/contact',
'/auth/discord', '/auth/discord',
'/auth/discord/callback', '/auth/discord/callback',
'/api/health',
'/api/health/database', '/api/health/database',
'/api/internal/notifications', '/api/internal/notifications',
'/_app/immutable/entry/start.js', '/_app/immutable/entry/start.js',
@@ -88,7 +89,6 @@ describe('isLaunchGateExempt', () => {
'/coming-soon/preview', '/coming-soon/preview',
'/sign-in/help', '/sign-in/help',
'/auth', '/auth',
'/api/health',
'/api/internal', '/api/internal',
'/favicon.png' '/favicon.png'
])('does not exempt %s', (pathname) => { ])('does not exempt %s', (pathname) => {
+1
View File
@@ -64,6 +64,7 @@ export function isLaunchGateExempt(pathname: string) {
pathname === '/dmca' || pathname === '/dmca' ||
pathname === '/contact' || pathname === '/contact' ||
pathname.startsWith('/auth/') || pathname.startsWith('/auth/') ||
pathname === '/api/health' ||
pathname.startsWith('/api/health/') || pathname.startsWith('/api/health/') ||
pathname.startsWith('/api/internal/') || pathname.startsWith('/api/internal/') ||
pathname.startsWith('/_app/') || pathname.startsWith('/_app/') ||
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({
row: { healthy: 1 } as { healthy?: number },
err: null as Error | null
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => {
if (state.err) {
throw state.err;
}
return {
prepare: async () => ({
get: async () => state.row
})
};
})
}));
import { GET } from './health/+server';
describe('GET /api/health', () => {
let originalBuildNumber: string | undefined;
let originalCommitSha: string | undefined;
let originalImage: string | undefined;
beforeEach(() => {
state.row = { healthy: 1 };
state.err = null;
originalBuildNumber = process.env.SHD_BUILD_NUMBER;
originalCommitSha = process.env.SHD_COMMIT_SHA;
originalImage = process.env.SHD_IMAGE;
delete process.env.SHD_BUILD_NUMBER;
delete process.env.SHD_COMMIT_SHA;
delete process.env.SHD_IMAGE;
});
afterEach(() => {
if (originalBuildNumber === undefined) {
delete process.env.SHD_BUILD_NUMBER;
} else {
process.env.SHD_BUILD_NUMBER = originalBuildNumber;
}
if (originalCommitSha === undefined) {
delete process.env.SHD_COMMIT_SHA;
} else {
process.env.SHD_COMMIT_SHA = originalCommitSha;
}
if (originalImage === undefined) {
delete process.env.SHD_IMAGE;
} else {
process.env.SHD_IMAGE = originalImage;
}
});
it('returns healthy status and metadata when checks pass', async () => {
process.env.SHD_BUILD_NUMBER = '123';
process.env.SHD_COMMIT_SHA = 'abc1234';
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload).toMatchObject({
status: 'ok',
status_code: 200,
ready: true,
service: 'app',
version: expect.any(String),
build_number: '123',
build_revision: 'abc1234',
checks: {
database: {
status: 'ok',
error: null
}
}
});
});
it('returns degraded status and 503 when DB check fails', async () => {
state.err = new Error('database unavailable');
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(503);
expect(payload).toMatchObject({
status: 'degraded',
status_code: 503,
ready: false,
checks: {
database: {
status: 'unhealthy',
error: 'database unavailable'
}
}
});
});
it('uses image tag as revision when commit sha is absent', async () => {
process.env.SHD_IMAGE = 'shd-build-production:7';
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.build_revision).toBe('7');
expect(payload.build_number).toBe('0');
});
});
+65
View File
@@ -0,0 +1,65 @@
import { getDatabase } from '$lib/server/database';
import { json } from '@sveltejs/kit';
import { version as appVersion } from '../../../../package.json';
function configuredBuildNumber() {
const raw = process.env.SHD_BUILD_NUMBER?.trim();
if (!raw || !/^\d+$/.test(raw)) return '0';
return raw;
}
function inferredRevision() {
const direct = process.env.SHD_COMMIT_SHA?.trim();
if (direct) return direct;
const image = process.env.SHD_IMAGE?.trim();
if (!image) return null;
const tag = image.includes(':') ? image.split(':').slice(-1)[0] : null;
return tag ? tag : null;
}
export async function GET() {
const startedAt = new Date().toISOString();
let databaseStatus: 'ok' | 'unhealthy' = 'ok';
let databaseError: string | null = null;
try {
const database = await getDatabase();
const statement = await database.prepare('SELECT 1 AS healthy');
const row = await statement.get();
if (row?.healthy !== 1) {
databaseStatus = 'unhealthy';
databaseError = 'health-check query returned unexpected value';
}
} catch (err) {
databaseStatus = 'unhealthy';
databaseError = err instanceof Error ? err.message : String(err);
}
const ready = databaseStatus === 'ok';
const responsePayload = {
status: 'ok',
service: 'app',
status_code: 200,
version: appVersion,
build_revision: inferredRevision(),
build_number: configuredBuildNumber(),
ready,
uptime_ms: process.uptime() * 1000,
timestamp: startedAt,
checks: {
database: {
status: databaseStatus,
error: databaseError
}
}
};
if (!ready) {
responsePayload.status = 'degraded';
responsePayload.status_code = 503;
}
return json(responsePayload, { status: ready ? 200 : 503 });
}