From 22e74ea5d810abea1b999e353503c2daa759ff3c Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Tue, 11 Aug 2026 01:48:07 +0200 Subject: [PATCH] chore(release): 0.10.0 - simple health API + object checks --- CHANGELOG.md | 33 +++++++++++++++++++++++ LICENSE | 21 +++++++++++++++ README.md | 49 ++++++++++++++++++++++++++-------- package.json | 4 +-- src/health.ts | 37 +++++++++++++++++++++++--- test/health.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c7109bc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +## 0.10.0 - 2026-08-11 + +- `/health` gains a simple public API, additive to everything that exists: + - `health(options?)` - fetch-native handler factory for Next routes, Hono, + Cloudflare Workers, Deno and Bun. `health()` with no options is a valid + liveness probe; add `checks` for readiness. + - `nodeHealth(options?)` - `createHealthHandler` under the matching name. + - `staticHealth(options?)` - `staticHealthJson` under the matching name. + - `checks` now also accepts a keyed object: `{ db: () => pool.query('SELECT 1') }` + or `{ cache: { run, timeoutMs: 250, optional: true } }`. The array form is + unchanged and stays supported. +- Added the LICENSE file (the manifest always said MIT; now the text ships too) + and this changelog. + +## 0.9.1 - 2026-08-10 + +- Health module is edge-safe by construction: zero imports, Web APIs only + (`performance.timeOrigin` instead of a Node-only uptime source), enforced by a + source-text scan test that also covers comments and the built `dist` output. + +## 0.9.0 - 2026-08-09 + +- Health envelope v1.1: `instance` (replica detection), `checkedAt` (cache + detection), `durationMs`, per-check `timings`, `reasons`, bounded `facts` + (`factsTimeoutMs`), and a `Server-Timing` response header. + +## 0.8.0 and earlier + +- OTLP trace bootstrap (`node --import`), pino logger preset with trace + correlation, `captureError`, MCP span helpers, Next.js `register`, and the + first `/health` envelope. History: git tags `v0.1.0`..`v0.8.1`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ccdf06 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Volodymyr Vreshch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a67ffc1..37919ef 100644 --- a/README.md +++ b/README.md @@ -131,20 +131,43 @@ The same numbers go out as a `Server-Timing` header (`health;dur=22.7, store;dur=21.5`), so the split shows in browser devtools and proxy logs without parsing the body. -**Express** - mount on `/health`, and on `/api/health` where the edge routes -only `/api`: +### Use it ```ts -import { createHealthHandler } from '@agentage/observability/health'; +import { health } from '@agentage/observability/health'; -const health = createHealthHandler({ - checks: [{ name: 'store', run: () => store.reachable(), timeoutMs: 500 }], - facts: () => ({ memories: store.count() }), +// Fetch-native handler: a Next route, Hono, Cloudflare Workers, Deno, Bun. +export const GET = health({ + checks: { db: () => pool.query('SELECT 1') }, + facts: () => ({ users: userCount }), }); -app.get('/health', health); ``` -A check may return a `CheckState` or a boolean, and may throw, reject or hang: +`health()` with no options is a valid **liveness** probe - process up, no +dependency checks, exactly what Kubernetes wants from liveness. Add `checks` +and the same factory is your **readiness** probe. + +**Express** - `nodeHealth` is the same factory as an Express handler. Mount on +`/health`, and on `/api/health` where the edge routes only `/api`: + +```ts +import { nodeHealth } from '@agentage/observability/health'; + +app.get( + '/health', + nodeHealth({ + checks: { + store: { run: () => store.reachable(), timeoutMs: 500 }, + cache: { run: () => redis.ping(), optional: true }, + }, + facts: () => ({ memories: store.count() }), + }) +); +``` + +A check is a bare function per key, or `{ run, timeoutMs, optional }` when it +needs either knob (the named-array form from earlier releases works unchanged). +It may return a `CheckState` or a boolean, and may throw, reject or hang: it is timed out (1s default) and read as `down`, or `degraded` when `optional: true`, with the reason recorded under `reasons`. Facts are decoration - a throwing producer is dropped, never reddening the service - and @@ -155,12 +178,12 @@ producer run unbounded: `/health` outliving the container `HEALTHCHECK **Next App Router** - `src/app/health/route.ts`: ```ts -import { healthResponse } from '@agentage/observability/health'; +import { health } from '@agentage/observability/health'; // Never prerender, or commit/buildTime are baked at build instead of read from // the running container. export const dynamic = 'force-dynamic'; -export const GET = () => healthResponse(); +export const GET = health(); ``` Exclude the route from the auth middleware matcher: a probe must not chase a @@ -172,10 +195,14 @@ _before_ any SPA or redirect fallback, or every path answers 200 and the probe asserts nothing: ```ts -import { staticHealthJson } from '@agentage/observability/health'; +import { staticHealth } from '@agentage/observability/health'; // -> one line, no startedAt/uptimeSeconds (there is no process to time) ``` +`createHealthHandler`, `healthResponse` and `staticHealthJson` remain exported +and unchanged; `health`/`nodeHealth`/`staticHealth` are the same factories under +the simpler names. + ## Configuration Standard `OTEL_*` env, read by the SDK itself: diff --git a/package.json b/package.json index ac4c180..55a89a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentage/observability", - "version": "0.9.1", + "version": "0.10.0", "description": "Shared observability kit for agentage services: OTLP trace bootstrap (node --import), pino logger preset with trace correlation, one-call error capture, and the estate /health envelope.", "type": "module", "license": "MIT", @@ -46,7 +46,7 @@ "test": "vitest run", "verify": "npm run type-check && npm run lint && npm run format:check && npm run test && npm run build && npm run smoke:dist", "prepublishOnly": "npm run verify", - "smoke:dist": "node --input-type=module -e \"const k = await import('./dist/index.js'); if (typeof k.resolveTracingConfig !== 'function' || typeof k.createLogger !== 'function' || typeof k.captureError !== 'function') process.exit(1); const h = await import('./dist/health.js'); if (h.healthEnvelope('smoke').data.service !== 'smoke' || typeof h.createHealthHandler !== 'function' || typeof h.staticHealthJson !== 'function') process.exit(1); await import('./dist/bootstrap.js'); console.log('dist smoke ok');\"", + "smoke:dist": "node --input-type=module -e \"const k = await import('./dist/index.js'); if (typeof k.resolveTracingConfig !== 'function' || typeof k.createLogger !== 'function' || typeof k.captureError !== 'function') process.exit(1); const h = await import('./dist/health.js'); if (h.healthEnvelope('smoke').data.service !== 'smoke' || typeof h.createHealthHandler !== 'function' || typeof h.staticHealthJson !== 'function' || typeof h.health !== 'function' || h.nodeHealth !== h.createHealthHandler || h.staticHealth !== h.staticHealthJson) process.exit(1); await import('./dist/bootstrap.js'); console.log('dist smoke ok');\"", "test:coverage": "vitest run --coverage" }, "dependencies": { diff --git a/src/health.ts b/src/health.ts index 1f37025..b12ad5b 100644 --- a/src/health.ts +++ b/src/health.ts @@ -258,9 +258,23 @@ export async function runChecks( return Object.fromEntries(Object.entries(outcomes).map(([name, o]) => [name, o.state])); } +/** Shorthand for a check given as an object entry: the key is the name. */ +export type CheckFn = HealthCheck['run']; +/** Object-entry form of a check: `{ db: { run, timeoutMs: 250, optional: true } }`. */ +export type CheckSpec = Omit; +/** The named list, or the simpler keyed object: `{ db: () => pool.query('SELECT 1') }`. */ +export type ChecksInput = HealthCheck[] | Record; + +const toCheckList = (checks?: ChecksInput): HealthCheck[] | undefined => + !checks || Array.isArray(checks) + ? checks + : Object.entries(checks).map(([name, spec]) => + typeof spec === 'function' ? { name, run: spec } : { name, ...spec } + ); + export interface HealthSourceOptions { service?: string; - checks?: HealthCheck[]; + checks?: ChecksInput; facts?: () => Promise> | Record; checkTimeoutMs?: number; /** Facts get the same budget as a check: a fact off a wedged DB must not hang /health. */ @@ -306,9 +320,10 @@ export async function resolveHealth( options: HealthSourceOptions = {} ): Promise<{ envelope: HealthEnvelope; httpStatus: number }> { const started = now(); + const checkList = toCheckList(options.checks); const [outcomes, factsOutcome] = await Promise.all([ - options.checks?.length - ? runCheckOutcomes(options.checks, options.checkTimeoutMs) + checkList?.length + ? runCheckOutcomes(checkList, options.checkTimeoutMs) : Promise.resolve(undefined), options.facts ? safeFacts(options.facts, options.factsTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS) @@ -382,6 +397,19 @@ export async function healthResponse(options: HealthSourceOptions = {}): Promise }); } +/** + * Fetch-native handler factory - the simplest mount for Next routes, Hono, + * Cloudflare Workers, Deno and Bun, which all accept a handler returning a + * `Response`. Zero-config `health()` is a valid liveness probe (process up, no + * dependency checks); add `checks` and it is your readiness probe. + */ +export function health(options: HealthSourceOptions = {}): () => Promise { + return () => healthResponse(options); +} + +/** Express/Connect handler factory: `app.get('/health', nodeHealth({ ... }))`. */ +export const nodeHealth = createHealthHandler; + export interface StaticHealthOptions { service?: string; checks?: Record; @@ -405,3 +433,6 @@ export function staticHealthJson(options: StaticHealthOptions = {}): string { data, } satisfies HealthEnvelope); } + +/** `staticHealthJson` under the `health`/`nodeHealth` naming. */ +export const staticHealth = staticHealthJson; diff --git a/test/health.test.ts b/test/health.test.ts index 9c32927..ccca5de 100644 --- a/test/health.test.ts +++ b/test/health.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect } from 'vitest'; import { createHealthHandler, + health, healthEnvelope, healthResponse, + nodeHealth, + staticHealth, httpStatusFor, resolveHealth, resolveServiceName, @@ -490,3 +493,64 @@ describe('staticHealthJson timing fields', () => { } }); }); + +describe('simple API', () => { + it('health() zero-config is a mountable liveness probe', async () => { + const handler = health({ service: 'sync', env: built }); + const res = await handler(); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-store'); + const body = (await res.json()) as { success: boolean; data: { service: string } }; + expect(body).toMatchObject({ success: true, data: { service: 'sync' } }); + }); + + it('accepts checks as a keyed object of bare functions', async () => { + const res = await health({ service: 'auth', env: built, checks: { db: () => false } })(); + expect(res.status).toBe(503); + const body = (await res.json()) as { data: { checks: Record } }; + expect(body.data.checks).toEqual({ db: 'down' }); + }); + + it('accepts the spec form per key: timeoutMs and optional flow through', async () => { + const { envelope } = await resolveHealth({ + service: 'sync', + env: built, + checks: { + cache: { + run: () => { + throw new Error('redis gone'); + }, + optional: true, + }, + slow: { run: () => new Promise(() => {}), timeoutMs: 25 }, + }, + }); + expect(envelope.data.checks).toEqual({ cache: 'degraded', slow: 'down' }); + expect(envelope.data.reasons?.cache).toBe('redis gone'); + expect(envelope.data.reasons?.slow).toBe('timed out after 25ms'); + }); + + it('object and array check forms produce the same envelope', async () => { + const asObject = await resolveHealth({ + service: 'sync', + env: built, + checks: { db: () => true, store: () => 'degraded' as const }, + }); + const asArray = await resolveHealth({ + service: 'sync', + env: built, + checks: [ + { name: 'db', run: () => true }, + { name: 'store', run: () => 'degraded' as const }, + ], + }); + expect(asObject.envelope.data.checks).toEqual(asArray.envelope.data.checks); + expect(asObject.envelope.data.status).toBe('degraded'); + expect(asObject.httpStatus).toBe(asArray.httpStatus); + }); + + it('nodeHealth and staticHealth are the existing factories under the new names', () => { + expect(nodeHealth).toBe(createHealthHandler); + expect(staticHealth).toBe(staticHealthJson); + }); +});