Initial Commit
This commit is contained in:
67
packages/observability/monitor/health-check/README.md
Normal file
67
packages/observability/monitor/health-check/README.md
Normal 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
|
||||
|
||||
101
packages/observability/monitor/health-check/package.json
Normal file
101
packages/observability/monitor/health-check/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { HealthCheckCode } from "./error-codes.js";
|
||||
export { HEALTH_CHECK_CODES, HealthCheckError } from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { HealthCheckerOptions } from "./health-checker.js";
|
||||
export { HealthChecker } from "./health-checker.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createHttpCheck } from "./http-check.js";
|
||||
45
packages/observability/monitor/health-check/src/index.ts
Normal file
45
packages/observability/monitor/health-check/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { ResponseBuilder } from "./response-builder.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createTcpCheck } from "./tcp-check.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
aggregateStatusCounter,
|
||||
checkDurationHistogram,
|
||||
checkRunCounter,
|
||||
recordAggregateStatus,
|
||||
recordCheckDuration,
|
||||
recordCheckRun,
|
||||
} from "./telemetry.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
183
packages/observability/monitor/health-check/src/types/types.ts
Normal file
183
packages/observability/monitor/health-check/src/types/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
51
packages/observability/monitor/health-check/vite.config.ts
Normal file
51
packages/observability/monitor/health-check/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/observability/monitor/health-check/vitest.config.ts
Normal file
32
packages/observability/monitor/health-check/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user