Health checks¶
A health check answers one question: is this dependency usable right now? The framework collects the answers, decides an overall status from the worst of them, and exposes that over HTTP and on the command line.
Classes: Pramnos\Health\HealthRegistry, Pramnos\Health\HealthCheck,
Pramnos\Health\HealthCheckResult, Pramnos\Health\HealthStatus
What you get without writing anything¶
Every application registers these during init():
| Check | Reports |
|---|---|
database |
Reachable, with latency, driver and server version. Skipped entirely when the connection failed at boot, so you get one error rather than two. |
disk_space |
Free space and percentage used on the application root. |
memory_limit |
Peak usage against memory_limit. |
cache |
Whether the cache is on the store it was configured for. degraded when it fell back — the application works, on the wrong store. |
With the authserver feature enabled, one more is registered by
AuthServerServiceProvider:
| Check | Reports |
|---|---|
signing_keys |
The RSA pair can be read, parsed, and actually signs and verifies. See Signing keys below. |
Redis has a check too — Pramnos\Health\Checks\RedisConnectivityCheck — which is
not registered by default because not every application uses Redis. Register it
yourself if yours does.
Why cache is degraded rather than down
Cache walks down to the next adapter when the configured one cannot be reached,
which is correct: a cache that cannot connect must not take the site down. What
was missing was anybody being told. An application whose PHP image lacks the
redis extension runs on local disk with redis in its settings, redis in its
compose file and Redis on its bill — and passes every test. Invalidation is then
per-server, so a two-node deployment serves whatever the node that was not asked
still holds.
The check names both stores (Running on file, configured for redis) and hints at
the missing extension, because the container is almost always up and the extension
is almost always the answer. It reports degraded: the site is working, and a
check that pages somebody for a working site is a check that gets muted.
The endpoints¶
GET /health HTML dashboard (sign-in required)
GET /health/check Full JSON report (public)
GET /health/status Flattened verdict (public — the safe monitor URL)
GET /health/phpinfo phpinfo() (usertype >= 90)
Both JSON endpoints are deliberately public, because a monitor calls them with no credentials, and both answer with the status in the body and in the HTTP code.
/health/check is the full report:
{
"status": "ok",
"checks": {
"database": { "status": "ok", "name": "database", "message": "Reachable", "details": { "latency_ms": 4.7 } }
}
}
| Overall status | HTTP |
|---|---|
ok |
200 |
degraded |
503 |
down |
503 |
Degraded answering 503 is a decision worth knowing about: a monitor that only
looks at the status code treats reduced capacity as an outage. That is the safer
default — a cache that has stopped working is not something to discover a week
later — but if you want the two distinguished, read status from the body.
/health/status — when the full report says too much¶
Same verdict, three keys. When something is wrong it adds the names of the failing checks and nothing else:
Reach for it in two situations.
The probe cannot read a nested document. A load balancer health probe, a
status page widget or a shell script wants one field. Asking it to walk
checks.*.status is how a probe ends up parsing JSON with grep.
The endpoint is reachable from the internet. /health/check publishes
versions, drivers, paths and latencies in details. That is a fair trade on a
private network and not one to make publicly — a database version and a driver
name are a starting point for somebody who is looking for one. /health/status
gives away whether the application is well and where to look, which is what an
operator needs and all an attacker gets.
It does not re-probe anything: both endpoints read the same
HealthRegistry::runAll(). Two endpoints answering the same question from two
sets of probes is how they come to disagree.
Whichever you expose, keep detail out of what you add. If a check's details
would carry a hostname or a fragment of a credential, that is worth a second
thought even on /health/check.
From the command line¶
php pramnos health:check # a table
php pramnos health:check --json # the same report as JSON
php pramnos health:check --only=database,redis # just these
Exit codes make it usable in CI or a deploy gate: 0 all ok, 1 something
degraded, 2 something down.
The command sees every check the application registered, not only the built-in
three — it boots the application first, so feature providers and your own
Application::init() registrations are all present.
Writing a check¶
Implement HealthCheck. Two methods, and one rule.
use Pramnos\Health\HealthCheck;
use Pramnos\Health\HealthCheckResult;
class PaymentGatewayCheck implements HealthCheck
{
public function getName(): string
{
return 'payment_gateway';
}
public function run(): HealthCheckResult
{
try {
$latency = $this->ping();
} catch (\Throwable $e) {
return HealthCheckResult::down($this->getName(), 'Unreachable: ' . $e->getMessage());
}
if ($latency > 2.0) {
return HealthCheckResult::degraded(
$this->getName(),
'Responding slowly',
['latency_s' => $latency]
);
}
return HealthCheckResult::ok($this->getName(), 'Reachable', ['latency_s' => $latency]);
}
}
The rule: run() must not throw. A check that raises takes the whole report
with it, so the one dependency you were worried about brings down the endpoint
you were going to use to find out about it. Catch everything and return down().
The name is a contract. The JSON report is keyed by it, so a monitor or
dashboard reading checks.payment_gateway breaks if you rename it. Use a stable
snake_case identifier.
Registering it¶
// In Application::init(), after parent::init()
\Pramnos\Health\HealthRegistry::register(new PaymentGatewayCheck());
Or, if the check belongs to a feature, in that feature's service provider
boot() — which is how signing_keys arrives. register() is keyed by name and
idempotent, so registering the same name twice replaces rather than duplicates;
that is also how an application overrides a built-in check with its own.
Choosing between degraded and down¶
down— the application cannot do its job. Somebody should be woken up.degraded— it works, at reduced capacity or with a problem that will become an outage if ignored. A cache that is unreachable, a disk at 95%, a key that is smaller than it should be.ok— withdetailscarrying whatever an operator would want from a green result. A latency figure or a key size in a passing check is what tells someone the trend before it crosses a threshold.
Signing keys¶
This one deserves its own section, because it is the check that exists for a failure the others cannot see.
An authorization server whose private key has gone missing answers every page
normally. The database is fine, the disk is fine, memory is fine, and every
/oauth/token request returns a 500. /health/check reported ok on exactly
that server.
So signing_keys does not ask whether the files exist. It walks the states in
which they exist and the server still cannot issue a usable token:
| State | Result |
|---|---|
openssl not loaded, or the OAuth2 library missing |
down |
| A key file missing, unreadable, or a directory | down — and it says which half |
| A key file present but unparseable (truncated write, mangled PEM) | down |
| Both keys valid, but from different pairs | down |
| A matching pair below 2048 bits | degraded |
| A matching pair, 2048 bits or more | ok, with bits in the details |
The mismatched-pair case is why the check signs and verifies a constant rather than stopping at file tests. Both files parse, both are real keys, every file test passes — and no token this server issues can be verified by anybody, so the failure appears in somebody else's application. One small signature rules it out.
The paths come from OAuth2ServerFactory::defaultPrivateKeyPath() and
defaultPublicKeyPath(), so the check always reports on the keys the server
actually signs with. Pass explicit paths to the constructor if yours live
elsewhere:
HealthRegistry::register(new \Pramnos\Auth\Health\SigningKeysCheck(
'/etc/keys/oauth-private.pem',
'/etc/keys/oauth-public.pem'
));
Related¶
- Console — the
health:checkcommand among the rest /.well-known/health— an authserver-only endpoint that reads the same registry and answers inok/errorper component; see Third-Party Integration- Third-Party Integration — what the signing keys are used for
- Redis — registering the Redis connectivity check