feat(errors): сбор клиентских (браузерных) ошибок в админ-вкладку «Ошибки»

Глобальный репортер в api.js (грузится на всех страницах) ловит необработанные JS-ошибки
(window 'error') и rejected-промисы ('unhandledrejection') в браузере пользователя и шлёт
в POST /api/client-errors. Дедуп по сигнатуре + лимит 15/страницу, только для залогиненных,
keepalive, не флудит и сам не падает.

Бэкенд: routes/clientErrors (auth + rate-limit 20/мин на юзера) → clientErrorController
пишет в общий error_log с level='client' (message/stack/route=url/method=kind/user_id),
поля обрезаются. Появляются в существующей админ-вкладке «Ошибки» с бейджем «БРАУЗЕР»
(фиолетовый акцент vs розовый у серверных). Тест client-errors.test.js 5/5.

lint:routes 0; node --check всех файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 23:17:04 +03:00
parent db1db68488
commit 43df41287f
6 changed files with 168 additions and 2 deletions
+52
View File
@@ -1959,3 +1959,55 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
});
});
})();
/* ── Глобальный репортер клиентских ошибок ───────────────────────────────
Ловит необработанные JS-ошибки и rejected-промисы в браузере пользователя
и шлёт в /api/client-errors → они появляются в админ-вкладке «Ошибки».
Дедуп + лимит на загрузку страницы (не флудим), только для залогиненных. */
(function initClientErrorReporter() {
const seen = new Set();
let sent = 0; const MAX_PER_PAGE = 15;
let inFlight = false;
function send(payload) {
try {
if (!isLoggedIn()) return; // отчёты только от залогиненных
if (sent >= MAX_PER_PAGE) return; // не флудим повторами
const sig = (payload.message || '') + '|' + (payload.source || '') + ':' + (payload.line || '');
if (seen.has(sig)) return;
seen.add(sig); sent++;
if (inFlight) return;
inFlight = true;
const token = getToken();
fetch(API + '/client-errors', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, token ? { Authorization: 'Bearer ' + token } : {}),
body: JSON.stringify(payload),
keepalive: true, // долетит даже при закрытии вкладки
}).catch(function () {}).finally(function () { inFlight = false; });
} catch (e) { inFlight = false; /* репортер не должен сам падать */ }
}
window.addEventListener('error', function (e) {
// Пропускаем ошибки загрузки ресурсов (img/script) — у них нет message/error.
if (!e || (!e.message && !e.error)) return;
send({
kind: 'error',
message: e.message || (e.error && (e.error.message || String(e.error))) || 'Script error',
stack: e.error && e.error.stack ? String(e.error.stack) : null,
source: e.filename || null, line: e.lineno || null, col: e.colno || null,
url: location.pathname + location.search + location.hash,
});
});
window.addEventListener('unhandledrejection', function (e) {
const r = e && e.reason;
let msg = 'Unhandled promise rejection';
let stack = null;
if (r) {
if (typeof r === 'string') msg = r;
else { msg = r.message || (r.toString && r.toString()) || msg; stack = r.stack ? String(r.stack) : null; }
}
send({ kind: 'unhandledrejection', message: msg, stack: stack, url: location.pathname + location.search + location.hash });
});
})();