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:
@@ -21,6 +21,7 @@ import companyRoutes from './modules/company/routes.js';
|
||||
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
|
||||
import searchRoutes from './modules/search/routes.js';
|
||||
import updaterRoutes from './modules/updater/routes.js';
|
||||
import errorLogsRoutes from './modules/errorLogs/routes.js';
|
||||
|
||||
export const app = express();
|
||||
|
||||
@@ -46,6 +47,7 @@ app.use('/api/company', companyRoutes);
|
||||
app.use('/api/recurring-tasks', recurringTasksRoutes);
|
||||
app.use('/api/search', searchRoutes);
|
||||
app.use('/api/updater', updaterRoutes);
|
||||
app.use('/api/error-logs', errorLogsRoutes);
|
||||
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HttpError } from '../utils/http.js';
|
||||
import { logBackendError } from '../modules/errorLogs/service.js';
|
||||
|
||||
export function notFoundHandler(req, res) {
|
||||
res.status(404).json({ success: false, data: null, error: 'Ruta no encontrada' });
|
||||
@@ -9,6 +10,9 @@ export function errorHandler(err, req, res, next) { // eslint-disable-line no-un
|
||||
return res.status(err.status).json({ success: false, data: null, error: err.message });
|
||||
}
|
||||
console.error('[error]', err);
|
||||
// Fire-and-forget: logBackendError nunca tira (se traga sus propios fallos), no bloquea la
|
||||
// respuesta al cliente.
|
||||
logBackendError(err, req);
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
|
||||
@@ -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