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:
@@ -0,0 +1,29 @@
|
|||||||
|
'use strict';
|
||||||
|
/* clientErrorController — приём ошибок из браузера пользователя.
|
||||||
|
Пишем в общий error_log с level='client', чтобы они появились в админ-вкладке «Ошибки».
|
||||||
|
Запись не должна ронять запрос — любые сбои глушим. */
|
||||||
|
const db = require('../db/db');
|
||||||
|
|
||||||
|
const MAX_MSG = 1000, MAX_STACK = 4000, MAX_ROUTE = 400;
|
||||||
|
const clamp = (v, n) => (v == null ? null : String(v).slice(0, n));
|
||||||
|
|
||||||
|
function report(req, res) {
|
||||||
|
const b = req.body || {};
|
||||||
|
const message = (clamp(b.message, MAX_MSG) || '').trim();
|
||||||
|
if (!message) return res.status(400).json({ error: 'message required' });
|
||||||
|
|
||||||
|
const kind = b.kind === 'unhandledrejection' ? 'rejection' : 'error';
|
||||||
|
const route = clamp(b.url || b.route, MAX_ROUTE);
|
||||||
|
let stack = clamp(b.stack, MAX_STACK);
|
||||||
|
// если стека нет — собираем источник:строка:колонка
|
||||||
|
if (!stack && (b.source || b.line)) stack = `${b.source || ''}:${b.line || ''}:${b.col || ''}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO error_log (level, message, stack, route, method, user_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
).run('client', message, stack, route, kind, req.user.id);
|
||||||
|
} catch { /* лог не должен ломать ответ */ }
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { report };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
'use strict';
|
||||||
|
const router = require('express').Router();
|
||||||
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
const rateLimit = require('../middleware/rateLimit');
|
||||||
|
const ctrl = require('../controllers/clientErrorController');
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
// Не больше 20 отчётов в минуту с пользователя — защита от флуда циклящихся ошибок.
|
||||||
|
router.post('/', rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много отчётов об ошибках' }), ctrl.report);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -199,6 +199,7 @@ app.use('/api/materials', require('./routes/materials'));
|
|||||||
app.use('/api/custom-sims', require('./routes/customSims'));
|
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||||
app.use('/api/game', require('./routes/game'));
|
app.use('/api/game', require('./routes/game'));
|
||||||
app.use('/api/wishes', require('./routes/wishes'));
|
app.use('/api/wishes', require('./routes/wishes'));
|
||||||
|
app.use('/api/client-errors', require('./routes/clientErrors'));
|
||||||
app.use('/api/prep', require('./routes/prep'));
|
app.use('/api/prep', require('./routes/prep'));
|
||||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integration: /api/client-errors — приём браузерных ошибок в error_log (level='client').
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { app, inject, getToken, db, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
app.use('/api/client-errors', require('../src/routes/clientErrors'));
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
describe('/api/client-errors', () => {
|
||||||
|
let student;
|
||||||
|
before(async () => { student = await getToken('student'); });
|
||||||
|
|
||||||
|
it('требует авторизацию (401)', async () => {
|
||||||
|
const res = await inject('POST', '/api/client-errors', { message: 'x' }, null);
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустое сообщение → 400', async () => {
|
||||||
|
const res = await inject('POST', '/api/client-errors', { message: ' ' }, student.token);
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пишет ошибку в error_log с level=client', async () => {
|
||||||
|
const res = await inject('POST', '/api/client-errors', {
|
||||||
|
kind: 'error', message: 'TypeError: x is null',
|
||||||
|
stack: 'at foo (app.js:10:5)', source: '/js/app.js', line: 10, col: 5,
|
||||||
|
url: '/lab?sim=demo#x',
|
||||||
|
}, student.token);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.ok, true);
|
||||||
|
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT * FROM error_log WHERE level='client' AND user_id=? ORDER BY id DESC LIMIT 1"
|
||||||
|
).get(student.userId);
|
||||||
|
assert.ok(row, 'строка должна появиться');
|
||||||
|
assert.equal(row.message, 'TypeError: x is null');
|
||||||
|
assert.equal(row.route, '/lab?sim=demo#x');
|
||||||
|
assert.equal(row.method, 'error');
|
||||||
|
assert.match(row.stack, /app\.js:10:5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unhandledrejection → method=rejection, stack из source при отсутствии stack', async () => {
|
||||||
|
const res = await inject('POST', '/api/client-errors', {
|
||||||
|
kind: 'unhandledrejection', message: 'boom', source: '/js/x.js', line: 3, col: 1, url: '/dashboard',
|
||||||
|
}, student.token);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT * FROM error_log WHERE level='client' AND message='boom' ORDER BY id DESC LIMIT 1"
|
||||||
|
).get();
|
||||||
|
assert.equal(row.method, 'rejection');
|
||||||
|
assert.match(row.stack, /x\.js:3:1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('длинные поля обрезаются (не падает)', async () => {
|
||||||
|
const res = await inject('POST', '/api/client-errors', {
|
||||||
|
message: 'M'.repeat(5000), stack: 'S'.repeat(20000), url: 'U'.repeat(2000),
|
||||||
|
}, student.token);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const row = db.prepare("SELECT * FROM error_log WHERE level='client' ORDER BY id DESC LIMIT 1").get();
|
||||||
|
assert.ok(row.message.length <= 1000);
|
||||||
|
assert.ok(row.stack.length <= 4000);
|
||||||
|
assert.ok(row.route.length <= 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -284,9 +284,15 @@
|
|||||||
el.innerHTML = rows.map(r => {
|
el.innerHTML = rows.map(r => {
|
||||||
const dt = new Date(r.created_at);
|
const dt = new Date(r.created_at);
|
||||||
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
|
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
|
||||||
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid var(--pink)">
|
const isClient = r.level === 'client';
|
||||||
|
const accent = isClient ? 'var(--violet)' : 'var(--pink)';
|
||||||
|
const badge = isClient
|
||||||
|
? `<span style="font-size:0.64rem;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:rgba(155,93,229,0.12);color:var(--violet)">БРАУЗЕР</span>`
|
||||||
|
: '';
|
||||||
|
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid ${accent}">
|
||||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
|
||||||
<span style="font-size:0.78rem;color:var(--pink);font-weight:700">${r.method || ''} ${esc(r.route || '')}</span>
|
${badge}
|
||||||
|
<span style="font-size:0.78rem;color:${accent};font-weight:700">${esc(r.method || '')} ${esc(r.route || '')}</span>
|
||||||
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
|
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
|
||||||
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
|
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user