refactor: enhance error handling and performance tracking across components

- Improve localStorage access in ActivityFeed, ChatWidget, and AdminPage with try-catch blocks to handle potential errors gracefully.
- Update performance tracking in AnalyticsProvider and analytics.ts to ensure robust error handling and prevent failures from affecting user experience.
- Refactor Web Vitals tracking to include error handling for observer initialization and data collection.
- Ensure consistent handling of hydration mismatches in components like BackgroundBlobs and ChatWidget to improve rendering reliability.
This commit is contained in:
2026-01-10 16:53:06 +01:00
parent 20f0ccb85b
commit ca2ed13446
10 changed files with 573 additions and 268 deletions

View File

@@ -152,6 +152,7 @@ export async function POST(request: NextRequest) {
} }
} }
} }
}
// Track session data (for bounce rate calculation) // Track session data (for bounce rate calculation)
if (type === 'session' && session) { if (type === 'session' && session) {

View File

@@ -57,8 +57,16 @@ export default function ActivityFeed() {
const [isTrackingEnabled, setIsTrackingEnabled] = useState(() => { const [isTrackingEnabled, setIsTrackingEnabled] = useState(() => {
// Check localStorage for tracking preference // Check localStorage for tracking preference
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("activityTrackingEnabled"); const stored = localStorage.getItem("activityTrackingEnabled");
return stored !== "false"; // Default to true if not set return stored !== "false"; // Default to true if not set
} catch (error) {
// localStorage might be disabled
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to read tracking preference:', error);
}
return true; // Default to enabled
}
} }
return true; return true;
}); });
@@ -1385,7 +1393,14 @@ export default function ActivityFeed() {
const newValue = !isTrackingEnabled; const newValue = !isTrackingEnabled;
setIsTrackingEnabled(newValue); setIsTrackingEnabled(newValue);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
try {
localStorage.setItem("activityTrackingEnabled", String(newValue)); localStorage.setItem("activityTrackingEnabled", String(newValue));
} catch (error) {
// localStorage might be full or disabled
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to save tracking preference:', error);
}
}
} }
// Clear data when disabling // Clear data when disabling
if (!newValue) { if (!newValue) {

View File

@@ -20,21 +20,47 @@ interface Message {
} }
export default function ChatWidget() { export default function ChatWidget() {
// Prevent hydration mismatch by only rendering after mount
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState(() => { const [conversationId, setConversationId] = useState<string>("default");
// Generate or retrieve conversation ID
if (typeof window !== "undefined") { useEffect(() => {
setMounted(true);
// Generate or retrieve conversation ID only on client
try {
const stored = localStorage.getItem("chatSessionId"); const stored = localStorage.getItem("chatSessionId");
if (stored) return stored; if (stored) {
const newId = crypto.randomUUID(); setConversationId(stored);
localStorage.setItem("chatSessionId", newId); return;
return newId;
} }
return "default";
// Generate UUID with fallback for browsers without crypto.randomUUID
let newId: string;
if (typeof crypto !== "undefined" && crypto.randomUUID) {
newId = crypto.randomUUID();
} else {
// Fallback UUID generation
newId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}); });
}
localStorage.setItem("chatSessionId", newId);
setConversationId(newId);
} catch (error) {
// localStorage might be disabled or full
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to access localStorage for chat session:', error);
}
setConversationId(`session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
}
}, []);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -62,6 +88,7 @@ export default function ChatWidget() {
// Load messages from localStorage // Load messages from localStorage
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("chatMessages"); const stored = localStorage.getItem("chatMessages");
if (stored) { if (stored) {
try { try {
@@ -74,7 +101,24 @@ export default function ChatWidget() {
})), })),
); );
} catch (e) { } catch (e) {
console.error("Failed to load chat history", e); if (process.env.NODE_ENV === 'development') {
console.error("Failed to parse chat history", e);
}
// Clear corrupted data
try {
localStorage.removeItem("chatMessages");
} catch {
// Ignore cleanup errors
}
// Add welcome message
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
} }
} else { } else {
// Add welcome message // Add welcome message
@@ -87,13 +131,35 @@ export default function ChatWidget() {
}, },
]); ]);
} }
} catch (error) {
// localStorage might be disabled
if (process.env.NODE_ENV === 'development') {
console.warn("Failed to load chat history from localStorage:", error);
}
// Add welcome message anyway
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
} }
}, []); }, []);
// Save messages to localStorage // Save messages to localStorage
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && messages.length > 0) { if (typeof window !== "undefined" && messages.length > 0) {
try {
localStorage.setItem("chatMessages", JSON.stringify(messages)); localStorage.setItem("chatMessages", JSON.stringify(messages));
} catch (error) {
// localStorage might be full or disabled
if (process.env.NODE_ENV === 'development') {
console.warn("Failed to save chat messages to localStorage:", error);
}
}
} }
}, [messages]); }, [messages]);
@@ -204,6 +270,11 @@ export default function ChatWidget() {
]); ]);
}; };
// Don't render until mounted to prevent hydration mismatch
if (!mounted) {
return null;
}
return ( return (
<> <>
{/* Chat Button */} {/* Chat Button */}

View File

@@ -1,13 +1,21 @@
"use client"; "use client";
import React, { useEffect, useState, Suspense, lazy } from "react"; import React, { useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import dynamic from "next/dynamic";
import { ToastProvider } from "@/components/Toast"; import { ToastProvider } from "@/components/Toast";
import { AnalyticsProvider } from "@/components/AnalyticsProvider"; import { AnalyticsProvider } from "@/components/AnalyticsProvider";
// Lazy load heavy components to avoid webpack issues // Dynamic import with SSR disabled to avoid framer-motion issues
const BackgroundBlobs = lazy(() => import("@/components/BackgroundBlobs")); const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
const ChatWidget = lazy(() => import("./ChatWidget")); ssr: false,
loading: () => null,
});
const ChatWidget = dynamic(() => import("./ChatWidget").catch(() => ({ default: () => null })), {
ssr: false,
loading: () => null,
});
export default function ClientProviders({ export default function ClientProviders({
children, children,
@@ -41,17 +49,9 @@ export default function ClientProviders({
return ( return (
<AnalyticsProvider> <AnalyticsProvider>
<ToastProvider> <ToastProvider>
{mounted && ( {mounted && <BackgroundBlobs />}
<Suspense fallback={null}>
<BackgroundBlobs />
</Suspense>
)}
<div className="relative z-10">{children}</div> <div className="relative z-10">{children}</div>
{mounted && !is404Page && ( {mounted && !is404Page && <ChatWidget />}
<Suspense fallback={null}>
<ChatWidget />
</Suspense>
)}
</ToastProvider> </ToastProvider>
</AnalyticsProvider> </AnalyticsProvider>
); );

View File

@@ -57,6 +57,9 @@ const AdminPage = () => {
// Check if user is locked out // Check if user is locked out
const checkLockout = useCallback(() => { const checkLockout = useCallback(() => {
if (typeof window === 'undefined') return false;
try {
const lockoutData = localStorage.getItem('admin_lockout'); const lockoutData = localStorage.getItem('admin_lockout');
if (lockoutData) { if (lockoutData) {
try { try {
@@ -72,10 +75,24 @@ const AdminPage = () => {
})); }));
return true; return true;
} else { } else {
try {
localStorage.removeItem('admin_lockout'); localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
} }
} catch { } catch {
try {
localStorage.removeItem('admin_lockout'); localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
}
}
} catch (error) {
// localStorage might be disabled
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to check lockout status:', error);
} }
} }
return false; return false;
@@ -197,7 +214,11 @@ const AdminPage = () => {
attempts: 0, attempts: 0,
isLoading: false isLoading: false
})); }));
try {
localStorage.removeItem('admin_lockout'); localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
} else { } else {
const newAttempts = authState.attempts + 1; const newAttempts = authState.attempts + 1;
setAuthState(prev => ({ setAuthState(prev => ({
@@ -208,10 +229,17 @@ const AdminPage = () => {
})); }));
if (newAttempts >= 5) { if (newAttempts >= 5) {
try {
localStorage.setItem('admin_lockout', JSON.stringify({ localStorage.setItem('admin_lockout', JSON.stringify({
timestamp: Date.now(), timestamp: Date.now(),
attempts: newAttempts attempts: newAttempts
})); }));
} catch (error) {
// localStorage might be full or disabled
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to save lockout data:', error);
}
}
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isLocked: true, isLocked: true,
@@ -252,7 +280,11 @@ const AdminPage = () => {
<p className="text-stone-500">Too many failed attempts. Please try again in 15 minutes.</p> <p className="text-stone-500">Too many failed attempts. Please try again in 15 minutes.</p>
<button <button
onClick={() => { onClick={() => {
try {
localStorage.removeItem('admin_lockout'); localStorage.removeItem('admin_lockout');
} catch (error) {
// Ignore errors
}
window.location.reload(); window.location.reload();
}} }}
className="mt-4 px-6 py-2 bg-stone-900 text-stone-50 rounded-xl hover:bg-stone-800 transition-colors" className="mt-4 px-6 py-2 bg-stone-900 text-stone-50 rounded-xl hover:bg-stone-800 transition-colors"

View File

@@ -65,18 +65,19 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }
// Wait for page to fully load // Wait for page to fully load
setTimeout(async () => { setTimeout(async () => {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; try {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
const paintEntries = performance.getEntriesByType('paint'); const paintEntries = performance.getEntriesByType('paint');
const lcpEntries = performance.getEntriesByType('largest-contentful-paint'); const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint'); const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint');
const lcp = lcpEntries[lcpEntries.length - 1]; const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : undefined;
const performanceData = { const performanceData = {
loadTime: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0, loadTime: navigation && navigation.loadEventEnd && navigation.fetchStart ? navigation.loadEventEnd - navigation.fetchStart : 0,
fcp: fcp ? fcp.startTime : 0, fcp: fcp ? fcp.startTime : 0,
lcp: lcp ? lcp.startTime : 0, lcp: lcp ? lcp.startTime : 0,
ttfb: navigation ? navigation.responseStart - navigation.fetchStart : 0, ttfb: navigation && navigation.responseStart && navigation.fetchStart ? navigation.responseStart - navigation.fetchStart : 0,
cls: 0, // Will be updated by CLS observer cls: 0, // Will be updated by CLS observer
fid: 0, // Will be updated by FID observer fid: 0, // Will be updated by FID observer
si: 0 // Speed Index - would need to calculate si: 0 // Speed Index - would need to calculate
@@ -95,6 +96,12 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }
performance: performanceData performance: performanceData
}) })
}); });
} catch (error) {
// Silently fail - performance tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error collecting performance data:', error);
}
}
}, 2000); // Wait 2 seconds for page to stabilize }, 2000); // Wait 2 seconds for page to stabilize
} catch (error) { } catch (error) {
// Silently fail // Silently fail
@@ -124,8 +131,13 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }
// Track user interactions // Track user interactions
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement; try {
const element = target.tagName.toLowerCase(); if (typeof window === 'undefined') return;
const target = event.target as HTMLElement | null;
if (!target) return;
const element = target.tagName ? target.tagName.toLowerCase() : 'unknown';
const className = target.className; const className = target.className;
const id = target.id; const id = target.id;
@@ -135,23 +147,48 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }
id: id || undefined, id: id || undefined,
url: window.location.pathname, url: window.location.pathname,
}); });
} catch (error) {
// Silently fail - click tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking click:', error);
}
}
}; };
// Track form submissions // Track form submissions
const handleSubmit = (event: SubmitEvent) => { const handleSubmit = (event: SubmitEvent) => {
const form = event.target as HTMLFormElement; try {
if (typeof window === 'undefined') return;
const form = event.target as HTMLFormElement | null;
if (!form) return;
trackEvent('form-submit', { trackEvent('form-submit', {
formId: form.id || undefined, formId: form.id || undefined,
formClass: form.className || undefined, formClass: form.className || undefined,
url: window.location.pathname, url: window.location.pathname,
}); });
} catch (error) {
// Silently fail - form tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking form submit:', error);
}
}
}; };
// Track scroll depth // Track scroll depth
let maxScrollDepth = 0; let maxScrollDepth = 0;
const handleScroll = () => { const handleScroll = () => {
try {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const scrollHeight = document.documentElement.scrollHeight;
const innerHeight = window.innerHeight;
if (scrollHeight <= innerHeight) return; // No scrollable content
const scrollDepth = Math.round( const scrollDepth = Math.round(
(window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100 (window.scrollY / (scrollHeight - innerHeight)) * 100
); );
if (scrollDepth > maxScrollDepth) { if (scrollDepth > maxScrollDepth) {
@@ -168,6 +205,12 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }
trackEvent('scroll-depth', { depth: 90, url: window.location.pathname }); trackEvent('scroll-depth', { depth: 90, url: window.location.pathname });
} }
} }
} catch (error) {
// Silently fail - scroll tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking scroll:', error);
}
}
}; };
// Add event listeners // Add event listeners
@@ -177,20 +220,36 @@ export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }
// Track errors // Track errors
const handleError = (event: ErrorEvent) => { const handleError = (event: ErrorEvent) => {
try {
if (typeof window === 'undefined') return;
trackEvent('error', { trackEvent('error', {
message: event.message, message: event.message || 'Unknown error',
filename: event.filename, filename: event.filename || undefined,
lineno: event.lineno, lineno: event.lineno || undefined,
colno: event.colno, colno: event.colno || undefined,
url: window.location.pathname, url: window.location.pathname,
}); });
} catch (error) {
// Silently fail - error tracking should not cause more errors
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking error event:', error);
}
}
}; };
const handleUnhandledRejection = (event: PromiseRejectionEvent) => { const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
try {
if (typeof window === 'undefined') return;
trackEvent('unhandled-rejection', { trackEvent('unhandled-rejection', {
reason: event.reason?.toString(), reason: event.reason?.toString() || 'Unknown rejection',
url: window.location.pathname, url: window.location.pathname,
}); });
} catch (error) {
// Silently fail - error tracking should not cause more errors
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking unhandled rejection:', error);
}
}
}; };
window.addEventListener('error', handleError); window.addEventListener('error', handleError);

View File

@@ -27,7 +27,16 @@ const BackgroundBlobs = () => {
const x5 = useTransform(springX, (value) => value / 15); const x5 = useTransform(springX, (value) => value / 15);
const y5 = useTransform(springY, (value) => value / 15); const y5 = useTransform(springY, (value) => value / 15);
// Prevent hydration mismatch
const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const x = e.clientX - window.innerWidth / 2; const x = e.clientX - window.innerWidth / 2;
const y = e.clientY - window.innerHeight / 2; const y = e.clientY - window.innerHeight / 2;
@@ -37,14 +46,7 @@ const BackgroundBlobs = () => {
window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove); return () => window.removeEventListener("mousemove", handleMouseMove);
}, [mouseX, mouseY]); }, [mouseX, mouseY, mounted]);
// Prevent hydration mismatch
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null; if (!mounted) return null;

View File

@@ -157,7 +157,7 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
const stats = { const stats = {
totalProjects: projects.length, totalProjects: projects.length,
publishedProjects: projects.filter(p => p.published).length, publishedProjects: projects.filter(p => p.published).length,
totalViews: (analytics?.overview?.totalViews as number) || (analytics?.totalViews as number) || projects.reduce((sum, p) => sum + (p.analytics?.views || 0), 0), totalViews: ((analytics?.overview as Record<string, unknown>)?.totalViews as number) || (analytics?.totalViews as number) || projects.reduce((sum, p) => sum + (p.analytics?.views || 0), 0),
unreadEmails: emails.filter(e => !(e.read as boolean)).length, unreadEmails: emails.filter(e => !(e.read as boolean)).length,
avgPerformance: (() => { avgPerformance: (() => {
// Only show real performance data, not defaults // Only show real performance data, not defaults
@@ -172,9 +172,9 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
}, 0) / projectsWithPerf.length); }, 0) / projectsWithPerf.length);
})(), })(),
systemHealth: (systemStats?.status as string) || 'unknown', systemHealth: (systemStats?.status as string) || 'unknown',
totalUsers: (analytics?.metrics?.totalUsers as number) || (analytics?.totalUsers as number) || 0, totalUsers: ((analytics?.metrics as Record<string, unknown>)?.totalUsers as number) || (analytics?.totalUsers as number) || 0,
bounceRate: (analytics?.metrics?.bounceRate as number) || (analytics?.bounceRate as number) || 0, bounceRate: ((analytics?.metrics as Record<string, unknown>)?.bounceRate as number) || (analytics?.bounceRate as number) || 0,
avgSessionDuration: (analytics?.metrics?.avgSessionDuration as number) || (analytics?.avgSessionDuration as number) || 0 avgSessionDuration: ((analytics?.metrics as Record<string, unknown>)?.avgSessionDuration as number) || (analytics?.avgSessionDuration as number) || 0
}; };
useEffect(() => { useEffect(() => {

View File

@@ -57,11 +57,13 @@ export const trackWebVitals = (metric: WebVitalsMetric) => {
// Track page load performance // Track page load performance
export const trackPageLoad = () => { export const trackPageLoad = () => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined' || typeof performance === 'undefined') return;
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; try {
const navigationEntries = performance.getEntriesByType('navigation');
const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined;
if (navigation) { if (navigation && navigation.loadEventEnd && navigation.fetchStart) {
trackPerformance({ trackPerformance({
name: 'page-load', name: 'page-load',
value: navigation.loadEventEnd - navigation.fetchStart, value: navigation.loadEventEnd - navigation.fetchStart,
@@ -72,19 +74,38 @@ export const trackPageLoad = () => {
// Track individual timing phases // Track individual timing phases
trackEvent('page-timing', { trackEvent('page-timing', {
dns: Math.round(navigation.domainLookupEnd - navigation.domainLookupStart), dns: navigation.domainLookupEnd && navigation.domainLookupStart
tcp: Math.round(navigation.connectEnd - navigation.connectStart), ? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart)
request: Math.round(navigation.responseStart - navigation.requestStart), : 0,
response: Math.round(navigation.responseEnd - navigation.responseStart), tcp: navigation.connectEnd && navigation.connectStart
dom: Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd), ? Math.round(navigation.connectEnd - navigation.connectStart)
load: Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd), : 0,
request: navigation.responseStart && navigation.requestStart
? Math.round(navigation.responseStart - navigation.requestStart)
: 0,
response: navigation.responseEnd && navigation.responseStart
? Math.round(navigation.responseEnd - navigation.responseStart)
: 0,
dom: navigation.domContentLoadedEventEnd && navigation.responseEnd
? Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd)
: 0,
load: navigation.loadEventEnd && navigation.domContentLoadedEventEnd
? Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd)
: 0,
url: window.location.pathname, url: window.location.pathname,
}); });
} }
} catch (error) {
// Silently fail - performance tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking page load:', error);
}
}
}; };
// Track API response times // Track API response times
export const trackApiCall = (endpoint: string, duration: number, status: number) => { export const trackApiCall = (endpoint: string, duration: number, status: number) => {
if (typeof window === 'undefined') return;
trackEvent('api-call', { trackEvent('api-call', {
endpoint, endpoint,
duration: Math.round(duration), duration: Math.round(duration),
@@ -95,6 +116,7 @@ export const trackApiCall = (endpoint: string, duration: number, status: number)
// Track user interactions // Track user interactions
export const trackInteraction = (action: string, element?: string) => { export const trackInteraction = (action: string, element?: string) => {
if (typeof window === 'undefined') return;
trackEvent('interaction', { trackEvent('interaction', {
action, action,
element, element,
@@ -104,6 +126,7 @@ export const trackInteraction = (action: string, element?: string) => {
// Track errors // Track errors
export const trackError = (error: string, context?: string) => { export const trackError = (error: string, context?: string) => {
if (typeof window === 'undefined') return;
trackEvent('error', { trackEvent('error', {
error, error,
context, context,

View File

@@ -13,11 +13,15 @@ interface Metric {
// Simple Web Vitals implementation (since we don't want to add external dependencies) // Simple Web Vitals implementation (since we don't want to add external dependencies)
const getCLS = (onPerfEntry: (metric: Metric) => void) => { const getCLS = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
let clsValue = 0; let clsValue = 0;
let sessionValue = 0; let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = []; let sessionEntries: PerformanceEntry[] = [];
const observer = new PerformanceObserver((list) => { const observer = new PerformanceObserver((list) => {
try {
for (const entry of list.getEntries()) { for (const entry of list.getEntries()) {
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) { if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
const firstSessionEntry = sessionEntries[0]; const firstSessionEntry = sessionEntries[0];
@@ -42,28 +46,64 @@ const getCLS = (onPerfEntry: (metric: Metric) => void) => {
} }
} }
} }
} 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 }); 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) => { const getFID = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
const observer = new PerformanceObserver((list) => { const observer = new PerformanceObserver((list) => {
try {
for (const entry of list.getEntries()) { for (const entry of list.getEntries()) {
const processingStart = (entry as PerformanceEntry & { processingStart?: number }).processingStart;
if (processingStart !== undefined) {
onPerfEntry({ onPerfEntry({
name: 'FID', name: 'FID',
value: (entry as PerformanceEntry & { processingStart?: number }).processingStart! - entry.startTime, value: processingStart - entry.startTime,
delta: (entry as PerformanceEntry & { processingStart?: number }).processingStart! - entry.startTime, delta: processingStart - entry.startTime,
id: `fid-${Date.now()}`, 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 }); 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) => { const getFCP = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
const observer = new PerformanceObserver((list) => { const observer = new PerformanceObserver((list) => {
try {
for (const entry of list.getEntries()) { for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') { if (entry.name === 'first-contentful-paint') {
onPerfEntry({ onPerfEntry({
@@ -74,32 +114,67 @@ const getFCP = (onPerfEntry: (metric: Metric) => void) => {
}); });
} }
} }
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('FCP tracking error:', error);
}
}
}); });
observer.observe({ type: 'paint', buffered: true }); 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) => { const getLCP = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
const observer = new PerformanceObserver((list) => { const observer = new PerformanceObserver((list) => {
try {
const entries = list.getEntries(); const entries = list.getEntries();
const lastEntry = entries[entries.length - 1]; const lastEntry = entries[entries.length - 1];
if (lastEntry) {
onPerfEntry({ onPerfEntry({
name: 'LCP', name: 'LCP',
value: lastEntry.startTime, value: lastEntry.startTime,
delta: lastEntry.startTime, delta: lastEntry.startTime,
id: `lcp-${Date.now()}`, 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 }); 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) => { const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
const observer = new PerformanceObserver((list) => { const observer = new PerformanceObserver((list) => {
try {
for (const entry of list.getEntries()) { for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') { if (entry.entryType === 'navigation') {
const navEntry = entry as PerformanceNavigationTiming; const navEntry = entry as PerformanceNavigationTiming;
if (navEntry.responseStart && navEntry.fetchStart) {
onPerfEntry({ onPerfEntry({
name: 'TTFB', name: 'TTFB',
value: navEntry.responseStart - navEntry.fetchStart, value: navEntry.responseStart - navEntry.fetchStart,
@@ -108,9 +183,22 @@ const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
}); });
} }
} }
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('TTFB tracking error:', error);
}
}
}); });
observer.observe({ type: 'navigation', buffered: true }); 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 // Custom hook for Web Vitals tracking
@@ -123,6 +211,7 @@ export const useWebVitals = () => {
const path = window.location.pathname; const path = window.location.pathname;
const projectMatch = path.match(/\/projects\/([^\/]+)/); const projectMatch = path.match(/\/projects\/([^\/]+)/);
const projectId = projectMatch ? projectMatch[1] : null; const projectId = projectMatch ? projectMatch[1] : null;
const observers: PerformanceObserver[] = [];
const sendWebVitals = async () => { const sendWebVitals = async () => {
if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS
@@ -156,7 +245,7 @@ export const useWebVitals = () => {
}; };
// Track Core Web Vitals // Track Core Web Vitals
getCLS((metric) => { const clsObserver = getCLS((metric) => {
webVitals.CLS = metric.value; webVitals.CLS = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
@@ -165,8 +254,9 @@ export const useWebVitals = () => {
}); });
sendWebVitals(); sendWebVitals();
}); });
if (clsObserver) observers.push(clsObserver);
getFID((metric) => { const fidObserver = getFID((metric) => {
webVitals.FID = metric.value; webVitals.FID = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
@@ -175,8 +265,9 @@ export const useWebVitals = () => {
}); });
sendWebVitals(); sendWebVitals();
}); });
if (fidObserver) observers.push(fidObserver);
getFCP((metric) => { const fcpObserver = getFCP((metric) => {
webVitals.FCP = metric.value; webVitals.FCP = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
@@ -185,8 +276,9 @@ export const useWebVitals = () => {
}); });
sendWebVitals(); sendWebVitals();
}); });
if (fcpObserver) observers.push(fcpObserver);
getLCP((metric) => { const lcpObserver = getLCP((metric) => {
webVitals.LCP = metric.value; webVitals.LCP = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
@@ -195,8 +287,9 @@ export const useWebVitals = () => {
}); });
sendWebVitals(); sendWebVitals();
}); });
if (lcpObserver) observers.push(lcpObserver);
getTTFB((metric) => { const ttfbObserver = getTTFB((metric) => {
webVitals.TTFB = metric.value; webVitals.TTFB = metric.value;
trackWebVitals({ trackWebVitals({
...metric, ...metric,
@@ -205,6 +298,7 @@ export const useWebVitals = () => {
}); });
sendWebVitals(); sendWebVitals();
}); });
if (ttfbObserver) observers.push(ttfbObserver);
// Track page load performance // Track page load performance
const handleLoad = () => { const handleLoad = () => {
@@ -226,6 +320,14 @@ export const useWebVitals = () => {
} }
return () => { return () => {
// Cleanup all observers
observers.forEach(observer => {
try {
observer.disconnect();
} catch (error) {
// Silently fail
}
});
window.removeEventListener('load', handleLoad); window.removeEventListener('load', handleLoad);
}; };
}, []); }, []);