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:
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user