Skip to content

@studnicky/health-registry

Named async health-check registry with worst-status-wins aggregation.

Install

bash
pnpm add @studnicky/health-registry

Usage

Register named async check functions, each resolving to { status, metadata? }. evaluate() runs every registered check in parallel via Promise.allSettled, applies each check's own configured timeoutMs (composed via @studnicky/signal), and folds a rejecting or timed-out check into the results as 'unhealthy' instead of crashing the evaluation of the other checks:

ts
import type { HealthCheckResultType, HealthStatusType } from '../src/index.js';

import { HealthRegistry } from '../src/index.js';

class TelemetryHealthRegistry extends HealthRegistry {
  static override create(): TelemetryHealthRegistry {
    return new TelemetryHealthRegistry();
  }

  readonly registeredChecks: string[] = [];
  readonly checkResults: { 'name': string; 'status': HealthStatusType }[] = [];
  readonly timeouts: { 'name': string; 'timeoutMs': number }[] = [];

  protected override onCheckRegistered(name: string): void {
    console.log(`[health] registered '${name}'`);
    this.registeredChecks.push(name);
  }

  protected override onCheckResult(name: string, status: HealthStatusType, metadata?: unknown): void {
    console.log(`[health] '${name}' -> ${status}${metadata !== undefined ? ` (${JSON.stringify(metadata)})` : ''}`);
    this.checkResults.push({ 'name': name, 'status': status });
  }

  protected override onCheckTimeout(name: string, timeoutMs: number): void {
    console.log(`[health] '${name}' exceeded its ${timeoutMs}ms timeout`);
    this.timeouts.push({ 'name': name, 'timeoutMs': timeoutMs });
  }

  protected override onAggregate(overall: HealthStatusType, results: ReadonlyMap<string, HealthCheckResultType>): void {
    console.log(`[health] overall: ${overall} (${String(results.size)} checks)`);
  }
}

const registry = TelemetryHealthRegistry.create();

registry.register('database', () => {return { 'status': 'healthy' };});

registry.register('cache', () => {return {
  'metadata': { 'hitRate': 0.42 },
  'status': 'degraded'
};});

registry.register('downstream-api', async () => {
  await new Promise((resolve) => { const result = setTimeout(resolve, 200); return result; });
  return { 'status': 'healthy' };
}, { 'timeoutMs': 20 });

const evaluation = await registry.evaluate();

console.log('Overall status:', evaluation.status);
console.log('Per-check results:', Object.fromEntries(evaluation.results));

Aggregation

The overall status is worst-status-wins: any 'unhealthy' check makes the overall status 'unhealthy', else any 'degraded' check makes it 'degraded', else 'healthy'. An empty registry evaluates to 'healthy' with an empty results map.

MethodDescription
HealthRegistry.create()Creates an empty registry
register(name, check, options?)Registers (or replaces) a named async check. options.timeoutMs bounds how long the check may run before it counts as 'unhealthy'
unregister(name)Removes a named check; no-op if it was never registered
has(name)Whether a check is currently registered under name
list()The names of every currently registered check
evaluate()Runs every registered check in parallel and returns { status, results }
hookErrorCountCount of hook failures recorded since construction
getHookErrors()Defensive copy of every hook failure recorded since construction

Hooks

HookFires
onCheckRegistered(name)After a check is registered (or replaces an existing registration under the same name)
onCheckResult(name, status, metadata?)Once per check as it settles during evaluate() — success, rejection, or timeout
onCheckTimeout(name, timeoutMs)When a check exceeds its configured timeoutMs, in addition to onCheckResult
onAggregate(overall, results)Once per evaluate() call, after every registered check has settled

A hook override that throws or rejects does not abort evaluate() — the failure is recorded instead of propagating; inspect it via hookErrorCount (a running total) and getHookErrors() (a defensive copy of every recorded { hookName, cause } entry), backed internally by @studnicky/errors's HookInvoker.

Scope

HealthRegistry owns only the registry-and-aggregate logic — the same boundary MachineRegistry draws for actors. It performs no HTTP endpoint wiring and makes no Kubernetes-specific liveness/readiness distinction; a consuming application wires evaluate() into whatever route or probe its runtime expects.

Documentation

Full reference: https://studnicky.github.io/substrate/packages/health-registry

Source on GitHub