From cb53ff67eebdcfe4b2dba2ee57c894a0a51a2e04 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Mon, 9 Nov 2026 22:51:13 +0100 Subject: [PATCH] SSR dashboard: new errors-and-hit-rate tile fed by /__stats samples in vitec:ssr-health (stat_* columns in healthlog) Co-Authored-By: Claude Fable 5 --- .../Command/SsrHealthSampleCommand.php | 41 +++++ .../vitec/Classes/Service/SsrPurgeService.php | 15 ++ .../vitec/Classes/Widgets/SsrErrorsWidget.php | 159 ++++++++++++++++++ packages/vitec/Configuration/Services.yaml | 11 ++ packages/vitec/ext_tables.sql | 6 + 5 files changed, 232 insertions(+) create mode 100644 packages/vitec/Classes/Widgets/SsrErrorsWidget.php diff --git a/packages/vitec/Classes/Command/SsrHealthSampleCommand.php b/packages/vitec/Classes/Command/SsrHealthSampleCommand.php index 8ca9ec9..e2a6e79 100644 --- a/packages/vitec/Classes/Command/SsrHealthSampleCommand.php +++ b/packages/vitec/Classes/Command/SsrHealthSampleCommand.php @@ -57,6 +57,36 @@ final class SsrHealthSampleCommand extends Command } catch (\Throwable $e) { $row['response_ms'] = (int)round((microtime(true) - $start) * 1000); } + + // Cumulative node counters from /__stats (-1 = no data this sample, + // e.g. node down or release without the endpoint). The errors tile + // computes 24h deltas from these, skipping -1 rows; the counters + // reset whenever the node restarts, which the delta logic clamps. + $stats = [ + 'stat_hit' => -1, 'stat_miss' => -1, 'stat_stale' => -1, + 'stat_bypass' => -1, 'stat_render_errors' => -1, 'stat_backend_errors' => -1, + ]; + if ($row['ok'] === 1 && ($statsEndpoint = $this->purgeService->getStatsEndpoint()) !== '') { + try { + $statsResponse = $this->requestFactory->request($statsEndpoint, 'GET', [ + 'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false, + ]); + if ($statsResponse->getStatusCode() === 200) { + $s = json_decode((string)$statsResponse->getBody(), true) ?: []; + $stats = [ + 'stat_hit' => (int)($s['hit'] ?? -1), + 'stat_miss' => (int)($s['miss'] ?? -1), + 'stat_stale' => (int)($s['stale'] ?? -1), + 'stat_bypass' => (int)($s['bypass'] ?? -1), + 'stat_render_errors' => (int)($s['renderErrors'] ?? -1), + 'stat_backend_errors' => (int)($s['backendErrors'] ?? -1), + ]; + } + } catch (\Throwable) { + // Stats are optional - the health sample must never fail on them. + } + } + $row = array_merge($row, $stats); // Previous samples, newest first, read BEFORE this one is inserted: // [0] is the state every transition decision compares against. $failoverAfter = $this->purgeService->getFailoverAfter(); @@ -81,6 +111,17 @@ final class SsrHealthSampleCommand extends Command $row['cache_mb'], $row['response_ms'] )); + if ($row['stat_hit'] >= 0) { + $output->writeln(sprintf( + 'stats: hit=%d miss=%d stale=%d bypass=%d renderErrors=%d backendErrors=%d', + $row['stat_hit'], + $row['stat_miss'], + $row['stat_stale'], + $row['stat_bypass'], + $row['stat_render_errors'], + $row['stat_backend_errors'] + )); + } $prevOk = $previous === [] ? null : $previous[0]; if ($prevOk !== null && $prevOk !== $row['ok']) { if ($row['ok'] === 1) { diff --git a/packages/vitec/Classes/Service/SsrPurgeService.php b/packages/vitec/Classes/Service/SsrPurgeService.php index e4eac19..c26d643 100644 --- a/packages/vitec/Classes/Service/SsrPurgeService.php +++ b/packages/vitec/Classes/Service/SsrPurgeService.php @@ -42,6 +42,21 @@ final class SsrPurgeService return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? ''; } + /** + * The /__stats sibling of the health endpoint (cumulative hit/miss/ + * error counters, in the node since release 20261109-211544). Derived + * from SSR_HEALTH_ENDPOINT; SSR_STATS_ENDPOINT overrides explicitly. + */ + public function getStatsEndpoint(): string + { + $explicit = $this->getConfig()['SSR_STATS_ENDPOINT'] ?? ''; + if ($explicit !== '') { + return $explicit; + } + $health = $this->getHealthEndpoint(); + return $health === '' ? '' : str_replace('/__health', '/__stats', $health); + } + /** * Comma-separated recipient list for vitec:ssr-health transition and * failover alerts (SSR_ALERT_EMAIL); empty string = alerting off. diff --git a/packages/vitec/Classes/Widgets/SsrErrorsWidget.php b/packages/vitec/Classes/Widgets/SsrErrorsWidget.php new file mode 100644 index 0000000..a668857 --- /dev/null +++ b/packages/vitec/Classes/Widgets/SsrErrorsWidget.php @@ -0,0 +1,159 @@ +latestStatsRow(); + if ($latest === null) { + return '

No /__stats samples yet. They arrive with the next ' + . 'vitec:ssr-health cron run (every 5 minutes) once the node release ' + . 'provides /__stats.

'; + } + + $served = (int)$latest['stat_hit'] + (int)$latest['stat_miss'] + (int)$latest['stat_stale']; + $hitRate = $served > 0 + ? number_format(((int)$latest['stat_hit'] + (int)$latest['stat_stale']) / $served * 100, 1) . ' %' + : '-'; + + [$newRender24h, $newBackend24h] = $this->errorDeltas24h(); + $down24h = $this->downSamples24h(); + + $rows = [ + ['Cache hit rate (since node start)', htmlspecialchars($hitRate) + . ' (hit ' . (int)$latest['stat_hit'] + . ' / stale ' . (int)$latest['stat_stale'] + . ' / miss ' . (int)$latest['stat_miss'] . ')'], + ['Render errors (new in 24 h / total)', $this->errorCell($newRender24h, (int)$latest['stat_render_errors'])], + ['Backend errors (new in 24 h / total)', $this->errorCell($newBackend24h, (int)$latest['stat_backend_errors'])], + ['DOWN samples (24 h)', $down24h['count'] > 0 + ? '' . $down24h['count'] . ' last: ' + . htmlspecialchars($down24h['last']) . '' + : '0'], + ['Sample age', htmlspecialchars($this->age((int)$latest['crdate']))], + ]; + + $html = ''; + foreach ($rows as [$label, $value]) { + $html .= ''; + } + $html .= '
' . $label . '' . $value . '
'; + $html .= '

Totals are cumulative since the node ' + . 'last (re)started; a deploy resets them. Error details: ' + . 'journalctl -u vitec-ssr on the VPS.

'; + return $html; + } + + public function getOptions(): array + { + return []; + } + + private function errorCell(?int $new24h, int $total): string + { + $newBadge = $new24h === null + ? 'n/a' + : ($new24h > 0 + ? '+' . $new24h . '' + : '+0'); + return $newBadge . ' / ' . $total . ' total'; + } + + private function latestStatsRow(): ?array + { + try { + $row = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog') + ->executeQuery( + 'SELECT crdate, stat_hit, stat_miss, stat_stale, stat_render_errors, stat_backend_errors' + . ' FROM tx_vitec_ssrhealthlog WHERE stat_hit >= 0 ORDER BY crdate DESC, uid DESC LIMIT 1' + )->fetchAssociative(); + return $row === false ? null : $row; + } catch (\Throwable) { + return null; // Column missing before database:updateschema - render the empty state. + } + } + + /** + * New errors in the last 24 h = sum of positive deltas between + * consecutive stats samples. Rows with -1 (node down / no stats) are + * skipped; counter resets produce a negative step, clamped to 0. + * + * @return array{0: ?int, 1: ?int} [renderErrors, backendErrors] + */ + private function errorDeltas24h(): array + { + try { + $rows = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog') + ->executeQuery( + 'SELECT stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog' + . ' WHERE crdate > ' . (time() - 86400) . ' AND stat_hit >= 0 ORDER BY crdate ASC, uid ASC' + )->fetchAllAssociative(); + } catch (\Throwable) { + return [null, null]; + } + if (count($rows) < 2) { + return [null, null]; + } + $render = 0; + $backend = 0; + $prev = null; + foreach ($rows as $r) { + if ($prev !== null) { + $render += max(0, (int)$r['stat_render_errors'] - (int)$prev['stat_render_errors']); + $backend += max(0, (int)$r['stat_backend_errors'] - (int)$prev['stat_backend_errors']); + } + $prev = $r; + } + return [$render, $backend]; + } + + /** + * @return array{count: int, last: string} + */ + private function downSamples24h(): array + { + try { + $conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog'); + $count = (int)$conn->executeQuery( + 'SELECT COUNT(*) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . (time() - 86400) . ' AND ok = 0' + )->fetchOne(); + $last = $count > 0 + ? date('d.m. H:i', (int)$conn->executeQuery( + 'SELECT MAX(crdate) FROM tx_vitec_ssrhealthlog WHERE ok = 0' + )->fetchOne()) + : '-'; + return ['count' => $count, 'last' => $last]; + } catch (\Throwable) { + return ['count' => 0, 'last' => '-']; + } + } + + private function age(int $crdate): string + { + $minutes = (int)floor((time() - $crdate) / 60); + return $minutes < 1 ? 'just now' : $minutes . ' min'; + } +} diff --git a/packages/vitec/Configuration/Services.yaml b/packages/vitec/Configuration/Services.yaml index 492c8b9..d71471d 100644 --- a/packages/vitec/Configuration/Services.yaml +++ b/packages/vitec/Configuration/Services.yaml @@ -51,3 +51,14 @@ services: iconIdentifier: 'content-widget-number' height: 'medium' width: 'medium' + + Evomedien\Vitec\Widgets\SsrErrorsWidget: + tags: + - name: dashboard.widget + identifier: vitecSsrErrors + groupNames: systemInfo + title: 'VITEC: SSR errors & hit rate' + description: 'Render/backend error counters and cache hit rate of the SSR node (/__stats, sampled every 5 minutes)' + iconIdentifier: 'content-widget-list' + height: 'medium' + width: 'medium' diff --git a/packages/vitec/ext_tables.sql b/packages/vitec/ext_tables.sql index 3c88a6a..e53e1d7 100755 --- a/packages/vitec/ext_tables.sql +++ b/packages/vitec/ext_tables.sql @@ -490,6 +490,12 @@ CREATE TABLE tx_vitec_ssrhealthlog ( cached_pages int(11) DEFAULT '0' NOT NULL, cache_mb int(11) DEFAULT '0' NOT NULL, response_ms int(11) DEFAULT '0' NOT NULL, + stat_hit int(11) DEFAULT '-1' NOT NULL, + stat_miss int(11) DEFAULT '-1' NOT NULL, + stat_stale int(11) DEFAULT '-1' NOT NULL, + stat_bypass int(11) DEFAULT '-1' NOT NULL, + stat_render_errors int(11) DEFAULT '-1' NOT NULL, + stat_backend_errors int(11) DEFAULT '-1' NOT NULL, PRIMARY KEY (uid), KEY crdate (crdate) );