Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 38 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
37 changes: 34 additions & 3 deletions src/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HealthCheck, 'name'>;
/** The named list, or the simpler keyed object: `{ db: () => pool.query('SELECT 1') }`. */
export type ChecksInput = HealthCheck[] | Record<string, CheckFn | CheckSpec>;

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<string, unknown>> | Record<string, unknown>;
checkTimeoutMs?: number;
/** Facts get the same budget as a check: a fact off a wedged DB must not hang /health. */
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<Response> {
return () => healthResponse(options);
}

/** Express/Connect handler factory: `app.get('/health', nodeHealth({ ... }))`. */
export const nodeHealth = createHealthHandler;

export interface StaticHealthOptions {
service?: string;
checks?: Record<string, CheckState>;
Expand All @@ -405,3 +433,6 @@ export function staticHealthJson(options: StaticHealthOptions = {}): string {
data,
} satisfies HealthEnvelope<StaticHealthData>);
}

/** `staticHealthJson` under the `health`/`nodeHealth` naming. */
export const staticHealth = staticHealthJson;
64 changes: 64 additions & 0 deletions test/health.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, it, expect } from 'vitest';
import {
createHealthHandler,
health,
healthEnvelope,
healthResponse,
nodeHealth,
staticHealth,
httpStatusFor,
resolveHealth,
resolveServiceName,
Expand Down Expand Up @@ -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<string, string> } };
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<boolean>(() => {}), 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);
});
});