feat: log de errores para el admin (backend + frontend)

Panel nuevo en Configuracion ("Errores del sistema", solo superadmin): errores de backend
(excepciones 500 no manejadas, con stack/ruta/metodo/usuario) y de frontend (errores JS y
promesas sin catch en el navegador del usuario, antes invisibles para cualquiera mas alla de esa
persona). Filtro por origen, boton para limpiar el historial.

Error Boundary de React: si algo rompe el render, ya no queda pantalla en blanco -- aviso simple
con boton de recargar, y el error queda reportado igual.

Nuevo error_logs (tabla, se recorta sola a 1000 filas) + GET/DELETE /api/error-logs (superadmin)
+ POST /api/error-logs (publico, rate-limited, para reportar incluso sin sesion).

Probado de punta a punta en dev: error real de backend (500) capturado con stack completo y
usuario correcto, error JS global y promesa rechazada capturados desde el navegador, panel
muestra ambos y el boton de limpiar funciona. Se encontro y corrigio un bug real en el camino: la
identificacion de usuario opcional leia jwt.payload.id en vez de .sub (como realmente lo firma
auth/service.js), asi que nunca identificaba a nadie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-17 14:31:40 -05:00
parent 956e78cd76
commit dec8891506
21 changed files with 394 additions and 30 deletions
@@ -0,0 +1,16 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getErrorLogs = asyncHandler(async (req, res) => {
ok(res, await service.listErrors({ source: req.query.source, limit: req.query.limit }));
});
export const postErrorLog = asyncHandler(async (req, res) => {
await service.logFrontendError(req.body, req);
ok(res, { logged: true }, 201);
});
export const deleteErrorLogs = asyncHandler(async (req, res) => {
await service.clearErrors();
ok(res, { cleared: true });
});
@@ -0,0 +1,36 @@
import { pool } from '../../config/db.js';
// Evita crecimiento sin limite (el frontend puede reportar seguido si algo entra en loop de
// errores): al insertar, se recorta a los MAX_ROWS mas recientes.
const MAX_ROWS = 1000;
export async function create({ source, message, stack, url, method, userId, userAgent }) {
await pool.query(
`INSERT INTO error_logs (source, message, stack, url, method, user_id, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[source, message, stack || null, url || null, method || null, userId || null, userAgent || null]
);
await pool.query(
`DELETE FROM error_logs WHERE id NOT IN (SELECT id FROM (SELECT id FROM error_logs ORDER BY created_at DESC LIMIT ?) x)`,
[MAX_ROWS]
);
}
export async function findAll({ source, limit = 200 } = {}) {
const clauses = [];
const params = [];
if (source) { clauses.push('el.source = ?'); params.push(source); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [rows] = await pool.query(
`SELECT el.id, el.source, el.message, el.stack, el.url, el.method,
el.user_id, u.name AS user_name, el.user_agent, el.created_at
FROM error_logs el LEFT JOIN users u ON u.id = el.user_id
${where} ORDER BY el.created_at DESC LIMIT ?`,
[...params, Number(limit)]
);
return rows;
}
export async function clearAll() {
await pool.query('DELETE FROM error_logs');
}
+16
View File
@@ -0,0 +1,16 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
import { getErrorLogs, postErrorLog, deleteErrorLogs } from './controller.js';
const router = Router();
// Publico (sin requireAuth): el frontend debe poder reportar un error incluso en paginas sin
// sesion (login, registro). Rate-limited para que no se pueda usar para floodear la tabla.
const reportLimiter = rateLimit({ windowMs: 5 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false });
router.post('/', reportLimiter, postErrorLog);
router.get('/', requireAuth, requireRole(...CAN_MANAGE_SETTINGS), getErrorLogs);
router.delete('/', requireAuth, requireRole(...CAN_MANAGE_SETTINGS), deleteErrorLogs);
export default router;
+52
View File
@@ -0,0 +1,52 @@
import jwt from 'jsonwebtoken';
import * as repo from './repository.js';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
// Nunca debe tirar: si falla el logueo de un error, no puede convertirse en OTRO error sin
// manejar que rompa la respuesta original. Se traga cualquier fallo propio (solo avisa por consola).
export async function logBackendError(err, req) {
try {
await repo.create({
source: 'backend',
message: (err?.message || String(err)).slice(0, 2000),
stack: (err?.stack || '').slice(0, 5000) || null,
url: req?.originalUrl?.slice(0, 500) || null,
method: req?.method || null,
userId: req?.currentUser?.id || null,
userAgent: (req?.headers?.['user-agent'] || '').slice(0, 255) || null,
});
} catch (logErr) {
console.error('[error_logs] no se pudo guardar el error:', logErr.message);
}
}
// El reporte desde el navegador es publico (debe funcionar incluso en /login, sin sesion) — si
// hay un token valido lo usamos solo para saber quien era, sin exigirlo.
function decodeOptionalUserId(req) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return null;
try {
return jwt.verify(token, env.jwtSecret).sub || null;
} catch {
return null;
}
}
export async function logFrontendError(payload, req) {
const message = (payload.message || '').trim().slice(0, 2000);
if (!message) throw new HttpError(400, 'message es obligatorio');
await repo.create({
source: 'frontend',
message,
stack: (payload.stack || '').slice(0, 5000) || null,
url: (payload.url || '').slice(0, 500) || null,
method: null,
userId: decodeOptionalUserId(req),
userAgent: (req.headers['user-agent'] || '').slice(0, 255) || null,
});
}
export const listErrors = (filters) => repo.findAll(filters);
export const clearErrors = () => repo.clearAll();