Status reports & health endpoint
Build a report of every monitored check with Health::report(), or of one owner’s checks with Health::for($owner)->report() — both optionally filtered by tags, limited to a window with since: and to scheduled checks with scheduledOnly: true:
use RoundlyConsulting\Alerts\Facades\Health;
$report = Health::report(); // HealthReport across every monitored check
$report->overall(); // worst Status across all checks (skipped never outranks ok)
$report->isHealthy(); // bool
foreach ($report->checks() as $check) {
// CheckStatus: key, name, status, lastAlertAt, message, tags, uptime, p95LatencyMs, muted, meta
}
Health::status(); // Status roll-up
Health::report(['critical']); // only checks tagged with one of these (row tags or the check's tags())
Health::report(since: now()->subDay()); // uptime and p95 over the last day only
Health::report(scheduledOnly: true); // leave out the on-demand rows run() creates
Health::for($team)->report(['critical']); // one owner, optionally tag-filtered
Health::for($team)->status(); // that owner's roll-up
$report->whereTag('db'); // filter an in-memory reportThe report is built from the health_checks rows, in id order — on-demand rows included unless scheduledOnly: true — not from the registry. Each row’s status is the status of its newest open alert, or ok when none is open — so failures below failAfter() still read ok, and a failed on-demand run counts until a passing run() of the same check for that owner closes its alert (or unmonitor() removes the row). An open alert follows the latest failing result, so a warning that turns into a failure reports as failed. overall() is the worst status by severity, ignoring skipped — a report of ok and skipped checks, or of skipped ones only, is ok — and isHealthy() is true when nothing is alertable.
| CheckStatus property | Meaning |
|---|---|
key | The check key of the scheduled row. |
name | The registered check’s name(), or the key when it is not registered. |
status | The status of the row’s newest open alert — it follows the latest failing result — or Status::Ok when none is open. |
lastAlertAt | When that open alert was triggered (null when none). |
message | The open alert’s message, from the latest failing result. |
tags | Effective tags — row tags merged with the check’s tags(). |
uptime | ?float — the share of non-alertable runs in the report’s window, two decimals; null when the row has no run in it, or history is off. |
p95LatencyMs | ?int — the nearest-rank 95th-percentile run duration in the same window; null without runs. |
muted | True when a silence in force covers the row — by the run pipeline’s own rule, with or without an open alert. |
meta | The open alert’s raw meta, null without an open alert. With store_exception_details on it can hold exception details — sanitise it before showing it to users. |
- Health::report(['critical']) and Health::for($team)->report(['critical']) keep rows tagged with any of the given tags — on the row or by the check’s tags(); status(), alerts:status --tag and /health?tag= filter the same way.
- $report->whereTag('db') filters a built report on effective tags — row tags merged with the check class’s tags().
- HealthReport::toArray() and CheckStatus::toArray() always give the full shape, meta included; the endpoint renders only an allow-list of it (below).
Windows, statistics and query count
since: limits uptime and p95LatencyMs to the runs at or after that moment; without it they cover the whole retained history. A row with no run in the window — or none at all, or with history.enabled off — reports both as null, so a check that never ran no longer looks perfect. status() takes no window and is unchanged.
However many rows and runs there are, the report takes a fixed number of queries — at most five: the rows, their open alerts, one run aggregate, one nearest-rank p95 read with a window function, and one silence snapshot, with row ids batched by 1000. The run queries are skipped with history off and the snapshot with silence off. The JSON endpoint and alerts:status build a report on every hit, so this keeps them cheap.
Caching the report
For a cache that stores arrays only (cache.serializable_classes off), store toArray() and read it back with HealthReport::tryFromArray() — or CheckStatus::tryFromArray() for one row. A payload they cannot read is null — a cache miss, never an error — and the stored overall status is ignored and recomputed:
use Illuminate\Support\Facades\Cache;
use RoundlyConsulting\Alerts\Facades\Health;
use RoundlyConsulting\Alerts\Status\HealthReport;
$report = HealthReport::tryFromArray(Cache::get('health'))
?? tap(Health::report(since: now()->subDay(), scheduledOnly: true),
fn (HealthReport $r) => Cache::put('health', $r->toArray(), 60));On the command line
A CLI summary that exits non-zero when anything is alertable — handy in CI or uptime probes:
php artisan alerts:status
php artisan alerts:status --tag=db # filter by tagJSON endpoint
An opt-in endpoint that returns 200 when healthy and 503 otherwise. Register it from your own routes file — the package never adds routes by default:
// routes/web.php
use RoundlyConsulting\Alerts\Facades\Health;
Health::routes(); // GET /health -> { "status": "...", "checks": [...] }
// GET /health?tag=critical -> only checks tagged "critical" (on the row or by the check's tags())
// Or on your own URI, behind your own middleware (routes() returns the Route):
Health::routes('internal/health')->middleware('auth.basic');{
"status": "warning",
"checks": [
{
"key": "http_ping_check",
"name": "Http Ping Check",
"status": "warning",
"uptime": 99.31,
"p95_latency_ms": 840
}
]
}By default each check renders only key, name, status, uptime and p95_latency_ms — uptime and p95_latency_ms are null for a check with no runs. Set route.details (ALERTS_ROUTE_DETAILS=true) to add message, tags, last_alert_at and muted. That is an explicit allow-list: the raw alert meta is never rendered here, details or not. Still keep details behind middleware — with store_exception_details on, a stored alert message can carry raw exception text such as hosts or database names.
The endpoint is unauthenticated unless you chain middleware onto the Route that routes() returns. It reports every monitored check across all owners, so one owner’s failing check turns the whole endpoint 503 — narrow it with ?tag=. The URI and route name default to route.uri and route.name from the config.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.