Vortos
Health Checks

Health Checks

Tri-state liveness/readiness/startup probes, a budget-bound aggregator, legacy check compatibility, and pre-flight diagnostics.

Health Checks

vortos-health is the canonical probe system — it answers "is the process alive," "can it safely receive traffic," and "has it finished starting up" as three genuinely separate questions, each with its own endpoint, so your container platform can make the right decision for each one instead of one fuzzy health check trying to answer all three.

Three states, not two

Every probe reports pass, warn, or fail — not just healthy/unhealthy. warn lets a probe flag "this is getting concerning" (disk at 88% used, a certificate expiring in nine days) without flipping /health/ready to a 503. Only fail takes an instance out of rotation.

Endpoints

EndpointProbe kindPurpose
GET /health/liveProbeKind::LivenessProcess liveness — container platform restarts on failure
GET /health/readyProbeKind::ReadinessTraffic readiness — load balancer / k8s readiness gate
GET /health/startupProbeKind::StartupHas the process finished its startup sequence
GET /health/monitorProbeKind::MonitoringInformational observability (cert expiry, …) — always HTTP 200, never a gate
GET /health/detailProbeKind::ReadinessAuthenticated full per-check breakdown (see below)
GET /healthOperational summary — runs every probe, for dashboards and humans
curl -i http://localhost:8000/health/ready

By default the anonymous readiness body is minimal — status, mode, timestamp — and, when it is not passing, the names of the non-passing checks (never their messages, values, or errors), so an operator can see which check degraded over HTTP without shelling into the box:

{ "status": "warn", "mode": "ready", "timestamp": "…", "degraded": ["disk-capacity"] }

Set VORTOS_HEALTH_PUBLIC_DEGRADED_NAMES=false to suppress even the names.

Authenticated detail — /health/detail

For the full per-check breakdown over HTTP, GET /health/detail requires a valid X-Health-Token regardless of the anonymous HEALTH_DETAILS policy — so you can always diagnose a warn/fail during an incident, even with HEALTH_DETAILS=never. A missing or wrong token returns 401 (an authorization failure, deliberately distinct from a health status, so a scraper never mistakes it for unreadiness):

curl -s -H "X-Health-Token: $HEALTH_TOKEN" http://localhost:8000/health/detail
{
  "status": "warn",
  "checks": {
    "database": { "status": "pass" },
    "redis": { "status": "pass" },
    "disk-capacity": { "status": "warn" }
  }
}
EnvEffect
HEALTH_TOKENThe secret compared (constant-time) against X-Health-Token; also unlocks /health/detail regardless of policy
HEALTH_DETAILSAnonymous detail policy: never (default) / token / debug / always
HEALTH_EXPOSE_ERRORSInclude raw probe error strings in detailed output (default off; on in a debug env)
VORTOS_HEALTH_PUBLIC_DEGRADED_NAMESAnonymous readiness lists non-passing check names when degraded (default on)

The probe contract

interface HealthProbeInterface extends DriverInterface
{
    public function name(): string;
    public function kind(): ProbeKind;
    public function check(): ProbeResult;
}

ProbeKind is Liveness, Readiness, Startup, or Monitoring — a probe declares which question it answers, and the aggregator only runs the probes relevant to the endpoint that was hit. A probe that talks to the database has no business running on /health/live, which exists specifically so a database outage doesn't make your container platform think the process is dead and start restarting it. Monitoring probes are the inverse case: they answer an observability question (is a certificate about to expire?), so they run only on /health/monitor — never on /health/ready — and that endpoint always returns HTTP 200 so a monitoring breach can never be mistaken for unreadiness and roll back a healthy instance.

enum ProbeStatus: string
{
    case Pass = 'pass';
    case Warn = 'warn';
    case Fail = 'fail';

    public function isHealthy(): bool
    {
        return $this !== self::Fail;
    }
}

The aggregator and its budget

HealthAggregator runs every probe registered for the requested kind and combines the results, bounded by a HealthBudget:

final readonly class HealthBudget
{
    public function __construct(
        public int $perProbeDeadlineMs = 3000,    // a single slow probe can't hang the whole check
        public int $overallBudgetMs = 10000,      // the entire aggregation has a hard ceiling
        public int $readyCacheTtlMs = 1000,       // /health/ready briefly caches its result
    ) {}
}

perProbeDeadlineMs must be less than or equal to overallBudgetMs — enforced at construction, not discovered at 3am when a hung probe takes your readiness endpoint down with it. The short readyCacheTtlMs window means a load balancer hammering /health/ready every few hundred milliseconds doesn't re-run every probe on every single request.

Custom probes

src/Payment/Infrastructure/Health/PaymentGatewayProbe.php
use Vortos\Health\Probe\HealthProbeInterface;
use Vortos\Health\Probe\ProbeKind;
use Vortos\Health\Probe\ProbeResult;
use Vortos\Health\Probe\ProbeStatus;
use Vortos\OpsKit\Attribute\AsDriver;
use Vortos\OpsKit\Driver\Capability\CapabilityDescriptor;

#[AsDriver('payment-gateway')]
final class PaymentGatewayProbe implements HealthProbeInterface
{
    public function __construct(private PaymentGatewayClient $client) {}

    public function name(): string
    {
        return 'payment-gateway';
    }

    public function kind(): ProbeKind
    {
        return ProbeKind::Readiness;
    }

    public function capabilities(): CapabilityDescriptor
    {
        return CapabilityDescriptor::create([]);
    }

    public function check(): ProbeResult
    {
        try {
            $this->client->ping();

            return ProbeResult::pass($this->name());
        } catch (\Throwable $e) {
            return ProbeResult::fail($this->name(), $e->getMessage());
        }
    }
}

Probes follow the Ops Kit driver pattern — #[AsDriver] plus registerForAutoconfiguration() is all the wiring needed; CollectHealthProbesPass collects every registered probe automatically.

Legacy compatibility

If your application still has health checks written against the older Vortos\Foundation\Health\Contract\HealthCheckInterface / #[AsHealthCheck] API, you don't need to migrate them. BridgeLegacyHealthChecksPass finds every class implementing the legacy interface and wraps each one in a LegacyHealthCheckProbe automatically — they run through the same aggregator, budget, and endpoints as native HealthProbeInterface probes, with zero code changes on your side.

Pre-flight diagnostics

vortos:doctor checks configuration correctness before you boot. This is separate from vortos:health, which probes live infrastructure:

php bin/console vortos:doctor

Two doctor checks specifically guard against a dangerous blind spot — having no way to know your monitoring itself has failed:

CheckWhat it refuses
LivenessIndependenceDoctorCheckA liveness probe that depends on the same infrastructure it's meant to detect failures in
DetectorIndependenceDoctorCheckFewer than 3 independent failure detectors configured in production: in-app probes, the dead-man heartbeat, and an external synthetic uptime monitor
[FAIL] health.detector_independence   1 of 3 independent failure detectors configured: in-app probes.
       Fix: Wire the dead-man heartbeat (vortos-observability) and a real external synthetic
       monitor driver (e.g. "betterstack") declaring SyntheticJourney — a dead host and its
       own monitoring being dead simultaneously is the failure mode this check exists to catch.

The reasoning behind requiring three independent detectors: if your only failure detection lives on the host that might fail, a host that goes fully dark (not just unhealthy — gone) never alerts anyone, because the thing that would have alerted is dead too. An external synthetic prober runs on someone else's infrastructure entirely, so it's the one detector that keeps working when yours doesn't.

This check is fail-closed in production, not elsewhere

Outside prod/production, a gap in detector independence surfaces as a passing check with an explanatory note — not a blocker. There's no advisory "warning" preflight state in the framework; every check is pass, fail, or skip, so non-prod environments intentionally aren't held to the same bar.

Sections

On this page