Files
freno-dev/src/server/track-download.ts
Paperclip Agent 39d874a7e2 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.
2026-10-29 17:02:39 +00:00

102 lines
3.8 KiB
TypeScript

import type { APIEvent } from "@solidjs/start/server";
import {
logVisit,
enrichAnalyticsEntry,
flushAnalytics
} from "~/server/analytics";
import { parseUtm } from "~/server/utm";
import { downloadPath } from "~/lib/download-asset";
import { resolveClientIp, requestHost } from "~/server/request-client";
/**
* Record one download event under the canonical path `/api/downloads/<asset>`.
*
* Every download door funnels through here so the analytics page has a single
* label per asset to group on: the tRPC click proxy, the S3 file route that
* serves Sparkle updates, and the Nook door.
*
* The `stage` is what separates the doors within one asset label: "click"
* for a door the visitor chose (landing button, tRPC proxy), "served" for
* the file route that actually streams the DMG. Until FRE-366 both wrote the
* same unlabelled row, so "download click → DMG served" could not be split —
* one served click wrote two identical rows. `summariseDownloads()` and the
* /analytics downloads block still group on the path alone; the stage is a
* separate column precisely so that grouping cannot change.
*
* Best-effort by design — a failed analytics write must never block a
* download. Flushed rather than merely buffered: a download is low-volume and
* the 30s batch timer is not guaranteed to survive the response on serverless.
*/
export async function trackDownload(
event: APIEvent,
asset: string,
stage: DownloadStage
): Promise<void> {
await trackRequest(event, {
path: downloadPath(asset),
method: "GET",
stage
});
}
export type DownloadStage = "click" | "served";
export interface TrackedRequest {
path: string;
method: string;
/** Funnel stage label; only download rows carry one today. */
stage?: string | null;
}
/**
* Record one server-side request event with the shared visitor dimensions:
* referrer, campaign tags, edge country, real client address and host.
*
* This is the one writer every measured door goes through — download doors
* via `trackDownload`, the checkout door directly — so the dimensions can
* never drift between doors. Same best-effort, flushed-immediately contract
* as `trackDownload`.
*
* `event.request` is expected to be a Fetch `Request` (true for Vinxi route
* handlers and browser tRPC calls). A server-side `createCaller` hands over a
* Node request, whose headers have no `get` — that throw is swallowed below,
* so such an event is not recorded. No server-side caller downloads today.
*/
export async function trackRequest(
event: APIEvent,
tracked: TrackedRequest
): Promise<void> {
const { request } = event;
try {
const referrer =
request.headers.get("referer") ?? request.headers.get("referrer");
await logVisit(
enrichAnalyticsEntry({
path: tracked.path,
method: tracked.method,
stage: tracked.stage ?? null,
referrer,
userAgent: request.headers.get("user-agent"),
// Behind the production proxy the direct peer is loopback; the real
// client address comes from the forwarded header the proxy sets —
// see `~/server/request-client` for the trust boundary.
ipAddress: resolveClientIp(event),
host: requestHost(event),
// The click carries its own tags when a campaign links straight at the
// door; otherwise the landing page's tags ride along in the referer.
...parseUtm(request.url, referrer),
// Edge header country, overridable with ?c= for manual verification.
country:
new URL(request.url).searchParams.get("c") ??
request.headers.get("x-vercel-ip-country") ??
request.headers.get("cf-ipcountry")
})
);
await flushAnalytics();
} catch (error) {
console.error(`Failed to log ${tracked.method} ${tracked.path}:`, error);
}
}