Advanced

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.

Enabling #[EnableActuator]Path /actuator/*Method GET only

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:

bootstrap.php
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
php
#[EnableActuator(middleware: InternalOnly::class)]
#[EnableActuator(indicator: MyIndicator::class)]

Checking that it came up

bash
curl -s localhost:8000/actuator/health | jq
json
{ "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

json
{
"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.

docker-compose.yml
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/actuator/health"]
interval: 30s
timeout: 5s
retries: 3

The -f flag makes curl exit with an error on a 5xx code — that is enough, and no JSON needs parsing.

pools — connection load

json
{
"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:

json
{
"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:

main/Health/QueueHealth.php
<?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:

json
{
"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:

php
HealthStatus::up();          // all is well
HealthStatus::degraded();    // working, but worse than usual
HealthStatus::down();        // not working

Three 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():

php
HealthStatus::degraded()
  ->withDetail('depth', 24817)
  ->withDetail('oldest_message_age', 412);
json
"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:

main/Health/QueueHealth.php
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:

text
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:

php
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);
  }
}
php
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.

php
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:

php
$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:

bash
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:

php
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')];
  }
}
php
#[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.

main/Middleware/InternalOnly.php
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);
      }
  }
}
php
#[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