From 06b9bc8293f4ab109556d904a00161c0041c615c Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Thu, 12 Nov 2026 11:42:57 +0100 Subject: [PATCH] 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 --- .../Controller/Backend/SsrController.php | 66 +++++++++++-------- .../Classes/Service/ClientErrorClassifier.php | 39 +++++++++++ .../vitec/Classes/Widgets/SsrStatsWidget.php | 34 ++++++++-- .../Private/Templates/Ssr/Index.html | 21 +++--- .../Resources/Public/Css/backend-ssr.css | 6 +- 5 files changed, 125 insertions(+), 41 deletions(-) create mode 100644 packages/vitec/Classes/Service/ClientErrorClassifier.php diff --git a/packages/vitec/Classes/Controller/Backend/SsrController.php b/packages/vitec/Classes/Controller/Backend/SsrController.php index 5e11f46..1183986 100644 --- a/packages/vitec/Classes/Controller/Backend/SsrController.php +++ b/packages/vitec/Classes/Controller/Backend/SsrController.php @@ -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, 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) { - $path = (string)(parse_url((string)$row['url'], PHP_URL_PATH) ?? ''); - $row['path'] = $path !== '' ? $path : (string)$row['url']; + foreach ($rows as $row) { $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'); + 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']; + $row['ua_short'] = mb_strlen($ua) > 60 ? mb_substr($ua, 0, 57) . '...' : $ua; + $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(); diff --git a/packages/vitec/Classes/Service/ClientErrorClassifier.php b/packages/vitec/Classes/Service/ClientErrorClassifier.php new file mode 100644 index 0000000..1e15212 --- /dev/null +++ b/packages/vitec/Classes/Service/ClientErrorClassifier.php @@ -0,0 +1,39 @@ +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 . 'ms' : '–', 'last 24 h') . $tile('Max. cache', $maxCache !== null ? $maxCache . 'MB' : '–', 'last 7 days') - . $tile('Client errors', $clientErrors24h . '/ ' . $clientErrors7d . '', '24 h / 7 days', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok') + . $tile('Client errors', $clientErrors24h . '/ ' . $clientErrors7d . '', '24 h / 7 days, no tests', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok') . ''; 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 { diff --git a/packages/vitec/Resources/Private/Templates/Ssr/Index.html b/packages/vitec/Resources/Private/Templates/Ssr/Index.html index da51150..d57629c 100644 --- a/packages/vitec/Resources/Private/Templates/Ssr/Index.html +++ b/packages/vitec/Resources/Private/Templates/Ssr/Index.html @@ -106,9 +106,10 @@
- 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. + 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.
Response time
{health.responseMs}–ms
+
last check · updates every 15 s
median 24 h – · p95 –
@@ -126,10 +127,10 @@
- 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. + 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.
Client errors · 24 h
{clientErrorCount24h}reports
-
{chunkCount24h} chunk reloads · {jsErrorCount24h} JS errors
+
{chunkCount24h} chunk reloads · {jsErrorCount24h} JS errors · {testCount24h} tests left out
@@ -270,7 +271,7 @@
- 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. + 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.

Client errors

kept 30 days @@ -279,6 +280,7 @@
24 h
{clientErrorCount24h}
Chunk reloads
{chunkCount24h}
JS errors
{jsErrorCount24h}
+
Tests
{testCount24h}
@@ -286,10 +288,13 @@
- - chunk reload - error - + + + chunk reload + error + + bot + {row.message}{row.path} @{row.crdate} diff --git a/packages/vitec/Resources/Public/Css/backend-ssr.css b/packages/vitec/Resources/Public/Css/backend-ssr.css index 66851a5..ccb0e1b 100644 --- a/packages/vitec/Resources/Public/Css/backend-ssr.css +++ b/packages/vitec/Resources/Public/Css/backend-ssr.css @@ -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; }