analytics: make each weekly funnel stage separately measurable (FRE-366)

Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.

Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.

Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.

The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
This commit is contained in:
Paperclip Agent
2026-10-29 17:02:39 +00:00
parent 89f56fc78a
commit 39d874a7e2
19 changed files with 643 additions and 221 deletions

View File

@@ -3,6 +3,7 @@ import { env } from "~/env/server";
import { TURNSTILE_CONFIG } from "~/config";
import { verifyTurnstileToken } from "~/server/fetch-utils";
import { json, error } from "./_lib";
import { trackRequest } from "~/server/track-download";
/**
* POST /api/the-nook/checkout
@@ -12,6 +13,14 @@ import { json, error } from "./_lib";
* session for the one-time $10 The Nook license. Returns the hosted
* checkout URL for the client to redirect to.
*
* Analytics: a created session is the funnel's "checkout started" stage
* (FRE-366). Until then the only trace was the client-side /checkout page
* view — a page, not a started checkout. Recorded through the shared
* `trackRequest` writer (referrer, campaign tags, country, real client
* address, host) under this route's own path, best-effort: a failed
* analytics write never blocks a checkout, and an attempt Turnstile or
* Stripe rejects is correctly not a started checkout.
*
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
* `stripe` npm dependency added.
*/
@@ -67,5 +76,14 @@ export async function POST(event: APIEvent) {
if (!data.url) {
return error("Stripe returned no checkout URL", 502);
}
// The session exists: that is "checkout started". Awaited so the row is
// flushed before the response — a buffered write is not guaranteed to
// survive the response on serverless.
await trackRequest(event, {
path: "/api/the-nook/checkout",
method: "POST"
});
return json({ checkoutUrl: data.url });
}

View File

@@ -113,6 +113,10 @@ describe("GET /api/the-nook/download", () => {
expect(logged[0]).toMatchObject({
path: "/api/downloads/thenook",
method: "GET",
// The door is the click half of the split; the file route writes
// "served". And the row names the site it came from (FRE-366).
stage: "click",
host: "nook.freno.me",
referrer: "https://nook.freno.me/"
});
// Flushed within the request, not left to the batch timer.

View File

@@ -13,8 +13,10 @@ import { resolveLatestDownload } from "./_download";
* Why a redirect and not a direct S3 link: the appcast route and the DMG
* route never logged anything, so step 2 of the funnel (download click →
* DMG served) was invisible. Every click now lands one row in
* VisitorAnalytics under `/api/downloads/thenook`, alongside the clicks and
* served files of every other product — see `~/server/track-download`.
* VisitorAnalytics under `/api/downloads/thenook` with `stage = "click"`,
* distinct from the `stage = "served"` row the file route writes when the
* DMG is actually streamed — the split FRE-366 added, alongside the clicks
* and served files of every other product — see `~/server/track-download`.
*
* Referrer, campaign tags, edge country (with `?c=` override) and user agent
* are read by `trackDownload`, which is shared with the other download doors.
@@ -28,7 +30,7 @@ import { resolveLatestDownload } from "./_download";
export async function GET(event: APIEvent): Promise<Response> {
// Recorded before resolving: a click that dies at resolution is exactly the
// failure the funnel needs to show.
await trackDownload(event, "thenook");
await trackDownload(event, "thenook", "click");
let target: string | null = null;
try {