Actuator / Health
A running application has to be able to answer “are you alive?” — and to answer in a
way an orchestrator, not a human, can understand. The actuator provides a set of
diagnostic endpoints under /actuator: the state of dependencies, metrics,
settings, the route table. It is off by default.
Why this exists
The problem. A load balancer and an orchestrator decide whether to send traffic to
an instance of the application. All they can do is make an HTTP request and look at
the response code. An application that answers 200 to any request is always alive as
far as they are concerned — even when the database is unreachable and every real
request fails.
The second, human question is “what is going on with this instance right now”: how much memory is used, which log channels are on, which routes it even knows about. Going in over SSH for that is inconvenient, and in a container there is often nothing to go in with.
The solution. A set of read-only endpoints returning JSON. The main one —
/actuator/health — returns not only a body but a response code fit for a probe.
The rest answer the human questions.
Where the word «actuator» comes from
The term is taken from Spring Boot Actuator, and Winter repeats the same idea: the application reports on itself at fixed addresses rather than requiring an external agent.
Despite the name there is no “actuation” here — every endpoint only reads. Nothing can
be changed through them, and exactly one method is supported: GET.
Enabling it
The actuator is switched on with an attribute on the application class:
use Flytachi\Winter\Kernel\App\Attribute\{EnableActuator, EnableWeb};
#[EnableWeb]
#[EnableActuator]
final class Application extends WinterApplication
{
public static function main(array $argv): never
{
parent::run($argv);
}
}Without the attribute the /actuator/* routes are not registered at all — they do not
“return 403”, they simply do not exist.
It needs `#[EnableWeb]`
The actuator is a set of HTTP routes, and there is nowhere to hang them if no web
server is up. An application without #[EnableWeb] — a scheduler, a queue, a set of
processes — runs headless, no route table is built there, and #[EnableActuator] gives
it nothing. There will be no error: the attribute simply has no effect.
To watch a background application from outside, it needs a web layer — if only for the actuator itself.
The attribute’s arguments:
| Argument | Purpose |
|---|---|
middleware |
A guard middleware every endpoint passes through |
indicator |
A report class of yours instead of the built-in one |
#[EnableActuator(middleware: InternalOnly::class)]
#[EnableActuator(indicator: MyIndicator::class)]Checking that it came up
curl -s localhost:8000/actuator/health | jq{ "status": "up", "components": { "db": { }, "redis": { }, "disk": { }, "memory": { } } }A 404 means the attribute is not on the application class, or the application was
not restarted after it was added.
The endpoints
| URL | What it returns |
|---|---|
/actuator/health |
The overall status and the components’ state: database, Redis, disk, memory |
/actuator/pools |
This worker’s connection-pool load |
/actuator/info |
The PHP, kernel and project versions, the runtime mode |
/actuator/metrics |
CPU, memory, disk, opcache, uptime, the current request |
/actuator/loggers |
The log channels, their levels and output destinations |
/actuator/mappings |
The table of registered routes |
/actuator/env |
A slice of the environment — empty by default |
/actuator with no name is the same as /actuator/health. Every endpoint answers
GET only.
health — state
{
"status": "degraded",
"components": {
"db": {
"status": "up",
"details": {
"Main\MainDbConfig": {
"status": "up", "driver": "pgsql", "latency": 1.24, "error": null
}
}
},
"redis": { "status": "up", "details": { } },
"disk": { "status": "degraded",
"details": { "usage_percent": 84.1,
"warning": "Disk usage above 80%" } },
"memory": { "status": "up", "details": { "usage_percent": 31.7 } }
}
}The overall status follows the worst component: if there is a down, the whole report
is down; no down but a degraded gives degraded; otherwise up.
The built-in components
| Component | How it is checked | When degraded |
When down |
|---|---|---|---|
db |
A live SELECT 1 into every configuration found |
Answer slower than 500 ms | The database did not answer |
redis |
The same for Redis configurations — a PING into each one found |
Answer slower than 500 ms | It did not answer |
disk |
Filesystem usage | Above 80 % | Above 90 % |
memory |
Usage against memory_limit |
Above 80 % | Above 90 % |
Database and Redis configurations are found by walking the project; there is nothing to list anywhere. The check runs on every request to the endpoint — it is a live ping, not a cached value.
The check costs a database query
health opens a connection and runs a SELECT 1 for every configuration. A probe
every few seconds is fine; a probe every second from ten balancers turns into constant
load.
If the check has to be frequent, point the probe at a cheaper endpoint and poll
health less often.
Response codes
Here is the main difference from the other endpoints: health puts its verdict in
the response code, not only in the body.
| Status | Code | Why |
|---|---|---|
up |
200 |
All is well |
degraded |
200 |
Working worse, but working |
down |
503 |
Not working |
Why `degraded` is a 200
The temptation to return an error on degraded is strong, and it is dangerous.
degraded means “slower than usual” or “the disk is filling up” — the application is
serving requests while it says so.
If a probe pulls the instance out of rotation, the load moves to its neighbours, which
are under the same conditions — and they degrade next. A partial failure turns into a
total one that way. So only down removes an instance from rotation.
Hence the practical split of probes: liveness and readiness both look at
/actuator/health, but they react to the code rather than to the body.
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/actuator/health"]
interval: 30s
timeout: 5s
retries: 3The -f flag makes curl exit with an error on a 5xx code — that is enough, and no
JSON needs parsing.
pools — connection load
{
"status": "degraded",
"pools": {
"Main\MainDbConfig": {
"total": 10, "idle": 0, "active": 10, "maximum": 10,
"saturated": true, "source": "ppa"
},
"Main\AppRedisConfig": {
"total": 2, "idle": 2, "active": 0, "maximum": 10,
"saturated": false, "source": "redis"
}
}
}saturated means every connection has been handed out and the next request will queue.
The numbers concern this worker: each has a pool of its own, and a request waits
for a free connection in its own.
source says which layer opened the pool: ppa for the database, redis for Redis.
Both report the same shape and land in one list, because the key is the config class name
and that is unique; the field is there so a reader can tell them apart without knowing a
particular application’s class names.
Only installed layers appear: an application with neither PPA nor
Redis gets {"status": "up", "pools": {}} — there are no pools, and
that is not something to worry about.
Why the pools were moved out of `health`
Database availability and pool saturation are different questions, and they change at different rates. A database is either there or not; a pool fills and empties within a second.
While they sat in one report, a busy but healthy application reported degraded — the
wrong signal for a probe deciding whether to send traffic. health now knows nothing
about pools.
The same figures across the whole fleet are shown by php call db pool — see
PPA.
The other endpoints
info — the PHP version and SAPI, the kernel version, the runtime mode, the
project package’s name and version. The things asked for first when working through an
incident:
{
"php": { "version": "8.4.3", "sapi": "cli", "zend_version": "4.4.3" },
"framework": { "name": "flytachi/winter-kernel", "version": "v4.0.4", "runtime": "Swoole" },
"project": { "name": "acme/shop", "type": "project", "version": "1.7.2", "isDev": false }
}metrics — the load average and core count, process memory against the limit,
free disk space, the opcache’s state, the host’s uptime and the current request’s
parameters.
loggers — the sys and http channels: the effective level, the format, the
output destination and the file path when writing to a file. A way to check that the
LOG_* variables in this environment applied the way you expected.
mappings — a snapshot of the route table, the same one
call mapping show prints. Useful when you need to confirm
that the deployed version knows a new route.
env — an empty array by default. That is deliberate: handing out the
environment automatically would mean handing out passwords and keys. Fill it in
yourself if you need it, by overriding the method in an indicator of your own.
Checks of your own
The built-in components know only about infrastructure: database, Redis, disk, memory. Anything specific to your application is unknown to the framework — whether the payment gateway is reachable, whether the queue is overflowing, whether the exchange-rate feed is stale. You add checks like that yourself.
What you have to write
One class with two methods. No registration and no inheritance:
<?php
namespace Main\Health;
use Flytachi\Winter\Kernel\Http\Health\HealthContributor;
use Flytachi\Winter\Kernel\Http\Health\HealthStatus;
final class QueueHealth implements HealthContributor
{
public function name(): string
{
return 'queue';
}
public function check(): HealthStatus
{
return HealthStatus::up();
}
}That is all it takes. Refresh /actuator/health — a new component has appeared in the
report:
{
"status": "up",
"components": {
"db": { "status": "up", "details": { } },
"redis": { "status": "up", "details": { } },
"disk": { "status": "up", "details": { } },
"memory": { "status": "up", "details": { } },
"queue": { "status": "up", "details": { } }
}
}The queue key is what name() returned. The value is what check() returned.
HealthContributor — what it is
An interface that tells the framework “this class is a check, include it in the report”. It means nothing else and demands nothing else.
| Method | What to return | When it is called |
|---|---|---|
name(): string |
The component’s key in the JSON — 'queue', 'payments' |
Once per request |
check(): HealthStatus |
The check’s verdict | On every request to /actuator/health |
The classes are found by the same project walk that collects controllers and configurations. Add the file and the check works; delete it and it disappears from the report. There is no list of checks anywhere.
HealthStatus — what it is
A check’s answer. It is not a string and not an array but a small object you
assemble with a factory rather than with new:
HealthStatus::up(); // all is well
HealthStatus::degraded(); // working, but worse than usual
HealthStatus::down(); // not workingThree factories, three possible states; there is no fourth. The constructor is closed deliberately: that way a status the system does not understand cannot be put into the report.
Details can be attached to any of them, chained with withDetail():
HealthStatus::degraded()
->withDetail('depth', 24817)
->withDetail('oldest_message_age', 412);"queue": {
"status": "degraded",
"details": { "depth": 24817, "oldest_message_age": 412 }
}The keys and values are arbitrary — they are your data and simply land in the JSON as
they are. The details are for the person who opens the endpoint while working through
an incident: a down with no reason says something broke, but not what.
| Method | What it does |
|---|---|
HealthStatus::up() |
Creates an answer with status up |
HealthStatus::degraded() |
The same with status degraded |
HealthStatus::down() |
The same with status down |
->withDetail($key, $value) |
Adds a field to details and returns the object — the calls chain |
A complete check
Now the same thing, but with real logic and a dependency:
final class QueueHealth implements HealthContributor
{
private const int WARN_DEPTH = 10_000;
public function __construct(private QueueClient $queue) {}
public function name(): string
{
return 'queue';
}
public function check(): HealthStatus
{
if (!$this->queue->isConnected()) {
return HealthStatus::down()
->withDetail('reason', 'broker unreachable');
}
$depth = $this->queue->depth();
if ($depth > self::WARN_DEPTH) {
return HealthStatus::degraded()
->withDetail('depth', $depth)
->withDetail('threshold', self::WARN_DEPTH);
}
return HealthStatus::up()->withDetail('depth', $depth);
}
}The dependency arrives through the constructor — no attribute needed, the container resolves the arguments by type. The object is created afresh for every request to the endpoint, so the check always sees the current state rather than what was true at startup.
How to choose the status
This is the main decision, and it is easy to get wrong. The guide is simple — what should the orchestrator do:
| Situation | Status | Why |
|---|---|---|
| Everything works | up |
— |
| The queue is growing, answers are slow, the disk is filling | degraded |
The application is serving requests; traffic must not be taken away |
| A required dependency is unavailable and it cannot work | down |
The instance should be pulled from rotation |
Remember that down produces a 503 and the probe will remove the instance from the
balancer. Set it only when the application genuinely cannot serve requests. An external
service whose absence degrades part of the functionality while the rest works is
degraded, not down.
How the overall status is formed
Each component gives its verdict and the overall one follows the worst:
db: up redis: up queue: degraded → overall degraded (HTTP 200)
db: up redis: up queue: down → overall down (HTTP 503)That is, one check of yours returning down will pull the whole instance out of
rotation. That is exactly what the mechanism exists for — and exactly what to keep in
mind when marking an optional dependency as critical.
Several checks
There can be as many classes as you like — one per area:
final class PaymentsHealth implements HealthContributor
{
public function __construct(private PaymentGateway $gateway) {}
public function name(): string { return 'payments'; }
public function check(): HealthStatus
{
$ms = $this->gateway->pingMs();
return $ms === null
? HealthStatus::degraded()->withDetail('reason', 'gateway timeout')
: HealthStatus::up()->withDetail('latency_ms', $ms);
}
}final class ExchangeRatesHealth implements HealthContributor
{
public function __construct(private RateRepository $rates) {}
public function name(): string { return 'rates'; }
public function check(): HealthStatus
{
$age = time() - $this->rates->lastUpdatedAt();
return $age > 3600
? HealthStatus::degraded()->withDetail('age_seconds', $age)
: HealthStatus::up()->withDetail('age_seconds', $age);
}
}The second example shows a common and useful case: what is checked is not a service’s availability but the freshness of the data. An external source can be answering while the feed has not updated for a day — and it is better to learn that from the endpoint than from your users.
Replacing a built-in check
The keys are not divided into “yours” and “the system’s”: if name() returns db,
your component takes the built-in one’s place.
public function name(): string
{
return 'db'; // replaces the built-in database check
}Useful when the built-in check is too expensive — it pings every configuration found, while one, the main one, is enough for you.
What not to do
An exception from `check()` takes the whole endpoint down
The check call is not wrapped in a try. An exception escaping check() will not make
the component down — it will abort the entire /actuator/health response, and the
probe gets an error instead of a report. The remaining components will not even be
polled.
Catch exceptions inside and turn them into a status yourself:
public function check(): HealthStatus
{
try {
return HealthStatus::up()->withDetail('latency_ms', $this->api->pingMs());
} catch (\Throwable $e) {
return HealthStatus::down()->withDetail('reason', $e->getMessage());
}
}An external call without a timeout is a source of hangs
check() runs synchronously on every request to the endpoint. An HTTP call without a
timeout turns a liveness check into the thing that hangs: the probe waits for an
answer, does not get one and considers the instance dead — even though the application
is fine.
Set a timeout of a second or two and treat exceeding it as a result of the check, not as an error.
Three more rules that save time:
- A check must be cheap. It is called often. A heavy aggregate query in
check()is a heavy query every few seconds, round the clock. - Do not write to the database. A check only reads: it runs concurrently on every instance and in any order.
- Do not log on every call. Otherwise the log fills up with probe records and real events get lost in it.
How to check that it works
The class is not tied to the framework, so it is tested with an ordinary unit test:
$health = new QueueHealth(new FakeQueue(depth: 50_000));
self::assertSame(Status::Degraded, $health->check()->status());
self::assertSame(50_000, $health->check()->toArray()['details']['depth']);Live — just open the endpoint:
curl -s localhost:8000/actuator/health | jq '.components.queue'If the component is not in the response, the class did not make it into the walk: check
that it lies under the project root (not in vendor/, storage/ or resources/) and
that the application was restarted.
Replacing the whole report
When it is not an individual component but the response format you need to change, pass an indicator of your own:
use Flytachi\Winter\Kernel\Http\Health\HealthIndicator;
final class MyIndicator extends HealthIndicator
{
public function env(): array
{
return ['APP_ENV' => env('APP_ENV'), 'TIME_ZONE' => env('TIME_ZONE')];
}
}#[EnableActuator(indicator: MyIndicator::class)]Extending HealthIndicator leaves the other endpoints as they are. Implementing
HealthIndicatorInterface from scratch is possible too, but then all seven methods are
yours to provide: health, pools, info, metrics, env, loggers, mappings.
A method’s name is the endpoint’s name: add a public queues() method and
/actuator/queues appears.
Protecting access
The actuator says quite a lot about how the application is built. It is better not exposed to the internet.
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
final class InternalOnly extends Middleware
{
public function before(HttpRequest $request, HttpResponse $response): void
{
$ip = $request->getClientIp();
if (!str_starts_with($ip, '10.') && $ip !== '127.0.0.1') {
throw new ResponseException('Not found', HttpCode::NOT_FOUND);
}
}
}#[EnableActuator(middleware: InternalOnly::class)]The middleware applies to every actuator endpoint at once. Answering 404 rather
than 403 is a deliberate device: it does not confirm that the path exists.
What is visible without protection
mappings shows every route the application has, administrative ones included; info
shows the versions people match known vulnerabilities against; metrics shows the
host’s state. None of that is a secret in itself, but it is a detailed map for someone
looking for a way in.
Close the actuator behind a middleware, or do not publish its port outside.
Next
- Application components —
#[EnableActuator]among the others - Middleware — how an access guard is written
- PPA — what the numbers in
poolsmean - Logging — what
loggersshows