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:
67
src/server/request-client.ts
Normal file
67
src/server/request-client.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user