349 lines
10 KiB
TypeScript
349 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import { trackWebVitals, trackPerformance } from './analytics';
|
|
|
|
// Web Vitals types
|
|
interface Metric {
|
|
name: string;
|
|
value: number;
|
|
delta: number;
|
|
id: string;
|
|
}
|
|
|
|
// Simple Web Vitals implementation (since we don't want to add external dependencies)
|
|
const getCLS = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
let clsValue = 0;
|
|
let sessionValue = 0;
|
|
let sessionEntries: PerformanceEntry[] = [];
|
|
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
for (const entry of list.getEntries()) {
|
|
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
|
|
const firstSessionEntry = sessionEntries[0];
|
|
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
|
|
|
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
|
|
sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0;
|
|
sessionEntries.push(entry);
|
|
} else {
|
|
sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0;
|
|
sessionEntries = [entry];
|
|
}
|
|
|
|
if (sessionValue > clsValue) {
|
|
clsValue = sessionValue;
|
|
onPerfEntry({
|
|
name: 'CLS',
|
|
value: clsValue,
|
|
delta: clsValue,
|
|
id: `cls-${Date.now()}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Silently fail - CLS tracking is not critical
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('CLS tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'layout-shift', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('CLS observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getFID = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
for (const entry of list.getEntries()) {
|
|
const processingStart = (entry as PerformanceEntry & { processingStart?: number }).processingStart;
|
|
if (processingStart !== undefined) {
|
|
onPerfEntry({
|
|
name: 'FID',
|
|
value: processingStart - entry.startTime,
|
|
delta: processingStart - entry.startTime,
|
|
id: `fid-${Date.now()}`,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('FID tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'first-input', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('FID observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getFCP = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
for (const entry of list.getEntries()) {
|
|
if (entry.name === 'first-contentful-paint') {
|
|
onPerfEntry({
|
|
name: 'FCP',
|
|
value: entry.startTime,
|
|
delta: entry.startTime,
|
|
id: `fcp-${Date.now()}`,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('FCP tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'paint', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('FCP observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getLCP = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
const entries = list.getEntries();
|
|
const lastEntry = entries[entries.length - 1];
|
|
|
|
if (lastEntry) {
|
|
onPerfEntry({
|
|
name: 'LCP',
|
|
value: lastEntry.startTime,
|
|
delta: lastEntry.startTime,
|
|
id: `lcp-${Date.now()}`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('LCP tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('LCP observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
|
|
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
try {
|
|
for (const entry of list.getEntries()) {
|
|
if (entry.entryType === 'navigation') {
|
|
const navEntry = entry as PerformanceNavigationTiming;
|
|
if (navEntry.responseStart && navEntry.fetchStart) {
|
|
onPerfEntry({
|
|
name: 'TTFB',
|
|
value: navEntry.responseStart - navEntry.fetchStart,
|
|
delta: navEntry.responseStart - navEntry.fetchStart,
|
|
id: `ttfb-${Date.now()}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('TTFB tracking error:', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe({ type: 'navigation', buffered: true });
|
|
return observer;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('TTFB observer initialization failed:', error);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Custom hook for Web Vitals tracking
|
|
export const useWebVitals = () => {
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
// Wrap everything in try-catch to prevent errors from breaking the app
|
|
try {
|
|
// Store web vitals for batch sending
|
|
const webVitals: Record<string, number> = {};
|
|
const path = window.location.pathname;
|
|
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
|
const projectId = projectMatch ? projectMatch[1] : null;
|
|
const observers: PerformanceObserver[] = [];
|
|
|
|
const sendWebVitals = async () => {
|
|
if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS
|
|
try {
|
|
await fetch('/api/analytics/track', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
type: 'performance',
|
|
projectId: projectId,
|
|
page: path,
|
|
performance: {
|
|
fcp: webVitals.FCP || 0,
|
|
lcp: webVitals.LCP || 0,
|
|
cls: webVitals.CLS || 0,
|
|
fid: webVitals.FID || 0,
|
|
ttfb: webVitals.TTFB || 0,
|
|
loadTime: performance.now()
|
|
}
|
|
})
|
|
});
|
|
} catch (error) {
|
|
// Silently fail
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error('Error sending web vitals:', error);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Track Core Web Vitals
|
|
const clsObserver = getCLS((metric) => {
|
|
webVitals.CLS = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (clsObserver) observers.push(clsObserver);
|
|
|
|
const fidObserver = getFID((metric) => {
|
|
webVitals.FID = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (fidObserver) observers.push(fidObserver);
|
|
|
|
const fcpObserver = getFCP((metric) => {
|
|
webVitals.FCP = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (fcpObserver) observers.push(fcpObserver);
|
|
|
|
const lcpObserver = getLCP((metric) => {
|
|
webVitals.LCP = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (lcpObserver) observers.push(lcpObserver);
|
|
|
|
const ttfbObserver = getTTFB((metric) => {
|
|
webVitals.TTFB = metric.value;
|
|
trackWebVitals({
|
|
...metric,
|
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
url: window.location.pathname,
|
|
});
|
|
sendWebVitals();
|
|
});
|
|
if (ttfbObserver) observers.push(ttfbObserver);
|
|
|
|
// Track page load performance
|
|
const handleLoad = () => {
|
|
setTimeout(() => {
|
|
trackPerformance({
|
|
name: 'page-load-complete',
|
|
value: performance.now(),
|
|
url: window.location.pathname,
|
|
timestamp: Date.now(),
|
|
userAgent: navigator.userAgent,
|
|
});
|
|
}, 0);
|
|
};
|
|
|
|
if (document.readyState === 'complete') {
|
|
handleLoad();
|
|
} else {
|
|
window.addEventListener('load', handleLoad);
|
|
}
|
|
|
|
return () => {
|
|
// Cleanup all observers
|
|
observers.forEach(observer => {
|
|
try {
|
|
observer.disconnect();
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
});
|
|
try {
|
|
window.removeEventListener('load', handleLoad);
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
};
|
|
} catch (error) {
|
|
// If Web Vitals initialization fails, don't break the app
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('Web Vitals initialization failed:', error);
|
|
}
|
|
// Return empty cleanup function
|
|
return () => {};
|
|
}
|
|
}, []);
|
|
};
|