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.
102 lines
3.8 KiB
TypeScript
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);
|
|
}
|
|
}
|