fix: namespace rate limit buckets per endpoint, remove custom analytics
- Add `prefix` param to checkRateLimit/getRateLimitHeaders so each endpoint
has its own bucket (previously all shared `admin_${ip}`, causing 429s when
analytics/track incremented past n8n endpoints' lower limits)
- n8n/hardcover/currently-reading → prefix 'n8n-reading'
- n8n/status → prefix 'n8n-status'
- analytics/track → prefix 'analytics-track'
- Remove custom analytics system (AnalyticsProvider, lib/analytics,
lib/useWebVitals, all /api/analytics/* routes) — was causing 500s in
production due to missing PostgreSQL PageView table
- Remove analytics consent toggle from ConsentBanner/ConsentProvider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
144
lib/analytics.ts
144
lib/analytics.ts
@@ -1,144 +0,0 @@
|
||||
// Analytics utilities for Umami with Performance Tracking
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (event: string, data?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PerformanceMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface WebVitalsMetric {
|
||||
name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB';
|
||||
value: number;
|
||||
delta: number;
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Track custom events to Umami
|
||||
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const trackFn = window.umami?.track;
|
||||
if (typeof trackFn !== "function") return;
|
||||
|
||||
try {
|
||||
trackFn(event, {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - analytics must never break the app
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Error tracking Umami event:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track performance metrics
|
||||
export const trackPerformance = (metric: PerformanceMetric) => {
|
||||
trackEvent('performance', {
|
||||
metric: metric.name,
|
||||
value: Math.round(metric.value),
|
||||
url: metric.url,
|
||||
userAgent: metric.userAgent,
|
||||
});
|
||||
};
|
||||
|
||||
// Track Web Vitals
|
||||
export const trackWebVitals = (metric: WebVitalsMetric) => {
|
||||
trackEvent('web-vitals', {
|
||||
name: metric.name,
|
||||
value: Math.round(metric.value),
|
||||
delta: Math.round(metric.delta),
|
||||
id: metric.id,
|
||||
url: metric.url,
|
||||
});
|
||||
};
|
||||
|
||||
// Track page load performance
|
||||
export const trackPageLoad = () => {
|
||||
if (typeof window === 'undefined' || typeof performance === 'undefined') return;
|
||||
|
||||
try {
|
||||
const navigationEntries = performance.getEntriesByType('navigation');
|
||||
const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined;
|
||||
|
||||
if (navigation && navigation.loadEventEnd && navigation.fetchStart) {
|
||||
trackPerformance({
|
||||
name: 'page-load',
|
||||
value: navigation.loadEventEnd - navigation.fetchStart,
|
||||
url: window.location.pathname,
|
||||
timestamp: Date.now(),
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
|
||||
// Track individual timing phases
|
||||
trackEvent('page-timing', {
|
||||
dns: navigation.domainLookupEnd && navigation.domainLookupStart
|
||||
? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart)
|
||||
: 0,
|
||||
tcp: navigation.connectEnd && navigation.connectStart
|
||||
? Math.round(navigation.connectEnd - navigation.connectStart)
|
||||
: 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,
|
||||
});
|
||||
}
|
||||
} 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
|
||||
export const trackApiCall = (endpoint: string, duration: number, status: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('api-call', {
|
||||
endpoint,
|
||||
duration: Math.round(duration),
|
||||
status,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
// Track user interactions
|
||||
export const trackInteraction = (action: string, element?: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('interaction', {
|
||||
action,
|
||||
element,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
// Track errors
|
||||
export const trackError = (error: string, context?: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('error', {
|
||||
error,
|
||||
context,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
@@ -196,9 +196,9 @@ if (typeof window === 'undefined') {
|
||||
}, 60000); // Clear every minute
|
||||
}
|
||||
|
||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
|
||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): boolean {
|
||||
const now = Date.now();
|
||||
const key = `admin_${ip}`;
|
||||
const key = `${prefix}_${ip}`;
|
||||
|
||||
const current = rateLimitMap.get(key);
|
||||
|
||||
@@ -215,8 +215,8 @@ export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: n
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record<string, string> {
|
||||
const current = rateLimitMap.get(`admin_${ip}`);
|
||||
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): Record<string, string> {
|
||||
const current = rateLimitMap.get(`${prefix}_${ip}`);
|
||||
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
'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 {
|
||||
const safeNow = () => {
|
||||
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
// 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: safeNow()
|
||||
}
|
||||
})
|
||||
});
|
||||
} 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: safeNow(),
|
||||
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 () => {};
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
Reference in New Issue
Block a user