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/`. * * 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 { 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 { 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); } }