Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
# @suaveplan/health-check - Server-tier standardised /health, /ready, /live endpoints with dependency checks — HealthChecker/ResponseBuilder/HttpCheck/TcpCheck with injectable Clock+TimersLike+LoggerLike+fetch, HEALTH_CHECK_CODES error registry, and OTel self-telemetry.
Server-tier health-check core for `/health`, `/ready`, `/live` endpoints with
dependency checks (HTTP, TCP, custom), aggregated status, caching, and
typed lifecycle events.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` provides the canonical error namespace for `@suaveplan/health-check`. Every public failure path — invalid…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` provides the canonical error namespace for `@suaveplan/health-check`. Every public failure path — invalid…
- [health-checker](./src/health-checker/health-checker.md) — `HealthChecker` is the orchestration core of `@suaveplan/health-check`. It owns a registry of dependency checks, runs t…
- [health-checker.helpers](./src/health-checker/health-checker.helpers.md) — `health-checker.helpers` carries the pure helpers that back the `HealthChecker` class: the Zod schemas that validate co…
- [health-checker](./src/health-checker/health-checker.md) — `HealthChecker` is the orchestration core of `@suaveplan/health-check`. It owns a registry of dependency checks, runs t…
- [http-check](./src/http-check/http-check.md) — `createHttpCheck` returns a `CheckFn` that probes an HTTP endpoint and reports `healthy` when the response status appea…
- [http-check](./src/http-check/http-check.md) — `createHttpCheck` returns a `CheckFn` that probes an HTTP endpoint and reports `healthy` when the response status appea…
- [response-builder](./src/response-builder/response-builder.md) — `ResponseBuilder` adapts a `HealthChecker` into HTTP-shaped responses suitable for direct return from a Hono / Express…
- [response-builder](./src/response-builder/response-builder.md) — `ResponseBuilder` adapts a `HealthChecker` into HTTP-shaped responses suitable for direct return from a Hono / Express…
- [tcp-check](./src/tcp-check/tcp-check.md) — `createTcpCheck` returns a `CheckFn` that opens a TCP socket against a `host:port` pair and resolves to `healthy` once…
- [tcp-check](./src/tcp-check/tcp-check.md) — `createTcpCheck` returns a `CheckFn` that opens a TCP socket against a `host:port` pair and resolves to `healthy` once…
- [telemetry](./src/telemetry/telemetry.md) — `telemetry` registers the OpenTelemetry meter that the health-check package emits to on every public call. The instrume…
- [telemetry](./src/telemetry/telemetry.md) — `telemetry` registers the OpenTelemetry meter that the health-check package emits to on every public call. The instrume…
<!-- end:auto-docs -->
## Modules
- [`health-checker/`](./src/health-checker/health-checker.md) — `HealthChecker`: registry, runner, aggregator, `EventBus`.
- [`http-check/`](./src/http-check/http-check.md) — `createHttpCheck`: fetch-based probe with injectable `fetchImpl`.
- [`tcp-check/`](./src/tcp-check/tcp-check.md) — `createTcpCheck`: socket probe with injectable `TcpConnectFn`.
- [`response-builder/`](./src/response-builder/response-builder.md) — `ResponseBuilder`: maps statuses to HTTP codes.
- [`error-codes/`](./src/error-codes/error-codes.md) — `HEALTH_CHECK_CODES` + `HealthCheckError`.
- [`telemetry/`](./src/telemetry/telemetry.md) — OTel meter (counter + histogram).
## Install
```bash
bun add @suaveplan/health-check
```
## Usage
```ts
import {
HealthChecker,
ResponseBuilder,
createHttpCheck,
createTcpCheck,
} from "@suaveplan/health-check";
const checker = new HealthChecker({ name: "api", version: "1.0.0" });
checker.addCheck({
name: "upstream",
check: createHttpCheck({ url: "https://up.example.com/health" }),
});
checker.addCheck({
name: "postgres",
check: createTcpCheck({ host: "db.internal", port: 5432 }),
});
const builder = new ResponseBuilder(checker);
const { statusCode, body } = await builder.health();
```
## License
MIT

View File

@@ -0,0 +1,101 @@
{
"name": "@suaveplan/health-check",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Server-tier standardised /health, /ready, /live endpoints with dependency checks — HealthChecker/ResponseBuilder/HttpCheck/TcpCheck with injectable Clock+TimersLike+LoggerLike+fetch, HEALTH_CHECK_CODES error registry, and OTel self-telemetry.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"engines": {
"node": ">=20"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./health-checker": {
"import": {
"types": "./dist/health-checker/index.d.ts",
"default": "./dist/health-checker/index.js"
}
},
"./http-check": {
"import": {
"types": "./dist/http-check/index.d.ts",
"default": "./dist/http-check/index.js"
}
},
"./tcp-check": {
"import": {
"types": "./dist/tcp-check/index.d.ts",
"default": "./dist/tcp-check/index.js"
}
},
"./response-builder": {
"import": {
"types": "./dist/response-builder/index.d.ts",
"default": "./dist/response-builder/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,57 @@
# `error-codes/error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes` provides the canonical error namespace for `@suaveplan/health-check`. Every public failure path — invalid options, duplicate registration, unknown look-ups, per-check timeouts, and unexpected check-function throws — surfaces as a `HealthCheckError` whose `code` is drawn from the `HEALTH_CHECK_CODES` registry. Consumers SHOULD discriminate on `error.code` rather than `instanceof` so the namespace survives module-boundary copies and JSON serialisation.
## Features
- Stable namespaced codes built via `@suaveplan/error`'s `defineCodes("HEALTH_CHECK", …)`.
- Typed `HealthCheckCode` literal union narrows `switch` blocks at compile time.
- `HealthCheckError` extends `SuaveplanError<HealthCheckCode>` with a **public** constructor that delegates to the protected base, so call-site `new HealthCheckError(...)` works.
- `cause` + `context` carried through the inherited options object.
## Usage: Basic
```ts
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "@suaveplan/health-check";
throw new HealthCheckError(
HEALTH_CHECK_CODES.UNKNOWN_CHECK,
"check \"db\" is not registered",
{ context: { name: "db" } },
);
```
## Usage: Advanced
```ts
try {
await checker.runCheck("db");
} catch (err) {
if (
err instanceof HealthCheckError &&
err.code === HEALTH_CHECK_CODES.CHECK_TIMEOUT
) {
metrics.increment("health.timeout", { ctx: err.context });
}
throw err;
}
```
## API Reference
- `HEALTH_CHECK_CODES` — frozen map: `INVALID_OPTIONS`, `DUPLICATE_CHECK`, `UNKNOWN_CHECK`, `CHECK_TIMEOUT`, `CHECK_EXECUTION_FAILED`.
- `HealthCheckCode` — the literal-union type derived from the registry values.
- `HealthCheckError(code, message, options?)` — `options` carries `cause` and `context`.
## Implementation Notes
`defineCodes` namespaces every leaf under `HEALTH_CHECK.` and freezes the registry to prevent accidental mutation. `HealthCheckError`'s public constructor exists only because `SuaveplanError` declares its constructor `protected` — without an explicit override the call-site `new HealthCheckError(...)` would not type-check. The constructor sets `this.name = "HealthCheckError"` so logs / traces show a meaningful class name even after structured clone.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HEALTH_CHECK_CODES, HealthCheckError } from "./error-codes.js";
describe("HEALTH_CHECK_CODES", () => {
it("namespaces every code under HEALTH_CHECK.", { timeout: 5000 }, () => {
for (const value of Object.values(HEALTH_CHECK_CODES)) {
expect(value.startsWith("HEALTH_CHECK.")).toBe(true);
}
});
it("exposes the expected leaf names", { timeout: 5000 }, () => {
expect(HEALTH_CHECK_CODES.INVALID_OPTIONS).toBe(
"HEALTH_CHECK.INVALID_OPTIONS"
);
expect(HEALTH_CHECK_CODES.DUPLICATE_CHECK).toBe(
"HEALTH_CHECK.DUPLICATE_CHECK"
);
expect(HEALTH_CHECK_CODES.UNKNOWN_CHECK).toBe(
"HEALTH_CHECK.UNKNOWN_CHECK"
);
expect(HEALTH_CHECK_CODES.CHECK_TIMEOUT).toBe(
"HEALTH_CHECK.CHECK_TIMEOUT"
);
expect(HEALTH_CHECK_CODES.CHECK_EXECUTION_FAILED).toBe(
"HEALTH_CHECK.CHECK_EXECUTION_FAILED"
);
});
});
describe("HealthCheckError", () => {
it("carries the supplied code, message, cause, and context", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
const err = new HealthCheckError(
HEALTH_CHECK_CODES.UNKNOWN_CHECK,
"unknown check",
{ cause, context: { name: "db" } }
);
expect(err.code).toBe(HEALTH_CHECK_CODES.UNKNOWN_CHECK);
expect(err.message).toBe("unknown check");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ name: "db" });
expect(err.name).toBe("HealthCheckError");
expect(err).toBeInstanceOf(Error);
});
it("works without options", { timeout: 5000 }, () => {
const err = new HealthCheckError(
HEALTH_CHECK_CODES.INVALID_OPTIONS,
"bad opts"
);
expect(err.context).toBeUndefined();
expect(err.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,59 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/health-check`.
*
* Codes follow `HEALTH_CHECK.<REASON>` and are resolved via
* `@suaveplan/error`'s `defineCodes`. Consumers SHOULD discriminate on
* `error.code` rather than `instanceof` so the namespace survives module
* boundaries and runtime serialisation.
*
* The registry covers Zod boundary failures (`INVALID_OPTIONS`),
* registration / lookup failures on the `HealthChecker`
* (`DUPLICATE_CHECK`, `UNKNOWN_CHECK`), per-check timeout enforcement
* (`CHECK_TIMEOUT`), and unexpected check-function throws
* (`CHECK_EXECUTION_FAILED`).
*/
export const HEALTH_CHECK_CODES = defineCodes("HEALTH_CHECK", [
"INVALID_OPTIONS",
"DUPLICATE_CHECK",
"UNKNOWN_CHECK",
"CHECK_TIMEOUT",
"CHECK_EXECUTION_FAILED",
] as const);
export type HealthCheckCode =
(typeof HEALTH_CHECK_CODES)[keyof typeof HEALTH_CHECK_CODES];
/**
* Typed error for every `@suaveplan/health-check` failure path.
*
* Extends `SuaveplanError<HealthCheckCode>` so the `code` field narrows
* against the literal union and consumers get full type-checking on
* `switch (err.code)` blocks. Carries a public constructor so that
* `new HealthCheckError(...)` works at the call site —
* `SuaveplanError`'s own constructor is protected.
*
* @example
* ```ts
* import { HEALTH_CHECK_CODES, HealthCheckError } from "@suaveplan/health-check";
*
* try {
* checker.addCheck({ name: "db", check });
* } catch (err) {
* if (err instanceof HealthCheckError && err.code === HEALTH_CHECK_CODES.DUPLICATE_CHECK) {
* console.error("already registered", err.context);
* }
* }
* ```
*/
export class HealthCheckError extends SuaveplanError<HealthCheckCode> {
public constructor(
code: HealthCheckCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "HealthCheckError";
}
}

View File

@@ -0,0 +1,2 @@
export type { HealthCheckCode } from "./error-codes.js";
export { HEALTH_CHECK_CODES, HealthCheckError } from "./error-codes.js";

View File

@@ -0,0 +1,49 @@
# `health-checker/health-checker.helpers`
## Purpose
`health-checker.helpers` carries the pure helpers that back the `HealthChecker` class: the Zod schemas that validate constructor + per-check options, the default `Clock` / `TimersLike` pair, the aggregate-status function, the raw-result normaliser, and the timeout-enforcing wrapper. They are split out of `health-checker.ts` to keep the main class file inside the project line-count budget and to make each helper independently unit-testable through `HealthChecker`'s tests.
## Features
- `dependencyCheckSchema` + `optionsSchema` — Zod boundary schemas; throw `HealthCheckError(INVALID_OPTIONS)` on failure when wired in.
- `defaultClock` — `() => Date.now()`.
- `defaultTimers` — `setTimeout` / `clearTimeout` adapter for the `TimersLike` contract.
- `aggregateStatus(results, isCritical)` — pure function mapping a result list to overall `CheckStatus`.
- `normalizeResult(name, raw, elapsed, clock)` — pure normaliser preserving raw `name` / `timestamp` when present.
- `executeWithTimeout(thunk, ms, timers)` — schedules a `HealthCheckError(CHECK_TIMEOUT)` rejection through the supplied `TimersLike`.
## Usage: Basic
```ts
import { aggregateStatus } from "@suaveplan/health-check/health-checker";
const status = aggregateStatus(results, (name) => criticalNames.has(name));
```
## Usage: Advanced
```ts
import { executeWithTimeout } from "@suaveplan/health-check/health-checker";
const result = await executeWithTimeout(
() => probe(),
250,
{ setTimer: setTimeout, clearTimer: clearTimeout },
);
```
## API Reference
- `DEFAULT_TIMEOUT: 5000`
- `defaultClock: Clock`
- `defaultTimers: TimersLike`
- `dependencyCheckSchema: ZodObject`
- `optionsSchema: ZodObject`
- `aggregateStatus(results, isCritical) → CheckStatus`
- `normalizeResult(name, raw, elapsed, clock) → CheckResult`
- `executeWithTimeout<T>(thunk, ms, timers) → Promise<T>`
## Implementation Notes
`executeWithTimeout` carries a `settled` flag to absorb the race between the timer firing and the thunk resolving. Either path is responsible for flipping the flag; the other path no-ops. `clearTimer` is only called on the resolution / rejection side because the timer-fired side has already executed and there is nothing to cancel. Rejections from the thunk are coerced into `HealthCheckError(CHECK_EXECUTION_FAILED)` so the outer `runCheck` always sees a typed error and can route on `code` instead of `instanceof Error`. The Zod schemas are constructed at module top level — instantiating them once per import keeps the per-call overhead to a single `safeParse` invocation.

View File

@@ -0,0 +1,156 @@
import type { Clock, TimersLike } from "@suaveplan/types";
import z from "zod";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import type {
CheckResult,
CheckStatus,
DependencyCheckConfig,
} from "../types/types.js";
/** Built-in default per-check timeout. */
export const DEFAULT_TIMEOUT = 5000;
/** Default wall-clock — `Date.now()`. */
export const defaultClock: Clock = () => Date.now();
/** Default `TimersLike` backed by `globalThis.setTimeout` / `clearTimeout`. */
export const defaultTimers: TimersLike = {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (h) => {
clearTimeout(h as unknown as ReturnType<typeof setTimeout>);
},
};
/** Zod schema for a single dependency-check registration. */
export const dependencyCheckSchema = z.object({
name: z.string().min(1, "check name must be non-empty"),
check: z.custom<DependencyCheckConfig["check"]>(
(v) => typeof v === "function",
{ message: "check must be a function" }
),
critical: z.boolean().optional(),
timeout: z.number().int().positive().optional(),
cacheTtlMs: z.number().int().nonnegative().optional(),
});
/** Zod schema for the `HealthChecker` constructor options. */
export const optionsSchema = z.object({
name: z.string().min(1, "service name must be non-empty"),
version: z.string().optional(),
defaultTimeout: z.number().int().positive().optional(),
defaultCacheTtlMs: z.number().int().nonnegative().optional(),
checks: z.array(dependencyCheckSchema).optional(),
});
/**
* Aggregate a set of `CheckResult` values into the overall `CheckStatus`.
* Critical-failure short-circuits to `unhealthy`; any non-critical
* failure or degraded result downgrades to `degraded`; otherwise `healthy`.
*/
export function aggregateStatus(
results: ReadonlyArray<CheckResult>,
isCritical: (name: string) => boolean
): CheckStatus {
let hasDegraded = false;
for (const result of results) {
if (result.status === "unhealthy" && isCritical(result.name)) {
return "unhealthy";
}
if (result.status === "unhealthy" || result.status === "degraded") {
hasDegraded = true;
}
}
return hasDegraded ? "degraded" : "healthy";
}
/**
* Normalise a raw check result onto the canonical `CheckResult` shape.
* Preserves `name` and `timestamp` when the raw value provides them;
* otherwise stamps the supplied defaults.
*/
export function normalizeResult(
name: string,
raw: CheckResult | Omit<CheckResult, "name" | "timestamp">,
elapsed: number,
clock: Clock
): CheckResult {
const rawAny = raw as CheckResult;
const result: CheckResult = {
name:
"name" in raw && typeof rawAny.name === "string"
? rawAny.name
: name,
status: raw.status,
responseTime: raw.responseTime ?? elapsed,
timestamp:
"timestamp" in raw && typeof rawAny.timestamp === "string"
? rawAny.timestamp
: new Date(clock()).toISOString(),
};
if (raw.message !== undefined) {
result.message = raw.message;
}
if (raw.details !== undefined) {
result.details = raw.details;
}
return result;
}
/**
* Run `thunk` with a `TimersLike`-scheduled timeout. Resolves with the
* thunk's value, rejects with `HealthCheckError(CHECK_TIMEOUT)` when the
* timer fires first, or `HealthCheckError(CHECK_EXECUTION_FAILED)` when
* the thunk itself rejects.
*/
export function executeWithTimeout<T>(
thunk: () => Promise<T>,
timeoutMs: number,
timers: TimersLike
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;
const handle = timers.setTimer(() => {
if (settled) {
return;
}
settled = true;
reject(
new HealthCheckError(
HEALTH_CHECK_CODES.CHECK_TIMEOUT,
`check timed out after ${timeoutMs}ms`,
{ context: { timeoutMs } }
)
);
}, timeoutMs);
thunk().then(
(value) => {
if (settled) {
return;
}
settled = true;
timers.clearTimer(handle);
resolve(value);
},
(err: unknown) => {
if (settled) {
return;
}
settled = true;
timers.clearTimer(handle);
reject(
new HealthCheckError(
HEALTH_CHECK_CODES.CHECK_EXECUTION_FAILED,
err instanceof Error
? err.message
: "check execution failed",
{ cause: err }
)
);
}
);
});
}

View File

@@ -0,0 +1,65 @@
# `health-checker/health-checker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HealthChecker` is the orchestration core of `@suaveplan/health-check`. It owns a registry of dependency checks, runs them on demand, enforces per-check timeouts through an injected `TimersLike`, caches results for a configurable TTL, and produces standardised health, readiness, and liveness payloads suitable for the `/health`, `/ready`, and `/live` endpoints common to Kubernetes, ECS, Nomad, and reverse-proxy probes. Wall-clock readings come from the injected `Clock`, so tests can drive deterministic uptime + cache-expiry behaviour.
## Features
- Registry of `DependencyCheckConfig` with per-check `critical`, `timeout`, and `cacheTtlMs` overrides.
- Result aggregation that returns `healthy` / `degraded` / `unhealthy`: critical failures short-circuit, non-critical or degraded results downgrade to `degraded`.
- Typed `EventBus<HealthEventMap>` emitting `check:passed`, `check:failed`, and `status:changed` events.
- Zod-validated boundaries — every option object is parsed before use; failures throw `HealthCheckError` with `INVALID_OPTIONS`.
- OTel instrumentation: every check run records on `health_check_runs_total` + `health_check_duration_ms`, and every aggregate evaluation increments `health_check_aggregate_status_total`.
- Optional `LoggerLike` injection for `debug` registration logs and `warn` on failed checks.
## Usage: Basic
```ts
import { HealthChecker, createHttpCheck } from "@suaveplan/health-check";
const checker = new HealthChecker({ name: "api", version: "1.0.0" });
checker.addCheck({
name: "upstream",
check: createHttpCheck({ url: "https://up.example.com/health" }),
});
const response = await checker.health();
```
## Usage: Advanced
```ts
import { HealthChecker, HealthEvent } from "@suaveplan/health-check";
const checker = new HealthChecker({
name: "api",
defaultTimeout: 2_000,
defaultCacheTtlMs: 5_000,
clock: () => performance.now() + epoch,
timers: fakeTimers,
logger,
});
checker.events.on(HealthEvent.STATUS_CHANGED, ({ previous, current }) => {
pagerduty.notify({ previous, current });
});
```
## API Reference
- `new HealthChecker(options)` — `options.name` required; `version`, `defaultTimeout`, `defaultCacheTtlMs`, `checks`, `clock`, `timers`, `logger` optional.
- `addCheck(config)` / `removeCheck(name)` / `getCheckNames()`.
- `runCheck(name)` → `CheckResult` (single check, honours cache).
- `health()` → `HealthResponse` (runs all, aggregates).
- `ready()` → `ReadinessResponse` (critical-only failures).
- `live()` → `LivenessResponse` (process is responding).
- `clearCache()` / `getUptimeMs()`.
- `events: EventBus<HealthEventMap>`.
## Implementation Notes
Timeout enforcement is delegated to the injected `TimersLike` rather than `setTimeout` so tests can drive timeouts deterministically without `vi.useFakeTimers()`. The cache is keyed by check name and stores a frozen `CheckResult` with an absolute `expiresAt` computed against `this.clock()` — clock skew between writes and reads cannot leak negative TTLs because we re-read the clock on every check. Cached reads tag results with `cached: true` so consumers can distinguish them. Failures inside `executeWithTimeout` are wrapped in `HealthCheckError(CHECK_EXECUTION_FAILED)` so the outer `runCheck` always sees a typed error; the `CHECK_TIMEOUT` branch is preserved separately so it can be surfaced in `details.timedOut`.

View File

@@ -0,0 +1,494 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import type { CheckFn } from "../types/types.js";
import { HealthEvent } from "../types/types.js";
import { HealthChecker } from "./health-checker.js";
/** FakeClock returns a counter that advances on call. Caller can poke `now`. */
function makeClock(start = 1_000) {
const state = { now: start };
const clock = () => state.now;
return { state, clock };
}
/** Real timer-pair backed by setTimeout — used in most tests. */
const realTimers: TimersLike = {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (h) =>
clearTimeout(h as unknown as ReturnType<typeof setTimeout>),
};
/** Always-pending timer; used to exercise the timeout path deterministically. */
function controlledTimers() {
const pending = new Map<number, () => void>();
let id = 0;
const timers: TimersLike = {
setTimer: (cb) => {
id += 1;
pending.set(id, cb);
return id;
},
clearTimer: (h) => {
pending.delete(h);
},
};
const fire = (h: number) => {
const cb = pending.get(h);
if (cb !== undefined) {
pending.delete(h);
cb();
}
};
return { timers, pending, fire };
}
describe("HealthChecker — construction & registration", () => {
it("rejects empty service name", { timeout: 5000 }, () => {
expect(() => new HealthChecker({ name: "" })).toThrow(HealthCheckError);
});
it("rejects invalid defaultTimeout", { timeout: 5000 }, () => {
const bad = {
name: "svc",
defaultTimeout: -1,
} as unknown as ConstructorParameters<typeof HealthChecker>[0];
try {
new HealthChecker(bad);
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(HealthCheckError);
expect((err as HealthCheckError).code).toBe(
HEALTH_CHECK_CODES.INVALID_OPTIONS
);
}
});
it("registers initial checks via options", { timeout: 5000 }, () => {
const checker = new HealthChecker({
name: "svc",
checks: [
{
name: "ok",
check: async () => ({ status: "healthy", responseTime: 1 }),
},
],
});
expect(checker.getCheckNames()).toEqual(["ok"]);
});
it("rejects duplicate check registration", { timeout: 5000 }, () => {
const check: CheckFn = async () => ({
status: "healthy",
responseTime: 1,
});
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({ name: "db", check });
try {
checker.addCheck({ name: "db", check });
expect.fail("should throw");
} catch (err) {
expect((err as HealthCheckError).code).toBe(
HEALTH_CHECK_CODES.DUPLICATE_CHECK
);
}
});
it("rejects invalid addCheck arguments", { timeout: 5000 }, () => {
const checker = new HealthChecker({ name: "svc" });
try {
checker.addCheck({
name: "",
check: async () => ({ status: "healthy", responseTime: 0 }),
});
expect.fail("should throw");
} catch (err) {
expect((err as HealthCheckError).code).toBe(
HEALTH_CHECK_CODES.INVALID_OPTIONS
);
}
});
it("removeCheck returns false for unknown check", { timeout: 5000 }, () => {
const checker = new HealthChecker({ name: "svc" });
expect(checker.removeCheck("nope")).toBe(false);
});
it("removeCheck clears cache + returns true", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({ status: "healthy", responseTime: 1 }),
cacheTtlMs: 60_000,
});
await checker.runCheck("db");
expect(checker.removeCheck("db")).toBe(true);
expect(checker.getCheckNames()).toEqual([]);
});
});
describe("HealthChecker — runCheck", () => {
let logCalls: Array<{ level: string; msg: string }>;
let logger: {
debug: (m: string) => void;
info: (m: string) => void;
warn: (m: string) => void;
error: (m: string) => void;
};
beforeEach(() => {
logCalls = [];
logger = {
debug: (m) => {
logCalls.push({ level: "debug", msg: m });
},
info: (m) => {
logCalls.push({ level: "info", msg: m });
},
warn: (m) => {
logCalls.push({ level: "warn", msg: m });
},
error: (m) => {
logCalls.push({ level: "error", msg: m });
},
};
});
it("throws UNKNOWN_CHECK for unregistered name", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
await expect(checker.runCheck("nope")).rejects.toBeInstanceOf(
HealthCheckError
);
});
it("records success result + emits CHECK_PASSED", {
timeout: 5000,
}, async () => {
const { clock, state } = makeClock();
const checker = new HealthChecker({
name: "svc",
clock,
timers: realTimers,
});
const events: string[] = [];
checker.events.on(HealthEvent.CHECK_PASSED, () => events.push("pass"));
checker.addCheck({
name: "db",
check: async () => {
state.now += 7;
return { status: "healthy", responseTime: 0 };
},
});
const result = await checker.runCheck("db");
expect(result.status).toBe("healthy");
expect(result.name).toBe("db");
expect(events).toEqual(["pass"]);
});
it("records failure result + emits CHECK_FAILED + logs warn", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc", logger });
const events: string[] = [];
checker.events.on(HealthEvent.CHECK_FAILED, () => events.push("fail"));
checker.addCheck({
name: "db",
check: async () => {
throw new Error("boom");
},
});
const result = await checker.runCheck("db");
expect(result.status).toBe("unhealthy");
expect(result.message).toBe("boom");
expect(events).toEqual(["fail"]);
expect(logCalls.some((c) => c.level === "warn")).toBe(true);
});
it("falls back to 'Unknown error' on non-Error throw", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
const throwString = async (): Promise<never> => {
const v: unknown = "boom";
throw v;
};
checker.addCheck({
name: "db",
check: throwString,
});
const result = await checker.runCheck("db");
// executeWithTimeout wraps in HealthCheckError with its own message, but
// we still get an Error instance, so message is preserved.
expect(result.status).toBe("unhealthy");
expect(typeof result.message).toBe("string");
});
it("enforces timeout via TimersLike", { timeout: 5000 }, async () => {
const { timers, pending, fire } = controlledTimers();
const checker = new HealthChecker({ name: "svc", timers });
checker.addCheck({
name: "slow",
check: () => new Promise(() => undefined),
timeout: 50,
});
const promise = checker.runCheck("slow");
// Fire the registered timeout to simulate elapse.
const [handle] = pending.keys();
fire(handle);
const result = await promise;
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("timed out");
expect((result.details as { timedOut?: boolean }).timedOut).toBe(true);
});
it("ignores late thunk resolution after timeout fires", {
timeout: 5000,
}, async () => {
const { timers, pending, fire } = controlledTimers();
let resolveThunk:
| ((v: { status: "healthy"; responseTime: number }) => void)
| undefined;
const checker = new HealthChecker({ name: "svc", timers });
checker.addCheck({
name: "slow",
check: () =>
new Promise((resolve) => {
resolveThunk = resolve;
}),
timeout: 50,
});
const promise = checker.runCheck("slow");
const [handle] = pending.keys();
fire(handle as number);
// Now resolve the thunk — its resolution branch must short-circuit.
resolveThunk?.({ status: "healthy", responseTime: 1 });
const result = await promise;
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("timed out");
});
it("ignores timer firing after thunk resolves", {
timeout: 5000,
}, async () => {
// Use timers that record but do NOT remove on clearTimer, so we can
// manually fire the registered callback after the thunk resolves.
const handlers: Array<() => void> = [];
const timers: TimersLike = {
setTimer: (cb) => {
handlers.push(cb);
return handlers.length - 1;
},
clearTimer: () => undefined,
};
const checker = new HealthChecker({ name: "svc", timers });
checker.addCheck({
name: "ok",
check: async () => ({ status: "healthy", responseTime: 1 }),
timeout: 50,
});
const result = await checker.runCheck("ok");
expect(result.status).toBe("healthy");
// Manually fire the still-registered timer callback. The settled guard
// inside executeWithTimeout MUST short-circuit so no error is thrown.
expect(() => handlers[0]?.()).not.toThrow();
});
it("ignores late thunk rejection after timeout fires", {
timeout: 5000,
}, async () => {
const { timers, pending, fire } = controlledTimers();
let rejectThunk: ((err: unknown) => void) | undefined;
const checker = new HealthChecker({ name: "svc", timers });
checker.addCheck({
name: "slow",
check: () =>
new Promise((_resolve, reject) => {
rejectThunk = reject;
}),
timeout: 50,
});
const promise = checker.runCheck("slow");
const [handle] = pending.keys();
fire(handle as number);
rejectThunk?.(new Error("late-error"));
const result = await promise;
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("timed out");
});
it("returns cached result on second call", { timeout: 5000 }, async () => {
const { clock, state } = makeClock();
let calls = 0;
const checker = new HealthChecker({ name: "svc", clock });
checker.addCheck({
name: "db",
check: async () => {
calls += 1;
return { status: "healthy", responseTime: 1 };
},
cacheTtlMs: 1_000,
});
await checker.runCheck("db");
const second = await checker.runCheck("db");
expect(calls).toBe(1);
expect(second.cached).toBe(true);
// After TTL expires the cache rebuilds.
state.now += 5_000;
await checker.runCheck("db");
expect(calls).toBe(2);
});
it("clearCache forces re-run", { timeout: 5000 }, async () => {
let calls = 0;
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => {
calls += 1;
return { status: "healthy", responseTime: 1 };
},
cacheTtlMs: 60_000,
});
await checker.runCheck("db");
checker.clearCache();
await checker.runCheck("db");
expect(calls).toBe(2);
});
it("falls back to elapsed when raw responseTime is missing", {
timeout: 5000,
}, async () => {
const { clock, state } = makeClock();
const checker = new HealthChecker({ name: "svc", clock });
checker.addCheck({
name: "db",
check: async () => {
state.now += 12;
const partial = { status: "healthy" } as unknown as {
status: "healthy";
responseTime: number;
};
return partial;
},
});
const r = await checker.runCheck("db");
expect(r.responseTime).toBe(12);
});
it("preserves raw result name + timestamp when supplied", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({
name: "renamed",
status: "degraded",
responseTime: 3,
timestamp: "2026-01-01T00:00:00.000Z",
message: "slow",
details: { latencyMs: 3 },
}),
});
const r = await checker.runCheck("db");
expect(r.name).toBe("renamed");
expect(r.timestamp).toBe("2026-01-01T00:00:00.000Z");
expect(r.message).toBe("slow");
expect(r.details).toEqual({ latencyMs: 3 });
});
});
describe("HealthChecker — health/ready/live aggregation", () => {
it("aggregates healthy when all critical checks pass", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc", version: "1.0.0" });
checker.addCheck({
name: "db",
check: async () => ({ status: "healthy", responseTime: 1 }),
});
const response = await checker.health();
expect(response.status).toBe("healthy");
expect(response.name).toBe("svc");
expect(response.version).toBe("1.0.0");
expect(response.checks.db?.status).toBe("healthy");
expect(response.uptimeMs).toBeGreaterThanOrEqual(0);
});
it("emits STATUS_CHANGED on transition", { timeout: 5000 }, async () => {
const checker = new HealthChecker({ name: "svc" });
const events: Array<{ previous: string; current: string }> = [];
checker.events.on(HealthEvent.STATUS_CHANGED, (p) => events.push(p));
let healthy = true;
checker.addCheck({
name: "db",
check: async () => ({
status: healthy ? "healthy" : "unhealthy",
responseTime: 1,
}),
});
await checker.health();
healthy = false;
await checker.health();
expect(events).toEqual([{ previous: "healthy", current: "unhealthy" }]);
});
it("degrades when non-critical check fails", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "cache",
critical: false,
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
const response = await checker.health();
expect(response.status).toBe("degraded");
});
it("aggregates degraded when a check is degraded", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "x",
check: async () => ({ status: "degraded", responseTime: 1 }),
});
const response = await checker.health();
expect(response.status).toBe("degraded");
});
it("ready reports notReady for unhealthy critical checks", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
checker.addCheck({
name: "cache",
critical: false,
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
const response = await checker.ready();
expect(response.ready).toBe(false);
expect(response.notReady).toEqual(["db"]);
});
it("live always reports alive=true", { timeout: 5000 }, () => {
const checker = new HealthChecker({ name: "svc" });
const response = checker.live();
expect(response.alive).toBe(true);
expect(response.uptimeMs).toBeGreaterThanOrEqual(0);
expect(typeof response.timestamp).toBe("string");
});
});

View File

@@ -0,0 +1,297 @@
import { EventBus } from "@suaveplan/events";
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import {
recordAggregateStatus,
recordCheckDuration,
recordCheckRun,
} from "../telemetry/telemetry.js";
import {
type CheckResult,
type CheckStatus,
type DependencyCheckConfig,
type HealthCheckerOptions,
HealthEvent,
type HealthEventMap,
type HealthResponse,
type LivenessResponse,
type ReadinessResponse,
} from "../types/types.js";
import {
aggregateStatus,
DEFAULT_TIMEOUT,
defaultClock,
defaultTimers,
dependencyCheckSchema,
executeWithTimeout,
normalizeResult,
optionsSchema,
} from "./health-checker.helpers.js";
export type { HealthCheckerOptions } from "../types/types.js";
interface CachedResult {
readonly result: CheckResult;
readonly expiresAt: number;
}
/**
* Core health checker that manages dependency checks and produces
* standardised health, readiness, and liveness responses.
*
* Wall-clock readings come from the injected `Clock`. Per-check timeouts
* are scheduled through the injected `TimersLike`. Lifecycle events are
* broadcast on a typed `EventBus<HealthEventMap>` accessible via `events`.
*/
export class HealthChecker {
public readonly events: EventBus<HealthEventMap>;
private readonly clock: Clock;
private readonly timers: TimersLike;
private readonly logger: LoggerLike | undefined;
private readonly name: string;
private readonly version: string | undefined;
private readonly defaultTimeout: number;
private readonly defaultCacheTtlMs: number;
private readonly checks = new Map<
string,
Required<DependencyCheckConfig>
>();
private readonly cache = new Map<string, CachedResult>();
private readonly startTime: number;
private lastStatus: CheckStatus = "healthy";
public constructor(options: HealthCheckerOptions) {
const parsed = optionsSchema.safeParse({
name: options.name,
version: options.version,
defaultTimeout: options.defaultTimeout,
defaultCacheTtlMs: options.defaultCacheTtlMs,
checks: options.checks,
});
if (!parsed.success) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.INVALID_OPTIONS,
"invalid HealthChecker options",
{ context: { issues: parsed.error.issues } }
);
}
this.name = options.name;
this.version = options.version;
this.defaultTimeout = options.defaultTimeout ?? DEFAULT_TIMEOUT;
this.defaultCacheTtlMs = options.defaultCacheTtlMs ?? 0;
this.clock = options.clock ?? defaultClock;
this.timers = options.timers ?? defaultTimers;
this.logger = options.logger;
this.events = new EventBus<HealthEventMap>();
this.startTime = this.clock();
if (options.checks !== undefined) {
for (const check of options.checks) {
this.addCheck(check);
}
}
}
/** Register a new dependency check. */
public addCheck(config: DependencyCheckConfig): void {
const parsed = dependencyCheckSchema.safeParse(config);
if (!parsed.success) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.INVALID_OPTIONS,
"invalid dependency-check config",
{ context: { issues: parsed.error.issues } }
);
}
if (this.checks.has(config.name)) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.DUPLICATE_CHECK,
`check "${config.name}" is already registered`,
{ context: { name: config.name } }
);
}
this.checks.set(config.name, {
name: config.name,
check: config.check,
critical: config.critical ?? true,
timeout: config.timeout ?? this.defaultTimeout,
cacheTtlMs: config.cacheTtlMs ?? this.defaultCacheTtlMs,
});
this.logger?.debug("health-check registered", { name: config.name });
}
/** Remove a registered dependency check. Returns `true` if removed. */
public removeCheck(name: string): boolean {
this.cache.delete(name);
return this.checks.delete(name);
}
/** Get names of all registered checks. */
public getCheckNames(): string[] {
return Array.from(this.checks.keys());
}
/** Get the service uptime in milliseconds. */
public getUptimeMs(): number {
return this.clock() - this.startTime;
}
/** Execute a single check with timeout enforcement and caching. */
public async runCheck(name: string): Promise<CheckResult> {
const config = this.checks.get(name);
if (config === undefined) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.UNKNOWN_CHECK,
`check "${name}" is not registered`,
{ context: { name } }
);
}
const cached = this.getCachedResult(name);
if (cached !== null) {
return cached;
}
const start = this.clock();
try {
const raw = await executeWithTimeout(
() => config.check(),
config.timeout,
this.timers
);
const elapsed = this.clock() - start;
const result = normalizeResult(name, raw, elapsed, this.clock);
this.cacheResult(name, result, config.cacheTtlMs);
this.emitCheckEvent(result);
recordCheckRun(name, result.status);
recordCheckDuration(name, result.responseTime);
return result;
} catch (error) {
const elapsed = this.clock() - start;
const isTimeout =
error instanceof HealthCheckError &&
error.code === HEALTH_CHECK_CODES.CHECK_TIMEOUT;
// executeWithTimeout always wraps in HealthCheckError → Error.
const message = (error as Error).message;
const result: CheckResult = {
name,
status: "unhealthy",
responseTime: elapsed,
message,
timestamp: new Date(this.clock()).toISOString(),
details: { timedOut: isTimeout },
};
this.cacheResult(name, result, config.cacheTtlMs);
this.emitCheckEvent(result);
recordCheckRun(name, result.status);
recordCheckDuration(name, result.responseTime);
this.logger?.warn("health-check failed", { name, message });
return result;
}
}
/** Run all registered checks and return a full health response. */
public async health(): Promise<HealthResponse> {
const results = await this.runAllChecks();
const status = aggregateStatus(results, (name) => {
const config = this.checks.get(name);
return config?.critical === true;
});
if (status !== this.lastStatus) {
const previous = this.lastStatus;
this.lastStatus = status;
this.events.emit(HealthEvent.STATUS_CHANGED, {
previous,
current: status,
});
}
recordAggregateStatus(status);
const response: HealthResponse = {
status,
timestamp: new Date(this.clock()).toISOString(),
uptimeMs: this.getUptimeMs(),
name: this.name,
checks: Object.fromEntries(results.map((r) => [r.name, r])),
};
if (this.version !== undefined) {
response.version = this.version;
}
return response;
}
/** Check if the service is ready (all critical checks pass). */
public async ready(): Promise<ReadinessResponse> {
const results = await this.runAllChecks();
const notReady = results
.filter((r) => {
const config = this.checks.get(r.name);
return config?.critical === true && r.status === "unhealthy";
})
.map((r) => r.name);
return {
ready: notReady.length === 0,
timestamp: new Date(this.clock()).toISOString(),
notReady,
};
}
/** Check if the service is alive (basic liveness). */
public live(): LivenessResponse {
return {
alive: true,
timestamp: new Date(this.clock()).toISOString(),
uptimeMs: this.getUptimeMs(),
};
}
/** Clear all cached results. */
public clearCache(): void {
this.cache.clear();
}
private async runAllChecks(): Promise<CheckResult[]> {
const names = this.getCheckNames();
return Promise.all(names.map((name) => this.runCheck(name)));
}
private getCachedResult(name: string): CheckResult | null {
const cached = this.cache.get(name);
if (cached === undefined) {
return null;
}
if (this.clock() > cached.expiresAt) {
this.cache.delete(name);
return null;
}
return { ...cached.result, cached: true };
}
private cacheResult(
name: string,
result: CheckResult,
ttlMs: number
): void {
if (ttlMs <= 0) {
return;
}
this.cache.set(name, {
result,
expiresAt: this.clock() + ttlMs,
});
}
private emitCheckEvent(result: CheckResult): void {
if (result.status === "healthy") {
this.events.emit(HealthEvent.CHECK_PASSED, result);
} else {
this.events.emit(HealthEvent.CHECK_FAILED, result);
}
}
}

View File

@@ -0,0 +1,2 @@
export type { HealthCheckerOptions } from "./health-checker.js";
export { HealthChecker } from "./health-checker.js";

View File

@@ -0,0 +1,54 @@
# `http-check/http-check`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createHttpCheck` returns a `CheckFn` that probes an HTTP endpoint and reports `healthy` when the response status appears in `expectedStatusCodes`. It is the standard building block for "is upstream X reachable?" probes — used to check downstream APIs, gateways, internal dependency surfaces, and anything else with a `/health` or `/status` endpoint. The factory accepts an injectable `fetchImpl` so tests never touch real network sockets, and an injectable `Clock` so `responseTime` is deterministic.
## Features
- Method, headers, and expected-status-code overrides via the `HttpCheckConfig` argument.
- AbortController-driven per-request timeout that maps to `responseTime` + the human-readable `"HTTP check timed out after Xms"` message.
- Network errors are returned as `unhealthy` with `details.error` containing the raw message — not thrown.
- Zod boundary validation: empty `url`, empty `method`, non-positive `timeout` all throw `HealthCheckError(INVALID_OPTIONS)`.
- `fetchImpl` defaults to `globalThis.fetch` bound to `globalThis`; tests pass a stub.
## Usage: Basic
```ts
import { createHttpCheck } from "@suaveplan/health-check";
const check = createHttpCheck({ url: "https://upstream.example/health" });
const result = await check();
```
## Usage: Advanced
```ts
import { createHttpCheck } from "@suaveplan/health-check";
const check = createHttpCheck(
{
url: "https://internal.api/health",
method: "HEAD",
headers: { authorization: `Bearer ${token}` },
timeout: 1_500,
expectedStatusCodes: [200, 204],
fetchImpl: instrumentedFetch,
},
{ clock: () => performance.now() },
);
```
## API Reference
- `createHttpCheck(config, options?)` → `CheckFn`.
- `config: HttpCheckConfig` — `url` required; `method`, `headers`, `timeout`, `expectedStatusCodes`, `fetchImpl` optional.
- `options.clock?: Clock` — overrides `Date.now` for response-time measurement.
## Implementation Notes
The check never re-throws. Network errors, abort errors, and unexpected status codes are all funnelled into `CheckResult` objects so the `HealthChecker` can record them uniformly. Abort detection is case-insensitive on `"abort"` / `"timeout"` and also consults `controller.signal.aborted` so adapters that surface only a generic error message still classify correctly. The `responseTime` is measured between the clock readings around `fetchImpl` so it includes DNS, TCP, TLS, and the server's processing time — matching how operators read latency dashboards. The factory is pure: it validates options once and returns a closure that captures the normalised config, so the per-call cost is just the fetch + a small amount of bookkeeping.

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import { createHttpCheck } from "./http-check.js";
function makeClock() {
const state = { now: 1_000 };
return {
state,
clock: () => state.now,
};
}
function makeFetch(
impl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
): typeof fetch {
return impl as typeof fetch;
}
describe("createHttpCheck", () => {
it("rejects empty URL", { timeout: 5000 }, () => {
try {
createHttpCheck({ url: "" });
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(HealthCheckError);
expect((err as HealthCheckError).code).toBe(
HEALTH_CHECK_CODES.INVALID_OPTIONS
);
}
});
it("returns healthy on expected status", { timeout: 5000 }, async () => {
const { clock, state } = makeClock();
const fetchImpl = makeFetch(async () => {
state.now += 5;
return new Response("ok", { status: 200 });
});
const check = createHttpCheck(
{ url: "https://example.test/health", fetchImpl },
{ clock }
);
const result = await check();
expect(result.status).toBe("healthy");
expect(result.responseTime).toBe(5);
expect(result.message).toContain("200");
const details = result.details as { statusCode: number };
expect(details.statusCode).toBe(200);
});
it("treats unexpected status as unhealthy", { timeout: 5000 }, async () => {
const fetchImpl = makeFetch(
async () => new Response("", { status: 500 })
);
const check = createHttpCheck({
url: "https://example.test/health",
fetchImpl,
});
const result = await check();
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("Unexpected status: 500");
});
it("respects expectedStatusCodes override", { timeout: 5000 }, async () => {
const fetchImpl = makeFetch(
async () => new Response(null, { status: 204 })
);
const check = createHttpCheck({
url: "https://example.test/health",
fetchImpl,
expectedStatusCodes: [204],
method: "HEAD",
headers: { "x-test": "1" },
});
const result = await check();
expect(result.status).toBe("healthy");
const details = result.details as { method: string };
expect(details.method).toBe("HEAD");
});
it("classifies network errors as unhealthy", {
timeout: 5000,
}, async () => {
const fetchImpl = makeFetch(async () => {
throw new Error("ECONNREFUSED");
});
const check = createHttpCheck({
url: "https://example.test/health",
fetchImpl,
});
const result = await check();
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("ECONNREFUSED");
});
it("classifies abort errors as timeouts", { timeout: 5000 }, async () => {
const fetchImpl = makeFetch(async (_input, init) => {
const signal = init?.signal as AbortSignal | undefined;
return new Promise<Response>((_resolve, reject) => {
signal?.addEventListener("abort", () => {
reject(new Error("The operation was aborted"));
});
});
});
const check = createHttpCheck({
url: "https://example.test/health",
fetchImpl,
timeout: 5,
});
const result = await check();
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("timed out");
});
it("defaults to globalThis.fetch when no fetchImpl supplied", {
timeout: 5000,
}, async () => {
const originalFetch = globalThis.fetch;
const stub = (async () =>
new Response("ok", { status: 200 })) as typeof fetch;
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: stub,
});
try {
const check = createHttpCheck({
url: "https://example.test/health",
});
const result = await check();
expect(result.status).toBe("healthy");
} finally {
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: originalFetch,
});
}
});
it("falls back to non-Error stringification", {
timeout: 5000,
}, async () => {
const fetchImpl = makeFetch(async () => {
const v: unknown = "weird";
throw v;
});
const check = createHttpCheck({
url: "https://example.test/health",
fetchImpl,
});
const result = await check();
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("Request failed");
});
});

View File

@@ -0,0 +1,106 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import type { CheckFn, HttpCheckConfig } from "../types/types.js";
const DEFAULT_TIMEOUT = 5000;
const DEFAULT_STATUS_CODES = [200];
const optionsSchema = z.object({
url: z.string().min(1, "url must be non-empty"),
method: z.string().min(1).optional(),
headers: z.record(z.string(), z.string()).optional(),
timeout: z.number().int().positive().optional(),
expectedStatusCodes: z.array(z.number().int()).optional(),
});
const defaultClock: Clock = () => Date.now();
/**
* Creates a health-check function that verifies an HTTP endpoint is
* reachable and returning expected status codes.
*
* `fetchImpl` defaults to `globalThis.fetch` when omitted. Tests SHOULD
* supply a stub. `clock` is injectable for deterministic response-time
* measurement.
*/
export function createHttpCheck(
config: HttpCheckConfig,
options: { clock?: Clock } = {}
): CheckFn {
const parsed = optionsSchema.safeParse({
url: config.url,
method: config.method,
headers: config.headers,
timeout: config.timeout,
expectedStatusCodes: config.expectedStatusCodes,
});
if (!parsed.success) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.INVALID_OPTIONS,
"invalid HttpCheck options",
{ context: { issues: parsed.error.issues } }
);
}
const url = config.url;
const method = config.method ?? "GET";
const headers = config.headers ?? {};
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
const expected = config.expectedStatusCodes ?? DEFAULT_STATUS_CODES;
const fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
const clock = options.clock ?? defaultClock;
return async () => {
const start = clock();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetchImpl(url, {
method,
headers,
signal: controller.signal,
});
clearTimeout(timer);
const responseTime = clock() - start;
const isExpected = expected.includes(response.status);
return {
status: isExpected ? "healthy" : "unhealthy",
responseTime,
message: isExpected
? `HTTP ${response.status} OK`
: `Unexpected status: ${response.status}`,
details: {
url,
method,
statusCode: response.status,
expectedStatusCodes: expected,
},
};
} catch (error) {
clearTimeout(timer);
const responseTime = clock() - start;
const message =
error instanceof Error ? error.message : "Request failed";
const isTimeout =
controller.signal.aborted ||
message.toLowerCase().includes("abort") ||
message.toLowerCase().includes("timeout");
return {
status: "unhealthy",
responseTime,
message: isTimeout
? `HTTP check timed out after ${timeout}ms`
: `HTTP check failed: ${message}`,
details: { url, method, error: message },
};
}
};
}

View File

@@ -0,0 +1 @@
export { createHttpCheck } from "./http-check.js";

View File

@@ -0,0 +1,45 @@
// Types
// Errors
export type { HealthCheckCode } from "./error-codes/error-codes.js";
export {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "./error-codes/error-codes.js";
// Core
export { HealthChecker } from "./health-checker/health-checker.js";
// Built-in checks
export { createHttpCheck } from "./http-check/http-check.js";
// Response builder
export { ResponseBuilder } from "./response-builder/response-builder.js";
export { createTcpCheck } from "./tcp-check/tcp-check.js";
// Telemetry
export {
aggregateStatusCounter,
checkDurationHistogram,
checkRunCounter,
recordAggregateStatus,
recordCheckDuration,
recordCheckRun,
} from "./telemetry/telemetry.js";
export type {
CheckFn,
CheckResult,
CheckStatus,
DependencyCheckConfig,
HealthCheckerOptions,
HealthEventMap,
HealthEventName,
HealthResponse,
HttpCheckConfig,
HttpHealthResponse,
LivenessResponse,
ReadinessResponse,
StatusChangedPayload,
TcpCheckConfig,
TcpConnectFn,
TcpSocketLike,
} from "./types/types.js";
export { HealthEvent } from "./types/types.js";

View File

@@ -0,0 +1 @@
export { ResponseBuilder } from "./response-builder.js";

View File

@@ -0,0 +1,64 @@
# `response-builder/response-builder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ResponseBuilder` adapts a `HealthChecker` into HTTP-shaped responses suitable for direct return from a Hono / Express / Fastify handler. It maps the abstract `CheckStatus` union to concrete HTTP status codes following the conventions baked into Kubernetes, ECS, and most load-balancer probes: `healthy` and `degraded` both produce `200` (so callers continue to receive traffic on degraded but still-serving services), `unhealthy` produces `503`, readiness produces `503` when any critical check is failing, and liveness always produces `200` so long as the process can run the handler.
## Features
- Three response factories — `health()`, `ready()`, `live()` — each returning `{ statusCode, body }`.
- Pure mapping: the builder never mutates the underlying checker.
- Status mapping is centralised in a single `statusToCode` switch so behaviour is auditable.
- Liveness is synchronous to keep `/live` cheap; readiness and health run the checks.
## Usage: Basic
```ts
import { HealthChecker, ResponseBuilder } from "@suaveplan/health-check";
const checker = new HealthChecker({ name: "api" });
const builder = new ResponseBuilder(checker);
app.get("/health", async (req, res) => {
const r = await builder.health();
res.status(r.statusCode).json(r.body);
});
```
## Usage: Advanced
```ts
import { Hono } from "hono";
import { HealthChecker, ResponseBuilder } from "@suaveplan/health-check";
const checker = new HealthChecker({ name: "api" });
const builder = new ResponseBuilder(checker);
const app = new Hono()
.get("/health", async (c) => {
const r = await builder.health();
return c.json(r.body, r.statusCode);
})
.get("/ready", async (c) => {
const r = await builder.ready();
return c.json(r.body, r.statusCode);
})
.get("/live", (c) => {
const r = builder.live();
return c.json(r.body, r.statusCode);
});
```
## API Reference
- `new ResponseBuilder(checker)` — accepts an existing `HealthChecker`.
- `health()` → `Promise<HttpHealthResponse<HealthResponse>>`.
- `ready()` → `Promise<HttpHealthResponse<ReadinessResponse>>`.
- `live()` → `HttpHealthResponse<LivenessResponse>`.
## Implementation Notes
Returning `200` for `degraded` is deliberate: a degraded service is still serving traffic — operators want it to remain in the load-balancer pool while alerting fires on the underlying check. Flipping a degraded service into the unreachable pool would amplify outages because non-critical dependency hiccups would drop the entire fleet. `live()` deliberately performs no work so it is safe to wire to a tight liveness-probe interval without amplifying load on the dependencies. The builder is intentionally a thin adapter so a future plugin could wrap the same `HealthChecker` in a different protocol (gRPC, JSON-RPC, etc.) without re-implementing the orchestration.

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HealthChecker } from "../health-checker/health-checker.js";
import { ResponseBuilder } from "./response-builder.js";
describe("ResponseBuilder", () => {
it("returns 200 for healthy /health", { timeout: 5000 }, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({ status: "healthy", responseTime: 1 }),
});
const builder = new ResponseBuilder(checker);
const response = await builder.health();
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe("healthy");
});
it("returns 200 for degraded /health", { timeout: 5000 }, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "cache",
critical: false,
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
const builder = new ResponseBuilder(checker);
const response = await builder.health();
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe("degraded");
});
it("returns 503 for unhealthy /health", { timeout: 5000 }, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
const builder = new ResponseBuilder(checker);
const response = await builder.health();
expect(response.statusCode).toBe(503);
expect(response.body.status).toBe("unhealthy");
});
it("returns 200 for ready /ready", { timeout: 5000 }, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({ status: "healthy", responseTime: 1 }),
});
const builder = new ResponseBuilder(checker);
const response = await builder.ready();
expect(response.statusCode).toBe(200);
expect(response.body.ready).toBe(true);
});
it("returns 503 for not-ready /ready", { timeout: 5000 }, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
const builder = new ResponseBuilder(checker);
const response = await builder.ready();
expect(response.statusCode).toBe(503);
expect(response.body.ready).toBe(false);
});
it("returns 200 for /live", { timeout: 5000 }, () => {
const checker = new HealthChecker({ name: "svc" });
const builder = new ResponseBuilder(checker);
const response = builder.live();
expect(response.statusCode).toBe(200);
expect(response.body.alive).toBe(true);
});
});

View File

@@ -0,0 +1,60 @@
import type { HealthChecker } from "../health-checker/health-checker.js";
import type {
CheckStatus,
HealthResponse,
HttpHealthResponse,
LivenessResponse,
ReadinessResponse,
} from "../types/types.js";
/**
* Builds standardised HTTP responses from `HealthChecker` results.
* Maps health statuses to appropriate HTTP status codes:
* `healthy`/`degraded` → 200, `unhealthy` → 503; readiness `ready=false`
* → 503; liveness is always 200 when the process can serve a response.
*/
export class ResponseBuilder {
private readonly checker: HealthChecker;
public constructor(checker: HealthChecker) {
this.checker = checker;
}
/** Build an HTTP response for the `/health` endpoint. */
public async health(): Promise<HttpHealthResponse<HealthResponse>> {
const result = await this.checker.health();
return {
statusCode: this.statusToCode(result.status),
body: result,
};
}
/** Build an HTTP response for the `/ready` endpoint. */
public async ready(): Promise<HttpHealthResponse<ReadinessResponse>> {
const result = await this.checker.ready();
return {
statusCode: result.ready ? 200 : 503,
body: result,
};
}
/** Build an HTTP response for the `/live` endpoint. */
public live(): HttpHealthResponse<LivenessResponse> {
const result = this.checker.live();
return {
statusCode: 200,
body: result,
};
}
private statusToCode(status: CheckStatus): number {
switch (status) {
case "healthy":
return 200;
case "degraded":
return 200;
case "unhealthy":
return 503;
}
}
}

View File

@@ -0,0 +1 @@
export { createTcpCheck } from "./tcp-check.js";

View File

@@ -0,0 +1,53 @@
# `tcp-check/tcp-check`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createTcpCheck` returns a `CheckFn` that opens a TCP socket against a `host:port` pair and resolves to `healthy` once the `connect` event fires. It complements `createHttpCheck` for layer-4 probes — useful for databases, message brokers, and proxies that do not expose an HTTP `/health` route. The connector is fully injectable through `TcpConnectFn`, so tests use a deterministic stub and production code uses `node:net.connect`.
## Features
- Injectable `TcpConnectFn`: tests pass a fake socket with controlled `connect` / `error` emission; production uses `node:net`.
- Timeout enforcement via `setTimeout` that resolves to an `unhealthy` `CheckResult` with `details.timedOut === true`.
- Late events (a stray `error` after `connect`) are ignored once the promise settles.
- Zod boundary validation: missing `host`, out-of-range `port`, non-positive `timeout` all throw `HealthCheckError(INVALID_OPTIONS)`.
- Always destroys the socket — connect, error, and timeout paths each call `socket.destroy()`.
## Usage: Basic
```ts
import { createTcpCheck } from "@suaveplan/health-check";
const check = createTcpCheck({ host: "db.internal", port: 5432 });
const result = await check();
```
## Usage: Advanced
```ts
import { createTcpCheck } from "@suaveplan/health-check";
const check = createTcpCheck(
{
host: "db.internal",
port: 5432,
timeout: 500,
connectImpl: instrumentedConnect,
},
{ clock: () => performance.now() },
);
```
## API Reference
- `createTcpCheck(config, options?)` → `CheckFn`.
- `config: TcpCheckConfig` — `host` + `port` required; `timeout`, `connectImpl` optional.
- `options.clock?: Clock` — overrides `Date.now` for response-time measurement.
- `TcpSocketLike` — minimal structural socket the check drives (`on("connect" | "error", cb)` + `destroy()`).
## Implementation Notes
The check resolves rather than rejects on every failure path so the `HealthChecker` can treat result production uniformly. Late events (a stray `error` after `connect` resolved) are absorbed by `Promise.resolve`'s idempotency — only the first `resolve()` call wins, so the call site can stay lock-free. The default `connectImpl` performs a runtime cast from `node:net`'s `Socket` to `TcpSocketLike`; the cast is sound because `Socket` exposes the `on("connect" | "error", …)` and `destroy()` methods unchanged. The `connectImpl` injection point means consumers can also drive this check against an alternative transport (Unix-domain sockets, mock fixtures, etc.) without touching the public API.

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import type { TcpConnectFn, TcpSocketLike } from "../types/types.js";
import { createTcpCheck } from "./tcp-check.js";
interface Handlers {
connect?: () => void;
error?: (err: Error) => void;
}
function makeSocketFactory(
behaviour: "connect" | "error" | "hang",
errorMsg = "ECONNREFUSED"
): { factory: TcpConnectFn; destroyCalls: number } {
let destroyCalls = 0;
const factory: TcpConnectFn = () => {
const handlers: Handlers = {};
const socket: TcpSocketLike = {
on: ((event: string, handler: unknown) => {
if (event === "connect") {
handlers.connect = handler as () => void;
}
if (event === "error") {
handlers.error = handler as (e: Error) => void;
}
return socket;
}) as TcpSocketLike["on"],
destroy: () => {
destroyCalls += 1;
},
};
if (behaviour === "connect") {
setImmediate(() => handlers.connect?.());
} else if (behaviour === "error") {
setImmediate(() => handlers.error?.(new Error(errorMsg)));
}
return socket;
};
return {
factory,
get destroyCalls() {
return destroyCalls;
},
} as { factory: TcpConnectFn; destroyCalls: number };
}
describe("createTcpCheck", () => {
it("rejects invalid port", { timeout: 5000 }, () => {
try {
createTcpCheck({ host: "h", port: 0 });
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(HealthCheckError);
expect((err as HealthCheckError).code).toBe(
HEALTH_CHECK_CODES.INVALID_OPTIONS
);
}
});
it("returns healthy when socket connects", { timeout: 5000 }, async () => {
const { factory } = makeSocketFactory("connect");
const check = createTcpCheck({
host: "127.0.0.1",
port: 5432,
connectImpl: factory,
});
const result = await check();
expect(result.status).toBe("healthy");
expect(result.message).toContain("succeeded");
});
it("returns unhealthy on socket error", { timeout: 5000 }, async () => {
const { factory } = makeSocketFactory("error", "boom");
const check = createTcpCheck({
host: "127.0.0.1",
port: 5432,
connectImpl: factory,
});
const result = await check();
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("boom");
});
it("returns unhealthy on timeout", { timeout: 5000 }, async () => {
const { factory } = makeSocketFactory("hang");
const check = createTcpCheck({
host: "127.0.0.1",
port: 5432,
timeout: 10,
connectImpl: factory,
});
const result = await check();
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("timed out");
});
it("ignores late events once settled", { timeout: 5000 }, async () => {
// Wire a socket that fires connect then immediately error: the error
// path should be a no-op because the connect already settled.
let connectHandler: (() => void) | undefined;
let errorHandler: ((e: Error) => void) | undefined;
const factory: TcpConnectFn = () => {
const socket: TcpSocketLike = {
on: ((event: string, handler: unknown) => {
if (event === "connect")
connectHandler = handler as () => void;
if (event === "error")
errorHandler = handler as (e: Error) => void;
return socket;
}) as TcpSocketLike["on"],
destroy: () => undefined,
};
setImmediate(() => {
connectHandler?.();
errorHandler?.(new Error("late"));
});
return socket;
};
const check = createTcpCheck({
host: "h",
port: 1,
connectImpl: factory,
});
const result = await check();
expect(result.status).toBe("healthy");
});
it("uses the default node:net connector when not injected", {
timeout: 5000,
}, async () => {
// 127.0.0.1:1 is a guaranteed-unreachable port; the real socket will
// emit ECONNREFUSED before the default 5s timeout elapses.
const check = createTcpCheck({
host: "127.0.0.1",
port: 1,
timeout: 1_500,
});
const result = await check();
expect(result.status).toBe("unhealthy");
});
});

View File

@@ -0,0 +1,96 @@
import { connect } from "node:net";
import type { Clock } from "@suaveplan/types";
import z from "zod";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import type {
CheckFn,
TcpCheckConfig,
TcpConnectFn,
TcpSocketLike,
} from "../types/types.js";
const DEFAULT_TIMEOUT = 5000;
const optionsSchema = z.object({
host: z.string().min(1, "host must be non-empty"),
port: z.number().int().min(1).max(65535),
timeout: z.number().int().positive().optional(),
});
const defaultClock: Clock = () => Date.now();
const defaultConnect: TcpConnectFn = (host, port) =>
connect({ host, port }) as unknown as TcpSocketLike;
/**
* Creates a health-check function that verifies a TCP endpoint is
* reachable by attempting a socket connection.
*
* `connectImpl` defaults to a `node:net` socket when omitted; tests
* SHOULD inject a fake. `clock` is injectable for deterministic
* response-time measurement.
*/
export function createTcpCheck(
config: TcpCheckConfig,
options: { clock?: Clock } = {}
): CheckFn {
const parsed = optionsSchema.safeParse({
host: config.host,
port: config.port,
timeout: config.timeout,
});
if (!parsed.success) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.INVALID_OPTIONS,
"invalid TcpCheck options",
{ context: { issues: parsed.error.issues } }
);
}
const host = config.host;
const port = config.port;
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
const connectImpl = config.connectImpl ?? defaultConnect;
const clock = options.clock ?? defaultClock;
return async () =>
new Promise((resolve) => {
const start = clock();
const socket = connectImpl(host, port);
const timer = setTimeout(() => {
socket.destroy();
resolve({
status: "unhealthy",
responseTime: clock() - start,
message: `TCP check timed out after ${timeout}ms`,
details: { host, port, timedOut: true },
});
}, timeout);
socket.on("connect", () => {
clearTimeout(timer);
socket.destroy();
resolve({
status: "healthy",
responseTime: clock() - start,
message: `TCP connection to ${host}:${port} succeeded`,
details: { host, port },
});
});
socket.on("error", (error: Error) => {
clearTimeout(timer);
socket.destroy();
resolve({
status: "unhealthy",
responseTime: clock() - start,
message: `TCP check failed: ${error.message}`,
details: { host, port, error: error.message },
});
});
});
}

View File

@@ -0,0 +1,8 @@
export {
aggregateStatusCounter,
checkDurationHistogram,
checkRunCounter,
recordAggregateStatus,
recordCheckDuration,
recordCheckRun,
} from "./telemetry.js";

View File

@@ -0,0 +1,47 @@
# `telemetry/telemetry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`telemetry` registers the OpenTelemetry meter that the health-check package emits to on every public call. The instruments are bound lazily through `metrics.getMeter("@suaveplan/health-check")` so the package can be imported in production builds that have not opted into OTel — the API returns a no-op meter when no provider is registered. Tests install a real in-memory `MeterProvider` in `tests/setup/polyfills.ts` before any source module imports, so the same imports observed in production are exercised in unit tests.
## Features
- Counter `health_check_runs_total` — tagged with `name` and `status` for every executed dependency check.
- Counter `health_check_aggregate_status_total` — tagged with the resulting overall status for every call to `HealthChecker.health()`.
- Histogram `health_check_duration_ms` — recorded with the `name` tag for every executed dependency check.
- Helper `record*` functions used by `HealthChecker` so the call-sites stay legible.
## Usage: Basic
```ts
import { recordCheckRun } from "@suaveplan/health-check";
recordCheckRun("db", "healthy");
```
## Usage: Advanced
```ts
import {
aggregateStatusCounter,
checkDurationHistogram,
} from "@suaveplan/health-check";
aggregateStatusCounter.add(1, { status: "degraded", region: "eu-west-1" });
checkDurationHistogram.record(latencyMs, { name: "db", region: "eu-west-1" });
```
## API Reference
- `recordCheckRun(name, status)` — increment `health_check_runs_total`.
- `recordAggregateStatus(status)` — increment `health_check_aggregate_status_total`.
- `recordCheckDuration(name, durationMs)` — record on `health_check_duration_ms`.
- Raw `Counter` / `Histogram` instruments are exported for callers that need bespoke labels.
## Implementation Notes
The meter is constructed at module top-level so its caching matches OTel's per-process semantics. Switching the global `MeterProvider` after import will not re-bind the meter — this matches OTel best practice and lets tests inject a provider before the module evaluates. The package emits **no** labels containing user data; all tag values are static enum strings (`healthy` / `degraded` / `unhealthy`) or operator-supplied check names. This keeps cardinality bounded and prevents accidental leakage.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
recordAggregateStatus,
recordCheckDuration,
recordCheckRun,
} from "./telemetry.js";
interface Point {
descriptor: { name: string };
}
function collectNames(metrics: unknown): string[] {
const out: string[] = [];
const top = metrics as Array<{
scopeMetrics: Array<{ metrics: Array<Point> }>;
}>;
for (const t of top) {
for (const sm of t.scopeMetrics) {
for (const m of sm.metrics) out.push(m.descriptor.name);
}
}
return out;
}
describe("telemetry", () => {
it("records check runs", { timeout: 5000 }, async () => {
recordCheckRun("db", "healthy");
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain("health_check_runs_total");
});
it("records aggregate status", { timeout: 5000 }, async () => {
recordAggregateStatus("healthy");
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain(
"health_check_aggregate_status_total"
);
});
it("records check duration", { timeout: 5000 }, async () => {
recordCheckDuration("db", 12.3);
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain("health_check_duration_ms");
});
});

View File

@@ -0,0 +1,53 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/health-check";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns a
* no-op meter, so instruments are safe to import in production code that
* does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
/** Counter for individual check executions, tagged by status + name. */
export const checkRunCounter: Counter = meter.createCounter(
"health_check_runs_total",
{
description:
"Total individual dependency checks executed, tagged with the check name and resulting status",
}
);
/** Counter for overall `health()` aggregate evaluations, tagged by status. */
export const aggregateStatusCounter: Counter = meter.createCounter(
"health_check_aggregate_status_total",
{
description:
"Total /health aggregate evaluations, tagged with the overall status",
}
);
/** Histogram for individual check durations in milliseconds. */
export const checkDurationHistogram: Histogram = meter.createHistogram(
"health_check_duration_ms",
{
description: "Duration of individual dependency checks in milliseconds",
unit: "ms",
}
);
/** Record a single check-run event. */
export function recordCheckRun(name: string, status: string): void {
checkRunCounter.add(1, { name, status });
}
/** Record an overall aggregate evaluation. */
export function recordAggregateStatus(status: string): void {
aggregateStatusCounter.add(1, { status });
}
/** Record an individual check duration in milliseconds. */
export function recordCheckDuration(name: string, durationMs: number): void {
checkDurationHistogram.record(durationMs, { name });
}

View File

@@ -0,0 +1,183 @@
/**
* Shared types for `@suaveplan/health-check`.
*
* Co-located in `src/types/` per the genesis layout exception for shared
* type aggregates. No runtime exports.
*/
/** Status of an individual check or overall service health. */
export type CheckStatus = "healthy" | "unhealthy" | "degraded";
/** Result from executing a single dependency check. */
export interface CheckResult {
/** Name of the check. */
name: string;
/** Current status. */
status: CheckStatus;
/** Time taken to execute the check in milliseconds. */
responseTime: number;
/** Optional human-readable message. */
message?: string;
/** Optional additional details. */
details?: Record<string, unknown>;
/** When this result was produced (ISO-8601). */
timestamp: string;
/** Whether this result came from cache. */
cached?: boolean;
}
/** A check function that returns health information. */
export type CheckFn = () => Promise<
CheckResult | Omit<CheckResult, "name" | "timestamp">
>;
/** Configuration for a dependency check. */
export interface DependencyCheckConfig {
/** Unique name for this check. */
name: string;
/** The check function to execute. */
check: CheckFn;
/** If true, failure marks overall status as unhealthy (default: true). */
critical?: boolean;
/** Timeout for this check in ms (default: 5000). */
timeout?: number;
/** Cache TTL for results in ms (default: 0, no caching). */
cacheTtlMs?: number;
}
/** Full health response returned by the `/health` endpoint. */
export interface HealthResponse {
/** Overall status of the service. */
status: CheckStatus;
/** ISO timestamp of this response. */
timestamp: string;
/** Service uptime in milliseconds. */
uptimeMs: number;
/** Service name. */
name: string;
/** Service version if provided. */
version?: string;
/** Individual check results keyed by check name. */
checks: Record<string, CheckResult>;
}
/** Readiness response (is the service ready to serve traffic?). */
export interface ReadinessResponse {
/** Whether the service is ready. */
ready: boolean;
/** ISO timestamp. */
timestamp: string;
/** Names of checks that are not yet healthy. */
notReady: string[];
}
/** Liveness response (is the service process alive?). */
export interface LivenessResponse {
/** Whether the service is alive. */
alive: boolean;
/** ISO timestamp. */
timestamp: string;
/** Service uptime in milliseconds. */
uptimeMs: number;
}
/** HTTP-style response with status code and body. */
export interface HttpHealthResponse<
T = HealthResponse | ReadinessResponse | LivenessResponse,
> {
/** HTTP status code. */
statusCode: number;
/** Response body. */
body: T;
}
/** Configuration for an HTTP-based health check. */
export interface HttpCheckConfig {
/** URL to check. */
url: string;
/** HTTP method (default: GET). */
method?: string;
/** Request headers. */
headers?: Record<string, string>;
/** Timeout in ms (default: 5000). */
timeout?: number;
/** Expected status codes (default: [200]). */
expectedStatusCodes?: number[];
/**
* Injectable fetch-like implementation. Defaults to `globalThis.fetch`
* when omitted. Tests SHOULD supply a stub to avoid real network IO.
*/
fetchImpl?: typeof fetch;
}
/** Configuration for a TCP-based health check. */
export interface TcpCheckConfig {
/** Hostname to connect to. */
host: string;
/** Port to connect to. */
port: number;
/** Timeout in ms (default: 5000). */
timeout?: number;
/**
* Injectable socket-connector. Returns an object exposing the four
* event handlers the check listens for plus a destroy method. Defaults
* to a `node:net` socket when omitted.
*/
connectImpl?: TcpConnectFn;
}
/** Minimal structural socket the TCP check drives. */
export interface TcpSocketLike {
on(event: "connect", handler: () => void): TcpSocketLike;
on(event: "error", handler: (error: Error) => void): TcpSocketLike;
destroy(): void;
}
/** Injectable socket connector for `createTcpCheck`. */
export type TcpConnectFn = (host: string, port: number) => TcpSocketLike;
/** Constructor options for `HealthChecker`. */
export interface HealthCheckerOptions {
/** Service name. Surfaces in `/health` payloads. */
name: string;
/** Service version. */
version?: string;
/** Default per-check timeout in ms. Defaults to 5000. */
defaultTimeout?: number;
/** Default cache TTL for results in ms. Defaults to 0 (no caching). */
defaultCacheTtlMs?: number;
/** Initial dependency checks. */
checks?: DependencyCheckConfig[];
/** Injectable wall-clock. Defaults to `Date.now`. */
clock?: import("@suaveplan/types").Clock;
/**
* Injectable timer pair. Defaults to a `globalThis.setTimeout`-backed
* pair when omitted. Tests SHOULD supply a fake.
*/
timers?: import("@suaveplan/types").TimersLike;
/** Optional structured logger. */
logger?: import("@suaveplan/types").LoggerLike;
}
/** Wire-event names emitted by `HealthChecker`. */
export const HealthEvent = {
CHECK_PASSED: "check:passed",
CHECK_FAILED: "check:failed",
STATUS_CHANGED: "status:changed",
} as const;
export type HealthEventName = (typeof HealthEvent)[keyof typeof HealthEvent];
/** Payload emitted on `STATUS_CHANGED`. */
export interface StatusChangedPayload {
previous: CheckStatus;
current: CheckStatus;
}
/** Strongly-typed event map used by the internal `EventBus`. */
export interface HealthEventMap {
"check:passed": CheckResult;
"check:failed": CheckResult;
"status:changed": StatusChangedPayload;
[key: string]: unknown;
}

View File

@@ -0,0 +1,39 @@
/**
* Test setup for @suaveplan/health-check.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it.
*/
import { webcrypto } from "node:crypto";
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,51 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/health-check",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"health-checker/index": "src/health-checker/index.ts",
"http-check/index": "src/http-check/index.ts",
"tcp-check/index": "src/tcp-check/index.ts",
"response-builder/index": "src/response-builder/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
hookTimeout: 5000,
},
});