Last active
April 21, 2026 19:58
-
-
Save npezarro/020617d8f27c31c64e9b2f9fcd561a70 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Browser Logs | |
| // @namespace https://github.com/npezarro/scripts | |
| // @version 1.5 | |
| // @description Lightweight read-only browser observer. Sends page state, console logs, and navigation events to a relay for CLI querying. No page interaction, no freezing. | |
| // @author npezarro | |
| // @match *://*/* | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_registerMenuCommand | |
| // @grant unsafeWindow | |
| // @connect pezant.ca | |
| // @connect localhost | |
| // @run-at document-start | |
| // @updateURL https://gist.githubusercontent.com/npezarro/020617d8f27c31c64e9b2f9fcd561a70/raw/browser-logs.user.js | |
| // @downloadURL https://gist.githubusercontent.com/npezarro/020617d8f27c31c64e9b2f9fcd561a70/raw/browser-logs.user.js | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // Skip iframes | |
| if (window.self !== window.top) return; | |
| // --- Config --- | |
| const API_BASE = GM_getValue('browserLogsApi', 'https://pezant.ca/api/browser-logs'); | |
| const SEND_INTERVAL_MS = 10000; | |
| const MAX_CONSOLE_BUFFER = 50; | |
| const MAX_ERROR_BUFFER = 20; | |
| // --- Device fingerprint (auto-detected, not persisted — identifies the machine) --- | |
| function detectDeviceInfo() { | |
| const ua = navigator.userAgent; | |
| const uad = navigator.userAgentData; | |
| // OS detection | |
| let os = 'unknown'; | |
| if (uad?.platform) { | |
| os = uad.platform.toLowerCase(); | |
| } else if (/Windows/.test(ua)) { | |
| os = 'windows'; | |
| } else if (/Macintosh|Mac OS/.test(ua)) { | |
| os = 'macos'; | |
| } else if (/Android/.test(ua)) { | |
| os = 'android'; | |
| } else if (/iPhone|iPad/.test(ua)) { | |
| os = 'ios'; | |
| } else if (/Linux/.test(ua)) { | |
| os = 'linux'; | |
| } else if (/CrOS/.test(ua)) { | |
| os = 'chromeos'; | |
| } | |
| // Browser detection | |
| let browser = 'unknown'; | |
| if (/Edg\//.test(ua)) browser = 'edge'; | |
| else if (/Firefox\//.test(ua)) browser = 'firefox'; | |
| else if (/Chrome\//.test(ua)) browser = 'chrome'; | |
| else if (/Safari\//.test(ua) && !/Chrome/.test(ua)) browser = 'safari'; | |
| // Device type | |
| const mobile = uad?.mobile ?? /Mobi|Android|iPhone|iPad/i.test(ua); | |
| const type = mobile ? 'mobile' : 'desktop'; | |
| // Screen signature (helps distinguish same-OS machines) | |
| const screen = `${window.screen.width}x${window.screen.height}`; | |
| return { os, browser, type, screen, ua: ua.slice(0, 200) }; | |
| } | |
| // --- Device ID: prompt on first run, persist across sessions --- | |
| let deviceId = GM_getValue('browserLogsDeviceId', ''); | |
| const deviceInfo = detectDeviceInfo(); | |
| if (!deviceId) { | |
| // Build a suggestion from detected info | |
| const suggestion = `${deviceInfo.type}-${deviceInfo.os}-${deviceInfo.browser}`; | |
| // Defer prompt until DOM is interactive so it doesn't block page load | |
| const promptForName = () => { | |
| const name = prompt( | |
| `[Browser Logs] Name this device for tracking.\n` + | |
| `Detected: ${deviceInfo.type}, ${deviceInfo.os}, ${deviceInfo.browser}, ${deviceInfo.screen}\n\n` + | |
| `Examples: "home-pc", "work-mac", "phone", "laptop"`, | |
| suggestion | |
| ); | |
| deviceId = (name || suggestion).trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'); | |
| GM_setValue('browserLogsDeviceId', deviceId); | |
| }; | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', promptForName, { once: true }); | |
| } else { | |
| promptForName(); | |
| } | |
| } | |
| // --- State --- | |
| const consoleBuffer = []; | |
| const errorBuffer = []; | |
| let lastUrl = location.href; | |
| let lastTitle = ''; | |
| let tabId = null; | |
| // --- Inject console patcher into the REAL page context via <script> tag --- | |
| // Perf: buffers raw args in a plain array in page context, NO JSON.stringify on hot path. | |
| // The TM script drains the buffer on a 2s interval instead of per-call CustomEvent dispatch. | |
| const patchScript = document.createElement('script'); | |
| patchScript.textContent = `(function() { | |
| var orig = { | |
| log: console.log.bind(console), | |
| warn: console.warn.bind(console), | |
| error: console.error.bind(console), | |
| info: console.info.bind(console) | |
| }; | |
| window.__browserLogsOrig = orig; | |
| window.__browserLogQueue = []; | |
| var MAX_QUEUE = 100; | |
| function enqueue(level, args) { | |
| var q = window.__browserLogQueue; | |
| if (q.length >= MAX_QUEUE) return; | |
| try { | |
| var parts = []; | |
| for (var i = 0; i < args.length && i < 5; i++) { | |
| try { | |
| parts.push(typeof args[i] === 'string' ? args[i].slice(0, 300) : typeof args[i] === 'object' ? '[object]' : String(args[i]).slice(0, 300)); | |
| } catch(e) { | |
| parts.push('[unserializable]'); | |
| } | |
| } | |
| q.push({ level: level, message: parts.join(' '), ts: Date.now() }); | |
| } catch(e) {} | |
| } | |
| console.log = function() { enqueue('log', arguments); return orig.log.apply(console, arguments); }; | |
| console.warn = function() { enqueue('warn', arguments); return orig.warn.apply(console, arguments); }; | |
| console.error = function() { enqueue('error', arguments); return orig.error.apply(console, arguments); }; | |
| console.info = function() { enqueue('info', arguments); return orig.info.apply(console, arguments); }; | |
| })();`; | |
| (document.head || document.documentElement).prepend(patchScript); | |
| patchScript.remove(); | |
| // --- Drain console log queue from page context on interval (not per-call) --- | |
| setInterval(() => { | |
| if (document.visibilityState === 'hidden') return; | |
| try { | |
| const queue = unsafeWindow.__browserLogQueue; | |
| if (!queue || queue.length === 0) return; | |
| const items = queue.splice(0, 50); | |
| for (const item of items) { | |
| consoleBuffer.push(item); | |
| if (consoleBuffer.length > MAX_CONSOLE_BUFFER) consoleBuffer.shift(); | |
| } | |
| } catch {} | |
| }, 2000); | |
| // --- Capture uncaught errors --- | |
| window.addEventListener('error', (e) => { | |
| errorBuffer.push({ | |
| message: e.message, | |
| source: e.filename, | |
| line: e.lineno, | |
| col: e.colno, | |
| ts: Date.now(), | |
| }); | |
| if (errorBuffer.length > MAX_ERROR_BUFFER) errorBuffer.shift(); | |
| }); | |
| window.addEventListener('unhandledrejection', (e) => { | |
| errorBuffer.push({ | |
| message: `Unhandled rejection: ${e.reason}`, | |
| ts: Date.now(), | |
| }); | |
| if (errorBuffer.length > MAX_ERROR_BUFFER) errorBuffer.shift(); | |
| }); | |
| // --- Init session state (deferred until DOM is available) --- | |
| function initSession() { | |
| tabId = sessionStorage.getItem('browserLogsTabId'); | |
| if (!tabId) { | |
| tabId = 'tab_' + Math.random().toString(36).slice(2, 10); | |
| sessionStorage.setItem('browserLogsTabId', tabId); | |
| } | |
| lastTitle = document.title; | |
| } | |
| // --- Build page snapshot --- | |
| function getSnapshot() { | |
| return { | |
| tabId, | |
| deviceId, | |
| deviceInfo, | |
| url: location.href, | |
| title: document.title, | |
| hostname: location.hostname, | |
| timestamp: new Date().toISOString(), | |
| viewport: { | |
| scrollY: Math.round(window.scrollY), | |
| scrollHeight: document.documentElement.scrollHeight, | |
| innerHeight: window.innerHeight, | |
| }, | |
| consoleLogs: consoleBuffer.slice(-20), | |
| errors: errorBuffer.slice(-10), | |
| meta: { | |
| hasFocus: document.hasFocus(), | |
| visibilityState: document.visibilityState, | |
| readyState: document.readyState, | |
| }, | |
| }; | |
| } | |
| // --- Send to relay --- | |
| function sendHeartbeat() { | |
| if (!tabId) return; | |
| if (document.visibilityState === 'hidden') return; | |
| const snapshot = getSnapshot(); | |
| try { | |
| GM_xmlhttpRequest({ | |
| method: 'POST', | |
| url: API_BASE + '/heartbeat', | |
| headers: { 'Content-Type': 'application/json' }, | |
| data: JSON.stringify(snapshot), | |
| timeout: 5000, | |
| onload: () => {}, | |
| onerror: () => {}, | |
| ontimeout: () => {}, | |
| }); | |
| } catch { | |
| // silently fail | |
| } | |
| } | |
| // --- Navigation change detection (SPA support) --- | |
| function checkNavigation() { | |
| if (location.href !== lastUrl || document.title !== lastTitle) { | |
| const from = lastUrl; | |
| lastUrl = location.href; | |
| lastTitle = document.title; | |
| consoleBuffer.push({ | |
| level: 'info', | |
| message: `[BrowserLogs] Navigation: ${from} → ${location.href}`, | |
| ts: Date.now(), | |
| }); | |
| } | |
| } | |
| // --- Start heartbeat loop once DOM is ready --- | |
| function start() { | |
| initSession(); | |
| console.log('[BrowserLogs] v1.5 active |', deviceId, '|', deviceInfo.os, deviceInfo.browser, deviceInfo.screen, '→', API_BASE); | |
| setInterval(() => { | |
| checkNavigation(); | |
| sendHeartbeat(); | |
| }, SEND_INTERVAL_MS); | |
| setTimeout(sendHeartbeat, 2000); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', start); | |
| } else { | |
| start(); | |
| } | |
| // --- Menu commands --- | |
| GM_registerMenuCommand('Browser Logs: Set API URL', () => { | |
| const current = GM_getValue('browserLogsApi', API_BASE); | |
| const newUrl = prompt('Browser Logs API base URL:', current); | |
| if (newUrl) { | |
| GM_setValue('browserLogsApi', newUrl); | |
| location.reload(); | |
| } | |
| }); | |
| GM_registerMenuCommand('Browser Logs: Rename Device', () => { | |
| const current = GM_getValue('browserLogsDeviceId', deviceId); | |
| const newName = prompt( | |
| `Current: "${current}"\nDetected: ${deviceInfo.os}, ${deviceInfo.browser}, ${deviceInfo.screen}\n\nNew device name:`, | |
| current | |
| ); | |
| if (newName) { | |
| const clean = newName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'); | |
| GM_setValue('browserLogsDeviceId', clean); | |
| deviceId = clean; | |
| alert(`Device renamed to "${clean}". Will take effect on next heartbeat.`); | |
| } | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment