Health and metrics¶
Namespaces Keel\Health and Keel\Metrics. Both are global middleware that answer their own
paths before routing and need no controller.
Because they answer before routing, /health, /metrics (and /openapi.json, /docs from
OpenAPI) cannot be protected by #[Auth] and shadow any application route on the
same path. Keep them off the public internet: restrict them in the reverse proxy or firewall, or
change the paths in the configs.
Health checks¶
#[Tag(HealthCheck::TAG)]
final class DatabaseCheck implements HealthCheck
{
public function __construct(private readonly \PDO $pdo) {}
public function name(): string { return 'database'; }
public function check(): HealthResult
{
$started = hrtime(true);
try {
$this->pdo->query('SELECT 1');
} catch (\PDOException $e) {
return HealthResult::down($e->getMessage(), exception: $e);
}
$ms = (hrtime(true) - $started) / 1e6;
return $ms > 200 ? HealthResult::degraded('slow', ['ms' => $ms]) : HealthResult::up(data: ['ms' => $ms]);
}
}
Tagged keel.health, the class is registered by discovery and injected into HealthMiddleware
(HttpConfig). Two endpoints, paths in HealthConfig (config/health.php), null disables:
/health(readiness): runs every check, answers{"status": "up"|"degraded"|"down", "checks": {...}}with 200, or 503 when any check is down. A check that throws is reported as down.Cache-Control: no-store./health/live(liveness):{"status": "up"}without running anything, as long as the worker answers.
Exception messages stay out of the report: a thrown exception, or a HealthResult::down() given
its exception, shows "detail": "check failed" and the exception is logged through the
LoggerInterface when one is registered. Connection errors name hosts, users and paths, and the
endpoint is usually reachable without credentials. new HealthConfig(exposeDetails: true) puts
the messages back, for development. Details you write yourself (down('queue lag 5s')) are
always shown.
Checks receive their dependencies from the container when the middleware is built, so take a
connection lazily (a closure, or the container) if opening it can fail: PdoCheck does, which is
why an unreachable database is a down entry in the report rather than a 500 for every request.
Point Kubernetes probes, load balancers and uptime monitors at them; keep readiness checks cheap, they run on every probe.
Prometheus metrics¶
MetricsMiddleware (HttpConfig) records two metrics for every request and serves the text
exposition at /metrics (MetricsConfig, config/metrics.php):
http_requests_total{method="GET",route="/users/{id}",status="200"} 42
http_request_duration_seconds_bucket{method="GET",route="/users/{id}",le="0.01"} 40
http_request_duration_seconds_count{method="GET",route="/users/{id}"} 42
http_request_duration_seconds_sum{method="GET",route="/users/{id}"} 0.3123
process_memory_bytes 35651584
The route label is the route pattern, so cardinality stays bounded; requests matching no route
are labelled unmatched. The method label is one of GET HEAD POST PUT PATCH DELETE OPTIONS
(MetricsMiddleware::METHODS); any other method is recorded as OTHER, so a client cannot grow
the label set by inventing verbs. Because the middleware runs before routing, the pattern comes
from a second match against the compiled tables, a hash lookup for static routes. Latency buckets
are configurable (MetricsConfig::$buckets, seconds); the exposition lists every configured
bucket in ascending order, with 0 for buckets nothing has reached yet, then +Inf, _count
and _sum.
Your own metrics¶
Registry is a shared service: register metrics once (in a provider or in the service that
uses them) and record from anywhere:
final class OrderService
{
public function __construct(private readonly Registry $metrics)
{
$metrics->counter('orders_total', 'Orders placed', ['channel'])
->histogram('order_value_eur', 'Order value', [], [10, 50, 100, 500]);
}
public function place(Order $order): void
{
$this->metrics->inc('orders_total', [$order->channel]);
$this->metrics->observe('order_value_eur', [], $order->total);
}
}
Counters (inc), gauges (set) and histograms (observe) with labels; has() tells whether a
metric is registered, render() returns the exposition.
Storage¶
Samples live in a MetricsStorage: APCu when the extension is loaded, so every php-fpm child or
FrankenPHP thread of a server adds to the same numbers, otherwise the memory of the current
process (one worker's view). Values are integers internally; gauges and histogram sums are scaled
by 10⁶, which keeps APCu's atomic increments usable for durations and caps a single gauge or sum
at about ±9.2·10¹² (larger values are clamped). Keel\Metrics\RedisStorage aggregates the whole
fleet in one Redis hash (RedisConfig(use: ['metrics']), see Cache); register
an implementation under MetricsStorage for anything else.
Tracing (OpenTelemetry)¶
Keel\Telemetry\TracingMiddleware starts one server span per request: named GET /users/{id}
(the route pattern, the method alone when nothing matched), kind SERVER, with the HTTP semantic
attributes (http.request.method, url.path, url.query, url.scheme, server.address,
http.route, user_agent.original, client.address, keel.request_id,
http.response.status_code). An incoming traceparent header continues the caller's trace
(W3C Trace Context; pass another TextMapPropagatorInterface to change that). A 5xx answer or an
exception marks the span as an error and records the exception; a 404 or 400 does not. The span is
active while the handler runs, so spans you start through the tracer become its children, and
TracingMiddleware::span($request) returns it for events and attributes of your own.
It needs open-telemetry/api and a TracerProviderInterface in the container, which is where
the SDK and the exporter live (composer require open-telemetry/sdk plus an exporter such as
open-telemetry/exporter-otlp):
// config/services.php, a provider
$definitions->instance(TracerProviderInterface::class, TracerProvider::builder()
->addSpanProcessor(new BatchSpanProcessor($exporter, ClockFactory::getDefault()))
->build());
// config/http.php
return new HttpConfig([RequestIdMiddleware::class, TracingMiddleware::class, /* ... */]);
Nothing of this is loaded unless the middleware is registered, so an application without tracing pays nothing.
Request ids and logs¶
RequestIdMiddleware gives every request an id (response header X-Request-Id, request
attribute request_id); include it in log context to correlate. The built-in StreamLogger
writes JSON lines to stderr, which FrankenPHP and RoadRunner collect; see
Application.