@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
| Option | Type | Default | Description |
|---|---|---|---|
endpoints | EndpointsOptions | { health: '/health', live: '/live', ready: '/ready' } | Probe paths. |
indicators | IHealthIndicator[] | [] | Indicators registered at startup. |
diagnostics | HealthDiagnosticsOptions | (absent — disabled) | Opt-in minimized health observations (M98d). |
details | boolean | false | Include 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
| Export | Kind |
|---|---|
createHttpIndicator | function |
HealthPlugin | function |
HealthService | class |
HealthCheckResult | interface |
HealthDiagnosticsObservation | interface |
HealthDiagnosticsOptions | interface |
HealthDiagnosticsScheduledOptions | interface |
HealthDiagnosticsSnapshot | interface |
HealthPluginOptions | interface |
HealthReport | interface |
HttpIndicatorOptions | interface |
IHealthDiagnosticsSource | interface |
IHealthIndicator | interface |
IHealthService | interface |
HealthIndicatorEntry | type |
HealthIndicatorFn | type |
HealthObservationState | type |
HealthStatus | type |
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.
includeforces a named indicator into readiness;excludeforces it out. Each overrides the membership the contributing plugin declared, including an indicator a plugin registers directly throughIHealthService.registerIndicator.HealthPlugin(...)(and a directly constructedHealthService) refuses an invalid option withTypeError: areadinessthat is not a plain object, a list that is not an array, an empty or non-string name, the nameself, 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./readyruns only the readiness members (neverself). An indicator outside readiness still runs on/health, where adownkeeps it 503 and alerts firing; it is absent from the/readyreport./liveis 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
| Endpoint | Method | Description | Status Codes |
|---|---|---|---|
/health | GET | Overall health (all indicators) | 200 (up/degraded), 503 (down) |
/live | GET | Liveness (self indicator only) | 200 (always, unless self is down) |
/ready | GET | Readiness (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.