@setu-ts/health-plugin

Health checks and Kubernetes-style probes. Registers an IHealthService under CAPABILITIES.HEALTH ('health') and serves /health, /live, and /ready.

Plugins across the framework contribute their own indicators (cache, storage, mail, scheduler, websocket, …), so a health report reflects the whole application without extra wiring.

Installation

import { createHttpIndicator, HealthPlugin } from '@setu-ts/health-plugin';

Usage

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { createHttpIndicator, HealthPlugin } from '@setu-ts/health-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    HealthPlugin({
      endpoints: { health: '/health', live: '/live', ready: '/ready' },
      indicators: [createHttpIndicator('external-api', { url: 'https://api.example.com/health' })],
      // M119b: a shared third-party API outage reports on /health but does not
      // take every replica out of /ready.
      readiness: { exclude: ['external-api'] },
    }),
  ],
});
await app.start({ port: 3000 });

Options

OptionTypeDefaultDescription
endpointsEndpointsOptions{ health: '/health', live: '/live', ready: '/ready' }Probe paths.
indicatorsIHealthIndicator[][]Indicators registered at startup.
diagnosticsHealthDiagnosticsOptions(absent — disabled)Opt-in minimized health observations (M98d).
detailsbooleanfalseInclude indicator data and latency in HTTP responses. Non-booleans throw at construction.

Indicators passed via indicators are registered before the onInit drain, so they are present even when the lifecycle is bypassed.

Statuses

HTTP responses from /health, /live, and /ready default to { status, timestamp, checks: { name: { status } } }. They omit indicator data and latencyMs, including runtime version and hostname. Set HealthPlugin({ details: true }) to restore full reports for a dashboard; protect those endpoints when their data is sensitive. The option affects only those HTTP bodies: HealthService.check(), checkLive() and checkReady() still return full reports, and the gRPC bridge is unchanged. Diagnostics are unaffected too, and record only their minimized observations, never indicator data (see Health observations).

An indicator reports 'up', 'degraded', or 'down'; the overall report takes the worst status among them. A result that is not one of those — not an object, or a status outside the three — is reported as { status: 'down', data: { reason: 'invalid-result' } } and its value is never published, so an unrecognized status can never hide another indicator’s down.

Health observations (M98d)

An opt-in diagnostics option exposes the health checks’ outcomes as minimized observations through the M98b diagnostics connector (GET /v1/health). It never changes /health, /live, or /ready.

import { HealthPlugin } from '@setu-ts/health-plugin';

// Opt-in minimized health observations (M98d). The allowlist maps each
// registered indicator name to a safe display alias; unlisted indicators are
// never retained. This never changes /health, /live, or /ready.
HealthPlugin({
  diagnostics: {
    enabled: true,
    indicators: { 'database.check': 'database' },
    staleAfterMs: 30000,
    // Optional bounded scheduled collection (absent by default).
    scheduled: {
      indicators: ['database.check'],
      intervalMs: 30000,
      timeoutMs: 5000,
      concurrency: 2,
    },
  },
});

Only the latest outcome per approved alias is retained — never a history. Each observation carries the approved alias, the framework’s own status (present only when reported), the outcome state (reported / timed-out / failed / never-observed), and monotonic latencyMs/ageMs. No indicator data, no error text, and no absolute time is ever projected; a result whose status is not up/degraded/down is observed as failed. enabled is the LITERAL true — any other value is refused when HealthPlugin(...) is called, as is every other invalid option — and an omitted option registers an inert, disabled source and performs no capture. Scheduled cycles cover every scheduled indicator from a rotating start; a hung check keeps only its own concurrency slot, so while fewer than concurrency checks are hung the rest keep refreshing. Once every slot is held by a hung check, no scheduled check starts until one settles — the work stays bounded, and each stalled alias keeps its last observation with a growing ageMs (or stays never-observed), while the snapshot’s state turns stale once any retained observation ages past staleAfterMs. The source is registered under CAPABILITIES.HEALTH_DIAGNOSTICS; see PUBLIC_API.md and docs/diagnostics-protocol.md for the wire shape.

Exports

ExportKind
createHttpIndicatorfunction
HealthPluginfunction
HealthServiceclass
HealthCheckResultinterface
HealthDiagnosticsObservationinterface
HealthDiagnosticsOptionsinterface
HealthDiagnosticsScheduledOptionsinterface
HealthDiagnosticsSnapshotinterface
HealthPluginOptionsinterface
HealthReportinterface
HttpIndicatorOptionsinterface
IHealthDiagnosticsSourceinterface
IHealthIndicatorinterface
IHealthServiceinterface
HealthIndicatorEntrytype
HealthIndicatorFntype
HealthObservationStatetype
HealthStatustype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

What degraded means

/ready runs the indicators that count toward readiness (never self), concurrently, each bounded by indicatorTimeoutMs. A timeout, a throw, or an invalid result counts as down. When no indicator counts toward readiness, /ready answers 200 with no checks. /live runs only self.

/ready returns 200 only when every indicator that counts toward readiness is up; degraded and down both return 503. /health returns 200 for up or degraded, and 503 for down. /live remains 200.

Use degraded when this replica should stop taking traffic. A shared problem that does not stop request serving belongs in the indicator’s data: draining every replica would remove healthy serving capacity. Alert on those data fields.

Readiness membership

Since M119b every indicator carries a readiness membership — whether it counts toward /ready — that the contributing plugin declares and the application overrides by exact name. Absent, an indicator counts toward readiness (the previous behaviour); a plugin registers one outside readiness with ctx.health.register(name, fn, { readiness: false }). The application overrides it with the readiness option, shown in the Usage example. Each name must match a registered indicator — for example, with MailPlugin and DatabasePlugin registered, HealthPlugin({ readiness: { include: ['mail'], exclude: ['database'] } }) brings mail back into readiness and takes the database out.

  • include forces a named indicator into readiness; exclude forces it out. Each overrides the membership the contributing plugin declared, including an indicator a plugin registers directly through IHealthService.registerIndicator.
  • HealthPlugin(...) (and a directly constructed HealthService) refuses an invalid option with TypeError: a readiness that is not a plain object, a list that is not an array, an empty or non-string name, the name self, or a name in both lists — the message names the option and never echoes a name. Names that match no registered indicator produce one warning at startup giving only a count.
  • /ready runs only the readiness members (never self). An indicator outside readiness still runs on /health, where a down keeps it 503 and alerts firing; it is absent from the /ready report. /live is unchanged.

Every framework comparison gives each check this membership — Spring Boot’s health groups (its mail indicator is not in the readiness group by default), ASP.NET Core’s health-check tags, and MicroProfile’s @Readiness — and Setu’s default follows them: the mail indicator is registered outside readiness, so an SMTP outage stays visible on /health without draining every replica (see the mail-plugin README).

API reference

Health (@setu-ts/health-plugin)

HealthPluginOptions.details?: boolean defaults to false. HTTP /health, /live, and /ready return only { status, timestamp, checks: { name: { status } } }, omitting each indicator’s data and latencyMs. HealthPlugin({ details: true }) restores the full HTTP report; non-booleans throw TypeError at construction. Only the HTTP bodies change: the programmatic health service returns full reports, the gRPC bridge is unchanged, and diagnostics keep recording minimized observations that never include indicator data. HTTP status rules are unchanged.

What degraded means

/ready runs only the indicators that count toward readiness (never self) and returns 200 only when every one of them is up; degraded and down both return 503. An indicator outside readiness still runs on /health. /health returns 200 for up or degraded, and 503 for down. /live remains 200.

Use degraded when this replica should stop taking traffic. A shared problem that does not stop request serving belongs in the indicator’s data: draining every replica would remove healthy serving capacity. Alert on those data fields.

Provides health check endpoints with pluggable indicators.

Service Interface

Resolve the service via ctx.services.get<IHealthService>('health'). The service provides:

interface IHealthService {
  registerIndicator(name: string, indicator: HealthIndicatorFn, options?: HealthIndicatorOptions): void;
  check(): Promise<HealthReport>;
  checkLive(): Promise<HealthReport>;
  checkReady(): Promise<HealthReport>;
}

interface HealthReport {
  readonly status: HealthStatus;
  readonly timestamp: string;
  readonly checks: Readonly<
    Record<string, Readonly<HealthCheckResult & { readonly latencyMs?: number }>>
  >;
}

Since M119b every indicator carries a readiness membership — whether it counts toward /ready — exposed through three additive, optional members: the exported HealthIndicatorOptions interface ({ readiness?: boolean }; absent means the indicator counts — the previous behaviour), the optional IHealthIndicator.readiness? member a plugin or application indicator instance sets, and the options? parameter on both registration surfaces — IHealthApi.register(name, indicator, options?) (the plugin context’s ctx.health.register) and IHealthService.registerIndicator(name, indicator, options?) above. checkReady() runs the indicators that are not self and whose effective membership is true; an indicator outside readiness still runs in check() (/health) and is absent from the /ready report. checkLive() is unchanged. HealthPluginOptions.readiness ({ include?: readonly string[], exclude?: readonly string[] }) overrides a contributor’s declared membership by exact indicator name: include forces a named indicator into readiness, exclude forces it out. The override is applied inside HealthService.registerIndicator, so it covers every registration path, including a plugin that calls IHealthService.registerIndicator directly; a directly constructed service takes it as new HealthService(runtime, { readiness }). Both HealthPlugin(...) and that constructor refuse an invalid option with TypeError — a readiness that is not a plain object (an array included), a list that is not an array, an empty or non-string name, the name self, or a name in both lists; the message names the option and never echoes a name. After every plugin has initialized (at bootstrap), names that match no registered indicator produce one count-only warning.

Since M70c each indicator’s result is projected to the declared {@linkcode HealthCheckResult} shape — status, and data when present — before it enters the report. An indicator that returns anything else (a typo’d details instead of data, or its own latencyMs) has that field dropped rather than published: excess-property checking does not survive Promise.resolve({ ... }) at the generic call, so the mistyped field type-checks, and /health is frequently the least protected endpoint in a deployment. The latencyMs in the report is always the one the health service measured.

Since M90b the selected indicators run concurrently, each raced against a per-indicator deadline (indicatorTimeoutMs, default 5,000 — a positive finite number of milliseconds; anything else — zero, negative, NaN, Infinity — throws. HealthService is barrel-exported, so the same validation runs in its constructor as in HealthPlugin: constructing the service directly cannot bypass the check and store a value the deadline timer cannot honour). Sequential awaiting multiplied outage latency — a 2-second outage across six dependency indicators held /health for 12+ seconds — and one never-settling indicator left the whole endpoint pending forever. A timeout is recorded as { status: 'down', data: { reason: 'timeout' } } and a rejection as { status: 'down', data: { reason: 'error' } } with the thrown value never serialized into the report. A result that is not a framework result — not an object, or a status outside up/degraded/down — is recorded as { status: 'down', data: { reason: 'invalid-result' } } and its value is never published: such a status has no severity rank, so aggregating it used to let it hide another indicator’s down (/health answered 200 degraded with a check down). A data value that is not an object is omitted; each indicator’s latencyMs is measured individually; and checks keeps registration order, so the report’s shape is stable even though execution is not.

interface HealthCheckResult {
  readonly status: HealthStatus;
  readonly data?: Readonly<Record<string, unknown>>;
}

type HealthStatus = 'up' | 'down' | 'degraded';

Registration

import { createHttpIndicator, HealthPlugin } from '@setu-ts/health-plugin';

app.register(
  HealthPlugin({
    endpoints: {
      health: '/health',
      live: '/live',
      ready: '/ready',
    },
    indicators: [
      createHttpIndicator('external-api', {
        url: 'https://api.example.com/health',
        timeoutMs: 3000,
      }),
      // Or a factory that builds the indicator from the service registry:
      (services) => createDatabaseIndicator(services),
    ],
  }),
);

indicators accepts an instance or a RegistryFactory ((services: IServiceRegistry) => IHealthIndicator), the exported HealthIndicatorEntry union. A factory is called at the onInit phase — the first phase at which the registry holds every capability — and, because HealthPlugin registers at priority 100, before the database and every other ordinary capability plugin, so a factory can resolve the capability it exists to probe and build the indicator with it. Factories are registered at the head of the existing onInit hook, before the CAPABILITIES.HEALTH_INDICATOR contribution drain. A factory that throws rejects start(), naming the option and the entry. Instance indicators keep their register() timing byte-identically.

Custom Health Indicators

app.register({
  name: 'custom-health',
  version: '1.0.0',
  dependencies: ['health'],
  register(ctx) {
    const health = ctx.services.get<IHealthService>('health');

    health.registerIndicator('external-api', async () => {
      const http = ctx.services.get<IHttpClient>('http-client');
      try {
        await http.get('/health', { timeout: 3000 });
        return { status: 'up', data: { responseTime: 123 } };
      } catch {
        return { status: 'down', data: { error: 'Connection failed' } };
      }
    });
  },
});

Endpoints

EndpointMethodDescriptionStatus Codes
/healthGETOverall health (all indicators)200 (up/degraded), 503 (down)
/liveGETLiveness (self indicator only)200 (always, unless self is down)
/readyGETReadiness (readiness members, excluding self)200 (all up), 503 (any down/degraded)

Response

GET /health
{
  "status": "up",
  "timestamp": "2024-01-01T00:00:00.000Z",
  "checks": {
    "self": { "status": "up" },
    "database": { "status": "up" },
    "cache": { "status": "up" },
    "external-api": { "status": "up" }
  }
}

Status aggregation rules:

  • /live: Always 200 as long as the process responds (self indicator always returns ‘up’)
  • /ready: 200 when all indicators that count toward readiness are ‘up’, 503 when any is ‘degraded’ or ‘down’
  • /health: 200 when no participating indicator is ‘down’ (degraded stays 200), 503 when any is ‘down’

Health observations (M98d)

The plugin accepts an opt-in diagnostics option that exposes the health checks’ outcomes as minimized observations through the M98b diagnostics connector — it never changes /health, /live, or /ready.

import { HealthPlugin } from '@setu-ts/health-plugin';

app.register(
  HealthPlugin({
    indicators: [databaseIndicator],
    diagnostics: {
      enabled: true,
      // Exact registered-name -> display-alias allowlist; unlisted indicators are never retained.
      indicators: { 'database.check': 'database' },
      staleAfterMs: 30000, // optional; default 30000
      // Optional bounded scheduled collection; absent by default.
      scheduled: {
        indicators: ['database.check'],
        intervalMs: 30000,
        timeoutMs: 5000,
        concurrency: 2,
      },
    },
  }),
);

diagnostics.enabled is the LITERAL true, not boolean: an absent option is the disabled path (an inert source is still registered under CAPABILITIES.HEALTH_DIAGNOSTICS, answering disabled), and enabled: false (or any value other than true) is refused when HealthPlugin(...) is called — every diagnostics option is validated there, with fixed messages that never echo a value. indicators is the exact registered-name to display-alias allowlist — at most 64 entries, each alias unique, 1–64 UTF-8 bytes, no control characters; an indicator whose registered name is not a key is never retained. staleAfterMs (default 30000) turns the snapshot’s state stale once any retained observation is older than that many monotonic milliseconds from capture; an observation itself has no stale state, only its growing ageMs. scheduled — when present — must name a subset of the approved indicator names and supplies a cadence (intervalMs, 1,000–300,000), a per-check reporting deadline (timeoutMs, 1–30,000 — a reporting bound, not a cancellation), and a concurrency cap (1–4); at most 16 indicators are scheduled. Each cycle covers every scheduled indicator not still in flight, starting from a rotating cursor so none is starved. A timed-out callback that has not settled keeps its concurrency slot — and only that slot — until it does, so it is never replaced early, and while fewer than concurrency callbacks are hung the other indicators keep refreshing. Once every slot is held by a hung callback, no scheduled check starts until one settles; the work stays bounded, each stalled alias keeps its last observation with a growing ageMs (or stays never-observed), and the snapshot’s state turns stale once any retained observation ages past staleAfterMs. An approved name no indicator is registered under stays never-observed, and the plugin logs one count-only warning at bootstrap. An indicator result whose status is not up/degraded/down is observed as failed; the value is never retained.

The plugin retains only the latest outcome per approved alias (never a history). Each observation carries the approved alias, the framework’s own status (present only when reported), the outcome state (reported / timed-out / failed / never-observed), and monotonic latencyMs/ageMs. No indicator data, no error text, and no absolute time is ever projected; the snapshot is deeply frozen and bounded by the connector’s 256 KiB response ceiling (later entries omitted and truncated set when the bound is hit). The source is registered under CAPABILITIES.HEALTH_DIAGNOSTICS and consumed by the Diagnostics Connector’s GET /v1/health; the wire shape and inspector manifest are in docs/diagnostics-protocol.md.


Agent card · TypeDoc API reference