From ed35413a44f7a0a34588ac51dc2107b14c1fe478 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 4 Oct 2026 12:51:54 +0200 Subject: [PATCH] fix(analytics): identify page views via fetch metadata, not router headers Next.js strips the router-internal headers (RSC, next-router-prefetch) before the request proxy runs, so prefetch detection based on them never fired: link prefetches were counted as page views. A page view is now a browser navigation, identified by the standard sec-fetch-dest: document header, which survives into the proxy. Prefetch payloads, subresource fetches and metadata-less clients are skipped as non-navigation requests. --- apps/web/proxy.ts | 12 +++--- apps/web/src/domain/pageview-decision.spec.ts | 38 ++++++++++--------- apps/web/src/domain/pageview-decision.ts | 23 +++++++---- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 55fb6fb..60027d2 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -3,6 +3,10 @@ * 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. + * + * Page views are browser navigations (`sec-fetch-dest: document`). + * Router-internal headers (RSC, next-router-prefetch) are stripped by the + * server before this shell runs and cannot be used here. */ import { NextResponse, type NextRequest } from "next/server"; @@ -18,14 +22,10 @@ const firstForwardedIp = (forwardedFor: string | null): string => 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, + secFetchDest: request.headers.get("sec-fetch-dest") ?? "", pathname: request.nextUrl.pathname, search: request.nextUrl.search, hostname: hostnameWithoutPort( @@ -37,8 +37,6 @@ export default function proxy(request: NextRequest): NextResponse { 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) { diff --git a/apps/web/src/domain/pageview-decision.spec.ts b/apps/web/src/domain/pageview-decision.spec.ts index 9ee54bb..8d267a7 100644 --- a/apps/web/src/domain/pageview-decision.spec.ts +++ b/apps/web/src/domain/pageview-decision.spec.ts @@ -8,6 +8,7 @@ const humanRequest = ( overrides: Partial = {}, ): IncomingPageviewRequest => ({ method: "GET", + secFetchDest: "document", pathname: "/", search: "", hostname: "mintel.me", @@ -15,13 +16,11 @@ const humanRequest = ( language: "de-DE", userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0", clientIp: "84.117.2.55", - isRscNavigation: false, - isPrefetch: false, ...overrides, }); describe("decidePageview", () => { - it("tracks a document request for the homepage", () => { + it("tracks a browser navigation for the homepage", () => { const decision = decidePageview(humanRequest()); expect(decision).toEqual({ track: true, @@ -60,11 +59,6 @@ describe("decidePageview", () => { expect(decision).toMatchObject({ track: true }); }); - it("tracks a client-side route change (RSC navigation without prefetch)", () => { - const decision = decidePageview(humanRequest({ isRscNavigation: true })); - expect(decision).toMatchObject({ track: true }); - }); - it("accepts the method in lower case", () => { const decision = decidePageview(humanRequest({ method: "get" })); expect(decision).toMatchObject({ track: true }); @@ -79,16 +73,17 @@ describe("decidePageview", () => { expect(decision).toEqual({ track: false, reason: "non-get-method" }); }); - it("skips a router prefetch", () => { - const decision = decidePageview(humanRequest({ isPrefetch: true })); - expect(decision).toEqual({ track: false, reason: "prefetch" }); - }); - - it("skips a prefetch even when the agent is a bot", () => { - const decision = decidePageview( - humanRequest({ isPrefetch: true, userAgent: "curl/8.1.2" }), - ); - expect(decision).toEqual({ track: false, reason: "prefetch" }); + it.each([ + ["a router prefetch payload", "empty"], + ["a script subresource", "script"], + ["an image subresource", "image"], + ["a request without fetch metadata", ""], + ])("skips %s", (_label, secFetchDest) => { + const decision = decidePageview(humanRequest({ secFetchDest })); + expect(decision).toEqual({ + track: false, + reason: "non-navigation-request", + }); }); it.each([ @@ -135,4 +130,11 @@ describe("decidePageview", () => { const decision = decidePageview(humanRequest({ userAgent })); expect(decision).toEqual({ track: false, reason: "bot-user-agent" }); }); + + it("skips a bot even when its request looks like a navigation", () => { + const decision = decidePageview( + humanRequest({ userAgent: "curl/8.1.2", secFetchDest: "document" }), + ); + expect(decision).toEqual({ track: false, reason: "bot-user-agent" }); + }); }); diff --git a/apps/web/src/domain/pageview-decision.ts b/apps/web/src/domain/pageview-decision.ts index 58b423e..defc036 100644 --- a/apps/web/src/domain/pageview-decision.ts +++ b/apps/web/src/domain/pageview-decision.ts @@ -1,11 +1,18 @@ /** * Decides whether an incoming HTTP request represents a human page view, * and distils the request into the facts a page view consists of. - * Pure classification for use in the request shell (src/proxy.ts). + * Pure classification for use in the request shell (proxy.ts). + * + * A page view is a browser navigation, identified by the Fetch Metadata + * header `sec-fetch-dest: document`. Router-internal headers (RSC, + * next-router-prefetch) are stripped by the server before the request + * shell runs and can therefore not be used. */ export type IncomingPageviewRequest = { readonly method: string; + /** Value of the sec-fetch-dest request header; "" when absent. */ + readonly secFetchDest: string; readonly pathname: string; readonly search: string; readonly hostname: string; @@ -13,8 +20,6 @@ export type IncomingPageviewRequest = { readonly language: string; readonly userAgent: string; readonly clientIp: string; - readonly isRscNavigation: boolean; - readonly isPrefetch: boolean; }; export type PageviewFacts = { @@ -28,15 +33,17 @@ export type PageviewFacts = { export type PageviewSkipReason = | "non-get-method" - | "prefetch" + | "non-navigation-request" + | "bot-user-agent" | "static-asset" - | "infrastructure-path" - | "bot-user-agent"; + | "infrastructure-path"; export type PageviewDecision = | { readonly track: true; readonly facts: PageviewFacts } | { readonly track: false; readonly reason: PageviewSkipReason }; +const NAVIGATION_DESTINATION = "document"; + const NEXT_INTERNAL_PREFIX = "/_next/"; const STATIC_FILE_PATTERN = @@ -69,8 +76,8 @@ export function decidePageview( if (request.method.toUpperCase() !== "GET") { return { track: false, reason: "non-get-method" }; } - if (request.isPrefetch) { - return { track: false, reason: "prefetch" }; + if (request.secFetchDest !== NAVIGATION_DESTINATION) { + return { track: false, reason: "non-navigation-request" }; } if (BOT_USER_AGENT_PATTERN.test(request.userAgent)) { return { track: false, reason: "bot-user-agent" };