Files
thrillwiki_django_no_react/static/js/performance-monitor.js
pacnpal edcd8f2076 Add secret management guide, client-side performance monitoring, and search accessibility enhancements
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols.
- Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage.
- Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
2025-12-23 16:41:42 -05:00

412 lines
14 KiB
JavaScript

/**
* 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();
}
})();