diff --git a/packages/vitec/Classes/Command/ClientErrorCleanupCommand.php b/packages/vitec/Classes/Command/ClientErrorCleanupCommand.php
new file mode 100644
index 0000000..f8e6f38
--- /dev/null
+++ b/packages/vitec/Classes/Command/ClientErrorCleanupCommand.php
@@ -0,0 +1,53 @@
+addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Keep entries younger than this many days', (string)self::DEFAULT_DAYS);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $days = (int)$input->getOption('days');
+ if ($days < 1) {
+ $output->writeln('--days must be at least 1');
+ return Command::INVALID;
+ }
+ $cutoff = time() - $days * 86400;
+ $conn = $this->connectionPool->getConnectionForTable(self::TABLE);
+ $deleted = $conn->executeStatement('DELETE FROM ' . self::TABLE . ' WHERE crdate < ?', [$cutoff]);
+ $remaining = (int)$conn->executeQuery('SELECT COUNT(*) FROM ' . self::TABLE)->fetchOne();
+ $output->writeln(sprintf(
+ 'deleted %d client error report(s) older than %d days (before %s), %d remaining',
+ $deleted,
+ $days,
+ date('Y-m-d H:i', $cutoff),
+ $remaining
+ ));
+ return Command::SUCCESS;
+ }
+}
diff --git a/packages/vitec/Classes/Controller/Backend/SsrController.php b/packages/vitec/Classes/Controller/Backend/SsrController.php
index 485ba42..3c8ff6c 100644
--- a/packages/vitec/Classes/Controller/Backend/SsrController.php
+++ b/packages/vitec/Classes/Controller/Backend/SsrController.php
@@ -11,6 +11,7 @@ use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Core\Environment;
+use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
@@ -73,10 +74,51 @@ final class SsrController
'healthError' => $healthError,
'purgeLog' => $this->rows('tx_vitec_ssrpurgelog', 30),
'healthLog' => $this->rows('tx_vitec_ssrhealthlog', 10),
+ 'clientErrors' => $this->clientErrors(50),
+ 'clientErrorCount24h' => $this->clientErrorCount24h(),
]);
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.
+ */
+ private function clientErrors(int $limit): array
+ {
+ 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)
+ ->executeQuery()->fetchAllAssociative();
+ } catch (\Throwable) {
+ return [];
+ }
+ foreach ($rows as &$row) {
+ $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');
+ }
+ unset($row);
+ return $rows;
+ }
+
+ 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/Middleware/ClientErrorMiddleware.php b/packages/vitec/Classes/Middleware/ClientErrorMiddleware.php
new file mode 100644
index 0000000..dcc2365
--- /dev/null
+++ b/packages/vitec/Classes/Middleware/ClientErrorMiddleware.php
@@ -0,0 +1,184 @@
+ 204 No Content
+ *
+ * Guards, all answered with a 4xx and no body:
+ * 405 method other than POST
+ * 413 body larger than 8 KB (Content-Length or actual bytes)
+ * 400 not a JSON object, or "message" missing/empty
+ * 429 more than 30 reports per minute from one IP (sliding window
+ * via TYPO3's rate limiter, cache "ratelimiter")
+ *
+ * Entries land in tx_vitec_clienterrorlog with the IP anonymized (IPv4
+ * last octet zeroed, IPv6 cut to /48); the full IP is only the key of
+ * the rate limiter and is never stored. No session, no cookies - a
+ * navigator.sendBeacon() call must get through unchanged. Rows older
+ * than 30 days are removed by vitec:client-error-cleanup.
+ */
+final class ClientErrorMiddleware implements MiddlewareInterface
+{
+ private const PATH = '/api/vitec/client-error';
+ private const MAX_BODY_BYTES = 8192;
+ private const LIMIT_PER_MINUTE = 30;
+ private const TABLE = 'tx_vitec_clienterrorlog';
+
+ public function __construct(
+ private readonly ConnectionPool $connectionPool,
+ private readonly RateLimiterFactory $rateLimiterFactory,
+ ) {}
+
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $path = rtrim($request->getUri()->getPath(), '/');
+ if ($path !== self::PATH) {
+ return $handler->handle($request);
+ }
+
+ $method = strtoupper($request->getMethod());
+ if ($method === 'OPTIONS') {
+ return $this->empty(204)->withHeader('Allow', 'POST, OPTIONS');
+ }
+ if ($method !== 'POST') {
+ return $this->empty(405)->withHeader('Allow', 'POST, OPTIONS');
+ }
+
+ $declared = (int)$request->getHeaderLine('Content-Length');
+ if ($declared > self::MAX_BODY_BYTES) {
+ return $this->empty(413);
+ }
+
+ // Rate limit first, so a flood of oversized or broken bodies is
+ // cheap to refuse as well.
+ $ip = '';
+ try {
+ $normalized = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
+ $ip = (string)$normalized->getRemoteAddress();
+ $limiter = $this->rateLimiterFactory->createLimiter([
+ 'id' => 'vitec-client-error',
+ 'policy' => 'sliding_window',
+ 'limit' => self::LIMIT_PER_MINUTE,
+ 'interval' => '1 minute',
+ ], $ip);
+ $limit = $limiter->consume(1);
+ if (!$limit->isAccepted()) {
+ $retry = max(1, $limit->getRetryAfter()->getTimestamp() - time());
+ return $this->empty(429)->withHeader('Retry-After', (string)$retry);
+ }
+ } catch (\Throwable) {
+ // A broken limiter must not take the endpoint down; the
+ // body limit and the 30-day cleanup still bound the damage.
+ }
+
+ $body = $request->getBody();
+ if ($body->isSeekable()) {
+ $body->rewind();
+ }
+ $raw = $body->read(self::MAX_BODY_BYTES + 1);
+ if (strlen($raw) > self::MAX_BODY_BYTES) {
+ return $this->empty(413);
+ }
+
+ try {
+ $data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
+ } catch (\JsonException) {
+ return $this->empty(400);
+ }
+ if (!\is_array($data) || array_is_list($data)) {
+ return $this->empty(400);
+ }
+ $message = trim($this->str($data['message'] ?? ''));
+ if ($message === '') {
+ return $this->empty(400);
+ }
+
+ $row = [
+ 'pid' => 0,
+ 'crdate' => time(),
+ 'url' => $this->cut($this->str($data['url'] ?? ''), 1024),
+ 'message' => $this->cut($message, 2000),
+ 'stack' => $this->cut($this->str($data['stack'] ?? ''), 6000),
+ 'release' => $this->cut($this->str($data['release'] ?? ''), 100),
+ 'user_agent' => $this->cut($this->str($data['userAgent'] ?? $request->getHeaderLine('User-Agent')), 500),
+ 'ip' => self::anonymizeIp($ip),
+ ];
+ try {
+ $this->connectionPool->getConnectionForTable(self::TABLE)->insert(self::TABLE, $row);
+ } catch (\Throwable) {
+ // Table missing (extension:setup not run yet) or DB hiccup:
+ // the client gets its 204 anyway - a reporting endpoint must
+ // never turn into a second error in the browser console.
+ }
+
+ return $this->empty(204);
+ }
+
+ /**
+ * IPv4: last octet zeroed (192.0.2.0). IPv6: first three groups
+ * kept (2001:db8:1::). Anything else: empty.
+ */
+ public static function anonymizeIp(string $ip): string
+ {
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
+ $parts = explode('.', $ip);
+ $parts[3] = '0';
+ return implode('.', $parts);
+ }
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
+ $packed = inet_pton($ip);
+ if ($packed === false) {
+ return '';
+ }
+ $hex = bin2hex($packed);
+ $groups = [];
+ for ($i = 0; $i < 3; $i++) {
+ $groups[] = ltrim(substr($hex, $i * 4, 4), '0') ?: '0';
+ }
+ return implode(':', $groups) . '::';
+ }
+ return '';
+ }
+
+ private function str(mixed $value): string
+ {
+ if (\is_string($value)) {
+ return $value;
+ }
+ if (\is_int($value) || \is_float($value) || \is_bool($value)) {
+ return (string)$value;
+ }
+ return '';
+ }
+
+ private function cut(string $value, int $max): string
+ {
+ $value = str_replace("\0", '', $value);
+ return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value;
+ }
+
+ private function empty(int $status): ResponseInterface
+ {
+ return (new Response())
+ ->withStatus($status)
+ ->withHeader('Cache-Control', 'no-store');
+ }
+}
diff --git a/packages/vitec/Classes/Widgets/SsrStatsWidget.php b/packages/vitec/Classes/Widgets/SsrStatsWidget.php
index 3957fa0..e8f3f6c 100644
--- a/packages/vitec/Classes/Widgets/SsrStatsWidget.php
+++ b/packages/vitec/Classes/Widgets/SsrStatsWidget.php
@@ -37,6 +37,11 @@ final class SsrStatsWidget implements WidgetInterface
$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
+ // (ClientErrorMiddleware); details in the VITEC SSR module.
+ $clientErrors24h = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d1);
+ $clientErrors7d = $this->count('tx_vitec_clienterrorlog', 'crdate > ' . $d7);
+
$rows = [
['Purges (24 h / 7 days)', $purges24h . ' / ' . $purges7d],
['of which full purges (7 d)', (string)$full7d],
@@ -47,6 +52,9 @@ final class SsrStatsWidget implements WidgetInterface
. ' (' . $ok24h . '/' . $samples24h . ' samples)'],
['Avg. response time (24 h)', $avgMs !== null ? $avgMs . ' ms' : '-'],
['Max. cache size (7 d)', $maxCache !== null ? $maxCache . ' MB' : '-'],
+ ['Client errors reported (24 h / 7 d)', ($clientErrors24h > 0
+ ? '' . $clientErrors24h . ''
+ : '0') . ' / ' . $clientErrors7d],
];
$html = '
';
diff --git a/packages/vitec/Configuration/RequestMiddlewares.php b/packages/vitec/Configuration/RequestMiddlewares.php
index 1a70cbb..c4f989f 100755
--- a/packages/vitec/Configuration/RequestMiddlewares.php
+++ b/packages/vitec/Configuration/RequestMiddlewares.php
@@ -56,6 +56,22 @@ return [
'typo3/cms-core/normalized-params-attribute',
],
],
+ // Client error reports from the React frontend:
+ // POST /api/vitec/client-error -> tx_vitec_clienterrorlog, 204
+ // (0072-FE section 4 / 0077-BE). Answers before site resolution,
+ // no session, rate-limited per IP, 8 KB body limit. Placed after
+ // the /_json strip so the prefixed form reaches it as well.
+ 'vitec/client-error' => [
+ 'target' => \Evomedien\Vitec\Middleware\ClientErrorMiddleware::class,
+ 'after' => [
+ 'typo3/cms-core/normalized-params-attribute',
+ 'vitec/json-prefix-rewrite',
+ ],
+ 'before' => [
+ 'typo3/cms-frontend/site',
+ 'vitec/form-submission',
+ ],
+ ],
// Outer response filter: Cache-Control: no-cache + ETag/304 for
// /_json (tickets 0011-FE / 0055-BE).
'vitec/json-cache-headers' => [
diff --git a/packages/vitec/Resources/Private/Templates/Ssr/Index.html b/packages/vitec/Resources/Private/Templates/Ssr/Index.html
index 8655773..7d25656 100644
--- a/packages/vitec/Resources/Private/Templates/Ssr/Index.html
+++ b/packages/vitec/Resources/Private/Templates/Ssr/Index.html
@@ -140,4 +140,42 @@
+Client errors (last 24 h — {clientErrorCount24h} reports, newest 50 shown)
+
+ Browser-side failures reported by the React frontend via POST /api/vitec/client-error
+ (JS errors, unhandled promise rejections, element error boundaries and "chunk reload" after a deploy).
+ Limited to 30 reports per minute and IP, 8 KB per report; IPs are stored anonymized.
+ Kept for 30 days (cron vitec:client-error-cleanup).
+
+
+ | Time | Release | Message | Page | Browser | IP |
+
+
+
+ | @{row.crdate} |
+ {row.release} |
+
+
+ chunk reload
+ error
+
+ {row.message}
+
+
+ stack
+ {row.stack}
+
+
+ |
+ {row.path} |
+ {row.ua_short} |
+ {row.ip} |
+
+
+
+ | No client errors reported in the last 24 hours. |
+
+
+
+