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
|
* the pure domain decision, and records a page view without touching the
|
||||||
* response. All analytics happen server-side; no tracking script ships to
|
* response. All analytics happen server-side; no tracking script ships to
|
||||||
* the browser.
|
* 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";
|
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 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 {
|
export default function proxy(request: NextRequest): NextResponse {
|
||||||
const decision = decidePageview({
|
const decision = decidePageview({
|
||||||
method: request.method,
|
method: request.method,
|
||||||
|
secFetchDest: request.headers.get("sec-fetch-dest") ?? "",
|
||||||
pathname: request.nextUrl.pathname,
|
pathname: request.nextUrl.pathname,
|
||||||
search: request.nextUrl.search,
|
search: request.nextUrl.search,
|
||||||
hostname: hostnameWithoutPort(
|
hostname: hostnameWithoutPort(
|
||||||
@@ -37,8 +37,6 @@ export default function proxy(request: NextRequest): NextResponse {
|
|||||||
language: primaryLanguage(request.headers.get("accept-language")),
|
language: primaryLanguage(request.headers.get("accept-language")),
|
||||||
userAgent: request.headers.get("user-agent") ?? "",
|
userAgent: request.headers.get("user-agent") ?? "",
|
||||||
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
|
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
|
||||||
isRscNavigation: request.headers.get("rsc") === "1",
|
|
||||||
isPrefetch: isPrefetchRequest(request),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (decision.track) {
|
if (decision.track) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const humanRequest = (
|
|||||||
overrides: Partial<IncomingPageviewRequest> = {},
|
overrides: Partial<IncomingPageviewRequest> = {},
|
||||||
): IncomingPageviewRequest => ({
|
): IncomingPageviewRequest => ({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
secFetchDest: "document",
|
||||||
pathname: "/",
|
pathname: "/",
|
||||||
search: "",
|
search: "",
|
||||||
hostname: "mintel.me",
|
hostname: "mintel.me",
|
||||||
@@ -15,13 +16,11 @@ const humanRequest = (
|
|||||||
language: "de-DE",
|
language: "de-DE",
|
||||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0",
|
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0",
|
||||||
clientIp: "84.117.2.55",
|
clientIp: "84.117.2.55",
|
||||||
isRscNavigation: false,
|
|
||||||
isPrefetch: false,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("decidePageview", () => {
|
describe("decidePageview", () => {
|
||||||
it("tracks a document request for the homepage", () => {
|
it("tracks a browser navigation for the homepage", () => {
|
||||||
const decision = decidePageview(humanRequest());
|
const decision = decidePageview(humanRequest());
|
||||||
expect(decision).toEqual({
|
expect(decision).toEqual({
|
||||||
track: true,
|
track: true,
|
||||||
@@ -60,11 +59,6 @@ describe("decidePageview", () => {
|
|||||||
expect(decision).toMatchObject({ track: true });
|
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", () => {
|
it("accepts the method in lower case", () => {
|
||||||
const decision = decidePageview(humanRequest({ method: "get" }));
|
const decision = decidePageview(humanRequest({ method: "get" }));
|
||||||
expect(decision).toMatchObject({ track: true });
|
expect(decision).toMatchObject({ track: true });
|
||||||
@@ -79,16 +73,17 @@ describe("decidePageview", () => {
|
|||||||
expect(decision).toEqual({ track: false, reason: "non-get-method" });
|
expect(decision).toEqual({ track: false, reason: "non-get-method" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips a router prefetch", () => {
|
it.each([
|
||||||
const decision = decidePageview(humanRequest({ isPrefetch: true }));
|
["a router prefetch payload", "empty"],
|
||||||
expect(decision).toEqual({ track: false, reason: "prefetch" });
|
["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("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([
|
it.each([
|
||||||
@@ -135,4 +130,11 @@ describe("decidePageview", () => {
|
|||||||
const decision = decidePageview(humanRequest({ userAgent }));
|
const decision = decidePageview(humanRequest({ userAgent }));
|
||||||
expect(decision).toEqual({ track: false, reason: "bot-user-agent" });
|
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,
|
* Decides whether an incoming HTTP request represents a human page view,
|
||||||
* and distils the request into the facts a page view consists of.
|
* 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 = {
|
export type IncomingPageviewRequest = {
|
||||||
readonly method: string;
|
readonly method: string;
|
||||||
|
/** Value of the sec-fetch-dest request header; "" when absent. */
|
||||||
|
readonly secFetchDest: string;
|
||||||
readonly pathname: string;
|
readonly pathname: string;
|
||||||
readonly search: string;
|
readonly search: string;
|
||||||
readonly hostname: string;
|
readonly hostname: string;
|
||||||
@@ -13,8 +20,6 @@ export type IncomingPageviewRequest = {
|
|||||||
readonly language: string;
|
readonly language: string;
|
||||||
readonly userAgent: string;
|
readonly userAgent: string;
|
||||||
readonly clientIp: string;
|
readonly clientIp: string;
|
||||||
readonly isRscNavigation: boolean;
|
|
||||||
readonly isPrefetch: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PageviewFacts = {
|
export type PageviewFacts = {
|
||||||
@@ -28,15 +33,17 @@ export type PageviewFacts = {
|
|||||||
|
|
||||||
export type PageviewSkipReason =
|
export type PageviewSkipReason =
|
||||||
| "non-get-method"
|
| "non-get-method"
|
||||||
| "prefetch"
|
| "non-navigation-request"
|
||||||
|
| "bot-user-agent"
|
||||||
| "static-asset"
|
| "static-asset"
|
||||||
| "infrastructure-path"
|
| "infrastructure-path";
|
||||||
| "bot-user-agent";
|
|
||||||
|
|
||||||
export type PageviewDecision =
|
export type PageviewDecision =
|
||||||
| { readonly track: true; readonly facts: PageviewFacts }
|
| { readonly track: true; readonly facts: PageviewFacts }
|
||||||
| { readonly track: false; readonly reason: PageviewSkipReason };
|
| { readonly track: false; readonly reason: PageviewSkipReason };
|
||||||
|
|
||||||
|
const NAVIGATION_DESTINATION = "document";
|
||||||
|
|
||||||
const NEXT_INTERNAL_PREFIX = "/_next/";
|
const NEXT_INTERNAL_PREFIX = "/_next/";
|
||||||
|
|
||||||
const STATIC_FILE_PATTERN =
|
const STATIC_FILE_PATTERN =
|
||||||
@@ -69,8 +76,8 @@ export function decidePageview(
|
|||||||
if (request.method.toUpperCase() !== "GET") {
|
if (request.method.toUpperCase() !== "GET") {
|
||||||
return { track: false, reason: "non-get-method" };
|
return { track: false, reason: "non-get-method" };
|
||||||
}
|
}
|
||||||
if (request.isPrefetch) {
|
if (request.secFetchDest !== NAVIGATION_DESTINATION) {
|
||||||
return { track: false, reason: "prefetch" };
|
return { track: false, reason: "non-navigation-request" };
|
||||||
}
|
}
|
||||||
if (BOT_USER_AGENT_PATTERN.test(request.userAgent)) {
|
if (BOT_USER_AGENT_PATTERN.test(request.userAgent)) {
|
||||||
return { track: false, reason: "bot-user-agent" };
|
return { track: false, reason: "bot-user-agent" };
|
||||||
|
|||||||
Reference in New Issue
Block a user