feat(analytics): record pageviews server-side only
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m31s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 2s

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:
2026-10-04 12:24:13 +02:00
parent 390936856c
commit a54df524c7
20 changed files with 417 additions and 821 deletions
-147
View File
@@ -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;
}
}