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

@@ -10,7 +10,7 @@ import {
} from "~/server/analytics";
import { ConnectionFactory } from "~/server/database";
import { v4 as uuid } from "uuid";
import { getRequestIP } from "vinxi/http";
import { resolveClientIp, requestHost } from "~/server/request-client";
import { parseUtm } from "~/server/utm";
import { VISITOR_ANALYTICS_INSERT } from "~/server/analytics-schema";
@@ -98,7 +98,8 @@ export const analyticsRouter = createTRPCRouter({
getHeader(req.headers, "referer") ||
getHeader(req.headers, "referrer") ||
getHeader(ctx.event.request?.headers, "referer");
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
const ipAddress = resolveClientIp(ctx.event) || undefined;
const host = requestHost(ctx.event) || undefined;
const enriched = enrichAnalyticsEntry({
userId: ctx.userId,
path: input.path,
@@ -106,6 +107,7 @@ export const analyticsRouter = createTRPCRouter({
userAgent,
referrer,
ipAddress,
host,
// The client beacon sends `pathname + search`, so the page URL's
// own query string is the campaign source; the referrer fills any
// gap (e.g. clicks that arrive on a clean path).
@@ -145,7 +147,9 @@ export const analyticsRouter = createTRPCRouter({
enriched.inp || null,
enriched.ttfb || null,
enriched.domLoad || null,
enriched.loadComplete || null
enriched.loadComplete || null,
enriched.stage || null,
enriched.host || null
]
});

View File

@@ -79,7 +79,7 @@ export const downloadsRouter = createTRPCRouter({
// exactly the event the funnel needs to show. Unknown asset names are
// skipped rather than recorded as a download that never happened.
if (assets[input.asset_name] || latestAssets[input.asset_name]) {
await trackDownload(ctx.event, input.asset_name);
await trackDownload(ctx.event, input.asset_name, "click");
}
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;

View File

@@ -1,7 +1,7 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { APIEvent } from "@solidjs/start/server";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { getRequestIP } from "vinxi/http";
import { resolveClientIp, requestHost } from "~/server/request-client";
import { getAuthPayloadFromEvent } from "~/server/auth";
export type Context = {
@@ -51,7 +51,11 @@ async function createContextInner(event: APIEvent): Promise<Context> {
getHeader(req.headers, "referer") ||
getHeader(req.headers, "referrer") ||
getHeader(event.request?.headers, "referer");
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
// Behind the production proxy the direct peer is loopback, so the real
// client address comes from the forwarded header the proxy sets — see
// `~/server/request-client` for the trust boundary (FRE-366).
const ipAddress = resolveClientIp(event) || undefined;
const host = requestHost(event) || undefined;
// Don't log the performance logging endpoint itself to avoid circular tracking
if (!path.includes("analytics.logPerformance")) {
@@ -62,7 +66,8 @@ async function createContextInner(event: APIEvent): Promise<Context> {
method,
userAgent,
referrer,
ipAddress
ipAddress,
host
})
);
}