/** * ThrillWiki Client-Side Performance Monitoring * * This module provides comprehensive client-side performance metrics collection * and monitoring. It tracks: * - Page load performance (Navigation Timing API) * - Paint metrics (First Paint, First Contentful Paint) * - Layout shifts (Cumulative Layout Shift) * - Largest Contentful Paint * - First Input Delay * - Memory usage (Chrome only) * - Long tasks (> 50ms) * - Resource timing * * Usage: * Include this script after DOMContentLoaded. * Metrics are logged to console in development and can be sent to a backend endpoint. */ (function() { 'use strict'; // Configuration const CONFIG = { enableConsoleLogging: true, // Log to console in development enableBeacon: false, // Send metrics to backend (set endpoint below) beaconEndpoint: '/api/v1/metrics/performance/', memoryCheckInterval: 30000, // Check memory every 30 seconds memoryWarningThreshold: 0.9, // Warn at 90% memory usage longTaskThreshold: 50, // Long task threshold in ms reportDelay: 3000, // Delay before reporting metrics }; // Metrics storage const metrics = { navigation: {}, paint: {}, resources: [], longTasks: [], layoutShifts: [], largestContentfulPaint: null, firstInputDelay: null, memory: [], errors: [], }; /** * Collect Navigation Timing metrics */ function collectNavigationTiming() { if (!performance.getEntriesByType) return; const navEntries = performance.getEntriesByType('navigation'); if (navEntries.length === 0) return; const nav = navEntries[0]; metrics.navigation = { // DNS lookup time dnsLookup: nav.domainLookupEnd - nav.domainLookupStart, // TCP connection time tcpConnect: nav.connectEnd - nav.connectStart, // TLS handshake time (if HTTPS) tlsHandshake: nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0, // Time to First Byte (TTFB) ttfb: nav.responseStart - nav.requestStart, // Response download time responseTime: nav.responseEnd - nav.responseStart, // DOM parsing time domParsing: nav.domContentLoadedEventEnd - nav.domInteractive, // DOM Content Loaded domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, // Full page load loadComplete: nav.loadEventEnd - nav.startTime, // Redirect time (if any) redirectTime: nav.redirectEnd - nav.redirectStart, // Transfer size transferSize: nav.transferSize, // Encoded body size encodedBodySize: nav.encodedBodySize, // Decoded body size decodedBodySize: nav.decodedBodySize, }; } /** * Collect Paint Timing metrics */ function collectPaintTiming() { if (!performance.getEntriesByType) return; const paintEntries = performance.getEntriesByType('paint'); paintEntries.forEach(entry => { if (entry.name === 'first-paint') { metrics.paint.firstPaint = entry.startTime; } else if (entry.name === 'first-contentful-paint') { metrics.paint.firstContentfulPaint = entry.startTime; } }); } /** * Observe Largest Contentful Paint */ function observeLCP() { if (!('PerformanceObserver' in window)) return; try { const lcpObserver = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); const lastEntry = entries[entries.length - 1]; metrics.largestContentfulPaint = { time: lastEntry.startTime, element: lastEntry.element ? lastEntry.element.tagName : 'unknown', size: lastEntry.size, url: lastEntry.url || null, }; }); lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }); } catch (e) { // LCP not supported } } /** * Observe First Input Delay */ function observeFID() { if (!('PerformanceObserver' in window)) return; try { const fidObserver = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); entries.forEach(entry => { if (!metrics.firstInputDelay) { metrics.firstInputDelay = { delay: entry.processingStart - entry.startTime, eventType: entry.name, target: entry.target ? entry.target.tagName : 'unknown', }; } }); }); fidObserver.observe({ type: 'first-input', buffered: true }); } catch (e) { // FID not supported } } /** * Observe Cumulative Layout Shift */ function observeCLS() { if (!('PerformanceObserver' in window)) return; let clsValue = 0; let sessionValue = 0; let sessionEntries = []; try { const clsObserver = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); entries.forEach(entry => { // Only count layout shifts without recent user input if (!entry.hadRecentInput) { const firstSessionEntry = sessionEntries[0]; const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; // Start a new session if gap > 1 second or session > 5 seconds if (sessionValue && (entry.startTime - lastSessionEntry.startTime > 1000 || entry.startTime - firstSessionEntry.startTime > 5000)) { clsValue = Math.max(clsValue, sessionValue); sessionValue = entry.value; sessionEntries = [entry]; } else { sessionValue += entry.value; sessionEntries.push(entry); } metrics.layoutShifts.push({ value: entry.value, time: entry.startTime, sources: entry.sources ? entry.sources.length : 0, }); } }); }); clsObserver.observe({ type: 'layout-shift', buffered: true }); } catch (e) { // CLS not supported } } /** * Observe Long Tasks */ function observeLongTasks() { if (!('PerformanceObserver' in window)) return; try { const longTaskObserver = new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); entries.forEach(entry => { if (entry.duration > CONFIG.longTaskThreshold) { metrics.longTasks.push({ duration: entry.duration, startTime: entry.startTime, name: entry.name, }); } }); }); longTaskObserver.observe({ entryTypes: ['longtask'] }); } catch (e) { // Long tasks not supported } } /** * Monitor memory usage (Chrome only) */ function monitorMemory() { if (!performance.memory) return; const checkMemory = () => { const memoryInfo = performance.memory; const usedRatio = memoryInfo.usedJSHeapSize / memoryInfo.jsHeapSizeLimit; const memorySnapshot = { timestamp: Date.now(), usedHeapSize: memoryInfo.usedJSHeapSize, totalHeapSize: memoryInfo.totalJSHeapSize, heapSizeLimit: memoryInfo.jsHeapSizeLimit, usedRatio: usedRatio, }; metrics.memory.push(memorySnapshot); // Keep only last 10 snapshots if (metrics.memory.length > 10) { metrics.memory.shift(); } // Warn if memory usage is high if (usedRatio > CONFIG.memoryWarningThreshold) { console.warn('ThrillWiki Performance: High memory usage detected', { usedPercent: (usedRatio * 100).toFixed(1) + '%', usedMB: (memoryInfo.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB', limitMB: (memoryInfo.jsHeapSizeLimit / 1024 / 1024).toFixed(2) + ' MB', }); } }; // Initial check checkMemory(); // Periodic checks setInterval(checkMemory, CONFIG.memoryCheckInterval); } /** * Collect resource timing */ function collectResourceTiming() { if (!performance.getEntriesByType) return; const resources = performance.getEntriesByType('resource'); metrics.resources = resources.slice(0, 50).map(resource => ({ name: resource.name.split('?')[0], // Remove query params type: resource.initiatorType, duration: resource.duration, transferSize: resource.transferSize, startTime: resource.startTime, })); } /** * Generate performance summary */ function generateSummary() { const summary = { timestamp: new Date().toISOString(), url: window.location.href, userAgent: navigator.userAgent, navigation: metrics.navigation, paint: metrics.paint, lcp: metrics.largestContentfulPaint, fid: metrics.firstInputDelay, cls: metrics.layoutShifts.reduce((sum, shift) => sum + shift.value, 0), longTaskCount: metrics.longTasks.length, longTaskTotalTime: metrics.longTasks.reduce((sum, task) => sum + task.duration, 0), resourceCount: metrics.resources.length, totalTransferSize: metrics.resources.reduce((sum, r) => sum + (r.transferSize || 0), 0), }; // Calculate Web Vitals ratings summary.webVitals = { lcp: rateMetric('lcp', summary.lcp?.time), fid: rateMetric('fid', summary.fid?.delay), cls: rateMetric('cls', summary.cls), }; return summary; } /** * Rate a metric according to Web Vitals thresholds */ function rateMetric(metric, value) { if (value === null || value === undefined) return 'unknown'; const thresholds = { lcp: { good: 2500, needsImprovement: 4000 }, fid: { good: 100, needsImprovement: 300 }, cls: { good: 0.1, needsImprovement: 0.25 }, }; const threshold = thresholds[metric]; if (!threshold) return 'unknown'; if (value <= threshold.good) return 'good'; if (value <= threshold.needsImprovement) return 'needs-improvement'; return 'poor'; } /** * Log metrics to console */ function logMetrics() { if (!CONFIG.enableConsoleLogging) return; const summary = generateSummary(); console.group('ThrillWiki Performance Metrics'); console.log('Page Load:', summary.navigation.loadComplete?.toFixed(0) + 'ms'); console.log('TTFB:', summary.navigation.ttfb?.toFixed(0) + 'ms'); console.log('First Paint:', summary.paint.firstPaint?.toFixed(0) + 'ms'); console.log('First Contentful Paint:', summary.paint.firstContentfulPaint?.toFixed(0) + 'ms'); console.log('Largest Contentful Paint:', summary.lcp?.time?.toFixed(0) + 'ms', `(${summary.webVitals.lcp})`); console.log('First Input Delay:', summary.fid?.delay?.toFixed(0) + 'ms', `(${summary.webVitals.fid})`); console.log('Cumulative Layout Shift:', summary.cls?.toFixed(3), `(${summary.webVitals.cls})`); console.log('Long Tasks:', summary.longTaskCount, `(${summary.longTaskTotalTime.toFixed(0)}ms total)`); console.log('Resources:', summary.resourceCount, `(${(summary.totalTransferSize / 1024).toFixed(0)}KB)`); console.groupEnd(); } /** * Send metrics to backend */ function sendMetrics() { if (!CONFIG.enableBeacon || !navigator.sendBeacon) return; const summary = generateSummary(); const blob = new Blob([JSON.stringify(summary)], { type: 'application/json' }); try { navigator.sendBeacon(CONFIG.beaconEndpoint, blob); } catch (e) { console.warn('Failed to send performance metrics:', e); } } /** * Initialize performance monitoring */ function init() { // Start observers early observeLCP(); observeFID(); observeCLS(); observeLongTasks(); // Collect metrics after page load window.addEventListener('load', () => { // Delay to ensure all metrics are available setTimeout(() => { collectNavigationTiming(); collectPaintTiming(); collectResourceTiming(); logMetrics(); sendMetrics(); }, CONFIG.reportDelay); }); // Start memory monitoring monitorMemory(); // Report on page unload window.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { sendMetrics(); } }); } // Expose API window.ThrillWikiPerformance = { getMetrics: () => metrics, getSummary: generateSummary, logMetrics: logMetrics, }; // Initialize if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();