Status Endpoint
Token-protected /en/status reports error and warning counts for the last 24 hours. Keep it open in a tab: it re-checks hourly and its favicon turns red on errors.
Overview
| Each site | Serves GET /en/status: error and warning counts from its logs for the last 24 hours. |
|---|---|
| Daily check | Now: one bookmark per site, left open in a tab (re-checks hourly, red favicon on errors). Later: a central cron job calls every site. |
| Result | One daily report: "any errors on any site?" No log contents ever leave the site. |
| Opt out | No STATUS_TOKEN on a site = endpoint off (404). Customer Controlled installs leave it unset. |
Build it on a project
Written so a developer or an AI assistant can add this block to another CakePHP 4.x project from this page alone. Every file is shown in full under Source files at the bottom of the page.
-
Copy the new files to the same paths (shown in full at the bottom of the page):
src/Controller/StatusController.php: handlesGET /en/statusand counts the log lines (self-contained, no Table or Service)templates/Status/index.php: browser monitor page (one file; split into elements once integrated, if needed)webroot/css/status-endpoint.cssandwebroot/js/status-endpoint.js
-
Leave the project's config alone. No changes to
Application.php,config/app.phpor the routes. -
No route, no database table, no Composer package. The default fallback routes
(
/{language}/{controller}) reachStatusController::index(); if the project has removed them, connect/{language}/statustoStatus::index.StatusControllerextends Cake's baseController, notAppController, so the login, menu, testing-site password gate and database are skipped. Keep it that way. If the project'sLogconfig writes somewhere other thanLOGS . 'error.log'/'cli-error.log', changeLOG_FILESin the controller. - Set
STATUS_TOKENon the server (see Setup below). Without it the endpoint stays off. -
Verify with curl:
- No token configured:
/en/statusreturns 404. - Correct header: 200 + JSON; wrong token or no token: 404.
- Browser with
?token=: monitor page, favicon green or red. - A wrong-token request raises
ignored, noterrors. - Stop the database:
/en/statusstill returns 200.
- No token configured:
Full spec in SetupCase core: docs/features/MVP-Status-Endpoint.md.
Part 1: on each site
What it returns
With the correct token, curl and cron jobs get HTTP 200 and JSON (a browser gets the monitor page, Part 2):
{
"app": "www.example.com",
"generated_at": "2026-10-27T08:00:00+00:00",
"window_hours": 24,
"errors": 3,
"warnings": 12,
"ignored": 140,
"php_version": "8.3.12",
"cakephp_version": "4.6.5"
}
errors: log lines at level error, critical, alert or emergency in the last 24 hours.warnings: log lines at level warning in the last 24 hours.ignored: error / warning entries skipped because they match the ignore list (see below).- Counted from
logs/error.logandlogs/cli-error.log, including rotated files. Stack-trace lines are not counted. - No database, session or login is used, so it still answers when the database is down.
Ignore list
Every public site logs noise: bots requesting /wp-admin, old links, and so on. Instead of hiding
it from the log, StatusController::IGNORE lists strings that are not counted. An entry is
skipped when its first log line contains one of them (plain, case-sensitive). Skipped entries show up as
ignored, so you can see the filter working.
- Start with the default list: only
[Cake\Http\Exception\NotFoundException], which also covers wrong tokens on this endpoint, so nobody can inflate your count. - When a tab turns red, read
logs/error.logon that site. - A real bug: fix it. Known noise: copy a distinctive part of its first line into
IGNORE.
private const IGNORE = [
'[Cake\Http\Exception\NotFoundException]', // includes wrong tokens on this endpoint
'Controller class DebugKit', // narrow: one known nuisance
// '[Cake\Http\Exception\MissingControllerException]', // broad: every unknown controller
];
Prefer narrow entries. A broad one can hide a real problem, e.g. ignoring every
MissingActionException also hides a link to an action someone forgot to write.
Access rules
Correct ?token=, opened in a browser | 200 + monitor page (hourly re-check, coloured favicon) |
|---|---|
Correct ?token= with &format=json | 200 + JSON |
Correct X-Status-Token header (curl, cron) | 200 + JSON (for automation) |
| Missing or wrong token | 404, same page as any missing URL |
| No token configured on the site | 404 (endpoint off) |
| Anything other than GET | 403 from CSRF for POST (like any POST without a token), otherwise 404 |
It never returns 401 or 403, so a scanner cannot tell the endpoint exists.
Setup
-
Generate a token for this site (one per site, never reused):
openssl rand -hex 32 -
Set it with the ENV Vars pattern:
STATUS_TOKENin the serverphp.inion hosting, or in the Docker env locally. Never commit it. -
Optional: set
STATUS_APP_NAMEto the name the report should show. Without it, the site's host name is used. -
Test it by opening the URL in a browser:
On this install the URL ishttps://www.example.com/en/status?token=<token>https://testflight.setupcase.com/en/status. - Bookmark that URL and keep it open in a tab (Part 2).
Part 2: daily check
Now: keep a tab open per site
- Bookmark each site:
<site>/en/status?token=<token>. Keep them in a "Status" folder. - Open the folder in tabs (or pin the tabs) and leave them open. In a browser the URL shows a small monitor page, not raw JSON.
- Each page re-checks its site every 60 minutes without reloading, and straight away when you return to a tab that is overdue. A Check now button checks on demand.
- Watch the favicons in your tab bar:
| Favicon | Tab title | Meaning |
|---|---|---|
OK - <app> |
No errors in the last 24 hours (warnings may still be shown on the page) | |
(3) ERRORS - <app> |
Errors logged in the last 24 hours; check that site's logs/error.log |
|
UNREACHABLE - <app> |
The check failed: site down, timeout, or the token was changed |
A red favicon stays red until the error is more than 24 hours old, even after it is fixed. Browsers can put background tabs to sleep, so a tab may turn red only when you look at it.
Later: central cron job
Same endpoint, with the token in a header instead of the URL:
- Once a day (e.g. 07:00), loop over every registered site.
-
Send
GET <site>/en/statuswith the headerX-Status-Token: <token>, a short timeout (e.g. 10 seconds), and no redirects followed. - Record the result per site, then send one report listing every site that needs a look.
- Store each site's URL and token together as a secret, not in source code.
curl -s --max-time 10 \
-H "X-Status-Token: $TOKEN" \
https://www.example.com/en/status
Reading the result
| Response | Meaning | Report as |
|---|---|---|
200, errors = 0 | No errors logged in 24 hours | OK |
200, errors > 0 | Errors were logged; check that site's logs/error.log | Needs a look |
200, warnings > 0 only | Warnings only | Info (optional) |
| 404 | Token wrong, token not set on the site, or the feature is not deployed | Monitoring broken |
| Timeout / 5xx / no connection | Site down or failing before it can log | Site down |
php_version and cakephp_version let the report list every site affected by a
security advisory without logging in anywhere.
Changing a token
- Generate a new token and set
STATUS_TOKENon the site. - Update the bookmark (or the central system) the same day; the old token stops working immediately.
Known limitations
- Counts only: a non-zero count means "look at the logs on that site", never what failed.
- Problems that never reach the logger (site down, early PHP fatals) only show as a failed poll.
- A very noisy site can rotate logs away within 24 hours; the count is then a minimum.
- Job heartbeats are a separate check:
/cron.php?action=status(Unified Cron Framework).
Controller: src/Controller/StatusController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use Cake\Controller\Controller;
use Cake\Core\Configure;
use Cake\Http\Exception\NotFoundException;
use Cake\View\View;
use DateTimeImmutable;
use DateTimeZone;
/**
* Status Endpoint block: token-protected GET /en/status with error / warning
* counts for the last 24 hours. See docs/features/MVP-Status-Endpoint.md.
*
* Self-contained on purpose (a drop-in code block): the log counting lives here
* as private helpers, not in a Table or Service, and nothing outside this
* controller, its templates and its CSS / JS is touched. A project may move the
* counting into its own model layer once the block is integrated.
*
* Extends Cake's base Controller, NOT AppController: no testing-site password
* gate, no Authentication component, no menu, no database. Reached by the
* default fallback routes; no route of its own.
*
* - Token: STATUS_TOKEN (ENV Vars pattern). Not set = endpoint off.
* - Sent as header X-Status-Token (automation) or ?token= (bookmarks).
* - Anything wrong = the normal 404 page, never 401/403.
* - Browsers get the monitor page; everything else (or ?format=json) gets JSON.
* - Only counts leave this class, never log contents.
*
* Scoping: log files are install-wide and only read here, so nothing is scoped
* by group_id / user_id. This is a deliberate exception.
*/
class StatusController extends Controller
{
private const WINDOW_HOURS = 24;
private const CHECK_MINUTES = 60;
// Log files counted, as base names in LOGS (console commands write cli-error)
private const LOG_FILES = ['error', 'cli-error'];
// Log level => response field
private const LEVEL_FIELDS = [
'emergency' => 'errors',
'alert' => 'errors',
'critical' => 'errors',
'error' => 'errors',
'warning' => 'warnings',
];
// Log entries whose first line contains any of these strings are not counted
// as errors / warnings (they are reported as `ignored`). Plain, case-sensitive
// substring match. Start small: watch logs/error.log and add known noise here.
// Narrow beats broad: 'Controller class DebugKit' silences one nuisance,
// '[Cake\Http\Exception\MissingControllerException]' silences them all.
private const IGNORE = [
'[Cake\Http\Exception\NotFoundException]', // includes wrong tokens on this endpoint
];
// Read logs backwards in chunks of this size, so large files are never loaded whole
private const CHUNK_BYTES = 65536;
private int $cutoff = 0;
private array $counts = [];
public function index()
{
if (!$this->request->is('get') || !$this->index_tokenValid()) {
throw new NotFoundException();
}
$now = new DateTimeImmutable();
$this->index_countLogs($now);
$status = [
'app' => (string)(get_cfg_var('STATUS_APP_NAME') ?: env('STATUS_APP_NAME') ?: $this->request->host()),
'generated_at' => $now->format(DATE_ATOM),
'window_hours' => self::WINDOW_HOURS,
'errors' => $this->counts['errors'],
'warnings' => $this->counts['warnings'],
'ignored' => $this->counts['ignored'],
'php_version' => PHP_VERSION,
'cakephp_version' => Configure::version(),
];
$this->response = $this->response
->withHeader('Cache-Control', 'no-store')
->withHeader('X-Robots-Tag', 'noindex')
->withHeader('Referrer-Policy', 'no-referrer');
if (!$this->index_wantsHtml()) {
return $this->response->withType('json')->withStringBody((string)json_encode($status));
}
$this->index_setMonitorVars($status);
}
/**
* Header first, then ?token=. Off (false) when STATUS_TOKEN is not set.
*/
private function index_tokenValid(): bool
{
$expected = (string)(get_cfg_var('STATUS_TOKEN') ?: env('STATUS_TOKEN') ?: '');
if ($expected === '') {
return false;
}
$given = $this->request->getHeaderLine('X-Status-Token');
if ($given === '') {
$given = (string)$this->request->getQuery('token', '');
}
return $given !== '' && hash_equals($expected, $given);
}
private function index_wantsHtml(): bool
{
if ($this->request->getQuery('format') === 'json') {
return false;
}
return strpos($this->request->getHeaderLine('Accept'), 'text/html') !== false;
}
/**
* Monitor page: plain View, no layout (AppView helpers expect a full app request).
*/
private function index_setMonitorVars(array $status): void
{
$hasErrors = $status['errors'] > 0;
$this->viewBuilder()->setClassName(View::class)->disableAutoLayout();
$this->set([
'status' => $status,
'statusState' => $hasErrors ? 'errors' : 'ok',
'statusTitle' => ($hasErrors ? '(' . $status['errors'] . ') ERRORS' : 'OK') . ' - ' . $status['app'],
'statusCheckMinutes' => self::CHECK_MINUTES,
]);
}
/**
* Sets $this->counts (errors, warnings, ignored) for entries inside the window,
* across LOG_FILES and their rotated siblings.
*/
private function index_countLogs(DateTimeImmutable $now): void
{
// Log lines carry no timezone, so compare as wall-clock time in $now's timezone
$local = new DateTimeImmutable($now->format('Y-m-d H:i:s'), new DateTimeZone('UTC'));
$this->cutoff = $local->getTimestamp() - self::WINDOW_HOURS * 3600;
$this->counts = ['errors' => 0, 'warnings' => 0, 'ignored' => 0];
foreach ($this->index_logFiles() as $file) {
$this->index_countFile($file);
}
}
/**
* Current files plus rotated ones (`error.log.<unix time>`) modified inside the window.
*/
private function index_logFiles(): array
{
$files = [];
foreach (self::LOG_FILES as $baseName) {
$current = LOGS . $baseName . '.log';
if (is_file($current)) {
$files[] = $current;
}
foreach (glob($current . '.*') ?: [] as $rotated) {
if (preg_match('/\.log\.\d+$/', $rotated) && @filemtime($rotated) >= time() - self::WINDOW_HOURS * 3600) {
$files[] = $rotated;
}
}
}
return $files;
}
/**
* Read one file from the end, line by line, until a dated line is older than the window.
*/
private function index_countFile(string $file): void
{
$handle = @fopen($file, 'rb');
if ($handle === false) {
return; // unreadable counts as zero, never an error
}
clearstatcache(true, $file); // the log may have grown since PHP last looked
$position = (int)filesize($file);
$partial = '';
while ($position > 0) {
$read = min(self::CHUNK_BYTES, $position);
$position -= $read;
fseek($handle, $position);
$lines = explode("\n", fread($handle, $read) . $partial);
// The first piece may be cut mid-line; keep it for the next chunk
$partial = $position > 0 ? array_shift($lines) : '';
for ($i = count($lines) - 1; $i >= 0; $i--) {
if ($this->index_countLine($lines[$i]) === false) {
fclose($handle);
return;
}
}
}
fclose($handle);
}
/**
* Count one line. Returns false once a dated line is older than the window.
* Lines without a leading timestamp (stack traces, multi-line messages) are ignored.
*/
private function index_countLine(string $line): bool
{
if (!preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\w+): /', $line, $match)) {
return true;
}
$time = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $match[1], new DateTimeZone('UTC'));
if ($time === false) {
return true;
}
if ($time->getTimestamp() < $this->cutoff) {
return false;
}
$field = self::LEVEL_FIELDS[strtolower($match[2])] ?? null;
if ($field === null) {
return true;
}
foreach (self::IGNORE as $needle) {
if ($needle !== '' && strpos($line, $needle) !== false) {
$this->counts['ignored']++;
return true;
}
}
$this->counts[$field]++;
return true;
}
}
Monitor page: templates/Status/index.php
<?php
/**
* Status Endpoint monitor page (full HTML, no layout, no database). status-endpoint.js
* re-checks every $statusCheckMinutes. The token is never printed; the JS re-fetches its own URL.
* Spec: docs/features/MVP-Status-Endpoint.md
*
* @var array $status app, generated_at, window_hours, errors, warnings, ignored, php_version, cakephp_version
* @var string $statusState ok|errors
* @var string $statusTitle
* @var int $statusCheckMinutes
*/
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title><?= h($statusTitle); ?></title>
<link rel="icon" id="status-endpoint-favicon" href="data:,">
<?= $this->Html->css('status-endpoint'); ?>
</head>
<body class="status-endpoint">
<main class="status-endpoint-card" data-state="<?= h($statusState); ?>"
data-app="<?= h($status['app']); ?>" data-check-minutes="<?= h($statusCheckMinutes); ?>">
<p class="status-endpoint-app"><?= h($status['app']); ?></p>
<div class="status-endpoint-banner" data-field="banner">Checking...</div>
<dl class="status-endpoint-counts">
<div><dt>Errors</dt><dd data-field="errors"><?= h($status['errors']); ?></dd></div>
<div><dt>Warnings</dt><dd data-field="warnings"><?= h($status['warnings']); ?></dd></div>
</dl>
<p class="status-endpoint-note">Last <span data-field="window_hours"><?= h($status['window_hours']); ?></span> hours, from <code>logs/error.log</code> and <code>logs/cli-error.log</code>.</p>
<table class="status-endpoint-meta">
<tr><th>Last checked</th><td data-field="checked"><?= h($status['generated_at']); ?></td></tr>
<tr><th>Next check</th><td data-field="next">-</td></tr>
<tr><th>Ignored</th><td data-field="ignored"><?= h($status['ignored']); ?></td></tr>
<tr><th>PHP</th><td data-field="php_version"><?= h($status['php_version']); ?></td></tr>
<tr><th>CakePHP</th><td data-field="cakephp_version"><?= h($status['cakephp_version']); ?></td></tr>
</table>
<button type="button" class="status-endpoint-button" data-action="check">Check now</button>
</main>
<?= $this->Html->script('status-endpoint'); ?>
</body>
</html>
Monitor JS: webroot/js/status-endpoint.js
/*
* Status Endpoint monitor page. Spec: docs/features/MVP-Status-Endpoint.md
*
* Re-fetches its own URL as JSON every data-check-minutes (no page reload, so a
* site that goes down shows UNREACHABLE instead of a browser error page), and
* sets the tab favicon + title: green OK, red ERRORS, grey UNREACHABLE.
*/
(function () {
var card = document.querySelector('.status-endpoint-card');
if (!card) {
return;
}
var app = card.getAttribute('data-app');
var intervalMs = parseInt(card.getAttribute('data-check-minutes'), 10) * 60 * 1000;
var button = card.querySelector('[data-action="check"]');
var lastCheck = Date.now();
var timer = null;
var states = {
ok: {color: '#198754', mark: '', banner: 'OK'},
errors: {color: '#dc3545', mark: '!', banner: 'ERRORS'},
unreachable: {color: '#6c757d', mark: '?', banner: 'UNREACHABLE'}
};
function field(name) {
return card.querySelector('[data-field="' + name + '"]');
}
function favicon(state) {
var s = states[state];
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">' +
'<circle cx="16" cy="16" r="15" fill="' + s.color + '"/>' +
(s.mark ? '<text x="16" y="23" font-size="20" font-weight="bold" font-family="Arial,sans-serif" text-anchor="middle" fill="#fff">' + s.mark + '</text>' : '') +
'</svg>';
document.getElementById('status-endpoint-favicon').href = 'data:image/svg+xml,' + encodeURIComponent(svg);
}
function render(state, data) {
card.setAttribute('data-state', state);
favicon(state);
if (data) {
['errors', 'warnings', 'ignored', 'window_hours', 'php_version', 'cakephp_version'].forEach(function (key) {
field(key).textContent = data[key];
});
}
if (state === 'errors') {
document.title = '(' + field('errors').textContent + ') ERRORS - ' + app;
} else {
document.title = states[state].banner + ' - ' + app;
}
field('banner').textContent = states[state].banner;
field('checked').textContent = new Date(lastCheck).toLocaleString();
field('next').textContent = new Date(lastCheck + intervalMs).toLocaleString();
}
function jsonUrl() {
var url = new URL(window.location.href);
url.searchParams.set('format', 'json');
return url.toString();
}
function check() {
button.disabled = true;
lastCheck = Date.now();
fetch(jsonUrl(), {cache: 'no-store', headers: {'Accept': 'application/json'}})
.then(function (response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then(function (data) {
render(data.errors > 0 ? 'errors' : 'ok', data);
})
.catch(function () {
render('unreachable', null);
})
.finally(function () {
button.disabled = false;
schedule();
});
}
function schedule() {
clearTimeout(timer);
timer = setTimeout(check, intervalMs);
}
// Background tabs can be slowed or unloaded; check on return if overdue
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && Date.now() - lastCheck >= intervalMs) {
check();
}
});
button.addEventListener('click', check);
// First paint uses the numbers the server rendered
render(card.getAttribute('data-state'), null);
schedule();
})();
Monitor CSS: webroot/css/status-endpoint.css
/* Status Endpoint monitor page. Spec: docs/features/MVP-Status-Endpoint.md */
.status-endpoint {
margin: 0;
padding: 16px;
background: #f4f5f7;
color: #212529;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}
.status-endpoint-card {
max-width: 480px;
margin: 40px auto;
padding: 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.status-endpoint-app {
margin: 0 0 12px;
color: #6c757d;
font-size: 14px;
word-break: break-all;
}
.status-endpoint-banner {
padding: 16px;
border-radius: 6px;
background: #6c757d;
color: #fff;
font-size: 24px;
font-weight: bold;
text-align: center;
}
.status-endpoint-card[data-state="ok"] .status-endpoint-banner { background: #198754; }
.status-endpoint-card[data-state="errors"] .status-endpoint-banner { background: #dc3545; }
.status-endpoint-card[data-state="unreachable"] .status-endpoint-banner { background: #6c757d; }
.status-endpoint-counts {
display: flex;
gap: 16px;
margin: 20px 0 4px;
}
.status-endpoint-counts div {
flex: 1;
padding: 12px;
border: 1px solid #dee2e6;
border-radius: 6px;
text-align: center;
}
.status-endpoint-counts dt {
color: #6c757d;
font-size: 13px;
}
.status-endpoint-counts dd {
margin: 4px 0 0;
font-size: 32px;
font-weight: bold;
}
.status-endpoint-note {
margin: 0 0 16px;
color: #6c757d;
font-size: 12px;
}
.status-endpoint-meta {
width: 100%;
margin-bottom: 16px;
border-collapse: collapse;
font-size: 14px;
}
.status-endpoint-meta th,
.status-endpoint-meta td {
padding: 4px 0;
border-bottom: 1px solid #f1f1f1;
text-align: left;
}
.status-endpoint-meta th {
width: 40%;
color: #6c757d;
font-weight: normal;
}
.status-endpoint-button {
width: 100%;
padding: 10px;
border: 0;
border-radius: 6px;
background: #0d6efd;
color: #fff;
font-size: 16px;
cursor: pointer;
}
.status-endpoint-button:disabled {
opacity: 0.6;
cursor: wait;
}