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.
This commit is contained in:
+5
-7
@@ -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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ const humanRequest = (
|
||||
overrides: Partial<IncomingPageviewRequest> = {},
|
||||
): 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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user