Skip to content

Health Check ​

Two endpoints with two audiences. GET /health is the minimal liveness answer a load balancer or a Kubernetes probe polls - status and the server clock, nothing more. GET /health/stats is the diagnostic one an operator opens during an incident - build stamp, process and memory - and it is closed on every host that cannot prove it is a development machine.

Quick Reference ​

ItemValue
Package@venizia/ignis
ClassHealthCheckComponent
ControllerHealthCheckController
ReporterHealthCheckReporter - assembles both answers, reusable from your own routes
RuntimesBoth

Import Paths ​

typescript
import { HealthCheckComponent, HealthCheckBindingKeys, HealthCheckHeaders } from '@venizia/ignis';
import type { IHealthCheckOptions } from '@venizia/ignis';
import { BuildInfoRegistry } from '@venizia/ignis-helpers/core';

In one example ​

Register the component with no configuration - GET /health and POST /health/ping are live immediately, and GET /health/stats is live when NODE_ENV names a development environment.

typescript
import { HealthCheckComponent, BaseApplication, ValueOrPromise } from '@venizia/ignis';

export class Application extends BaseApplication {
  preConfigure(): ValueOrPromise<void> {
    this.component(HealthCheckComponent);
  }
}
bash
curl localhost:3000/health
# { "status": "ok", "timestamp": "2026-09-11T07:30:00.000Z" }

How it works ​

  • GET /health discloses nothing. status and timestamp only. The clock is the one addition that costs nothing to disclose while catching the failure behind every JWT and HMAC mystery: a server whose time has drifted.
  • GET /health/stats is gated twice. First by stats.enable, decided when the controller is bound - a disabled route is unmounted, not empty, so it is absent from the OpenAPI document too. Second by stats.secretKey, checked per request against the X-Health-Key header.
  • The default is closed. With no enable set, the route is mounted only when NODE_ENV is one of Environment.DEVELOPMENT_ENVS (local, debug, development, dev, sit). staging, uat, production, an unrecognised name and an unset NODE_ENV all keep it shut - the same fail-closed rule AppErrorMiddleware uses to decide whether a stack trace may leave the server.
  • A refusal is a 404, and byte-identical to an unmounted route. A wrong or missing key runs the application's own notFound handler rather than throwing, so the answer cannot confirm the route exists. A secretKey that is set but blank fails closed too.
  • The build stamp is pushed in, never read by the reporter. The reporter never opens a file and never spawns git. It reads stats.buildInfo first, then BuildInfoRegistry (filled at your entrypoint by BuildInfoStamp.register()), then the application's own getAppInfo(). A host that registered no stamp still reports its real name and version; anything unknowable reads unspecified.
  • The gate is per application, not per process. The controller receives its options and the application info through @inject, so two applications in one process - IGNIS nests them - each keep their own gate.
  • Auto-registered controller. HealthCheckComponent.binding() applies @controller({ path }) via Reflect.decorate at runtime, then calls this.application.controller(HealthCheckController). The path comes from the options binding, not a hardcoded class decorator.
  • Your options always win, whatever the order. The constructor only STORES a default Binding object. initDefaultBindings() applies it from inside configure(), which the boot sweep runs at the registerComponents step - four steps after preConfigure() returns. So a this.bind() anywhere in preConfigure() takes the key first, and the default finds it taken and steps aside. Options passed as this.component(HealthCheckComponent, { options }) win over both: configure() binds them before initDefaultBindings() runs.
  • A mounted, unguarded stats route warns at boot. stats.enable: true bypasses the environment gate, so the code that opened the route locally opens it in production too. When HealthCheckReporter.isStatsUnguarded() says yes, binding() logs one warning naming the path and the ambient environment. It warns rather than refuses - only the operator knows whether the port is reachable.

Common tasks ​

Open the stats route in production behind a key ​

typescript
this.component(HealthCheckComponent, {
  options: {
    restOptions: { path: '/health' },
    stats: { enable: true, secretKey: process.env.APP_HEALTH_SECRET_KEY },
  },
});
bash
curl -H 'X-Health-Key: ...' localhost:3000/health/stats
json
{
  "status": "ok",
  "timestamp": "2026-09-11T07:30:00.000Z",
  "build": {
    "service": "@nx/sale", "version": "1.4.2",
    "commit": "8f80b2a90d12", "branch": "develop",
    "builtAt": "2026-09-10T15:00:00.000Z"
  },
  "process": {
    "pid": 42, "uptime": 3600.5, "uptimeHuman": "1h 0m 0s",
    "runtime": "bun 1.4.2", "environment": "production"
  },
  "memory": { "rss": "64.2 MB", "heapUsed": "32.1 MB", "heapTotal": "48.0 MB" }
}

Without secretKey the route is open to whatever can reach the port. On a cluster-internal port that is often the right call; on a public one it is not.

Stamp the build so stats.build names a real commit ​

typescript
// .configs/builders/compile.ts - the compile bakes the stamp in
import { BunCompiler } from '@venizia/dev-configs/compilers';
import { BuildInfoStamp } from '@venizia/ignis-boot/build-info';

await BunCompiler.compile({ entrypoint: './src/index.ts', define: await BuildInfoStamp.define() });
typescript
// src/index.ts - one line at the entrypoint
import { BuildInfoStamp } from '@venizia/ignis-boot/build-info';

await BuildInfoStamp.register();

A compiled binary reports the stamp it was compiled with. A run from source resolves it from git at start. The resolver reads APP_ENV_BUILD_VERSION / APP_ENV_BUILD_COMMIT_TAG / APP_ENV_BUILD_DATE first, then the generic APP_BUILD_* and provider-specific variables, then git, then package.json. See Stamping a build.

Customize the health check path ​

Pass the options at the call site:

typescript
this.component(HealthCheckComponent, { options: { restOptions: { path: '/health-check' } } });

Or bind them yourself, anywhere in preConfigure() - before or after this.component():

typescript
import { HealthCheckBindingKeys, IHealthCheckOptions } from '@venizia/ignis';

this.bind<IHealthCheckOptions>({
  key: HealthCheckBindingKeys.HEALTH_CHECK_OPTIONS,
}).toValue({ restOptions: { path: '/health-check' } });

this.component(HealthCheckComponent);

Call the ping endpoint ​

POST /health/ping echoes message back with a server timestamp - useful for round-trip latency checks and body-parsing smoke tests.

bash
curl -X POST localhost:3000/health/ping \
  -H 'content-type: application/json' \
  -d '{"message":"hello"}'
# { "type": "PONG", "date": "2026-02-11T12:00:00.000Z", "message": "hello" }
FieldTypeRequiredConstraints
typestringNoDefaults to "PING"
messagestringYesMin 1, max 255 characters

Reference ​

Configuration ​

OptionTypeDefaultDescription
restOptions.pathstring (optional)'/health'Base path for every health endpoint
stats.enableboolean (optional)open only in Environment.DEVELOPMENT_ENVSMounts GET <path>/stats. false unmounts it. A non-boolean (a config-derived "false") is ignored and the environment default applies
stats.secretKeystring (optional)none - route openRequired in X-Health-Key. A blank value fails closed
stats.buildInfoIBuildInfo (optional)BuildInfoRegistry.get()Wins over the registry; a host that stamps its own build passes it here
typescript
interface IHealthCheckOptions {
  restOptions?: { path?: string };
  stats?: {
    enable?: boolean;
    secretKey?: string;
    buildInfo?: IBuildInfo;
  };
}

Binding keys ​

KeyConstantTypeRequiredDefault
@app/health-check/optionsHealthCheckBindingKeys.HEALTH_CHECK_OPTIONSIHealthCheckOptionsNo{ restOptions: { path: '/health' } }
@app/health-check/application-infoHealthCheckBindingKeys.APPLICATION_INFOIApplicationInfoNo - bound by the componentthe application's getAppInfo()

Headers ​

ConstantValueUsed by
HealthCheckHeaders.SECRET_KEYx-health-keyGET /health/stats when stats.secretKey is set

The stats route declares this header in its OpenAPI definition, so the API reference renders an input for it. It stays optional there: a host with no stats.secretKey needs no key.

REST paths ​

ConstantValueFull path (default)
HealthCheckRestPaths.ROOT/GET /health
HealthCheckRestPaths.STATS/statsGET /health/stats
HealthCheckRestPaths.PING/pingPOST /health/ping

Paths are relative to the base path configured in IHealthCheckOptions.restOptions.path.

API endpoints ​

MethodPathDescriptionResponse
GET/healthLiveness{ "status": "ok", "timestamp": "..." }
GET/health/statsBuild stamp, process, memory - gatedsee above; 404 when unmounted or refused
POST/health/pingEcho test{ "type": "PONG", "date": "...", "message": "..." }

The reporter ​

HealthCheckReporter is exported so a host with its own health route can answer the same shapes:

MethodReturns
buildSummary(){ status, timestamp }
buildStats({ options, appInfo })the full /health/stats body
resolveBuildInfo({ options, appInfo })the resolved build block, every field a string
isStatsEnabled({ options })the mount decision
isStatsUnguarded({ options })whether the route is mounted, keyless, and off a development host. The component turns a true into one boot warning
isStatsAuthorized({ options, header })the per-request decision

isStatsEnabled and isStatsUnguarded each take an optional environment: () => string \| undefined so a test can supply the ambient environment without writing process.env.

Troubleshooting ​

SymptomCauseFix
GET /health returns 404HealthCheckComponent not registered, or registered after controllers were already mountedCall this.component(HealthCheckComponent) in preConfigure()
GET /health/stats returns 404 on stagingThe default is closed outside the development environmentsSet stats: { enable: true, secretKey } explicitly
GET /health/stats returns 404 with the right keysecretKey is blank, or arrived as a non-string from configExport the variable; the gate fails closed on a blank or wrong-typed key
build.commit reads unspecifiedNo stamp was registered and getAppInfo() carries no commitPass define: await BuildInfoStamp.define() in the compile and call BuildInfoStamp.register() at the entrypoint
Custom path not appliedThe options were bound outside preConfigure(), so they landed after configure() had already read the keyBind inside preConfigure(), or pass this.component(HealthCheckComponent, { options })
POST /health/ping returns a validation errormessage is missing, or exceeds 255 charactersSend a message string between 1 and 255 characters

See also ​

Files: