Compare commits

..

12 Commits

Author SHA1 Message Date
Elijah Duffy
7522e9e0f3 node: breaking naming changes to match go version 2026-01-27 18:13:51 -08:00
Elijah Duffy
0ab0168a8d go: implement readiness library 2026-01-27 18:13:33 -08:00
Elijah Duffy
a73105586b v0.1.2 2025-12-24 22:07:36 -08:00
Elijah Duffy
c6ff80655b node: fix readiness waiting for next result instead of using latest 2025-12-24 22:07:17 -08:00
Elijah Duffy
0956271e29 v0.1.1 2025-12-24 19:08:28 -08:00
Elijah Duffy
620be9ccda fix exports 2025-12-24 19:04:55 -08:00
Elijah Duffy
061ee7c7fd node: configure publish 2025-12-24 18:51:20 -08:00
Elijah Duffy
ea3b90aca2 node: redesign readiness check system & improve event loop lag check 2025-12-24 18:47:03 -08:00
Elijah Duffy
a6d9d72322 node: add initial standard checks 2025-12-24 18:29:28 -08:00
Elijah Duffy
ae664da0e4 node: use performance.now for readiness duration 2025-12-24 18:29:19 -08:00
Elijah Duffy
d88c1c0c49 node: implement live & ready check helpers 2025-12-24 18:12:57 -08:00
Elijah Duffy
85b698d940 node: fix eslint, typescript, dev dependencies 2025-12-24 18:12:28 -08:00
12 changed files with 634 additions and 78 deletions

View File

@@ -1,17 +0,0 @@
module.exports = {
root: true,
env: {
node: true,
es2021: true,
},
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'prettier'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
rules: {
'prettier/prettier': 'error',
},
};

42
go-health/liveness.go Normal file
View File

@@ -0,0 +1,42 @@
package health
import (
"encoding/json"
"net/http"
"time"
)
// Status of the service liveness.
type LivenessStatus string
const (
// Service is alive and functioning properly.
LivenessStatusAlive LivenessStatus = "ok"
)
// Result of a liveness check.
type LivenessResult struct {
// Status of the service (always LivenessStatusAlive)
Status LivenessStatus
// Timestamp of the liveness check in milliseconds since Unix epoch
Timestamp int64
}
// Checks if the service is alive.
func Liveness() LivenessResult {
return LivenessResult{
Status: LivenessStatusAlive,
Timestamp: time.Now().UnixMilli(),
}
}
// Handler for liveness HTTP requests.
// Returns a response with status 200 and a JSON body matching LivenessResult.
func LivenessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
result := Liveness()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}
}

221
go-health/readiness.go Normal file
View File

@@ -0,0 +1,221 @@
package health
import (
"encoding/json"
"net/http"
"slices"
"time"
)
// Status of the service readiness.
type ReadinessStatus string
// String returns the string representation of the readiness status.
func (s ReadinessStatus) String() string {
return string(s)
}
// HTTPStatus returns the corresponding HTTP status code for the readiness status.
func (s ReadinessStatus) HTTPStatus() int {
switch s {
case ReadinessStatusReady:
return http.StatusOK
case ReadinessStatusDegraded:
return http.StatusOK // 206 could also be suitable, but let's avoid false alarms
case ReadinessStatusFatal:
return http.StatusServiceUnavailable
case ReadinessStatusNotReady:
return http.StatusOK // treat as ok to avoid false alarms
default:
return http.StatusOK
}
}
const (
// Service is ready to handle requests.
ReadinessStatusReady ReadinessStatus = "ready"
// Service is not ready to handle requests.
ReadinessStatusNotReady ReadinessStatus = "not_ready"
// Service experienced a fatal error and cannot handle requests.
ReadinessStatusFatal ReadinessStatus = "fatal"
// Service is degraded but can still handle requests.
ReadinessStatusDegraded ReadinessStatus = "degraded"
)
// Returns the worst readiness status from a list of statuses.
func worstStatus(statuses []ReadinessStatus) ReadinessStatus {
if slices.Contains(statuses, ReadinessStatusFatal) {
return ReadinessStatusFatal
}
if slices.Contains(statuses, ReadinessStatusNotReady) {
return ReadinessStatusNotReady
}
if slices.Contains(statuses, ReadinessStatusDegraded) {
return ReadinessStatusDegraded
}
return ReadinessStatusReady
}
// Readiness check function return type.
type ReadinessReturn struct {
// Status of the readiness check.
Status ReadinessStatus
// Optional message providing additional information about the readiness status.
Message *string
}
// Function that performs a readiness check.
type ReadinessCheckFn func() ReadinessReturn
// Individual readiness check.
type ReadinessCheck struct {
// Name of the readiness check.
Name string
// Function that performs the readiness check.
Fn ReadinessCheckFn
// Timeout for the readiness check (default: 5 seconds)
Timeout time.Duration
}
// System-generated result from an individual readiness check.
type ReadinessCheckResult struct {
// Name of the readiness check.
Name string
// Status of the readiness check.
Status ReadinessStatus
// Optional message providing additional information about the readiness status.
Message *string
// Duration of the readiness check in milliseconds.
Duration int64
}
// System readiness status.
type Readiness struct {
// Status of the service readiness. Aggregated as the worst status from
// all individual readiness checks. If no checks have been performed
// yet, the status is ReadinessStatusNotReady.
Status ReadinessStatus
// Start time of the readiness check in milliseconds since Unix epoch.
Start int64
// Duration of the readiness check in milliseconds.
Duration int64
// Individual readiness check results.
Details map[string]ReadinessCheckResult
}
// Performs a full system readiness check given a list of individual checks.
func CheckReadiness(checks []ReadinessCheck) Readiness {
start := time.Now()
details := make(map[string]ReadinessCheckResult)
var statuses []ReadinessStatus
for _, check := range checks {
checkt0 := time.Now()
result := checkWithTimeout(check)
duration := time.Since(checkt0).Milliseconds()
details[check.Name] = ReadinessCheckResult{
Name: check.Name,
Status: result.Status,
Message: result.Message,
Duration: duration,
}
statuses = append(statuses, result.Status)
}
return Readiness{
Status: worstStatus(statuses),
Start: start.UnixMilli(),
Duration: time.Since(start).Milliseconds(),
Details: details,
}
}
// Creates a handler function for readiness HTTP requests.
func ReadinessHandler(checks []ReadinessCheck) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
respondWithResult(w, CheckReadiness(checks))
}
}
// Runs a readiness check with a timeout.
func checkWithTimeout(check ReadinessCheck) ReadinessReturn {
timeout := check.Timeout
if timeout == 0 {
timeout = 5 * time.Second
}
resultCh := make(chan ReadinessReturn, 1)
go func() {
resultCh <- check.Fn()
}()
select {
case result := <-resultCh:
return result
case <-time.After(timeout):
msg := "readiness check timed out"
return ReadinessReturn{
Status: ReadinessStatusFatal,
Message: &msg,
}
}
}
func respondWithResult(w http.ResponseWriter, result Readiness) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(result.Status.HTTPStatus())
json.NewEncoder(w).Encode(result)
}
// ScheduledReadiness performs periodic readiness checks and stores the latest result.
type ScheduledReadiness struct {
checks []ReadinessCheck
interval time.Duration
currentState Readiness
ticker *time.Ticker
quit chan struct{}
}
// NewScheduledReadiness creates a new ScheduledReadiness instance.
func NewScheduledReadiness(checks []ReadinessCheck, interval time.Duration) *ScheduledReadiness {
return &ScheduledReadiness{
checks: checks,
interval: interval,
quit: make(chan struct{}),
}
}
// Start begins the periodic readiness checks.
func (sr *ScheduledReadiness) Start() {
sr.ticker = time.NewTicker(sr.interval)
go func() {
for {
select {
case <-sr.ticker.C:
sr.currentState = CheckReadiness(sr.checks)
case <-sr.quit:
sr.ticker.Stop()
return
}
}
}()
}
// Stop halts the periodic readiness checks.
func (sr *ScheduledReadiness) Stop() {
close(sr.quit)
}
// GetCurrentState returns the latest readiness state.
func (sr *ScheduledReadiness) GetCurrentState() Readiness {
return sr.currentState
}
// Creates a handler function for scheduled readiness HTTP requests.
func (sr *ScheduledReadiness) ReadinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
respondWithResult(w, sr.GetCurrentState())
}
}

View File

@@ -1,10 +0,0 @@
module.exports = {
root: true,
extends: ['../../.eslintrc.cjs'],
parserOptions: {
project: './tsconfig.json',
},
rules: {
// package-level overrides
},
};

View File

@@ -0,0 +1,14 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig([
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts}'],
plugins: { js },
extends: ['js/recommended'],
languageOptions: { globals: globals.browser },
},
tseslint.configs.recommended,
]);

View File

@@ -1,7 +1,14 @@
{
"name": "@health/node",
"version": "0.1.0",
"private": true,
"name": "@end/node-health",
"repository": {
"type": "git",
"url": "https://gitea.auvem.com/end/health.git"
},
"publishConfig": {
"registry": "https://gitea.auvem.com/api/packages/end/npm/"
},
"version": "0.1.2",
"license": "MIT",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
@@ -14,6 +21,13 @@
"test": "echo \"No tests configured\" && exit 0",
"check": "npm run lint && npm run build"
},
"dependencies": {},
"devDependencies": {}
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",
"eslint": "^9.39.2",
"globals": "^16.5.0",
"jiti": "^2.6.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1"
}
}

40
node-health/src/checks.ts Normal file
View File

@@ -0,0 +1,40 @@
import { monitorEventLoopDelay } from 'node:perf_hooks';
import { ReadinessCheck, ReadinessFunctionReturn, ReadinessStatus } from './readiness';
/**
* Builds a readiness check function that monitors event loop lag.
* @param options - Configuration options for the event loop lag check.
* @param options.degradedMs - Threshold in milliseconds for degraded status (default: 200).
* @param options.failMs - Threshold in milliseconds for error status (default: 1000).
* @param options.histResetMs - Interval in milliseconds to reset the histogram (default: 60000).
* @param options.percentile - Percentile to monitor (default: 50).
* @returns A ReadinessFunction that checks event loop lag.
*/
export const buildEventLoopLagCheck = (options: {
degradedMs?: number;
failMs?: number;
histResetMs?: number;
percentile?: number;
}): ReadinessCheck => {
const { degradedMs = 200, failMs = 1000, histResetMs = 60000, percentile = 50 } = options;
const hist = monitorEventLoopDelay({ resolution: 10 });
hist.enable();
setInterval(() => {
hist.reset();
}, histResetMs).unref();
return {
name: 'event-loop-lag',
fn: async (): Promise<ReadinessFunctionReturn> => {
const lag = hist.percentile(percentile) / 1e6; // Convert from nanoseconds to milliseconds
const status: ReadinessStatus = lag < degradedMs ? 'ok' : lag < failMs ? 'degraded' : 'error';
return {
status,
message: `Event loop lag is ${lag.toFixed(2)} ms`,
};
},
timeout: 500,
};
};

View File

@@ -1,30 +1,3 @@
/**
* Simple liveness & readiness helpers
*/
export type ReadinessResult = {
ok: boolean;
details: { name: string; ok: boolean; error?: string }[];
};
export function liveness() {
return {
status: 'ok',
timestamp: Date.now(),
};
}
export async function readiness(
checks: Array<{ name: string; fn: () => Promise<boolean> | boolean }>
): Promise<ReadinessResult> {
const results: ReadinessResult['details'] = [];
for (const c of checks) {
try {
const r = await Promise.resolve(c.fn());
results.push({ name: c.name, ok: !!r });
} catch (err: any) {
results.push({ name: c.name, ok: false, error: err?.message ?? String(err) });
}
}
return { ok: results.every((r) => r.ok), details: results };
}
export * from './liveness';
export * from './readiness';
export * from './checks';

View File

@@ -0,0 +1,31 @@
/**
* Type representing the result of a liveness check.
*/
export type LivenessResult = {
/** Status of the service (always 'ok'). */
status: 'ok';
/** Timestamp of the liveness check in milliseconds since the Unix epoch. */
timestamp: number;
};
/**
* Liveness check - indicates if the service is running.
* @returns A LivenessResult object.
*/
export function liveness() {
return {
status: 'ok',
timestamp: Date.now(),
} as LivenessResult;
}
/**
* Handler for liveness HTTP requests.
* @returns A Response object with LivenessResult in JSON format and status 200.
*/
export const handleLiveness = (): Response => {
return new Response(JSON.stringify(liveness()), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};

View File

@@ -0,0 +1,253 @@
/** Readiness check function return type */
export type ReadinessReturn = {
/** Status of the readiness check */
status: ReadinessStatus;
/** Optional message providing additional information about the readiness check */
message?: string;
};
/** Function that performs a readiness check */
export type ReadinessCheckFn = (check: ReadinessCheck) => Promise<ReadinessReturn>;
/** Status of the service readiness */
export type ReadinessStatus = 'fatal' | 'not_ready' | 'degraded' | 'ready';
/** Returns the worst readiness status from a list of statuses. */
const worstStatus = (statuses: ReadinessStatus[]): ReadinessStatus => {
if (statuses.includes('fatal')) return 'fatal';
if (statuses.includes('not_ready')) return 'not_ready';
if (statuses.includes('degraded')) return 'degraded';
return 'ready';
};
/** Individual readiness check */
export type ReadinessCheck = {
/** Name of the readiness check */
name: string;
/** Function that performs the readiness check */
fn: ReadinessCheckFn;
/** Timeout in milliseconds for the readiness check (default: 5000) */
timeout?: number;
};
/** System readiness status */
export type Readiness = {
/**
* Status of the system readiness check, aggregated as the worst status
* among individual checks. If no checks have been performed yet, the status
* is 'not_ready'.
*/
status: ReadinessStatus;
/** Start time of the system readiness check in milliseconds since the Unix epoch */
start: number;
/** Duration of the system readiness check in milliseconds */
duration: number;
/** Details of individual readiness checks */
details: ReadinessCheckResult[];
};
/** System-generated result from an individual readiness check */
export type ReadinessCheckResult = {
/** Name of the readiness check */
name: string;
/** Status of the readiness check */
status: ReadinessStatus;
/** Duration of the readiness check in milliseconds */
duration: number;
/** Message providing additional information about the readiness check */
message?: string;
};
/**
* Performs a readiness check by executing the provided readiness functions.
* @param checks - An array of readiness functions to execute.
* @returns A Promise that resolves to a Readiness object.
*/
export const readiness = async (checks: ReadinessCheck[]): Promise<Readiness> => {
const start = Date.now();
const t0 = performance.now();
const details: ReadinessCheckResult[] = [];
for (const check of checks) {
const checkt0 = performance.now();
try {
const result = await withTimeout(
check.fn(check),
check.timeout ?? 5000,
`Readiness check '${check.name}' timed out after ${check.timeout ?? 5000} ms`,
);
details.push({
name: check.name,
status: result.status,
message: result.message,
duration: performance.now() - checkt0,
});
} catch (err) {
details.push({
name: check.name,
status: 'fatal',
message: err instanceof Error ? err.message : String(err),
duration: performance.now() - checkt0,
});
}
}
const duration = performance.now() - t0;
return {
status: worstStatus(details.map((d) => d.status)),
start,
duration,
details,
};
};
/**
* Creates a handler function for readiness HTTP requests. Warning: this runs all
* checks on each request and may be slow.
* @param checks - An array of readiness functions to execute.
* @returns A function that returns a Response object with Readiness in JSON format.
*/
export const createReadinessHandler = (checks: ReadinessCheck[]): (() => Promise<Response>) => {
return async () => {
const result = await readiness(checks);
return respondWithResult(result);
};
};
/**
* Class that schedules periodic readiness checks.
*/
export class ScheduledReadiness {
private checks: ReadinessCheck[];
private interval: number;
private started: boolean = false;
private timer: NodeJS.Timeout | null = null;
private latestResult: Readiness | null = null;
private nextResult: Promise<Readiness> | null = null;
/**
* Creates an instance of ScheduledReadiness.
* @param checks - An array of readiness functions to execute.
* @param interval - Interval in milliseconds between readiness checks.
*/
constructor(checks: ReadinessCheck[], interval: number) {
this.checks = checks;
this.interval = interval;
}
/** Starts the scheduled readiness checks */
async start() {
if (this.started) return; // Already started
this.started = true;
const runCheck = async () => {
// Prevent concurrent runs
if (this.nextResult) return;
this.nextResult = readiness(this.checks);
try {
this.latestResult = await this.nextResult;
} finally {
this.nextResult = null;
}
};
await runCheck(); // Initial run
this.timer = setInterval(runCheck, this.interval);
}
/** Stops the scheduled readiness checks */
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.started = false;
}
/** Gets the next readiness result, waiting if a check is in progress */
async getNextResult(): Promise<Readiness | null> {
if (this.nextResult) {
return await this.nextResult;
}
return this.latestResult;
}
/** Gets the latest readiness result without waiting */
getResult(): Readiness | null {
return this.latestResult;
}
/**
* Sets the interval for readiness checks and restarts the timer if already started.
* @param ms - Interval in milliseconds.
*/
setInterval(ms: number) {
this.interval = ms;
if (this.timer) {
this.stop();
this.start();
}
}
/**
* Creates a handler function for readiness HTTP requests using the latest scheduled result.
* Scheduled handler always returns the most recent result, or 'unknown' if no checks have run yet.
* @returns A function that returns a Response object with Readiness in JSON format.
*/
createHandler(): () => Promise<Response> {
return async () => {
const result = this.getResult();
if (!result) {
return new Response(
JSON.stringify({
status: 'not_ready',
start: Date.now(),
duration: 0,
details: [],
} as Readiness),
{
status: httpStatusFromReadiness('not_ready'),
headers: { 'Content-Type': 'application/json' },
},
);
}
return respondWithResult(result);
};
}
}
/** Returns a Response object with the given Readiness in JSON format */
const respondWithResult = (result: Readiness) => {
return new Response(JSON.stringify(result), {
status: httpStatusFromReadiness(result.status),
headers: { 'Content-Type': 'application/json' },
});
};
/** Returns the HTTP status code corresponding to a given readiness status */
const httpStatusFromReadiness = (status: ReadinessStatus): number => {
if (status === 'ready') return 200;
if (status === 'degraded') return 200; // 206 could also be suitable, but let's avoid false alarms
if (status === 'fatal') return 503;
return 200; // not_ready, treat as ok to avoid false alarms
};
const withTimeout = async <T>(
promise: Promise<T>,
ms: number,
timeoutMessage: string,
): Promise<T> => {
let timeoutHandle: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(timeoutMessage));
}, ms);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutHandle);
});
};

View File

@@ -1,6 +1,16 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["es2023"],
"target": "es2023",
"module": "node20",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"rootDir": "src",
"outDir": "dist",
"composite": false

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ES2020",
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
}
}