/** * remoteDevlog.js — клиент удалённого dev-логгера. * * Перехватывает: console.error/warn, window.onerror, unhandledrejection, * все fetch/XHR ошибки и не-2xx ответы; батчит и шлёт на бэкенд. * * Запускается только в localhost (dev), на проде no-op. */ const IS_DEV = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); const ENDPOINT = '/api-rbxl/devlog'; const FLUSH_INTERVAL_MS = 1500; const MAX_BATCH = 50; const queue = []; let flushTimer = null; function push(ev) { if (!IS_DEV) return; ev.ts = Date.now(); ev.page = location.pathname + location.search; queue.push(ev); if (queue.length >= MAX_BATCH) flush(); else scheduleFlush(); } function scheduleFlush() { if (flushTimer) return; flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS); } function flush() { flushTimer = null; if (queue.length === 0) return; const events = queue.splice(0, MAX_BATCH); try { const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' }); // sendBeacon — не блокирует, переживёт unload if (navigator.sendBeacon) { navigator.sendBeacon(ENDPOINT, blob); } else { fetch(ENDPOINT, { method: 'POST', body: blob, headers: { 'Content-Type': 'application/json' }, keepalive: true }) .catch(() => {}); } } catch (e) { /* swallow */ } } function truncate(s, max = 4000) { if (typeof s !== 'string') { try { s = JSON.stringify(s); } catch { s = String(s); } } return s.length > max ? s.slice(0, max) + '...[truncated]' : s; } export function installRemoteDevlog() { if (!IS_DEV) return; // 1. console.error / console.warn (но НЕ console.log — слишком шумно) const origError = console.error.bind(console); const origWarn = console.warn.bind(console); console.error = (...args) => { try { push({ kind: 'console.error', message: truncate(args.map(formatArg).join(' ')) }); } catch {} origError(...args); }; console.warn = (...args) => { try { push({ kind: 'console.warn', message: truncate(args.map(formatArg).join(' ')) }); } catch {} origWarn(...args); }; // 2. window.onerror window.addEventListener('error', (ev) => { push({ kind: 'window.error', message: ev.message, filename: ev.filename, lineno: ev.lineno, colno: ev.colno, stack: ev.error?.stack ? truncate(ev.error.stack) : null, }); }); // 3. Unhandled promise rejection window.addEventListener('unhandledrejection', (ev) => { const reason = ev.reason; push({ kind: 'unhandledrejection', message: truncate(reason?.message || String(reason)), stack: reason?.stack ? truncate(reason.stack) : null, }); }); // 4. fetch wrapper — логируем все не-2xx и failed const origFetch = window.fetch.bind(window); window.fetch = async (input, init) => { const url = typeof input === 'string' ? input : input?.url || ''; const method = (init?.method || 'GET').toUpperCase(); const t0 = performance.now(); try { const resp = await origFetch(input, init); if (!resp.ok) { let body = ''; try { const cloned = resp.clone(); body = truncate(await cloned.text(), 2000); } catch {} push({ kind: 'fetch.bad', url, method, status: resp.status, duration_ms: Math.round(performance.now() - t0), body, }); } return resp; } catch (e) { push({ kind: 'fetch.fail', url, method, duration_ms: Math.round(performance.now() - t0), message: e?.message || String(e), }); throw e; } }; // 5. XHR wrapper — для axios и т.п. const XhrOpen = XMLHttpRequest.prototype.open; const XhrSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__rdl = { method, url, t0: performance.now() }; return XhrOpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.send = function (body) { this.addEventListener('loadend', () => { const meta = this.__rdl || {}; const status = this.status; if (status === 0 || status >= 400) { push({ kind: 'xhr.bad', url: meta.url, method: meta.method, status, duration_ms: Math.round(performance.now() - (meta.t0 || performance.now())), body: truncate(this.responseText || '', 2000), }); } }); return XhrSend.call(this, body); }; // Периодически флушим (для долгоживущих логов) setInterval(() => { if (queue.length) flush(); }, 5000); window.addEventListener('beforeunload', flush); // Стартовая отметка чтобы в логе было видно начало сессии push({ kind: 'session.start', ua: navigator.userAgent }); } function formatArg(a) { if (a == null) return String(a); if (typeof a === 'string') return a; if (a instanceof Error) return a.message + (a.stack ? '\n' + a.stack : ''); try { return JSON.stringify(a); } catch { return String(a); } }