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,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decidePageview,
|
||||
type IncomingPageviewRequest,
|
||||
} from "./pageview-decision";
|
||||
|
||||
const humanRequest = (
|
||||
overrides: Partial<IncomingPageviewRequest> = {},
|
||||
): IncomingPageviewRequest => ({
|
||||
method: "GET",
|
||||
pathname: "/",
|
||||
search: "",
|
||||
hostname: "mintel.me",
|
||||
referrer: "",
|
||||
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", () => {
|
||||
const decision = decidePageview(humanRequest());
|
||||
expect(decision).toEqual({
|
||||
track: true,
|
||||
facts: {
|
||||
url: "/",
|
||||
hostname: "mintel.me",
|
||||
referrer: "",
|
||||
language: "de-DE",
|
||||
userAgent: humanRequest().userAgent,
|
||||
clientIp: "84.117.2.55",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the query string in the tracked url", () => {
|
||||
const decision = decidePageview(
|
||||
humanRequest({ pathname: "/imprint", search: "?from=footer" }),
|
||||
);
|
||||
expect(decision).toMatchObject({
|
||||
track: true,
|
||||
facts: { url: "/imprint?from=footer" },
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks a blog-style slug that merely contains a file-like segment", () => {
|
||||
const decision = decidePageview(
|
||||
humanRequest({ pathname: "/blog/next.js-vs-remix" }),
|
||||
);
|
||||
expect(decision).toMatchObject({ track: true });
|
||||
});
|
||||
|
||||
it("tracks a slug that merely starts like a metadata image route", () => {
|
||||
const decision = decidePageview(
|
||||
humanRequest({ pathname: "/notes/opengraph-image-deep-dive" }),
|
||||
);
|
||||
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 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a form submission", { method: "POST" }],
|
||||
["a head request", { method: "HEAD" }],
|
||||
["an options probe", { method: "OPTIONS" }],
|
||||
])("skips %s", (_label, overrides) => {
|
||||
const decision = decidePageview(humanRequest(overrides));
|
||||
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 next build chunk", "/_next/static/chunks/main-abc123.js"],
|
||||
["a next image request", "/_next/image"],
|
||||
["a public image", "/images/portrait.webp"],
|
||||
["a jpeg photo", "/images/photo.jpg"],
|
||||
["a woff font", "/fonts/inter.woff"],
|
||||
["a font", "/fonts/inter.woff2"],
|
||||
["a feed document", "/rss.xml"],
|
||||
["the sitemap", "/sitemap.xml"],
|
||||
["the robots file", "/robots.txt"],
|
||||
["the web manifest", "/manifest.webmanifest"],
|
||||
["an open graph image route", "/opengraph-image"],
|
||||
["a twitter image route", "/twitter-image"],
|
||||
["a favicon", "/favicon.ico"],
|
||||
])("skips %s at %s", (_label, pathname) => {
|
||||
const decision = decidePageview(humanRequest({ pathname }));
|
||||
expect(decision).toEqual({ track: false, reason: "static-asset" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the analytics proxy", "/stats/api/send"],
|
||||
["the error relay", "/errors/api/relay"],
|
||||
["an api route", "/api/contact"],
|
||||
["the api health check", "/api/health"],
|
||||
["the health check", "/health"],
|
||||
["the gatekeeper", "/gatekeeper/api/verify"],
|
||||
["a well-known challenge", "/.well-known/acme-challenge/token"],
|
||||
])("skips %s at %s", (_label, pathname) => {
|
||||
const decision = decidePageview(humanRequest({ pathname }));
|
||||
expect(decision).toEqual({ track: false, reason: "infrastructure-path" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["curl", "curl/8.1.2"],
|
||||
["wget", "Wget/1.21"],
|
||||
["python requests", "python-requests/2.31"],
|
||||
["go http", "Go-http-client/2.0"],
|
||||
["uptime kuma", "Uptime-Kuma/1.23.11"],
|
||||
["a crawler", "Googlebot/2.1 (+http://www.google.com/bot.html)"],
|
||||
["a link preview", "WhatsApp/2.23.20.0"],
|
||||
])("skips the %s user agent", (_label, userAgent) => {
|
||||
const decision = decidePageview(humanRequest({ userAgent }));
|
||||
expect(decision).toEqual({ track: false, reason: "bot-user-agent" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user