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

@@ -0,0 +1,67 @@
import type { APIEvent } from "@solidjs/start/server";
import { getRequestIP } from "vinxi/http";
import { hostFromEventLike } from "./site-context-server";
/**
* The real client address behind the production proxy (FRE-366).
*
* Until this existed every analytics writer called `getRequestIP` and only
* looked at `x-forwarded-for` when it returned null — so behind the
* production proxy the recorded address was the proxy itself, and all
* 12,035 addressed rows in VisitorAnalytics carried 127.0.0.1 / ::1.
*
* Trust boundary, stated plainly: `x-forwarded-for` is client-controlled
* text. It is believed only when the direct peer is a loopback address —
* the production shape, where a same-host proxy (nginx/caddy) terminates
* TLS and sets the header itself. Anywhere else the direct peer IS the
* client (dev, direct hit), and a forwarded header from it is ignored as
* spoofable. The first hop of the header is taken; later hops are the
* client's own claims.
*/
export function resolveClientIp(event: APIEvent): string | null {
const direct = getRequestIP(event.nativeEvent);
if (direct && !isLoopbackAddress(direct)) {
return direct;
}
const forwarded = event.request?.headers?.get?.("x-forwarded-for");
const first = forwarded?.split(",")[0]?.trim();
if (first) {
return first;
}
return direct ?? null;
}
export function isLoopbackAddress(ip: string): boolean {
return (
ip === "::1" ||
ip === "::ffff:127.0.0.1" ||
ip === "127.0.0.1" ||
ip.startsWith("127.")
);
}
/**
* The site the request addressed — the `Host` the client asked for, e.g.
* `nook.freno.me`. Recorded on every analytics row so a weekly read can
* scope to one site; without it `/` on nook.freno.me is the same row as
* `/` on freno.me. Thin alias over the site-context extractor so the
* analytics writers share one host-parsing implementation with routing.
*/
export function requestHost(event: APIEvent): string | null {
const header = hostFromEventLike(event);
if (header) return header;
// `host` is a forbidden header and some server shapes drop it from
// `event.request.headers`; the request URL is absolute on the server and
// carries the same host the client addressed.
try {
const url = event.request?.url;
if (url) return new URL(url).host || null;
} catch {
/* noop */
}
return null;
}