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:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Server request shell. Reads the incoming request once, classifies it via
|
||||
* the pure domain decision, and records a page view without touching the
|
||||
* response. All analytics happen server-side; no tracking script ships to
|
||||
* the browser.
|
||||
*/
|
||||
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
import { recordPageviewToUmami } from "@/src/analytics/recordPageviewToUmami";
|
||||
import { decidePageview } from "@/src/domain/pageview-decision";
|
||||
|
||||
const primaryLanguage = (acceptLanguage: string | null): string =>
|
||||
acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||
|
||||
const firstForwardedIp = (forwardedFor: string | null): string =>
|
||||
forwardedFor?.split(",")[0]?.trim() ?? "";
|
||||
|
||||
const hostnameWithoutPort = (host: string): string => host.replace(/:\d+$/, "");
|
||||
|
||||
const isPrefetchRequest = (request: NextRequest): boolean =>
|
||||
request.headers.get("next-router-prefetch") === "1" ||
|
||||
request.headers.get("purpose") === "prefetch" ||
|
||||
request.headers.get("sec-purpose") === "prefetch";
|
||||
|
||||
export default function proxy(request: NextRequest): NextResponse {
|
||||
const decision = decidePageview({
|
||||
method: request.method,
|
||||
pathname: request.nextUrl.pathname,
|
||||
search: request.nextUrl.search,
|
||||
hostname: hostnameWithoutPort(
|
||||
request.headers.get("x-forwarded-host") ??
|
||||
request.headers.get("host") ??
|
||||
"",
|
||||
),
|
||||
referrer: request.headers.get("referer") ?? "",
|
||||
language: primaryLanguage(request.headers.get("accept-language")),
|
||||
userAgent: request.headers.get("user-agent") ?? "",
|
||||
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
|
||||
isRscNavigation: request.headers.get("rsc") === "1",
|
||||
isPrefetch: isPrefetchRequest(request),
|
||||
});
|
||||
|
||||
if (decision.track) {
|
||||
recordPageviewToUmami(decision.facts);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|.*\\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|mjs|cjs|js|map|txt|xml|json|webmanifest|woff2?|ttf|otf|mp4|webm|mov|pdf|zip)$).*)",
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user