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
- node
- -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
timeout: 5s
retries: 8
+1 -1
View File
@@ -25,7 +25,7 @@ services:
- CMD
- node
- -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
timeout: 5s
retries: 8
+1 -1
View File
@@ -108,7 +108,7 @@ The workflow:
4. creates a timestamped JSON backup if the staging database exists;
5. applies additive, repeatable migrations;
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.
The outage covers backup, migration, and container startup. Migrations may also
+1 -1
View File
@@ -73,6 +73,7 @@ describe('isLaunchGateExempt', () => {
'/contact',
'/auth/discord',
'/auth/discord/callback',
'/api/health',
'/api/health/database',
'/api/internal/notifications',
'/_app/immutable/entry/start.js',
@@ -88,7 +89,6 @@ describe('isLaunchGateExempt', () => {
'/coming-soon/preview',
'/sign-in/help',
'/auth',
'/api/health',
'/api/internal',
'/favicon.png'
])('does not exempt %s', (pathname) => {
+1
View File
@@ -64,6 +64,7 @@ export function isLaunchGateExempt(pathname: string) {
pathname === '/dmca' ||
pathname === '/contact' ||
pathname.startsWith('/auth/') ||
pathname === '/api/health' ||
pathname.startsWith('/api/health/') ||
pathname.startsWith('/api/internal/') ||
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 });
}