feat(analytics): record pageviews server-side only
Umami never received data: the client-side path (browser beacon to /stats/api/send) has been dead in production. Pageviews are now classified and recorded entirely on the server in the request proxy; no tracking script ships to the browser anymore. - Pure domain decision (decidePageview) filters real page views from assets, infrastructure paths, prefetches and bot user agents - Pure payload builder maps facts onto the Umami send contract as an unnamed event, with the client identity travelling in headers - proxy.ts wires request facts into the domain and fires the send without delaying the response; configured via UMAMI_WEBSITE_ID and UMAMI_API_ENDPOINT - Removes the dead client analytics stack (components, adapters, /stats proxy route) and its layout mount
This commit is contained in:
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, Suspense } from "react";
|
||||
import {
|
||||
useSafePathname,
|
||||
useSafeSearchParams,
|
||||
} from "./analytics/useSafePathname";
|
||||
import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker";
|
||||
import { getDefaultAnalytics } from "../utils/analytics";
|
||||
import { getDefaultErrorTracking } from "../utils/error-tracking";
|
||||
|
||||
const AnalyticsInner: React.FC = () => {
|
||||
const pathname = useSafePathname();
|
||||
const searchParams = useSafeSearchParams();
|
||||
|
||||
// Track pageviews on route change
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
const analytics = getDefaultAnalytics();
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
|
||||
|
||||
analytics.page(url);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const analytics = getDefaultAnalytics();
|
||||
const errorTracking = getDefaultErrorTracking();
|
||||
|
||||
// Track page load performance
|
||||
const trackPageLoad = () => {
|
||||
// ... existing implementation ...
|
||||
const perfData = performance.getEntriesByType(
|
||||
"navigation",
|
||||
)[0] as PerformanceNavigationTiming;
|
||||
if (
|
||||
perfData &&
|
||||
typeof perfData.loadEventEnd === "number" &&
|
||||
typeof perfData.startTime === "number"
|
||||
) {
|
||||
const loadTime = perfData.loadEventEnd - perfData.startTime;
|
||||
analytics.trackPageLoad(
|
||||
loadTime,
|
||||
window.location.pathname,
|
||||
navigator.userAgent,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Track outbound links
|
||||
const trackOutboundLinks = () => {
|
||||
document.querySelectorAll('a[href^="http"]').forEach((link) => {
|
||||
const anchor = link as HTMLAnchorElement;
|
||||
if (!anchor.href.includes(window.location.hostname)) {
|
||||
anchor.addEventListener("click", () => {
|
||||
analytics.trackOutboundLink(
|
||||
anchor.href,
|
||||
anchor.textContent?.trim() || "unknown",
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Track search
|
||||
const trackSearch = () => {
|
||||
const searchInput = document.querySelector(
|
||||
'input[type="search"]',
|
||||
) as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
const handleSearch = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value) {
|
||||
analytics.trackSearch(target.value, window.location.pathname);
|
||||
}
|
||||
};
|
||||
searchInput.addEventListener("search", handleSearch);
|
||||
return () => searchInput.removeEventListener("search", handleSearch);
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler for error tracking
|
||||
const handleGlobalError = (event: ErrorEvent) => {
|
||||
errorTracking.captureException(event.error || event.message);
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
errorTracking.captureException(event.reason);
|
||||
};
|
||||
|
||||
window.addEventListener("error", handleGlobalError);
|
||||
window.addEventListener("unhandledrejection", handleUnhandledRejection);
|
||||
|
||||
// Initial load tracking
|
||||
if (document.readyState === "complete") {
|
||||
trackPageLoad();
|
||||
trackOutboundLinks();
|
||||
const cleanupSearch = trackSearch();
|
||||
return () => {
|
||||
if (cleanupSearch) cleanupSearch();
|
||||
window.removeEventListener("error", handleGlobalError);
|
||||
window.removeEventListener(
|
||||
"unhandledrejection",
|
||||
handleUnhandledRejection,
|
||||
);
|
||||
};
|
||||
} else {
|
||||
window.addEventListener("load", () => {
|
||||
trackPageLoad();
|
||||
trackOutboundLinks();
|
||||
// search tracking might need to wait for hydration/render
|
||||
});
|
||||
// Fallback/standard cleanup
|
||||
return () => {
|
||||
window.removeEventListener("error", handleGlobalError);
|
||||
window.removeEventListener(
|
||||
"unhandledrejection",
|
||||
handleUnhandledRejection,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const analytics = getDefaultAnalytics();
|
||||
const adapter = analytics.getAdapter();
|
||||
const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollDepthTracker />
|
||||
{scriptTag && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: scriptTag }}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Analytics: React.FC = () => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsInner />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSafePathname } from "./useSafePathname";
|
||||
import { useAnalytics } from "./useAnalytics";
|
||||
import { AnalyticsEvents } from "./analytics-events";
|
||||
|
||||
/**
|
||||
* ScrollDepthTracker
|
||||
* Tracks user scroll progress across pages.
|
||||
* Fires events at 25%, 50%, 75%, and 100% depth.
|
||||
*/
|
||||
export function ScrollDepthTracker() {
|
||||
const pathname = useSafePathname();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const trackedDepths = useRef<Set<number>>(new Set());
|
||||
|
||||
// Reset tracking when path changes
|
||||
useEffect(() => {
|
||||
trackedDepths.current.clear();
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollY = window.scrollY;
|
||||
const windowHeight = window.innerHeight;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
|
||||
// Calculate how far the user has scrolled in percentage
|
||||
// documentHeight - windowHeight is the total scrollable distance
|
||||
const totalScrollable = documentHeight - windowHeight;
|
||||
if (totalScrollable <= 0) return; // Not scrollable
|
||||
|
||||
const scrollPercentage = Math.round((scrollY / totalScrollable) * 100);
|
||||
|
||||
// We only care about specific milestones
|
||||
const milestones = [25, 50, 75, 100];
|
||||
|
||||
milestones.forEach((milestone) => {
|
||||
if (
|
||||
scrollPercentage >= milestone &&
|
||||
!trackedDepths.current.has(milestone)
|
||||
) {
|
||||
trackedDepths.current.add(milestone);
|
||||
trackEvent(AnalyticsEvents.SCROLL_DEPTH, {
|
||||
depth: milestone,
|
||||
path: pathname,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Use passive listener for better performance
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
// Initial check (in case page is short or already scrolled)
|
||||
if (document.readyState === "complete") {
|
||||
handleScroll();
|
||||
} else {
|
||||
window.addEventListener("load", handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("load", handleScroll);
|
||||
};
|
||||
}, [pathname, trackEvent]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Analytics Events Utility
|
||||
*
|
||||
* Centralized definitions for common analytics events and their properties.
|
||||
*/
|
||||
|
||||
export const AnalyticsEvents = {
|
||||
// Page & Navigation Events
|
||||
PAGE_VIEW: "pageview",
|
||||
PAGE_SCROLL: "page_scroll",
|
||||
PAGE_EXIT: "page_exit",
|
||||
SCROLL_DEPTH: "scroll_depth",
|
||||
|
||||
// User Interaction Events
|
||||
BUTTON_CLICK: "button_click",
|
||||
LINK_CLICK: "link_click",
|
||||
FORM_SUBMIT: "form_submit",
|
||||
FORM_START: "form_start",
|
||||
FORM_ERROR: "form_error",
|
||||
FORM_FIELD_FOCUS: "form_field_focus",
|
||||
|
||||
// UI Interaction Events
|
||||
MODAL_OPEN: "modal_open",
|
||||
MODAL_CLOSE: "modal_close",
|
||||
TOGGLE_SWITCH: "toggle_switch",
|
||||
ACCORDION_TOGGLE: "accordion_toggle",
|
||||
TAB_SWITCH: "tab_switch",
|
||||
|
||||
// Error & Performance Events
|
||||
ERROR: "error",
|
||||
PERFORMANCE: "performance",
|
||||
API_ERROR: "api_error",
|
||||
API_SUCCESS: "api_success",
|
||||
|
||||
// Custom Business Events
|
||||
CONTACT_FORM_SUBMIT: "contact_form_submit",
|
||||
NEWSLETTER_SUBSCRIBE: "newsletter_subscribe",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type-safe event properties for common events
|
||||
*/
|
||||
export type AnalyticsEventName =
|
||||
(typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { getDefaultAnalytics } from "../../utils/analytics";
|
||||
import type { AnalyticsEventName } from "./analytics-events";
|
||||
|
||||
/**
|
||||
* Custom hook for tracking analytics events.
|
||||
* Wraps the analytics service for easy use in components.
|
||||
*/
|
||||
export function useAnalytics() {
|
||||
const trackEvent = useCallback(
|
||||
(
|
||||
eventName: string | AnalyticsEventName,
|
||||
properties?: Record<string, any>,
|
||||
) => {
|
||||
const analytics = getDefaultAnalytics();
|
||||
analytics.trackEvent(eventName, properties);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("[Analytics] Tracked event:", eventName, properties);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const trackPageview = useCallback((url?: string) => {
|
||||
const analytics = getDefaultAnalytics();
|
||||
analytics.page(url || window.location.pathname);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("[Analytics] Tracked pageview:", url ?? "current location");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
trackEvent,
|
||||
trackPageview,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export function useSafePathname(): string | null {
|
||||
try {
|
||||
return usePathname();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Caught usePathname exception (likely Next.js static prerender bug):", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSafeSearchParams() {
|
||||
try {
|
||||
return useSearchParams();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Caught useSearchParams exception (likely Next.js static prerender bug):", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user