/** * Browser Logger - Captures all browser-side activity * * Records: * - Console logs (log, warn, error, info, debug) * - Uncaught errors and exceptions * - Network requests (via fetch/XMLHttpRequest) * - Performance metrics * - Memory usage * - User interactions * * Can be exported to server or retrieved from sessionStorage */ class BrowserLogger { constructor(maxEntries = 1000) { this.maxEntries = maxEntries; this.entries = []; this.startTime = Date.now(); this.sessionId = this.generateSessionId(); this.lastSyncedIndex = 0; // Track which logs have been sent to server this.autoSyncInterval = 30000; // 30 seconds this.apiEndpoint = '/api/browser-logs'; this.lastUrl = window.location.href; // Track URL for navigation detection // Storage key for persistence across page reloads this.storageKey = `dss-browser-logs-${this.sessionId}`; // Core Web Vitals tracking this.lcp = null; // Largest Contentful Paint this.cls = 0; // Cumulative Layout Shift this.axeLoadingPromise = null; // Promise for axe-core script loading // Try to load existing logs this.loadFromStorage(); // Start capturing this.captureConsole(); this.captureErrors(); this.captureNetworkActivity(); this.capturePerformance(); this.captureMemory(); this.captureWebVitals(); // Initialize Shadow State capture this.setupSnapshotCapture(); // Start auto-sync to server this.startAutoSync(); } /** * Generate unique session ID */ generateSessionId() { return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Add log entry */ log(level, category, message, data = {}) { const entry = { timestamp: Date.now(), relativeTime: Date.now() - this.startTime, level, category, message, data, url: window.location.href, userAgent: navigator.userAgent, }; this.entries.push(entry); // Keep size manageable if (this.entries.length > this.maxEntries) { this.entries.shift(); } // Persist to storage this.saveToStorage(); return entry; } /** * Capture console methods */ captureConsole() { const originalLog = console.log; const originalError = console.error; const originalWarn = console.warn; const originalInfo = console.info; const originalDebug = console.debug; console.log = (...args) => { this.log('log', 'console', args.join(' '), { args }); originalLog.apply(console, args); }; console.error = (...args) => { this.log('error', 'console', args.join(' '), { args }); originalError.apply(console, args); }; console.warn = (...args) => { this.log('warn', 'console', args.join(' '), { args }); originalWarn.apply(console, args); }; console.info = (...args) => { this.log('info', 'console', args.join(' '), { args }); originalInfo.apply(console, args); }; console.debug = (...args) => { this.log('debug', 'console', args.join(' '), { args }); originalDebug.apply(console, args); }; } /** * Capture uncaught errors */ captureErrors() { // Unhandled promise rejections window.addEventListener('unhandledrejection', (event) => { this.log('error', 'unhandledRejection', event.reason?.message || String(event.reason), { reason: event.reason, stack: event.reason?.stack, }); }); // Global error handler window.addEventListener('error', (event) => { this.log('error', 'uncaughtError', event.message, { filename: event.filename, lineno: event.lineno, colno: event.colno, stack: event.error?.stack, }); }); } /** * Capture network activity using PerformanceObserver * This is non-invasive and doesn't monkey-patch fetch or XMLHttpRequest */ captureNetworkActivity() { // Use PerformanceObserver to monitor network requests (modern approach) if ('PerformanceObserver' in window) { try { const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { // resource entries are generated automatically for fetch/xhr if (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') { const method = entry.name.split('?')[0]; // Extract method from name if available this.log('network', entry.initiatorType, `${entry.initiatorType.toUpperCase()} ${entry.name}`, { url: entry.name, initiatorType: entry.initiatorType, duration: entry.duration, transferSize: entry.transferSize, encodedBodySize: entry.encodedBodySize, decodedBodySize: entry.decodedBodySize, }); } } }); // Observe resource entries (includes fetch/xhr) observer.observe({ entryTypes: ['resource'] }); } catch (e) { // PerformanceObserver might not support resource entries in some browsers // Gracefully degrade - network logging simply won't work } } } /** * Capture performance metrics */ capturePerformance() { // Wait for page load window.addEventListener('load', () => { setTimeout(() => { try { const perfData = window.performance.getEntriesByType('navigation')[0]; if (perfData) { this.log('metric', 'performance', 'Page load completed', { domContentLoaded: perfData.domContentLoadedEventEnd - perfData.domContentLoadedEventStart, loadComplete: perfData.loadEventEnd - perfData.loadEventStart, totalTime: perfData.loadEventEnd - perfData.fetchStart, dnsLookup: perfData.domainLookupEnd - perfData.domainLookupStart, tcpConnection: perfData.connectEnd - perfData.connectStart, requestTime: perfData.responseStart - perfData.requestStart, responseTime: perfData.responseEnd - perfData.responseStart, renderTime: perfData.domInteractive - perfData.domLoading, }); } } catch (e) { // Performance API might not be available } }, 0); }); // Monitor long tasks if ('PerformanceObserver' in window) { try { const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.duration > 50) { // Log tasks that take >50ms this.log('metric', 'longTask', 'Long task detected', { name: entry.name, duration: entry.duration, startTime: entry.startTime, }); } } }); observer.observe({ entryTypes: ['longtask'] }); } catch (e) { // Long task API might not be available } } } /** * Capture memory usage */ captureMemory() { if ('memory' in performance) { // Check memory every 10 seconds setInterval(() => { const memory = performance.memory; const usagePercent = (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100; if (usagePercent > 80) { this.log('warn', 'memory', 'High memory usage detected', { usedJSHeapSize: memory.usedJSHeapSize, jsHeapSizeLimit: memory.jsHeapSizeLimit, usagePercent: usagePercent.toFixed(2), }); } }, 10000); } } /** * Capture Core Web Vitals (LCP, CLS) using PerformanceObserver * These observers run in the background to collect metrics as they occur. */ captureWebVitals() { try { // Capture Largest Contentful Paint (LCP) const lcpObserver = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); if (entries.length > 0) { // The last entry is the most recent LCP candidate this.lcp = entries[entries.length - 1].startTime; } }); lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }); // Capture Cumulative Layout Shift (CLS) const clsObserver = new PerformanceObserver((entryList) => { for (const entry of entryList.getEntries()) { // Only count shifts that were not caused by recent user input. if (!entry.hadRecentInput) { this.cls += entry.value; } } }); clsObserver.observe({ type: 'layout-shift', buffered: true }); } catch (e) { this.log('warn', 'performance', 'Could not initialize Web Vitals observers.', { error: e.message }); } } /** * Get Core Web Vitals and other key performance metrics. * Retrieves metrics collected by observers or from the Performance API. * @returns {object} An object containing the collected metrics. */ getCoreWebVitals() { try { const navEntry = window.performance.getEntriesByType('navigation')[0]; const paintEntries = window.performance.getEntriesByType('paint'); const fcpEntry = paintEntries.find(e => e.name === 'first-contentful-paint'); const ttfb = navEntry ? navEntry.responseStart - navEntry.requestStart : null; return { ttfb: ttfb, fcp: fcpEntry ? fcpEntry.startTime : null, lcp: this.lcp, cls: this.cls, }; } catch (e) { return { error: 'Failed to retrieve Web Vitals.' }; } } /** * Dynamically injects and runs an axe-core accessibility audit. * @returns {Promise} A promise that resolves with the axe audit results. */ async runAxeAudit() { // Check if axe is already available if (typeof window.axe === 'undefined') { // If not, and we are not already loading it, inject it if (!this.axeLoadingPromise) { this.axeLoadingPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.8.4/axe.min.js'; script.onload = () => { this.log('info', 'accessibility', 'axe-core loaded successfully.'); resolve(); }; script.onerror = () => { this.log('error', 'accessibility', 'Failed to load axe-core script.'); this.axeLoadingPromise = null; // Allow retry reject(new Error('Failed to load axe-core.')); }; document.head.appendChild(script); }); } await this.axeLoadingPromise; } try { // Configure axe to run on the entire document const results = await window.axe.run(document.body); this.log('metric', 'accessibility', 'Accessibility audit completed.', { violations: results.violations.length, passes: results.passes.length, incomplete: results.incomplete.length, results, // Store full results }); return results; } catch (error) { this.log('error', 'accessibility', 'Error running axe audit.', { error: error.message }); return null; } } /** * Captures a comprehensive snapshot including DOM, accessibility, and performance data. * @returns {Promise} */ async captureAccessibilitySnapshot() { const domSnapshot = await this.captureDOMSnapshot(); const accessibility = await this.runAxeAudit(); const performance = this.getCoreWebVitals(); this.log('metric', 'accessibilitySnapshot', 'Full accessibility snapshot captured.', { snapshot: domSnapshot, accessibility, performance, }); return { snapshot: domSnapshot, accessibility, performance }; } /** * Save logs to sessionStorage */ saveToStorage() { try { const data = { sessionId: this.sessionId, entries: this.entries, savedAt: Date.now(), }; sessionStorage.setItem(this.storageKey, JSON.stringify(data)); } catch (e) { // Storage might be full or unavailable } } /** * Load logs from sessionStorage */ loadFromStorage() { try { const data = sessionStorage.getItem(this.storageKey); if (data) { const parsed = JSON.parse(data); this.entries = parsed.entries || []; } } catch (e) { // Storage might be unavailable } } /** * Get all logs */ getLogs(options = {}) { let entries = [...this.entries]; // Filter by level if (options.level) { entries = entries.filter(e => e.level === options.level); } // Filter by category if (options.category) { entries = entries.filter(e => e.category === options.category); } // Filter by time range if (options.minTime) { entries = entries.filter(e => e.timestamp >= options.minTime); } if (options.maxTime) { entries = entries.filter(e => e.timestamp <= options.maxTime); } // Search in message if (options.search) { const searchLower = options.search.toLowerCase(); entries = entries.filter(e => e.message.toLowerCase().includes(searchLower) || JSON.stringify(e.data).toLowerCase().includes(searchLower) ); } // Limit results const limit = options.limit || 100; if (options.reverse) { entries.reverse(); } return entries.slice(-limit); } /** * Get errors only */ getErrors() { return this.getLogs({ level: 'error', limit: 50, reverse: true }); } /** * Get network requests */ getNetworkRequests() { return this.getLogs({ category: 'fetch', limit: 100, reverse: true }); } /** * Get metrics */ getMetrics() { return this.getLogs({ category: 'metric', limit: 100, reverse: true }); } /** * Get diagnostic summary */ getDiagnostic() { return { sessionId: this.sessionId, uptime: Date.now() - this.startTime, totalLogs: this.entries.length, errorCount: this.entries.filter(e => e.level === 'error').length, warnCount: this.entries.filter(e => e.level === 'warn').length, networkRequests: this.entries.filter(e => e.category === 'fetch').length, memory: performance.memory ? { usedJSHeapSize: performance.memory.usedJSHeapSize, jsHeapSizeLimit: performance.memory.jsHeapSizeLimit, usagePercent: ((performance.memory.usedJSHeapSize / performance.memory.jsHeapSizeLimit) * 100).toFixed(2), } : null, url: window.location.href, userAgent: navigator.userAgent, recentErrors: this.getErrors().slice(0, 5), recentNetworkRequests: this.getNetworkRequests().slice(0, 5), }; } /** * Export logs as JSON */ exportJSON() { return { sessionId: this.sessionId, exportedAt: new Date().toISOString(), logs: this.entries, diagnostic: this.getDiagnostic(), }; } /** * Print formatted logs to console */ printFormatted(options = {}) { const logs = this.getLogs(options); console.group(`📋 Browser Logs (${logs.length} entries)`); console.table(logs.map(e => ({ Time: new Date(e.timestamp).toLocaleTimeString(), Level: e.level.toUpperCase(), Category: e.category, Message: e.message, }))); console.groupEnd(); } /** * Clear logs */ clear() { this.entries = []; this.lastSyncedIndex = 0; this.saveToStorage(); } /** * Start auto-sync to server */ startAutoSync() { // Sync immediately on startup (after a delay to let the page load) setTimeout(() => this.syncToServer(), 5000); // Then sync every 30 seconds this.syncTimer = setInterval(() => this.syncToServer(), this.autoSyncInterval); // Sync before page unload window.addEventListener('beforeunload', () => this.syncToServer()); } /** * Sync logs to server */ async syncToServer() { // Only sync if there are new logs if (this.lastSyncedIndex >= this.entries.length) { return; } try { const data = this.exportJSON(); const response = await fetch(this.apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); if (response.ok) { this.lastSyncedIndex = this.entries.length; console.debug(`[BrowserLogger] Synced ${this.entries.length} logs to server`); } else { console.warn(`[BrowserLogger] Failed to sync logs: ${response.statusText}`); } } catch (error) { console.warn('[BrowserLogger] Failed to sync logs:', error.message); } } /** * Stop auto-sync */ stopAutoSync() { if (this.syncTimer) { clearInterval(this.syncTimer); this.syncTimer = null; } } /** * Capture DOM Snapshot (Shadow State) * Returns the current state of the DOM and viewport for remote debugging. * Can optionally include accessibility and performance data. * @param {object} [options={}] - Options for the snapshot. * @param {boolean} [options.includeAccessibility=false] - Whether to run an axe audit. * @param {boolean} [options.includePerformance=false] - Whether to include Core Web Vitals. * @returns {Promise} A promise that resolves with the snapshot data. */ async captureDOMSnapshot(options = {}) { const snapshot = { timestamp: Date.now(), url: window.location.href, html: document.documentElement.outerHTML, viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio, }, title: document.title, }; if (options.includeAccessibility) { snapshot.accessibility = await this.runAxeAudit(); } if (options.includePerformance) { snapshot.performance = this.getCoreWebVitals(); } return snapshot; } /** * Setup Shadow State Capture * Monitors navigation and errors to create state checkpoints. */ setupSnapshotCapture() { // Helper to capture state and log it. const handleSnapshot = async (trigger, details) => { try { const snapshot = await this.captureDOMSnapshot(); this.log(details.level || 'info', 'snapshot', `State Capture (${trigger})`, { trigger, details, snapshot, }); // If it was a critical error, attempt to flush logs immediately. if (details.level === 'error') { this.flushViaBeacon(); } } catch (e) { this.log('error', 'snapshot', 'Failed to capture snapshot.', { error: e.message }); } }; // 1. Capture on Navigation (Periodic check for SPA support) setInterval(async () => { const currentUrl = window.location.href; if (currentUrl !== this.lastUrl) { const previousUrl = this.lastUrl; this.lastUrl = currentUrl; await handleSnapshot('navigation', { from: previousUrl, to: currentUrl }); } }, 1000); // 2. Capture on Critical Errors window.addEventListener('error', (event) => { handleSnapshot('uncaughtError', { level: 'error', error: { message: event.message, filename: event.filename, lineno: event.lineno, }, }); }); window.addEventListener('unhandledrejection', (event) => { handleSnapshot('unhandledRejection', { level: 'error', error: { reason: event.reason?.message || String(event.reason), }, }); }); } /** * Flush logs via Beacon API * Used for critical events where fetch might be cancelled (e.g. page unload/crash) */ flushViaBeacon() { if (!navigator.sendBeacon) return; // Save current state first this.saveToStorage(); // Prepare payload const data = this.exportJSON(); // Create Blob for proper Content-Type const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); // Send beacon const success = navigator.sendBeacon(this.apiEndpoint, blob); if (success) { this.lastSyncedIndex = this.entries.length; console.debug('[BrowserLogger] Critical logs flushed via Beacon'); } } } // Create global instance const dssLogger = new BrowserLogger(); // Expose to window ONLY in development mode // This is for debugging purposes only. Production should not expose this. if (typeof window !== 'undefined' && ( (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'development') || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' )) { // Only expose debugging interface with warning window.__DSS_BROWSER_LOGS = { all: () => dssLogger.getLogs({ limit: 1000 }), errors: () => dssLogger.getErrors(), network: () => dssLogger.getNetworkRequests(), metrics: () => dssLogger.getMetrics(), diagnostic: () => dssLogger.getDiagnostic(), export: () => dssLogger.exportJSON(), print: (options) => dssLogger.printFormatted(options), clear: () => dssLogger.clear(), // Accessibility and performance auditing audit: () => dssLogger.captureAccessibilitySnapshot(), vitals: () => dssLogger.getCoreWebVitals(), axe: () => dssLogger.runAxeAudit(), // Auto-sync controls sync: () => dssLogger.syncToServer(), stopSync: () => dssLogger.stopAutoSync(), startSync: () => dssLogger.startAutoSync(), // Quick helpers help: () => { console.log('%c📋 DSS Browser Logger Commands', 'font-weight: bold; font-size: 14px; color: #4CAF50'); console.log('%c __DSS_BROWSER_LOGS.errors()', 'color: #FF5252', '- Show all errors'); console.log('%c __DSS_BROWSER_LOGS.diagnostic()', 'color: #2196F3', '- System diagnostic'); console.log('%c __DSS_BROWSER_LOGS.all()', 'color: #666', '- All captured logs'); console.log('%c __DSS_BROWSER_LOGS.network()', 'color: #9C27B0', '- Network requests'); console.log('%c __DSS_BROWSER_LOGS.print()', 'color: #FF9800', '- Print formatted table'); console.log('%c __DSS_BROWSER_LOGS.audit()', 'color: #673AB7', '- Run full accessibility audit'); console.log('%c __DSS_BROWSER_LOGS.vitals()', 'color: #009688', '- Get Core Web Vitals (LCP, CLS, FCP, TTFB)'); console.log('%c __DSS_BROWSER_LOGS.axe()', 'color: #E91E63', '- Run axe-core accessibility scan'); console.log('%c __DSS_BROWSER_LOGS.export()', 'color: #00BCD4', '- Export all data (copy this!)'); console.log('%c __DSS_BROWSER_LOGS.clear()', 'color: #F44336', '- Clear all logs'); console.log('%c __DSS_BROWSER_LOGS.share()', 'color: #4CAF50', '- Generate shareable JSON'); console.log('%c __DSS_BROWSER_LOGS.sync()', 'color: #2196F3', '- Sync logs to server now'); console.log('%c __DSS_BROWSER_LOGS.stopSync()', 'color: #FF9800', '- Stop auto-sync'); console.log('%c __DSS_BROWSER_LOGS.startSync()', 'color: #4CAF50', '- Start auto-sync (30s)'); }, // Generate shareable JSON for debugging with Claude share: () => { const data = dssLogger.exportJSON(); const json = JSON.stringify(data, null, 2); console.log('%c📤 Copy this and share with Claude:', 'font-weight: bold; color: #4CAF50'); console.log(json); return data; } }; console.info('%c🔍 DSS Browser Logger Active', 'color: #4CAF50; font-weight: bold;'); console.info('%c📡 Auto-sync enabled - logs sent to server every 30s', 'color: #2196F3; font-style: italic;'); console.info('%cType: %c__DSS_BROWSER_LOGS.help()%c for commands', 'color: #666', 'color: #2196F3; font-family: monospace', 'color: #666'); } export default dssLogger;