SSR cockpit: label the response time, leave test reports out of the client error counts (0114-FE)

The big response time number is labelled last check. ClientErrorClassifier tells smoke and curl test reports and bot reports apart; tests are counted separately and left out of the tile, the hourly bars, the list and the dashboard tile, bots keep a label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-12 11:42:57 +01:00
1 parent 4bc38b1760
commit 06b9bc8293
5 files changed
+119 -35

No files matched your search

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Service\ClientErrorClassifier;
use Evomedien\Vitec\Service\SsrPurgeService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -68,7 +69,8 @@ final class SsrController
}
$healthLog = $this->rows('tx_vitec_ssrhealthlog', 10);
$lastSample = $healthLog[0] ?? null;
$clientErrors = $this->clientErrors(50);
$errorReport = $this->clientErrors(50);
$clientErrors = $errorReport['list'];
$cockpit = $this->cockpitData($health, $lastSample);
$alert = $this->purgeService->getAlertRecipients();
@@ -97,8 +99,10 @@ final class SsrController
'alertList' => implode(', ', $alert['recipients']),
'alertCount' => count($alert['recipients']),
'alertInvalid' => implode(', ', $alert['invalid']),
'chunkCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => $r['is_chunk_reload'])),
'jsErrorCount24h' => count(array_filter($clientErrors, static fn(array $r): bool => !$r['is_chunk_reload'])),
'chunkCount24h' => $errorReport['chunk'],
'jsErrorCount24h' => $errorReport['js'],
'testCount24h' => $errorReport['tests'],
'botCount24h' => $errorReport['bots'],
'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'),
'configured' => $this->purgeService->isConfigured(),
'health' => $health,
@@ -111,37 +115,51 @@ final class SsrController
'moduleUri' => (string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'),
'healthLog' => $healthLog,
'clientErrors' => $clientErrors,
'clientErrorCount24h' => $this->clientErrorCount24h(),
'clientErrorCount24h' => $errorReport['real'],
]);
return $view->renderResponse('Ssr/Index');
}
/**
* Client error reports of the last 24 hours (POST /api/vitec/client-error,
* ClientErrorMiddleware), newest first. Rows carry a "host" (URL without
* scheme/query) and a shortened user agent for the table; the full
* values stay in the row for the details toggle.
* ClientErrorMiddleware), newest first. Test reports (smoke tests, curl)
* are counted apart and left out of the list (0114-FE); bot reports stay
* in with a label. Rows carry a "path" and a shortened user agent for the
* list; the full values stay in the row for the details toggle.
*
* @return array{list: list<array>, real: int, chunk: int, js: int, tests: int, bots: int}
*/
private function clientErrors(int $limit): array
{
$report = ['list' => [], 'real' => 0, 'chunk' => 0, 'js' => 0, 'tests' => 0, 'bots' => 0];
try {
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_clienterrorlog');
$rows = $qb->select('*')->from('tx_vitec_clienterrorlog')
->where($qb->expr()->gt('crdate', $qb->createNamedParameter(time() - 86400, Connection::PARAM_INT)))
->orderBy('uid', 'DESC')->setMaxResults($limit)
->orderBy('uid', 'DESC')->setMaxResults(2000)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return [];
return $report;
}
foreach ($rows as &$row) {
foreach ($rows as $row) {
$ua = (string)$row['user_agent'];
if (ClientErrorClassifier::isTest((string)$row['message'], $ua, (string)$row['release'])) {
$report['tests']++;
continue;
}
$row['is_chunk_reload'] = ClientErrorClassifier::isChunkReload((string)$row['message']);
$row['is_bot'] = ClientErrorClassifier::isBot($ua);
$report['real']++;
$report[$row['is_chunk_reload'] ? 'chunk' : 'js']++;
$report['bots'] += $row['is_bot'] ? 1 : 0;
if (count($report['list']) < $limit) {
$path = (string)(parse_url((string)$row['url'], PHP_URL_PATH) ?? '');
$row['path'] = $path !== '' ? $path : (string)$row['url'];
$ua = (string)$row['user_agent'];
$row['ua_short'] = mb_strlen($ua) > 60 ? mb_substr($ua, 0, 57) . '...' : $ua;
$row['is_chunk_reload'] = str_contains(strtolower((string)$row['message']), 'chunk reload');
$report['list'][] = $row;
}
unset($row);
return $rows;
}
return $report;
}
/**
@@ -225,7 +243,7 @@ final class SsrController
[$from]
)->fetchAllAssociative();
$errors = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
'SELECT crdate, message FROM tx_vitec_clienterrorlog WHERE crdate > ?',
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
[$now - 86400]
)->fetchAllAssociative();
} catch (\Throwable) {
@@ -362,6 +380,9 @@ final class SsrController
$errHours = array_fill(0, 24, 0);
$hourEnd = intdiv($now, 3600) * 3600 + 3600;
foreach ($errors as $e) {
if (ClientErrorClassifier::isTest((string)$e['message'], (string)$e['user_agent'], (string)$e['release'])) {
continue;
}
$i = 23 - intdiv($hourEnd - 1 - (int)$e['crdate'], 3600);
if ($i >= 0 && $i < 24) {
$errHours[$i]++;
@@ -395,17 +416,6 @@ final class SsrController
];
}
private function clientErrorCount24h(): int
{
try {
return (int)$this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')
->executeQuery('SELECT COUNT(*) FROM tx_vitec_clienterrorlog WHERE crdate > ?', [time() - 86400])
->fetchOne();
} catch (\Throwable) {
return 0;
}
}
public function purgeAction(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
/**
* Tells test reports and bot reports in tx_vitec_clienterrorlog apart
* from real visitor errors (ticket 0114-FE: the cockpit tile must not
* count smoke tests). One rule set for the module and the dashboard.
*
* Test: sent with curl, a user agent that says "smoke" or "client-error
* test", a message starting with "test", or the dummy release "t" of the
* endpoint smoke test. Bot: a crawler user agent (Googlebot & Co.); bot
* reports stay real errors, they only get a label.
*/
final class ClientErrorClassifier
{
public static function isTest(string $message, string $userAgent, string $release = ''): bool
{
if ($release === 't') {
return true;
}
if (preg_match('#curl/|smoke|client-error test#i', $userAgent) === 1) {
return true;
}
return preg_match('#^\s*test\b#i', $message) === 1;
}
public static function isBot(string $userAgent): bool
{
return preg_match('#bot\b|crawler|spider|slurp|lighthouse|headlesschrome#i', $userAgent) === 1;
}
public static function isChunkReload(string $message): bool
{
return str_contains(strtolower($message), 'chunk reload');
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use Evomedien\Vitec\Service\ClientErrorClassifier;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
@@ -35,9 +36,9 @@ final class SsrStatsWidget implements WidgetInterface, AdditionalCssInterface
$full7d = $this->count('tx_vitec_ssrpurgelog', "crdate > " . $d7 . " AND paths = '*'");
$avgMs = $this->scalar('SELECT ROUND(AVG(response_ms)) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d1 . ' AND ok = 1');
$maxCache = $this->scalar('SELECT MAX(cache_mb) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d7);
// Browser-side errors reported via POST /api/vitec/client-error.
$clientErrors24h = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d1);
$clientErrors7d = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d7);
// Browser-side errors reported via POST /api/vitec/client-error,
// without smoke/curl test reports (0114-FE).
[$clientErrors24h, $clientErrors7d] = $this->realClientErrors($d1, $d7);
$strip = $h::strip($this->connectionPool);
$uptime = $strip['uptime'];
@@ -59,7 +60,7 @@ final class SsrStatsWidget implements WidgetInterface, AdditionalCssInterface
. $tile('Failed purges', (string)$failed7d, 'last 7 days', $failed7d > 0 ? 'vcw-bad' : 'vcw-ok')
. $tile('Avg. response', $avgMs !== null ? $avgMs . '<small>ms</small>' : '–', 'last 24 h')
. $tile('Max. cache', $maxCache !== null ? $maxCache . '<small>MB</small>' : '–', 'last 7 days')
. $tile('Client errors', $clientErrors24h . '<small>/ ' . $clientErrors7d . '</small>', '24 h / 7 days', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok')
. $tile('Client errors', $clientErrors24h . '<small>/ ' . $clientErrors7d . '</small>', '24 h / 7 days, no tests', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok')
. '</div>';
if ($strip['samples'] === 0) {
@@ -82,6 +83,31 @@ final class SsrStatsWidget implements WidgetInterface, AdditionalCssInterface
return [CockpitHtml::CSS];
}
/**
* @return array{0: int, 1: int} real client error reports in 24 h / 7 days
*/
private function realClientErrors(int $d1, int $d7): array
{
try {
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
[$d7]
)->fetchAllAssociative();
} catch (\Throwable) {
return [0, 0];
}
$day = 0;
$week = 0;
foreach ($rows as $r) {
if (ClientErrorClassifier::isTest((string)$r['message'], (string)$r['user_agent'], (string)$r['release'])) {
continue;
}
$week++;
$day += (int)$r['crdate'] > $d1 ? 1 : 0;
}
return [$day, $week];
}
private function count(string $table, string $where): int
{
try {
@@ -106,9 +106,10 @@
</section>
<section class="vc-card vc-s3" aria-label="Response time">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-ms">?</button><span class="vc-tip" role="tooltip" id="vc-tip-ms">How long TYPO3 needs to reach the node, measured when this page loads and every 15 seconds after. Median and p95 come from the cron samples of the last 24 hours, taken every 5 minutes. The curve shows the last 4 hours. Values above 400 ms turn the availability strip yellow.</span></span>
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-ms">?</button><span class="vc-tip" role="tooltip" id="vc-tip-ms">The big number is the last check: how long TYPO3 needed to reach the node, measured when this page loads and every 15 seconds after. Median and p95 come from the cron samples of the last 24 hours, taken every 5 minutes. The curve shows the last 4 hours. Values above 400 ms turn the availability strip yellow.</span></span>
<div class="vc-label">Response time</div>
<div class="vc-big"><span id="vc-ms"><f:if condition="{health}"><f:then>{health.responseMs}</f:then><f:else>–</f:else></f:if></span><small>ms</small></div>
<div class="vc-big-note">last check · updates every 15 s</div>
<div class="vc-sub">median 24 h <b id="vc-ms-median">–</b> · p95 <b id="vc-ms-p95">–</b></div>
<svg class="vc-spark" id="vc-spark" viewBox="0 0 300 46" preserveAspectRatio="none" aria-hidden="true"></svg>
</section>
@@ -126,10 +127,10 @@
</section>
<section class="vc-card vc-s3" aria-label="Client errors">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errtile">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errtile">Errors that visitor browsers reported from the React frontend in the last 24 hours. A chunk reload happens when an open tab still runs an older bundle after a deploy; that is usually harmless. JS errors point to a real bug. The bars show reports per hour, details are in the Client errors card below.</span></span>
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errtile">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errtile">Errors that visitor browsers reported from the React frontend in the last 24 hours. A chunk reload happens when an open tab still runs an older bundle after a deploy; that is usually harmless. JS errors point to a real bug. Test reports (smoke tests, curl) are left out; reports from crawlers like Googlebot count and carry a bot label. The bars show reports per hour, details are in the Client errors card below.</span></span>
<div class="vc-label">Client errors · 24 h</div>
<div class="vc-big">{clientErrorCount24h}<small>report<f:if condition="{clientErrorCount24h} != 1">s</f:if></small></div>
<div class="vc-sub">{chunkCount24h} chunk reload<f:if condition="{chunkCount24h} != 1">s</f:if> · {jsErrorCount24h} JS error<f:if condition="{jsErrorCount24h} != 1">s</f:if></div>
<div class="vc-sub">{chunkCount24h} chunk reload<f:if condition="{chunkCount24h} != 1">s</f:if> · {jsErrorCount24h} JS error<f:if condition="{jsErrorCount24h} != 1">s</f:if><f:if condition="{testCount24h}"> · <span class="vc-dim" title="Smoke tests and curl reports are left out">{testCount24h} test<f:if condition="{testCount24h} != 1">s</f:if> left out</span></f:if></div>
<div class="vc-mini" id="vc-mini" role="img" aria-label="Client errors per hour, last 24 hours"></div>
</section>
@@ -270,7 +271,7 @@
<!-- Client errors -->
<section class="vc-card vc-s5" aria-label="Client errors">
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errors">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errors">Every report from visitor browsers in the last 24 hours, newest first. Open an entry for release, URL, browser, anonymized IP and stack trace. “old bundle” means the tab ran an older release than the node. Reports are limited to 30 per minute and IP and are kept for 30 days.</span></span>
<span class="vc-info"><button type="button" class="vc-info-btn" aria-label="About this tile" aria-describedby="vc-tip-errors">?</button><span class="vc-tip" role="tooltip" id="vc-tip-errors">Every report from visitor browsers in the last 24 hours, newest first. Open an entry for release, URL, browser, anonymized IP and stack trace. “old bundle” means the tab ran an older release than the node. Test reports (smoke tests, curl) are counted under Tests but not listed. Reports are limited to 30 per minute and IP and are kept for 30 days.</span></span>
<div class="vc-card-h">
<h2 class="vc-card-t"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3 2 21h20zM12 10v4M12 17.5v.5"/></svg>Client errors</h2>
<span class="vc-pill is-sky" title="Reported by the React frontend via POST /api/vitec/client-error; max. 30 per minute and IP, IPs anonymized, cleaned up after 30 days by cron vitec:client-error-cleanup">kept 30 days</span>
@@ -279,6 +280,7 @@
<div><dt>24 h</dt><dd>{clientErrorCount24h}</dd></div>
<div><dt>Chunk reloads</dt><dd class="vc-warn-text">{chunkCount24h}</dd></div>
<div><dt>JS errors</dt><dd class="vc-bad-text">{jsErrorCount24h}</dd></div>
<div title="Smoke tests and curl reports are not counted and not listed"><dt>Tests</dt><dd class="vc-muted-text">{testCount24h}</dd></div>
</dl>
<f:if condition="{clientErrors}">
<f:then>
@@ -286,10 +288,13 @@
<f:for each="{clientErrors}" as="row">
<details class="vc-err">
<summary>
<span class="vc-err-pills">
<f:if condition="{row.is_chunk_reload}">
<f:then><span class="vc-pill is-warn">chunk reload</span></f:then>
<f:else><span class="vc-pill is-bad">error</span></f:else>
</f:if>
<f:if condition="{row.is_bot}"><span class="vc-pill is-muted" title="Reported by a crawler, e.g. Googlebot. Still a real error: the page broke for this bot.">bot</span></f:if>
</span>
<span class="vc-err-msg">{row.message}<small>{row.path}</small></span>
<span class="vc-err-t"><f:format.date format="H:i">@{row.crdate}</f:format.date></span>
</summary>
@@ -112,6 +112,7 @@
.vc-big { font-size: 32px; font-weight: 800; line-height: 1.05; margin: 6px 0 4px; font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap; }
.vc-big small { font-size: 15px; font-weight: 600; color: var(--vc-muted); margin-left: 4px; letter-spacing: 0; }
.vc-sub { font-size: 12px; color: var(--vc-muted); }
.vc-big-note { font-size: 10.5px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--vc-faint); margin: -2px 0 6px; }
.vc-sub b { color: var(--vc-text); }
.vc-sub .is-up { color: var(--vc-ok); } .vc-sub .is-down { color: var(--vc-bad); }
.vc-ring { flex: none; }
@@ -206,7 +207,10 @@
}
/* Client errors */
.vc-err-sum { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0 0 16px; }
.vc-err-sum { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0 0 16px; }
@media (max-width: 619.98px) { .vc-err-sum { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.vc-muted-text { color: var(--vc-faint); }
.vc-err-pills { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.vc-err-sum div { background: var(--vc-panel-2); border-radius: 10px; padding: 10px 12px; min-width: 0; }
.vc-err-sum dt { font-size: 11px; font-weight: 400; color: var(--vc-faint); text-transform: uppercase; letter-spacing: .07em; }
.vc-err-sum dd { margin: 2px 0 0; font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; }