feat(analytics): record pageviews server-side only
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m31s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 2s

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:
2026-10-04 12:24:13 +02:00
parent 390936856c
commit a54df524c7
20 changed files with 417 additions and 821 deletions
-2
View File
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { Analytics } from "@/src/components/Analytics";
import { BitBurst } from "@/src/components/bits/BitBurst";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
@@ -72,7 +71,6 @@ export default function RootLayout({
<Footer />
<InteractiveElements />
<BitBurst />
<Analytics />
</body>
</html>
);
@@ -1,77 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { env } from "@/lib/env";
/**
* Smart Proxy for Umami Analytics.
*
* This Route Handler receives tracking events from the browser,
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
* internal Umami API endpoint.
*
* This ensures:
* 1. The Website ID is NOT leaked to the client bundle.
* 2. The Umami API endpoint is hidden behind our domain.
* 3. We have full control over the tracking data.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { type, payload } = body;
// Inject the secret websiteId from server config
const websiteId = env.UMAMI_WEBSITE_ID || env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
if (!websiteId) {
console.warn(
"Umami tracking received but no Website ID configured on server",
);
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
// Prepare the enhanced payload with the secret ID
const enhancedPayload = {
...payload,
website: websiteId,
};
const umamiEndpoint = env.UMAMI_API_ENDPOINT;
// Log the event (debug only)
if (process.env.NODE_ENV === "development") {
console.debug("Forwarding analytics event", {
type,
url: payload.url,
website: websiteId.slice(0, 8) + "...",
});
}
const response = await fetch(`${umamiEndpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": request.headers.get("user-agent") || "Mintel-Smart-Proxy",
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
},
body: JSON.stringify({ type, payload: enhancedPayload }),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Umami API responded with error", {
status: response.status,
error: errorText.slice(0, 100),
});
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: "ok" });
} catch (error) {
console.error("Failed to proxy analytics request", {
error: (error as Error).message,
});
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}